Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions python/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ agent_framework/
- **`FunctionTool`** - Wraps Python functions as tools with JSON schema generation
- **`@tool`** decorator - Converts functions to tools
- **`use_function_invocation()`** - Decorator to add automatic function calling to chat clients
- **`_normalize_tool_description_format` / `_format_tool_parameters`** - Private configuration normalizer and
structured parameter formatter shared by Hyperlight and Monty descriptions. They validate and detach compact/JSON
settings, return detached parameter data, and fall back to full JSON Schema when compact data cannot preserve
constraints. They do not change `FunctionTool.parameters()` or render runtime-specific text.

### Vector stores

Expand Down
85 changes: 85 additions & 0 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,91 @@ def _annotation_includes_function_invocation_context(annotation: Any) -> bool:
)


_ToolParameterFormat: TypeAlias = Literal["compact", "json"]
_ToolDescriptionFormat: TypeAlias = _ToolParameterFormat | Mapping[str, _ToolParameterFormat]
_NormalizedToolDescriptionFormat: TypeAlias = _ToolParameterFormat | dict[str, _ToolParameterFormat]
_TOOL_DESCRIPTION_FORMAT_ERROR = "tool_description_format must be 'compact', 'json', or a tool-name mapping."


def _normalize_tool_description_format( # pyright: ignore[reportUnusedFunction]
value: object,
) -> _NormalizedToolDescriptionFormat:
"""Validate and detach model-facing tool parameter description settings."""
if isinstance(value, str):
if value not in ("compact", "json"):
raise ValueError(_TOOL_DESCRIPTION_FORMAT_ERROR)
return value
if not isinstance(value, Mapping):
raise TypeError(_TOOL_DESCRIPTION_FORMAT_ERROR)

normalized: dict[str, _ToolParameterFormat] = {}
for name, choice in cast(Mapping[object, object], value).items():
if not isinstance(name, str):
raise TypeError("tool_description_format mapping keys must be strings.")
if not isinstance(choice, str):
raise TypeError(f"tool_description_format[{name!r}] must be a string ('compact' or 'json').")
if choice not in ("compact", "json"):
raise ValueError(f"tool_description_format[{name!r}] must be 'compact' or 'json'; got {choice!r}.")
normalized[name] = choice
return normalized


def _format_tool_parameters( # pyright: ignore[reportUnusedFunction]
parameters: dict[str, Any],
*,
parameter_format: _ToolParameterFormat,
) -> tuple[_ToolParameterFormat, dict[str, Any]]:
"""Return the effective format and detached parameter data for tool descriptions.

Compact data maps parameter names to scalar type, requiredness, and optional
description, enum, and default metadata. Schemas with unrepresented constraints
retain their full JSON Schema so callers can explain the fallback to the model.
"""
if parameter_format not in ("compact", "json"):
raise ValueError("parameter_format must be 'compact' or 'json'.")

if parameter_format == "json":
return "json", copy.deepcopy(parameters)

properties = parameters.get("properties")
required = parameters.get("required", [])
if (
parameters.get("type") != "object"
or parameters.keys() - {"type", "properties", "required", "title", "description"}
or not isinstance(properties, dict)
or not isinstance(required, list)
):
return "json", copy.deepcopy(parameters)

property_schemas = cast(dict[object, Any], properties)
required_names = cast(list[object], required)
if not all(isinstance(name, str) and name in property_schemas for name in required_names):
return "json", copy.deepcopy(parameters)

compact: dict[str, Any] = {}
for name, property_schema in property_schemas.items():
if not isinstance(name, str) or not isinstance(property_schema, dict):
return "json", copy.deepcopy(parameters)

schema = cast(dict[str, Any], property_schema)
if schema.get("type") not in ("string", "integer", "number", "boolean", "null") or schema.keys() - {
"type",
"title",
"description",
"enum",
"default",
}:
return "json", copy.deepcopy(parameters)

compact[name] = {
"type": schema["type"],
"required": name in required_names,
**{key: copy.deepcopy(schema[key]) for key in ("description", "enum", "default") if key in schema},
Comment thread
eavanvalkenburg marked this conversation as resolved.
}

return "compact", compact


ClassT = TypeVar("ClassT", bound="SerializationMixin")


Expand Down
179 changes: 179 additions & 0 deletions python/packages/core/tests/core/test_tools.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import copy
import logging
import threading
from contextvars import ContextVar
Expand All @@ -21,6 +22,8 @@
from agent_framework._middleware import FunctionInvocationContext
from agent_framework._tools import (
_auto_invoke_function,
_format_tool_parameters,
_normalize_tool_description_format,
_parse_annotation,
_parse_inputs,
_try_execute_function_call_groups,
Expand All @@ -31,6 +34,182 @@
# region FunctionTool and tool decorator tests


def test_normalize_tool_description_format_returns_detached_mapping():
formats = {"lookup": "json"}

normalized = _normalize_tool_description_format(formats)
formats.clear()

assert normalized == {"lookup": "json"}


@pytest.mark.parametrize(
("value", "error_type"),
[
("yaml", ValueError),
(None, TypeError),
({1: "json"}, TypeError),
({"lookup": 1}, TypeError),
({"lookup": "yaml"}, ValueError),
],
)
def test_normalize_tool_description_format_rejects_invalid_values(value, error_type):
with pytest.raises(error_type, match="tool_description_format"):
_normalize_tool_description_format(value)


def test_format_tool_parameters_compact_preserves_scalar_metadata():
schema = {
"type": "object",
"title": "InventoryInput",
"properties": {
"partNumber": {"type": "string", "description": "Part identifier", "title": "Part Number"},
"units": {"type": "integer", "default": 1},
"currency": {"type": "string", "enum": ["EUR", "USD"], "default": "EUR"},
"price": {"type": "number"},
"available": {"type": "boolean", "default": False},
"empty": {"type": "null", "default": None},
},
"required": ["partNumber", "empty"],
}
original = copy.deepcopy(schema)

effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact")

assert effective_format == "compact"
assert parameters == {
"partNumber": {"type": "string", "required": True, "description": "Part identifier"},
"units": {"type": "integer", "required": False, "default": 1},
"currency": {"type": "string", "required": False, "enum": ["EUR", "USD"], "default": "EUR"},
"price": {"type": "number", "required": False},
"available": {"type": "boolean", "required": False, "default": False},
"empty": {"type": "null", "required": True, "default": None},
}
parameters["currency"]["enum"].append("GBP")
assert schema == original


@pytest.mark.parametrize("required", [[], ["value"]])
def test_format_tool_parameters_does_not_infer_requiredness_from_defaults(required):
schema = {
"type": "object",
"properties": {"value": {"type": "string", "default": "default"}},
"required": required,
}

effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact")

assert effective_format == "compact"
assert parameters["value"]["required"] == ("value" in required)
assert parameters["value"]["default"] == "default"


@pytest.mark.parametrize(
"schema",
[
{"type": "object", "properties": {}},
{"type": "object", "properties": {}, "required": [], "title": "EmptyInput"},
],
)
def test_format_tool_parameters_empty_object(schema):
assert _format_tool_parameters(schema, parameter_format="compact") == ("compact", {})


@pytest.mark.parametrize(
"property_schema",
[
{"type": "object", "properties": {"nested": {"type": "string"}}, "required": ["nested"]},
{"type": "array", "items": {"type": "integer"}},
{"$ref": "#/$defs/Address"},
{"anyOf": [{"type": "string"}, {"type": "null"}]},
{"type": ["string", "null"]},
{"type": "string", "minLength": 1},
{"type": "number", "minimum": 0},
{"type": "integer", "exclusiveMaximum": 10},
{"type": "string", "pattern": "^[A-Z]+$"},
{"type": "string", "format": "date-time"},
{"type": "string", "const": "fixed"},
{"type": "string", "x-custom-keyword": {"constraint": "custom"}},
{"type": "string", "allOf": [{"maxLength": 10}]},
{"description": "An unconstrained parameter"},
True,
False,
],
)
def test_format_tool_parameters_compact_falls_back_for_rich_properties(property_schema):
schema = {"type": "object", "properties": {"value": property_schema}, "required": ["value"]}

effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact")

assert effective_format == "json"
assert parameters == schema


@pytest.mark.parametrize(
"extra",
[
{"additionalProperties": False},
{"additionalProperties": {"type": "string"}},
{"$defs": {"Address": {"type": "string"}}},
{"oneOf": [{"required": ["value"]}, {"required": ["other"]}]},
{"dependentRequired": {"value": ["other"]}},
{"patternProperties": {"^x-": {"type": "integer"}}},
{"minProperties": 1},
{"x-custom-keyword": "preserve"},
{"required": ["unlisted"]},
{"required": "value"},
{"required": [1]},
{"properties": []},
{"properties": {1: {"type": "string"}}},
{"properties": {"value": {"type": "string", "required": True}}},
{"type": "array"},
],
)
def test_format_tool_parameters_compact_falls_back_for_root_constraints(extra):
schema = {"type": "object", "properties": {"value": {"type": "string"}}, **extra}

effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact")

assert effective_format == "json"
assert parameters == schema


@pytest.mark.parametrize("parameter_format", ["compact", "json"])
def test_format_tool_parameters_full_schema_result_is_detached(parameter_format):
schema = {
"type": "object",
"properties": {"value": {"$ref": "#/$defs/Value"}},
"$defs": {"Value": {"type": "string", "enum": ["one", "two"]}},
"required": ["value"],
"additionalProperties": False,
}
original = copy.deepcopy(schema)

effective_format, parameters = _format_tool_parameters(schema, parameter_format=parameter_format)

assert effective_format == "json"
assert parameters == original
parameters["$defs"]["Value"]["enum"].append("three")
assert schema == original


def test_format_tool_parameters_json_preserves_simple_schema():
schema = {"type": "object", "properties": {"value": {"type": "string", "title": "Value"}}}

assert _format_tool_parameters(schema, parameter_format="json") == ("json", schema)


@pytest.mark.parametrize("schema", [{}, {"properties": {"value": {"type": "string"}}}])
def test_format_tool_parameters_does_not_treat_unconstrained_schema_as_empty(schema):
assert _format_tool_parameters(schema, parameter_format="compact") == ("json", schema)


@pytest.mark.parametrize("parameter_format", ["other", "", None])
def test_format_tool_parameters_rejects_unknown_format(parameter_format):
with pytest.raises(ValueError, match="parameter_format"):
_format_tool_parameters({"type": "object", "properties": {}}, parameter_format=parameter_format)


async def test_sequential_function_invocation_runs_calls_in_model_order() -> None:
execution_order: list[str] = []

Expand Down
38 changes: 38 additions & 0 deletions python/packages/hyperlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,44 @@ codeact = HyperlightCodeActProvider(
)
```

### Sandbox tool parameter descriptions

Both `HyperlightExecuteCodeTool` and `HyperlightCodeActProvider` accept the
keyword-only `tool_description_format` option. The default, `"compact"`, includes
each parameter's scalar type, required/optional status, description, enum values,
and default when present. Use `"json"` to include the complete JSON Schema:

```python
execute_code = HyperlightExecuteCodeTool(
tools=[compute],
tool_description_format="json",
)

codeact = HyperlightCodeActProvider(
tools=[compute],
tool_description_format={"compute": "json", "send_email": "compact"},
)
```

A string applies to every registered tool. A mapping selects formats by exact,
case-sensitive tool name; missing names use `"compact"`. Mappings are copied at
construction and when creating run-scoped tools, and entries for unregistered
tools are retained for later registration.

Compact mode automatically falls back to full JSON Schema, with an explanatory
note, when a schema cannot be represented faithfully (for example, nested objects,
arrays, references, or additional constraints). No schema details are discarded.
Only `"compact"` and `"json"` are accepted; `None` is not supported.

Tool parameter schemas are model-visible metadata, just as they are for direct
function calling. Do not put credentials, tenant identifiers, or other secrets in
parameter descriptions, enum values, defaults, or custom schema fields.

This option affects `HyperlightExecuteCodeTool.description`, or the injected
run tool's `.description` when using `HyperlightCodeActProvider`. It does not
change the short CodeAct instructions, the `execute_code` input schema, sandbox
execution, or runtime caching.

### Output attachment limits

Files written under `/output` are returned as inline data attachments. Hyperlight
Expand Down
Loading
Loading