From df62c2a1a2cef36841efe5ae5e634109d2566ba0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 13:50:10 +0200 Subject: [PATCH 1/5] Python: expose CodeAct tool parameter schemas --- python/packages/core/AGENTS.md | 3 + .../packages/core/agent_framework/_tools.py | 56 +++++ python/packages/core/tests/core/test_tools.py | 154 ++++++++++++ python/packages/hyperlight/README.md | 34 +++ .../_execute_code_tool.py | 47 +++- .../_instructions.py | 44 +++- .../agent_framework_hyperlight/_provider.py | 15 +- .../hyperlight/test_hyperlight_codeact.py | 220 +++++++++++++++- python/packages/monty/AGENTS.md | 13 + python/packages/monty/README.md | 26 ++ .../_execute_code_tool.py | 43 +++- .../agent_framework_monty/_instructions.py | 47 +++- .../monty/agent_framework_monty/_provider.py | 13 +- .../monty/tests/monty/test_monty_codeact.py | 236 +++++++++++++++++- 14 files changed, 923 insertions(+), 28 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 0f2bdfc5ab9..d37fbb1859d 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -73,6 +73,9 @@ 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 +- **`_format_tool_parameters`** - Private structured parameter formatter shared by Hyperlight and Monty descriptions. + Returns the effective compact/JSON format and detached parameter data, falling back to full JSON Schema when + compact data cannot preserve constraints. Does not change `FunctionTool.parameters()` or render runtime-specific text. ### Vector stores diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a292383ac7a..2261768cdd1 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -400,6 +400,62 @@ def _annotation_includes_function_invocation_context(annotation: Any) -> bool: ) +def _format_tool_parameters( # pyright: ignore[reportUnusedFunction] + parameters: dict[str, Any], + *, + parameter_format: Literal["compact", "json"], +) -> tuple[Literal["compact", "json"], 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}, + } + + return "compact", compact + + ClassT = TypeVar("ClassT", bound="SerializationMixin") diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 89aa73486cc..2f39e265ee0 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import copy import logging import threading from typing import Annotated, Any, Literal, get_args, get_origin @@ -19,6 +20,7 @@ from agent_framework._middleware import FunctionInvocationContext from agent_framework._tools import ( _auto_invoke_function, + _format_tool_parameters, _parse_annotation, _parse_inputs, normalize_function_invocation_configuration, @@ -28,6 +30,158 @@ # region FunctionTool and tool decorator tests +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) + + def test_tool_decorator(): """Test the tool decorator.""" diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md index 02332e100f4..a27ac0248a8 100644 --- a/python/packages/hyperlight/README.md +++ b/python/packages/hyperlight/README.md @@ -118,6 +118,40 @@ 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. + +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 diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index 3618cc9e6b8..55ec0254df0 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -10,14 +10,14 @@ import stat import threading import time -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from copy import copy from dataclasses import dataclass from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory -from typing import Any, Protocol, TypeGuard, TypeVar, cast +from typing import Any, Literal, Protocol, TypeGuard, TypeVar, cast from urllib.parse import urlparse from agent_framework import Content, FunctionTool @@ -1505,13 +1505,43 @@ def _build_sandbox() -> tuple[Any, Any]: ) +def _normalize_tool_description_format( + value: object, +) -> Literal["compact", "json"] | dict[str, Literal["compact", "json"]]: + if isinstance(value, str): + if value not in ("compact", "json"): + raise ValueError("tool_description_format must be 'compact' or 'json'.") + return value + if not isinstance(value, Mapping): + raise TypeError("tool_description_format must be 'compact', 'json', or a mapping of tool names to formats.") + + result: dict[str, Literal["compact", "json"]] = {} + for name, parameter_format in cast(Mapping[object, object], value).items(): + if not isinstance(name, str): + raise TypeError("tool_description_format mapping keys must be tool name strings.") + if not isinstance(parameter_format, str): + raise TypeError(f"tool_description_format for {name!r} must be a string: 'compact' or 'json'.") + if parameter_format not in ("compact", "json"): + raise ValueError(f"tool_description_format for {name!r} must be 'compact' or 'json'.") + result[name] = parameter_format + return result + + class HyperlightExecuteCodeTool(FunctionTool): - """Execute Python code inside a Hyperlight sandbox.""" + """Execute Python code inside a Hyperlight sandbox. + + Keyword Args: + tool_description_format: Parameter documentation in ``.description``: ``"compact"`` (default) + or ``"json"``, globally or mapped by exact, case-sensitive tool name. Missing names use + compact format. Schemas that cannot be represented faithfully in compact form use JSON Schema. + Mappings are copied, including entries for tools registered later. + """ def __init__( self, *, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", approval_mode: ApprovalMode | None = None, workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, @@ -1524,6 +1554,7 @@ def __init__( module_path: str | None = None, _registry: SandboxRuntime | None = None, ) -> None: + normalized_description_format = _normalize_tool_description_format(tool_description_format) max_output_files = _validate_positive_integer(name="max_output_files", value=max_output_files) max_output_file_bytes = _validate_positive_integer(name="max_output_file_bytes", value=max_output_file_bytes) max_output_total_bytes = _validate_positive_integer(name="max_output_total_bytes", value=max_output_total_bytes) @@ -1535,6 +1566,9 @@ def __init__( input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._state_lock = threading.RLock() + self._tool_description_format: Literal["compact", "json"] | dict[str, Literal["compact", "json"]] = ( + normalized_description_format + ) self._registry = _registry or _SandboxRegistry() self._default_approval_mode: ApprovalMode = approval_mode or "never_require" self._workspace_root = _resolve_workspace_root(workspace_root) @@ -1571,6 +1605,7 @@ def description(self) -> str: workspace_enabled=self._workspace_root is not None, mounted_paths=[_display_mount_path(mount.mount_path) for mount in self._file_mounts.values()], allowed_domains=allowed_domains, + tool_description_format=self._tool_description_format, ) @description.setter @@ -1692,6 +1727,7 @@ def create_run_tool(self) -> HyperlightExecuteCodeTool: return HyperlightExecuteCodeTool( tools=self.get_tools(), + tool_description_format=self._tool_description_format, approval_mode=self._default_approval_mode, workspace_root=self._workspace_root, file_mounts=file_mounts or None, @@ -1717,6 +1753,11 @@ def build_serializable_state(self) -> dict[str, Any]: "max_output_file_bytes": config.max_output_file_bytes, "max_output_total_bytes": config.max_output_total_bytes, "tool_names": [tool_obj.name for tool_obj in config.tools], + "tool_description_format": ( + dict(self._tool_description_format) + if isinstance(self._tool_description_format, dict) + else self._tool_description_format + ), "filesystem_enabled": config.filesystem_enabled, "workspace_root": str(config.workspace_root) if config.workspace_root is not None else None, "file_mounts": [ diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py index c44a1830626..919afc6fc30 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py @@ -2,24 +2,53 @@ from __future__ import annotations -from collections.abc import Sequence +import json +from collections.abc import Mapping, Sequence +from typing import Literal from agent_framework import FunctionTool +from agent_framework._tools import _format_tool_parameters # pyright: ignore[reportPrivateUsage] from ._types import AllowedDomain -def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str: +def _format_tool_summaries( + tools: Sequence[FunctionTool], + *, + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", +) -> str: if not tools: return "- No tools are currently registered inside the sandbox." lines: list[str] = [] for tool_obj in tools: - parameters = tool_obj.parameters().get("properties", {}) - parameter_names = [name for name in parameters if isinstance(name, str)] - parameter_summary = ", ".join(parameter_names) if parameter_names else "none" + requested_format = ( + tool_description_format + if isinstance(tool_description_format, str) + else tool_description_format.get(tool_obj.name, "compact") + ) + effective_format, parameters = _format_tool_parameters(tool_obj.parameters(), parameter_format=requested_format) description = str(tool_obj.description or "").strip() or "No description provided." - lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.") + lines.append(f"- `{tool_obj.name}`: {description}") + if effective_format == "json": + if requested_format == "compact": + lines.append( + " Using JSON Schema because the parameter schema cannot be represented faithfully in compact form." + ) + lines.extend([" Parameters (JSON Schema):", "```json", json.dumps(parameters, indent=2), "```"]) + elif not parameters: + lines.append(" Parameters: none.") + else: + for name, parameter in parameters.items(): + requirement = "required" if parameter["required"] else "optional" + line = f" - `{name}` ({parameter['type']}, {requirement})" + if parameter.get("description"): + line += f": {parameter['description']}" + if "enum" in parameter: + line += f" Allowed values: {json.dumps(parameter['enum'])}." + if "default" in parameter: + line += f" Default: {json.dumps(parameter['default'])}." + lines.append(line) return "\n".join(lines) @@ -107,6 +136,7 @@ def build_execute_code_description( workspace_enabled: bool, mounted_paths: Sequence[str], allowed_domains: Sequence[AllowedDomain], + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", ) -> str: """Build the dynamic execute_code tool description for standalone usage.""" filesystem_text = _format_filesystem_capabilities( @@ -126,7 +156,7 @@ def build_execute_code_description( tool name. Registered sandbox tools: -{_format_tool_summaries(tools)} +{_format_tool_summaries(tools, tool_description_format=tool_description_format)} Filesystem capabilities: {filesystem_text} diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py index 7d26f295ef8..63394115299 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Any +from typing import Any, Literal from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext from agent_framework._telemetry import mark_feature_used @@ -22,7 +22,14 @@ class HyperlightCodeActProvider(ContextProvider): - """Inject a Hyperlight-backed CodeAct surface using provider-owned tools.""" + """Inject a Hyperlight-backed CodeAct surface using provider-owned tools. + + Keyword Args: + tool_description_format: Parameter documentation in the injected tool's ``.description``: + ``"compact"`` (default) or ``"json"``, globally or mapped by exact, case-sensitive tool name. + Missing names use compact format; rich schemas fall back to full JSON Schema. + Mappings are copied, including entries for tools registered later. + """ DEFAULT_SOURCE_ID = "hyperlight_codeact" @@ -31,6 +38,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, *, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", approval_mode: ApprovalMode | None = None, workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, @@ -46,6 +54,7 @@ def __init__( super().__init__(source_id) self._execute_code_tool = HyperlightExecuteCodeTool( tools=tools, + tool_description_format=tool_description_format, approval_mode=approval_mode, workspace_root=workspace_root, file_mounts=file_mounts, diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index c9abad2af7b..b9ed18feceb 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -17,10 +17,12 @@ import threading import time from collections.abc import Awaitable, Callable, Coroutine, Generator, Mapping, Sequence +from copy import deepcopy from dataclasses import dataclass from pathlib import Path from tempfile import TemporaryDirectory -from typing import Any, cast +from types import MappingProxyType +from typing import Any, Literal, cast from unittest.mock import patch import pytest @@ -1880,6 +1882,222 @@ def test_execute_code_tool_description_contains_call_tool_guidance(tmp_path: Pat assert "github.com" in description +@pytest.fixture +def documented_tool() -> FunctionTool: + return FunctionTool( + name="lookup", + description="Look up an item.", + func=lambda **kwargs: kwargs, + input_model={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search text"}, + "limit": {"type": "integer", "description": "Maximum results", "default": 10}, + "order": {"type": "string", "enum": ["ascending", "descending"], "default": "ascending"}, + }, + "required": ["query"], + }, + ) + + +@pytest.mark.parametrize( + "mode", + ["compact", {}, {"lookup": "compact"}, {"LOOKUP": "json"}, MappingProxyType({"inactive": "json"})], +) +def test_description_format_compact_preserves_parameter_metadata(documented_tool: FunctionTool, mode: Any) -> None: + execute_code = HyperlightExecuteCodeTool( + tools=[documented_tool], tool_description_format=mode, _registry=_FakeRuntime() + ) + description = execute_code.description + + assert "- `lookup`: Look up an item." in description + assert "- `query` (string, required): Search text" in description + assert "- `limit` (integer, optional): Maximum results Default: 10." in description + expected_order = '- `order` (string, optional) Allowed values: ["ascending", "descending"]. Default: "ascending".' + assert expected_order in description + assert "Parameters (JSON Schema)" not in description + assert execute_code.to_dict()["description"] == description + assert execute_code.parameters() == execute_code_module.EXECUTE_CODE_INPUT_SCHEMA + + +@pytest.mark.parametrize("mode", ["json", {"lookup": "json"}]) +@pytest.mark.parametrize("rich_schema", [False, True]) +def test_description_format_json_preserves_full_schema( + documented_tool: FunctionTool, mode: Any, rich_schema: bool +) -> None: + schema = deepcopy(documented_tool.parameters()) + if rich_schema: + schema["additionalProperties"] = False + schema["properties"]["filters"] = {"type": "array", "items": {"type": "integer", "minimum": 1}} + registered_tool = FunctionTool(name="lookup", description="", func=lambda **kwargs: kwargs, input_model=schema) + original_schema = deepcopy(registered_tool.parameters()) + execute_code = HyperlightExecuteCodeTool( + tools=[registered_tool], tool_description_format=mode, _registry=_FakeRuntime() + ) + + description = execute_code.description + rendered_schema = json.loads(description.split("```json\n", 1)[1].split("\n```", 1)[0]) + assert rendered_schema == original_schema + assert "Parameters (JSON Schema)" in description + assert "cannot be represented faithfully" not in description + assert registered_tool.parameters() == original_schema + assert execute_code.to_dict()["description"] == description + + +def test_description_format_rich_schema_falls_back_without_mutation() -> None: + schema = { + "type": "object", + "properties": { + "record": { + "type": "object", + "properties": {"ids": {"type": "array", "items": {"type": "integer"}}}, + "required": ["ids"], + } + }, + "required": ["record"], + "additionalProperties": False, + } + original_schema = deepcopy(schema) + registered_tool = FunctionTool(name="nested", description="", func=lambda **kwargs: kwargs, input_model=schema) + execute_code = HyperlightExecuteCodeTool(tools=[registered_tool], _registry=_FakeRuntime()) + + description = execute_code.description + assert ( + "Using JSON Schema because the parameter schema cannot be represented faithfully in compact form." + in description + ) + rendered_schema = json.loads(description.split("```json\n", 1)[1].split("\n```", 1)[0]) + assert rendered_schema == registered_tool.parameters() == original_schema + assert schema == original_schema + assert execute_code.to_dict()["description"] == description + assert execute_code.build_serializable_state()["tool_description_format"] == "compact" + + +def test_description_format_mixed_mapping_and_dynamic_registration(documented_tool: FunctionTool) -> None: + modes: dict[str, Literal["compact", "json"]] = {"lookup": "json", "compute": "compact", "future": "json"} + execute_code = HyperlightExecuteCodeTool(tools=[compute], tool_description_format=modes, _registry=_FakeRuntime()) + run_tool = execute_code.create_run_tool() + assert execute_code._tool_description_format is not modes + assert run_tool._tool_description_format is not execute_code._tool_description_format + modes["lookup"] = "compact" + execute_code.add_tools([documented_tool, dangerous_compute]) + + description = execute_code.description + assert description.count("Parameters (JSON Schema)") == 1 + assert "- `a` (integer, required)" in description + assert "- `dangerous_compute`: No description provided." in description + assert "lookup" not in run_tool.description + run_tool.add_tools(documented_tool) + assert "Parameters (JSON Schema)" in run_tool.description + execute_code.remove_tool("lookup") + execute_code.add_tools(documented_tool) + assert "Parameters (JSON Schema)" in execute_code.description + state = execute_code.build_serializable_state() + assert state["tool_description_format"] == {"lookup": "json", "compute": "compact", "future": "json"} + state["tool_description_format"]["lookup"] = "compact" + assert "Parameters (JSON Schema)" in execute_code.description + assert json.loads(json.dumps(run_tool.build_serializable_state()))["tool_description_format"]["future"] == "json" + + +@pytest.mark.parametrize("mode", ["compact", "json", {"compute": "json", "future": "compact"}]) +async def test_description_format_provider_run_state_and_snapshot(mode: Any) -> None: + provider = HyperlightCodeActProvider(tools=[compute], tool_description_format=mode, _registry=_FakeRuntime()) + expected_mode = deepcopy(mode) + if isinstance(mode, dict): + mode["compute"] = "compact" + context = _FakeSessionContext() + state: dict[str, Any] = {} + + await provider.before_run(agent=object(), session=None, context=cast(Any, context), state=state) + run_tool = context.tools[0][1][0] + assert isinstance(run_tool, HyperlightExecuteCodeTool) + assert state[provider.source_id]["tool_description_format"] == expected_mode + assert run_tool.build_serializable_state() == state[provider.source_id] + assert run_tool.to_dict()["description"] == run_tool.description + assert ("Parameters (JSON Schema)" in run_tool.description) == (expected_mode != "compact") + assert "Parameters (JSON Schema)" not in context.instructions[0][1] + assert "compute" not in context.instructions[0][1] + assert run_tool._tool_description_format == provider._execute_code_tool._tool_description_format + if isinstance(expected_mode, dict): + assert run_tool._tool_description_format is not provider._execute_code_tool._tool_description_format + provider.clear_tools() + assert "- `compute`:" in run_tool.description + assert "No tools are currently registered" in provider._execute_code_tool.description + json.dumps(state) + + +@pytest.mark.parametrize( + ("mode", "error_type"), + [ + ("verbose", ValueError), + ("JSON", ValueError), + (" compact", ValueError), + ({"inactive": "verbose"}, ValueError), + (None, TypeError), + (1, TypeError), + (["compact"], TypeError), + ({1: "json"}, TypeError), + ({"compute": None}, TypeError), + ({"inactive": 1}, TypeError), + ], +) +@pytest.mark.parametrize("entry_point", [HyperlightExecuteCodeTool, HyperlightCodeActProvider]) +def test_description_format_rejects_invalid_inputs(mode: Any, error_type: type[Exception], entry_point: Any) -> None: + with pytest.raises(error_type, match="tool_description_format"): + entry_point(tool_description_format=mode, _registry=_FakeRuntime()) + + +def test_description_format_defaults_no_tools_and_zero_parameters() -> None: + execute_code = HyperlightExecuteCodeTool(_registry=_FakeRuntime()) + assert "- No tools are currently registered inside the sandbox." in execute_code.description + assert execute_code.build_serializable_state()["tool_description_format"] == "compact" + + execute_code.add_tools( + FunctionTool(name="noop", description="", func=lambda: None, input_model={"type": "object", "properties": {}}) + ) + assert "- `noop`: No description provided.\n Parameters: none." in execute_code.description + assert "call_tool(name, **kwargs)" in execute_code.description + assert "arguments only. Do not pass a dict or any other positional arguments" in execute_code.description + assert "Filesystem access is unavailable" in execute_code.description + assert "Outbound network access is unavailable" in execute_code.description + + +async def test_description_format_does_not_change_runtime_config_or_input_schema() -> None: + runtime = _FakeRuntime() + compact_tool = HyperlightExecuteCodeTool(tools=[compute], _registry=runtime) + json_tool = HyperlightExecuteCodeTool(tools=[compute], tool_description_format="json", _registry=runtime) + for execute_code in (compact_tool, json_tool): + result = await execute_code.invoke(arguments={"code": "print(42)"}) + assert result[0].text == "ok" + assert execute_code.parameters() == execute_code_module.EXECUTE_CODE_INPUT_SCHEMA + compact_config, compact_code = runtime.calls[0] + json_config, json_code = runtime.calls[1] + assert compact_config == json_config + assert compact_config.cache_key() == json_config.cache_key() + assert not hasattr(compact_config, "tool_description_format") + assert compact_code == json_code == "print(42)" + assert compact_tool.build_instructions(tools_visible_to_model=False) == json_tool.build_instructions( + tools_visible_to_model=False + ) + + +async def test_description_format_reuses_sandbox_cache(monkeypatch: pytest.MonkeyPatch) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + registry = execute_code_module._SandboxRegistry() + compact_tool = HyperlightExecuteCodeTool(tools=[compute], _registry=registry) + json_tool = HyperlightExecuteCodeTool(tools=[compute], tool_description_format="json", _registry=registry) + + try: + for execute_code in (compact_tool, json_tool): + await execute_code.invoke(arguments={"code": "None"}) + assert len(_FakeSandbox.instances) == 1 + assert set(_FakeSandbox.instances[0].registered_tools) == {"compute"} + assert _FakeSandbox.instances[0].restore_calls == ["snapshot", "snapshot"] + finally: + registry.close() + + async def test_execute_code_tool_executes_with_structured_content(monkeypatch: pytest.MonkeyPatch) -> None: _FakeSandbox.instances.clear() monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) diff --git a/python/packages/monty/AGENTS.md b/python/packages/monty/AGENTS.md index 67c3f45d8cb..e366b78750b 100644 --- a/python/packages/monty/AGENTS.md +++ b/python/packages/monty/AGENTS.md @@ -36,6 +36,19 @@ from agent_framework.monty import ( - `file_mounts` — sequence of `FileMountInput` (str shorthand, `(host_path, mount_path)` tuple, or `FileMount`) - `resource_limits` — Monty `ResourceLimits` TypedDict +- `tool_description_format` — keyword-only + `Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact"`. + Selects parameter documentation in both the description and instructions. + Compact includes scalar types, required/optional status, descriptions, enums, + and defaults; schemas that cannot be represented faithfully fall back to full + JSON Schema with an explanatory note. `"json"` always includes the full schema. + Mappings use exact, case-sensitive tool names, with compact for missing names; + mappings are copied on construction and per-run snapshots, retaining inactive + names for dynamic registration. Unsupported choices raise `ValueError`; invalid + input types (including `None`), non-string keys, and non-string choices raise + `TypeError`. Serializable state stores the configured string or mapping as + `tool_description_format`. This affects documentation only, not type checking + or invocation. Tool-management methods on both classes: `add_tools`, `get_tools`, `remove_tool`, `clear_tools`. Mount-management methods: `add_file_mounts`, diff --git a/python/packages/monty/README.md b/python/packages/monty/README.md index d6f5a8475a4..d1c2bc3e606 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -105,6 +105,32 @@ agent = Agent( ) ``` +### Tool parameter documentation + +Both `MontyCodeActProvider` and `MontyExecuteCodeTool` accept the keyword-only +`tool_description_format` parameter. It controls the registered tools' parameter +documentation in both the `execute_code` description and CodeAct instructions: + +- `"compact"` (default) lists scalar parameter types, required/optional status, + descriptions, enum choices, and defaults. +- `"json"` includes the complete parameter JSON Schema. +- A mapping such as `{"compute": "json", "send_email": "compact"}` selects formats + by exact, case-sensitive tool name. Names missing from the mapping use compact. + +Compact automatically falls back to complete JSON Schema, with an explanatory +note, for schemas it cannot represent faithfully, such as nested objects, arrays, +references, unions, or additional constraints. Parameter schemas and runtime +type checking are not changed. + +Mappings are copied at construction and for each run snapshot. Entries for +currently unregistered tools are retained for later `add_tools` calls, including +after removal or clearing of the registry. State snapshots include the configured +string or mapping in `tool_description_format`. + +Only `"compact"`, `"json"`, or mappings from string names to these values are +accepted: unsupported choices raise `ValueError`; `None`, invalid input types, +non-string mapping keys, and non-string choices raise `TypeError`. + ### Host tool lifetime Registered `FunctionTool` instances retain their invocation and exception counters diff --git a/python/packages/monty/agent_framework_monty/_execute_code_tool.py b/python/packages/monty/agent_framework_monty/_execute_code_tool.py index 18e0ab85bd0..a818dfb9246 100644 --- a/python/packages/monty/agent_framework_monty/_execute_code_tool.py +++ b/python/packages/monty/agent_framework_monty/_execute_code_tool.py @@ -14,10 +14,10 @@ import json import mimetypes -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from functools import partial from pathlib import Path, PurePosixPath -from typing import Any, cast +from typing import Any, Literal, cast from agent_framework import Content, FunctionTool from agent_framework._tools import ApprovalMode, normalize_tools @@ -68,6 +68,27 @@ def _collect_tools(*tool_groups: Any) -> list[FunctionTool]: return list(tools_by_name.values()) +def _normalize_tool_description_format( + value: object, +) -> Literal["compact", "json"] | dict[str, Literal["compact", "json"]]: + if isinstance(value, str): + if value not in ("compact", "json"): + raise ValueError("tool_description_format must be 'compact', 'json', or a mapping of tool names to these.") + return value + if not isinstance(value, Mapping): + raise TypeError("tool_description_format must be a string or a mapping of tool names to 'compact' or 'json'.") + normalized: dict[str, Literal["compact", "json"]] = {} + 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 _resolve_execute_code_approval_mode( *, base_approval_mode: ApprovalMode, @@ -186,6 +207,12 @@ class MontyExecuteCodeTool(FunctionTool): ``resource_limits`` is forwarded to Monty's ``ResourceLimits`` to cap CPU time, memory, output size, recursion depth, and GC frequency. + ``tool_description_format`` controls parameter documentation in both the + description and instructions: ``"compact"`` (default) or ``"json"`` globally, + or a mapping of exact, case-sensitive tool names to either format. Missing + names use compact; rich schemas fall back to full JSON Schema. Mappings + are copied and retain entries for tools registered later. + All mutators (``add_tools``, ``add_file_mounts`` etc.) must be called from the same task/thread that owns the tool. Monty itself runs on the event loop, so no internal locking is needed. @@ -199,6 +226,7 @@ def __init__( workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, resource_limits: dict[str, Any] | None = None, + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", ) -> None: super().__init__( name=EXECUTE_CODE_TOOL_NAME, @@ -208,6 +236,9 @@ def __init__( input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._default_approval_mode: ApprovalMode = approval_mode or "never_require" + self._tool_description_format: Literal["compact", "json"] | dict[str, Literal["compact", "json"]] = ( + _normalize_tool_description_format(tool_description_format) + ) self._managed_tools: list[FunctionTool] = [] self._workspace_root: Path | None = ( _resolve_existing_directory(workspace_root) if workspace_root is not None else None @@ -230,6 +261,7 @@ def description(self) -> str: return build_execute_code_description( tools=self._managed_tools, mounts=self._effective_mounts(), + tool_description_format=self._tool_description_format, ) @description.setter @@ -307,6 +339,7 @@ def build_instructions(self, *, tools_visible_to_model: bool) -> str: tools=list(self._managed_tools), tools_visible_to_model=tools_visible_to_model, mounts=self._effective_mounts(), + tool_description_format=self._tool_description_format, ) def create_run_tool(self) -> MontyExecuteCodeTool: @@ -317,6 +350,7 @@ def create_run_tool(self) -> MontyExecuteCodeTool: workspace_root=self._workspace_root, file_mounts=list(self._file_mounts.values()) or None, resource_limits=self._resource_limits, + tool_description_format=self._tool_description_format, ) def build_serializable_state(self) -> dict[str, Any]: @@ -330,6 +364,11 @@ def build_serializable_state(self) -> dict[str, Any]: "runtime": "monty", "approval_mode": approval_mode, "tool_names": [tool_obj.name for tool_obj in self._managed_tools], + "tool_description_format": ( + dict(self._tool_description_format) + if isinstance(self._tool_description_format, dict) + else self._tool_description_format + ), "workspace_root": str(self._workspace_root) if self._workspace_root is not None else None, "file_mounts": [ { diff --git a/python/packages/monty/agent_framework_monty/_instructions.py b/python/packages/monty/agent_framework_monty/_instructions.py index c560e356d31..6503b556ad6 100644 --- a/python/packages/monty/agent_framework_monty/_instructions.py +++ b/python/packages/monty/agent_framework_monty/_instructions.py @@ -4,24 +4,53 @@ from __future__ import annotations -from collections.abc import Sequence +import json +from collections.abc import Mapping, Sequence +from typing import Literal from agent_framework import FunctionTool +from agent_framework._tools import _format_tool_parameters # pyright: ignore[reportPrivateUsage] from ._types import FileMount -def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str: +def _format_tool_summaries( + tools: Sequence[FunctionTool], + *, + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", +) -> str: if not tools: return "- No tools are currently registered." lines: list[str] = [] for tool_obj in tools: - parameters = tool_obj.parameters().get("properties", {}) - parameter_names = [name for name in parameters if isinstance(name, str)] - parameter_summary = ", ".join(parameter_names) if parameter_names else "none" + requested_format = ( + tool_description_format + if isinstance(tool_description_format, str) + else tool_description_format.get(tool_obj.name, "compact") + ) + effective_format, parameters = _format_tool_parameters(tool_obj.parameters(), parameter_format=requested_format) description = str(tool_obj.description or "").strip() or "No description provided." - lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.") + lines.append(f"- `{tool_obj.name}`: {description}") + if effective_format == "json": + if requested_format == "compact": + lines.append( + " Using JSON Schema because the parameter schema cannot be represented faithfully in compact form." + ) + lines.extend([" Parameters (JSON Schema):", " ```json", json.dumps(parameters, indent=2), " ```"]) + elif not parameters: + lines.append(" Parameters: none.") + else: + for name, parameter in parameters.items(): + required = "required" if parameter["required"] else "optional" + line = f" - `{name}` ({parameter['type']}, {required})" + if parameter.get("description"): + line += f": {parameter['description']}" + if "enum" in parameter: + line += f" Allowed values: {json.dumps(parameter['enum'])}." + if "default" in parameter: + line += f" Default: {json.dumps(parameter['default'])}." + lines.append(line) return "\n".join(lines) @@ -56,9 +85,10 @@ def build_codeact_instructions( tools: Sequence[FunctionTool], tools_visible_to_model: bool, mounts: Sequence[FileMount] = (), + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", ) -> str: """Build dynamic CodeAct instructions for the effective Monty tool set.""" - tool_summaries = _format_tool_summaries(tools) + tool_summaries = _format_tool_summaries(tools, tool_description_format=tool_description_format) filesystem_text = _format_filesystem_capabilities(mounts) usage_note = ( @@ -99,9 +129,10 @@ def build_execute_code_description( *, tools: Sequence[FunctionTool], mounts: Sequence[FileMount] = (), + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", ) -> str: """Build the dynamic ``execute_code`` tool description for standalone usage.""" - tool_summaries = _format_tool_summaries(tools) + tool_summaries = _format_tool_summaries(tools, tool_description_format=tool_description_format) filesystem_text = _format_filesystem_capabilities(mounts) return f"""Execute Python code in a Monty interpreter. diff --git a/python/packages/monty/agent_framework_monty/_provider.py b/python/packages/monty/agent_framework_monty/_provider.py index 6e45e958a19..51ababa5401 100644 --- a/python/packages/monty/agent_framework_monty/_provider.py +++ b/python/packages/monty/agent_framework_monty/_provider.py @@ -4,9 +4,9 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Any +from typing import Any, Literal from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext from agent_framework._telemetry import mark_feature_used @@ -24,6 +24,13 @@ class MontyCodeActProvider(ContextProvider): the subset of capabilities that apply to the Monty interpreter: ``tools``, ``approval_mode``, ``workspace_root``, ``file_mounts``, and ``resource_limits`` (Monty-only). + + ``tool_description_format`` controls parameter documentation in both + injected instructions and the tool description. Pass ``"compact"`` (the + default) or ``"json"`` globally, or a mapping of exact, case-sensitive tool + names to either format. Missing names use compact; rich schemas fall back + to full JSON Schema. Mappings are copied on construction and for each run, + retaining entries for tools registered later. """ DEFAULT_SOURCE_ID = "monty_codeact" @@ -37,6 +44,7 @@ def __init__( workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, resource_limits: dict[str, Any] | None = None, + tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", ) -> None: super().__init__(source_id) self._execute_code_tool = MontyExecuteCodeTool( @@ -45,6 +53,7 @@ def __init__( workspace_root=workspace_root, file_mounts=file_mounts, resource_limits=resource_limits, + tool_description_format=tool_description_format, ) def add_tools( diff --git a/python/packages/monty/tests/monty/test_monty_codeact.py b/python/packages/monty/tests/monty/test_monty_codeact.py index 35548130ae4..db1fc8c6750 100644 --- a/python/packages/monty/tests/monty/test_monty_codeact.py +++ b/python/packages/monty/tests/monty/test_monty_codeact.py @@ -11,12 +11,13 @@ import asyncio import json +import re import sys import types -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import Annotated, Any +from typing import Annotated, Any, Literal from unittest.mock import MagicMock import pytest @@ -307,6 +308,194 @@ def test_dynamic_description_reflects_registered_tools() -> None: assert "mul_tool" in description_updated +@pytest.fixture +def documented_tool() -> FunctionTool: + return FunctionTool( + name="documented", + description="Documented scalar parameters.", + func=lambda **kwargs: kwargs, + input_model={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search text."}, + "count": {"type": "integer", "description": "Result count.", "default": 0}, + "enabled": {"type": "boolean", "default": False}, + "ratio": {"type": "number", "enum": [0.5, 1.0], "default": 0.5}, + "empty": {"type": "null", "default": None}, + }, + "required": ["query"], + }, + ) + + +def _json_schemas(text: str) -> list[dict[str, Any]]: + return [json.loads(schema) for schema in re.findall(r"```json\n(.*?)\n\s*```", text, re.DOTALL)] + + +@pytest.mark.parametrize("tools_visible_to_model", [False, True]) +@pytest.mark.parametrize("tool_description_format", ["compact", {}, {"documented": "compact"}]) +def test_compact_parameter_documentation( + documented_tool: FunctionTool, + tools_visible_to_model: bool, + tool_description_format: Any, +) -> None: + monty_tool = MontyExecuteCodeTool(tools=[documented_tool], tool_description_format=tool_description_format) + instructions = monty_tool.build_instructions(tools_visible_to_model=tools_visible_to_model) + expected_lines = [ + "- `documented`: Documented scalar parameters.", + "- `query` (string, required): Search text.", + "- `count` (integer, optional): Result count. Default: 0.", + "- `enabled` (boolean, optional) Default: false.", + "- `ratio` (number, optional) Allowed values: [0.5, 1.0]. Default: 0.5.", + "- `empty` (null, optional) Default: null.", + ] + for text in (monty_tool.description, instructions): + for line in expected_lines: + assert line in text + assert "JSON Schema" not in text + assert "await tool_name(param=value)" in text + assert "await call_tool('name', **kwargs)" in text + assert "asyncio.gather" in text + assert "type-checked" in text + usage_note = ( + "Some tools may also appear directly" + if tools_visible_to_model + else "Provider-owned sandbox tools are not exposed separately" + ) + assert usage_note in instructions + assert monty_tool.to_dict()["description"] == monty_tool.description + assert MontyExecuteCodeTool(tools=[documented_tool]).description == monty_tool.description + + +@pytest.mark.parametrize("tools_visible_to_model", [False, True]) +@pytest.mark.parametrize( + ("tool_description_format", "json_tool_names"), + [ + ("json", ["documented", "add_tool"]), + ({"documented": "json", "add_tool": "compact"}, ["documented"]), + ({"documented": "json"}, ["documented"]), + ({"Documented": "json", "unused": "json"}, []), + ], +) +def test_parameter_documentation_formats( + documented_tool: FunctionTool, + tools_visible_to_model: bool, + tool_description_format: Any, + json_tool_names: list[str], +) -> None: + tools = [documented_tool, add_tool] + monty_tool = MontyExecuteCodeTool(tools=tools, tool_description_format=tool_description_format) + expected = [registered.parameters() for registered in tools if registered.name in json_tool_names] + for text in ( + monty_tool.description, + monty_tool.build_instructions(tools_visible_to_model=tools_visible_to_model), + ): + assert _json_schemas(text) == expected + assert text.count("Parameters (JSON Schema)") == len(expected) + assert "Using JSON Schema because" not in text + if "add_tool" not in json_tool_names: + assert "- `a` (integer, required): First addend" in text + if "documented" not in json_tool_names: + assert "- `query` (string, required): Search text." in text + assert monty_tool.to_dict()["description"] == monty_tool.description + state = monty_tool.build_serializable_state() + assert state["tool_description_format"] == tool_description_format + assert json.loads(json.dumps(state)) == state + + +@pytest.mark.parametrize("tool_description_format", ["compact", "json", {"rich": "compact"}, {"rich": "json"}]) +def test_rich_parameter_schema_is_preserved(tool_description_format: Any) -> None: + schema: dict[str, Any] = { + "type": "object", + "properties": { + "items": {"type": "array", "items": {"$ref": "#/$defs/Entry"}, "minItems": 1}, + "limit": {"type": "integer", "minimum": 1, "default": 2}, + }, + "$defs": {"Entry": {"type": "object", "properties": {"value": {"type": "string"}}}}, + "required": ["items"], + "additionalProperties": False, + } + rich = FunctionTool(name="rich", description="Rich schema.", func=lambda **kwargs: kwargs, input_model=schema) + monty_tool = MontyExecuteCodeTool(tools=[rich], tool_description_format=tool_description_format) + requested = tool_description_format if isinstance(tool_description_format, str) else tool_description_format["rich"] + for text in ( + monty_tool.description, + monty_tool.build_instructions(tools_visible_to_model=False), + monty_tool.build_instructions(tools_visible_to_model=True), + ): + assert _json_schemas(text) == [rich.parameters()] + assert rich.parameters() == schema + assert ( + "Using JSON Schema because the parameter schema cannot be represented faithfully in compact form." in text + ) == (requested == "compact") + assert monty_tool.to_dict()["description"] == monty_tool.description + + +@pytest.mark.parametrize("constructor", [MontyExecuteCodeTool, MontyCodeActProvider]) +@pytest.mark.parametrize( + ("value", "error"), + [ + ("unknown", ValueError), + ("JSON", ValueError), + (" compact", ValueError), + ({"inactive": "invalid"}, ValueError), + ({"inactive": "JSON"}, ValueError), + (None, TypeError), + (1, TypeError), + (False, TypeError), + (["compact"], TypeError), + ([("name", "json")], TypeError), + ({1: "json"}, TypeError), + ({"name": None}, TypeError), + ({"name": 1}, TypeError), + ({"name": ["json"]}, TypeError), + ], +) +def test_tool_description_format_validation(constructor: Any, value: Any, error: type[Exception]) -> None: + with pytest.raises(error, match="tool_description_format"): + constructor(tool_description_format=value) + + +def test_description_format_mapping_is_copied_and_retained_for_dynamic_tools() -> None: + formats: dict[str, Literal["compact", "json"]] = {"add_tool": "json", "mul_tool": "json"} + monty_tool = MontyExecuteCodeTool(tools=[add_tool], tool_description_format=types.MappingProxyType(formats)) + run_tool = monty_tool.create_run_tool() + snapshot_description = run_tool.description + snapshot_instructions = run_tool.build_instructions(tools_visible_to_model=False) + formats.clear() + state = monty_tool.build_serializable_state() + state["tool_description_format"].clear() + assert monty_tool.build_serializable_state()["tool_description_format"] == {"add_tool": "json", "mul_tool": "json"} + assert run_tool._tool_description_format is not monty_tool._tool_description_format + monty_tool.add_tools(mul_tool) + assert _json_schemas(monty_tool.description) == [add_tool.parameters(), mul_tool.parameters()] + monty_tool.remove_tool("add_tool") + assert "- `add_tool`:" not in monty_tool.description + monty_tool.clear_tools() + assert "No tools are currently registered." in monty_tool.description + monty_tool.add_tools(add_tool) + assert _json_schemas(monty_tool.description) == [add_tool.parameters()] + assert run_tool.description == snapshot_description + assert run_tool.build_instructions(tools_visible_to_model=False) == snapshot_instructions + + +@pytest.mark.parametrize("tool_description_format", ["compact", "json", {}]) +def test_empty_registry_and_zero_parameter_documentation(tool_description_format: Any) -> None: + monty_tool = MontyExecuteCodeTool(tool_description_format=tool_description_format) + assert "- No tools are currently registered." in monty_tool.description + assert "- No tools are currently registered." in monty_tool.build_instructions(tools_visible_to_model=False) + empty = FunctionTool( + name="empty", description="", func=lambda: None, input_model={"type": "object", "properties": {}} + ) + monty_tool.add_tools(empty) + for text in (monty_tool.description, monty_tool.build_instructions(tools_visible_to_model=True)): + assert "- `empty`: No description provided." in text + if tool_description_format == "json": + assert _json_schemas(text) == [empty.parameters()] + else: + assert "Parameters: none." in text + + def test_create_run_tool_snapshots_current_state() -> None: monty_tool = MontyExecuteCodeTool(tools=[add_tool], approval_mode="never_require") run_tool = monty_tool.create_run_tool() @@ -329,6 +518,7 @@ def test_build_serializable_state_matches_effective_config() -> None: assert state["workspace_root"] is None assert state["file_mounts"] == [] assert state["resource_limits"] is None + assert state["tool_description_format"] == "compact" def test_file_mounts_normalized_and_round_tripped(tmp_path: Path) -> None: @@ -726,6 +916,48 @@ async def test_provider_injects_execute_code_tool_and_instructions() -> None: assert context.tools[0] is not provider._execute_code_tool # type: ignore[attr-defined] +@pytest.mark.parametrize("tool_description_format", ["compact", "json", {"add_tool": "json"}]) +async def test_provider_parameter_documentation_and_run_state(tool_description_format: Any) -> None: + provider = MontyCodeActProvider(tools=[add_tool, mul_tool], tool_description_format=tool_description_format) + context = SessionContext(input_messages=[]) + state: dict[str, Any] = {} + await provider.before_run(agent=MagicMock(), session=None, context=context, state=state) + run_tool = context.tools[0] + assert isinstance(run_tool, MontyExecuteCodeTool) + instructions = "\n".join(context.instructions) + expected_registry = instructions_module._format_tool_summaries( + [add_tool, mul_tool], tool_description_format=tool_description_format + ) + assert expected_registry in run_tool.description + assert expected_registry in instructions + assert "Provider-owned sandbox tools are not exposed separately" in instructions + assert state["monty_codeact"]["tool_description_format"] == tool_description_format + assert run_tool.to_dict()["description"] == run_tool.description + + +async def test_provider_copies_format_mapping_and_snapshots_dynamic_registry() -> None: + formats: dict[str, Literal["compact", "json"]] = {"add_tool": "json", "mul_tool": "json"} + configuration: Mapping[str, Literal["compact", "json"]] = types.MappingProxyType(formats) + provider = MontyCodeActProvider(tools=[add_tool], tool_description_format=configuration) + formats["add_tool"] = "compact" + first = SessionContext(input_messages=[]) + await provider.before_run(agent=MagicMock(), session=None, context=first, state={}) + first_tool = first.tools[0] + assert isinstance(first_tool, MontyExecuteCodeTool) + first_description = first_tool.description + assert _json_schemas(first_description) == [add_tool.parameters()] + provider.clear_tools() + provider.add_tools([mul_tool]) + second = SessionContext(input_messages=[]) + state: dict[str, Any] = {} + await provider.before_run(agent=MagicMock(), session=None, context=second, state=state) + second_tool = second.tools[0] + assert isinstance(second_tool, MontyExecuteCodeTool) + assert _json_schemas(second_tool.description) == [mul_tool.parameters()] + assert state["monty_codeact"]["tool_description_format"] == {"add_tool": "json", "mul_tool": "json"} + assert first_tool.description == first_description + + def test_provider_delegates_tool_management_to_internal_tool() -> None: provider = MontyCodeActProvider() provider.add_tools([add_tool, mul_tool]) From 19934609925b970338fad2bd7cba5a23f91d59fc Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 17 Sep 2026 14:02:28 +0200 Subject: [PATCH 2/5] Python: document CodeAct schema visibility --- python/packages/hyperlight/README.md | 4 ++++ python/packages/monty/README.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md index a27ac0248a8..f64930b9b3f 100644 --- a/python/packages/hyperlight/README.md +++ b/python/packages/hyperlight/README.md @@ -147,6 +147,10 @@ note, when a schema cannot be represented faithfully (for example, nested object 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 diff --git a/python/packages/monty/README.md b/python/packages/monty/README.md index d1c2bc3e606..640150a89b4 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -131,6 +131,10 @@ Only `"compact"`, `"json"`, or mappings from string names to these values are accepted: unsupported choices raise `ValueError`; `None`, invalid input types, non-string mapping keys, and non-string choices raise `TypeError`. +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. + ### Host tool lifetime Registered `FunctionTool` instances retain their invocation and exception counters From a994bb920987b9bb3440d8f782ccc5a8fa15d89a Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 18 Sep 2026 10:08:24 +0200 Subject: [PATCH 3/5] Python: centralize CodeAct format validation --- python/packages/core/AGENTS.md | 7 ++-- .../packages/core/agent_framework/_tools.py | 33 ++++++++++++++- python/packages/core/tests/core/test_tools.py | 25 ++++++++++++ .../_execute_code_tool.py | 40 +++++-------------- .../_instructions.py | 12 +++--- .../agent_framework_hyperlight/_provider.py | 8 ++-- .../_execute_code_tool.py | 39 ++++++------------ .../agent_framework_monty/_instructions.py | 14 ++++--- .../monty/agent_framework_monty/_provider.py | 8 ++-- 9 files changed, 106 insertions(+), 80 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index d37fbb1859d..d32843f4204 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -73,9 +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 -- **`_format_tool_parameters`** - Private structured parameter formatter shared by Hyperlight and Monty descriptions. - Returns the effective compact/JSON format and detached parameter data, falling back to full JSON Schema when - compact data cannot preserve constraints. Does not change `FunctionTool.parameters()` or render runtime-specific text. +- **`_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 diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 2261768cdd1..0d484e7eae5 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -400,11 +400,40 @@ 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: Literal["compact", "json"], -) -> tuple[Literal["compact", "json"], 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 diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 2f39e265ee0..6f90355d089 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -21,6 +21,7 @@ from agent_framework._tools import ( _auto_invoke_function, _format_tool_parameters, + _normalize_tool_description_format, _parse_annotation, _parse_inputs, normalize_function_invocation_configuration, @@ -30,6 +31,30 @@ # 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", diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index 55ec0254df0..8a0fb0dab2a 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -10,18 +10,24 @@ import stat import threading import time -from collections.abc import Callable, Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Sequence from concurrent.futures import ThreadPoolExecutor from contextlib import suppress from copy import copy from dataclasses import dataclass from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory -from typing import Any, Literal, Protocol, TypeGuard, TypeVar, cast +from typing import Any, Protocol, TypeGuard, TypeVar, cast from urllib.parse import urlparse from agent_framework import Content, FunctionTool -from agent_framework._tools import ApprovalMode, normalize_tools +from agent_framework._tools import ( + ApprovalMode, + _normalize_tool_description_format, # pyright: ignore[reportPrivateUsage] + _NormalizedToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] + _ToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] + normalize_tools, +) from ._instructions import build_codeact_instructions, build_execute_code_description from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountHostPath, FileMountInput @@ -1505,28 +1511,6 @@ def _build_sandbox() -> tuple[Any, Any]: ) -def _normalize_tool_description_format( - value: object, -) -> Literal["compact", "json"] | dict[str, Literal["compact", "json"]]: - if isinstance(value, str): - if value not in ("compact", "json"): - raise ValueError("tool_description_format must be 'compact' or 'json'.") - return value - if not isinstance(value, Mapping): - raise TypeError("tool_description_format must be 'compact', 'json', or a mapping of tool names to formats.") - - result: dict[str, Literal["compact", "json"]] = {} - for name, parameter_format in cast(Mapping[object, object], value).items(): - if not isinstance(name, str): - raise TypeError("tool_description_format mapping keys must be tool name strings.") - if not isinstance(parameter_format, str): - raise TypeError(f"tool_description_format for {name!r} must be a string: 'compact' or 'json'.") - if parameter_format not in ("compact", "json"): - raise ValueError(f"tool_description_format for {name!r} must be 'compact' or 'json'.") - result[name] = parameter_format - return result - - class HyperlightExecuteCodeTool(FunctionTool): """Execute Python code inside a Hyperlight sandbox. @@ -1541,7 +1525,7 @@ def __init__( self, *, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _ToolDescriptionFormat = "compact", approval_mode: ApprovalMode | None = None, workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, @@ -1566,9 +1550,7 @@ def __init__( input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._state_lock = threading.RLock() - self._tool_description_format: Literal["compact", "json"] | dict[str, Literal["compact", "json"]] = ( - normalized_description_format - ) + self._tool_description_format: _NormalizedToolDescriptionFormat = normalized_description_format self._registry = _registry or _SandboxRegistry() self._default_approval_mode: ApprovalMode = approval_mode or "never_require" self._workspace_root = _resolve_workspace_root(workspace_root) diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py index 919afc6fc30..081608ebbe2 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py @@ -3,11 +3,13 @@ from __future__ import annotations import json -from collections.abc import Mapping, Sequence -from typing import Literal +from collections.abc import Sequence from agent_framework import FunctionTool -from agent_framework._tools import _format_tool_parameters # pyright: ignore[reportPrivateUsage] +from agent_framework._tools import ( + _format_tool_parameters, # pyright: ignore[reportPrivateUsage] + _NormalizedToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] +) from ._types import AllowedDomain @@ -15,7 +17,7 @@ def _format_tool_summaries( tools: Sequence[FunctionTool], *, - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _NormalizedToolDescriptionFormat = "compact", ) -> str: if not tools: return "- No tools are currently registered inside the sandbox." @@ -136,7 +138,7 @@ def build_execute_code_description( workspace_enabled: bool, mounted_paths: Sequence[str], allowed_domains: Sequence[AllowedDomain], - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _NormalizedToolDescriptionFormat = "compact", ) -> str: """Build the dynamic execute_code tool description for standalone usage.""" filesystem_text = _format_filesystem_capabilities( diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py index 63394115299..119e7fbcdc4 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py @@ -2,13 +2,13 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Sequence from pathlib import Path -from typing import Any, Literal +from typing import Any from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext from agent_framework._telemetry import mark_feature_used -from agent_framework._tools import ApprovalMode +from agent_framework._tools import ApprovalMode, _ToolDescriptionFormat # pyright: ignore[reportPrivateUsage] from ._execute_code_tool import ( DEFAULT_MAX_OUTPUT_FILE_BYTES, @@ -38,7 +38,7 @@ def __init__( source_id: str = DEFAULT_SOURCE_ID, *, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _ToolDescriptionFormat = "compact", approval_mode: ApprovalMode | None = None, workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, diff --git a/python/packages/monty/agent_framework_monty/_execute_code_tool.py b/python/packages/monty/agent_framework_monty/_execute_code_tool.py index a818dfb9246..a4da33b364f 100644 --- a/python/packages/monty/agent_framework_monty/_execute_code_tool.py +++ b/python/packages/monty/agent_framework_monty/_execute_code_tool.py @@ -14,13 +14,19 @@ import json import mimetypes -from collections.abc import Callable, Iterator, Mapping, Sequence +from collections.abc import Callable, Iterator, Sequence from functools import partial from pathlib import Path, PurePosixPath -from typing import Any, Literal, cast +from typing import Any, cast from agent_framework import Content, FunctionTool -from agent_framework._tools import ApprovalMode, normalize_tools +from agent_framework._tools import ( + ApprovalMode, + _normalize_tool_description_format, # pyright: ignore[reportPrivateUsage] + _NormalizedToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] + _ToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] + normalize_tools, +) from ._instructions import build_codeact_instructions, build_execute_code_description from ._monty_bridge import InlineCodeBridge, generate_type_stubs @@ -68,27 +74,6 @@ def _collect_tools(*tool_groups: Any) -> list[FunctionTool]: return list(tools_by_name.values()) -def _normalize_tool_description_format( - value: object, -) -> Literal["compact", "json"] | dict[str, Literal["compact", "json"]]: - if isinstance(value, str): - if value not in ("compact", "json"): - raise ValueError("tool_description_format must be 'compact', 'json', or a mapping of tool names to these.") - return value - if not isinstance(value, Mapping): - raise TypeError("tool_description_format must be a string or a mapping of tool names to 'compact' or 'json'.") - normalized: dict[str, Literal["compact", "json"]] = {} - 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 _resolve_execute_code_approval_mode( *, base_approval_mode: ApprovalMode, @@ -226,7 +211,7 @@ def __init__( workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, resource_limits: dict[str, Any] | None = None, - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _ToolDescriptionFormat = "compact", ) -> None: super().__init__( name=EXECUTE_CODE_TOOL_NAME, @@ -236,8 +221,8 @@ def __init__( input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._default_approval_mode: ApprovalMode = approval_mode or "never_require" - self._tool_description_format: Literal["compact", "json"] | dict[str, Literal["compact", "json"]] = ( - _normalize_tool_description_format(tool_description_format) + self._tool_description_format: _NormalizedToolDescriptionFormat = _normalize_tool_description_format( + tool_description_format ) self._managed_tools: list[FunctionTool] = [] self._workspace_root: Path | None = ( diff --git a/python/packages/monty/agent_framework_monty/_instructions.py b/python/packages/monty/agent_framework_monty/_instructions.py index 6503b556ad6..c7e6ad9c6a3 100644 --- a/python/packages/monty/agent_framework_monty/_instructions.py +++ b/python/packages/monty/agent_framework_monty/_instructions.py @@ -5,11 +5,13 @@ from __future__ import annotations import json -from collections.abc import Mapping, Sequence -from typing import Literal +from collections.abc import Sequence from agent_framework import FunctionTool -from agent_framework._tools import _format_tool_parameters # pyright: ignore[reportPrivateUsage] +from agent_framework._tools import ( + _format_tool_parameters, # pyright: ignore[reportPrivateUsage] + _NormalizedToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] +) from ._types import FileMount @@ -17,7 +19,7 @@ def _format_tool_summaries( tools: Sequence[FunctionTool], *, - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _NormalizedToolDescriptionFormat = "compact", ) -> str: if not tools: return "- No tools are currently registered." @@ -85,7 +87,7 @@ def build_codeact_instructions( tools: Sequence[FunctionTool], tools_visible_to_model: bool, mounts: Sequence[FileMount] = (), - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _NormalizedToolDescriptionFormat = "compact", ) -> str: """Build dynamic CodeAct instructions for the effective Monty tool set.""" tool_summaries = _format_tool_summaries(tools, tool_description_format=tool_description_format) @@ -129,7 +131,7 @@ def build_execute_code_description( *, tools: Sequence[FunctionTool], mounts: Sequence[FileMount] = (), - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _NormalizedToolDescriptionFormat = "compact", ) -> str: """Build the dynamic ``execute_code`` tool description for standalone usage.""" tool_summaries = _format_tool_summaries(tools, tool_description_format=tool_description_format) diff --git a/python/packages/monty/agent_framework_monty/_provider.py b/python/packages/monty/agent_framework_monty/_provider.py index 51ababa5401..81192a038ee 100644 --- a/python/packages/monty/agent_framework_monty/_provider.py +++ b/python/packages/monty/agent_framework_monty/_provider.py @@ -4,13 +4,13 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Callable, Sequence from pathlib import Path -from typing import Any, Literal +from typing import Any from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext from agent_framework._telemetry import mark_feature_used -from agent_framework._tools import ApprovalMode +from agent_framework._tools import ApprovalMode, _ToolDescriptionFormat # pyright: ignore[reportPrivateUsage] from ._execute_code_tool import MontyExecuteCodeTool from ._feature_usage import FeatureIndex @@ -44,7 +44,7 @@ def __init__( workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, resource_limits: dict[str, Any] | None = None, - tool_description_format: Literal["compact", "json"] | Mapping[str, Literal["compact", "json"]] = "compact", + tool_description_format: _ToolDescriptionFormat = "compact", ) -> None: super().__init__(source_id) self._execute_code_tool = MontyExecuteCodeTool( From fb065891ed845d15aec2846c89c1402674b080fb Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 18 Sep 2026 10:08:26 +0200 Subject: [PATCH 4/5] Python: update Hyperlight runtime to 0.7 --- python/packages/hyperlight/pyproject.toml | 6 ++-- python/uv.lock | 36 +++++++++++------------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 1a61ad65940..1a537316e20 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -24,9 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.13.0,<2", - "hyperlight-sandbox>=0.6.0,<0.7", - "hyperlight-sandbox-backend-wasm>=0.6.0,<0.7 ; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')", - "hyperlight-sandbox-python-guest>=0.6.0,<0.7", + "hyperlight-sandbox>=0.7.0,<0.8", + "hyperlight-sandbox-backend-wasm>=0.7.0,<0.8 ; (sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')", + "hyperlight-sandbox-python-guest>=0.7.0,<0.8", ] [tool.uv] diff --git a/python/uv.lock b/python/uv.lock index 2ae5de419bd..1100e319e4f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -822,9 +822,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "hyperlight-sandbox", specifier = ">=0.6.0,<0.7" }, - { name = "hyperlight-sandbox-backend-wasm", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')", specifier = ">=0.6.0,<0.7" }, - { name = "hyperlight-sandbox-python-guest", specifier = ">=0.6.0,<0.7" }, + { name = "hyperlight-sandbox", specifier = ">=0.7.0,<0.8" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')", specifier = ">=0.7.0,<0.8" }, + { name = "hyperlight-sandbox-python-guest", specifier = ">=0.7.0,<0.8" }, ] [[package]] @@ -2704,35 +2704,35 @@ wheels = [ [[package]] name = "hyperlight-sandbox" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/c2/df0daba1a3f4401ecc6af06d1294e45fb8fad8f01c7257e74aa56261481b/hyperlight_sandbox-0.6.0.tar.gz", hash = "sha256:b35a6a20429423d9b2b518b3a6451a1fc346752e04f39a3cd507efff70f6c612", size = 9367, upload-time = "2026-08-26T18:11:30.431Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/50/31d35922c87c648fbce8c51fef2d2b0ef277fa09acd58eb74227c90c3343/hyperlight_sandbox-0.7.0.tar.gz", hash = "sha256:c272dc483c69cbd3f8e96b963af8a3038c8df8a20a0c8228bbd5d821a0037dfa", size = 10864, upload-time = "2026-09-12T00:16:41.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2a/823bc8704f7ef9ea28150633b473750ff677c5c22b4c811977c2b99879b0/hyperlight_sandbox-0.6.0-py3-none-any.whl", hash = "sha256:741780f8984ae83360c3ace1391992920fcac15651a336efba7696050e450928", size = 5761, upload-time = "2026-08-26T18:11:29.425Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/0a62893dbec00955aad63739ced80167eac476fce73bb110e6cfeee655e2/hyperlight_sandbox-0.7.0-py3-none-any.whl", hash = "sha256:7f4e52c7ef6013f461d19db60ec537c18e9cf41045ac819d4af1bb9cb72bdca3", size = 6372, upload-time = "2026-09-12T00:16:40.115Z" }, ] [[package]] name = "hyperlight-sandbox-backend-wasm" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/98/36343dc012562af86dd463ec151c127417d707a635ed9cecb2ef0ad44039/hyperlight_sandbox_backend_wasm-0.6.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ecbddae076b0a292c7cea34366ff9b0c2d8c70150c634d44ab4cd66df3ed5768", size = 3936333, upload-time = "2026-08-26T18:10:38.815Z" }, - { url = "https://files.pythonhosted.org/packages/22/10/f93854693f860f846f00293e3ccd4d48b370a6b2fa85beb2fbff4b35c515/hyperlight_sandbox_backend_wasm-0.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:dab08eb12c5f6000b87b488ce8d961eb89676848abe4e20bad5866aad491e1e3", size = 3338233, upload-time = "2026-08-26T18:10:40.393Z" }, - { url = "https://files.pythonhosted.org/packages/d2/34/e0e39e26415b5e0564f342b4d7b94534ec9da2ee640bf6dba0eb779f25c7/hyperlight_sandbox_backend_wasm-0.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cd6b0e9549a73a8dd3fc6ce0586e8e77ee279546141bdbfda9a29728f3475a7f", size = 3930424, upload-time = "2026-08-26T18:10:42.016Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/6116abb341568e2f904a0bc2acae822d9ec8e21468356ea24109b78cdd97/hyperlight_sandbox_backend_wasm-0.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd48f2238a3469a088518f2a0d321c13bdbdcb0edc0fe28a6c3ced52ee38bfac", size = 3335651, upload-time = "2026-08-26T18:10:43.481Z" }, - { url = "https://files.pythonhosted.org/packages/f6/47/5a704a91bf4a06a3470079a45dfc51ea2f07366160df9e30a158c0ecc98c/hyperlight_sandbox_backend_wasm-0.6.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:b8ec68cd5cfdb111e4d0293b4560004c4cfeaac0e265b392fa8db9f2ce78fd6d", size = 3931152, upload-time = "2026-08-26T18:10:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/78/9f/e7cbb0b1e05d5d3e4b69616242bcf13d091d6889b799b455e9958a39587b/hyperlight_sandbox_backend_wasm-0.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:b35654fb0ac9ec8d024e161bf352521c8610d6ad9f33fd228f5f5754e7b10c3a", size = 3335457, upload-time = "2026-08-26T18:10:46.896Z" }, - { url = "https://files.pythonhosted.org/packages/ea/77/5cbeb1f691e14a7fb4d885233a1d5caccb91cb46675bb9cb5a0c6c2ea702/hyperlight_sandbox_backend_wasm-0.6.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f0a82e81bc4bc0a8754d47848efcaf853ced7b0ebd2c3fd401404d81553b3ebd", size = 3932804, upload-time = "2026-08-26T18:10:48.476Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e5/e56ff38a8b7140db74ccc6447742d32670c47866d97a84aa73f302e83b08/hyperlight_sandbox_backend_wasm-0.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:43cd25ccbd0c8be5f7af38c2f586da2b3c70b65a027b09601924947fa5a10eb2", size = 3336245, upload-time = "2026-08-26T18:10:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/4d/35/1ef748c7af301edcd83c83a0e65acb5816dea9dd02c0243ec80ed2007613/hyperlight_sandbox_backend_wasm-0.7.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c8cab776e20fd57a394e9a6839debd04a69a455af31d785ea1f6f7483c33e23d", size = 3965726, upload-time = "2026-09-12T00:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bc/4255f7d8c2718d53f4f62e377a5d065bcaf52469d94ad8cb53fc8a7689b5/hyperlight_sandbox_backend_wasm-0.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:52f8681915b41b0a00a6358b9aa4ac60b305a82564c9d254d8691f208178f7bb", size = 3368999, upload-time = "2026-09-12T00:15:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/ee/72/a8136c87e00d9f59a9b7f8e483013e6b6f1ab48c9c79e380750b8c0b1d13/hyperlight_sandbox_backend_wasm-0.7.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:0f525aa236f2a846a44f9f303baece11d78c635ef82acd6422ae3f6a9d47cedd", size = 3959525, upload-time = "2026-09-12T00:15:53.508Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/18bd4e207e57c1570da6d1d0a3d131177bed3b8e8fa1ff2a106bdce14a35/hyperlight_sandbox_backend_wasm-0.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:eb3316d4789adec2d872170f1f86f3b7c9cf10777ca050dd68a0806b193d62d2", size = 3366405, upload-time = "2026-09-12T00:15:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/9f/73/1754d3fe6d5201547e83efa24876ca414ee5145beb3ece614635a4a5fb7f/hyperlight_sandbox_backend_wasm-0.7.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9f432799c2275bf57c0da32db922ff52855d83e587fda2128b76b65419065f30", size = 3959746, upload-time = "2026-09-12T00:15:56.648Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9e/86eaf3afa60b7fff0c7f016724e6c5bd3efe98a9893104c9f9b52cca4af4/hyperlight_sandbox_backend_wasm-0.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:aa5b71886e322b4796130214cfd0227590589e174345063837d12b81769d8881", size = 3366407, upload-time = "2026-09-12T00:15:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/82/52/47012514f883d3ac4f5577cdf659e322764c8b7d54ba8cdf1c7bf529990d/hyperlight_sandbox_backend_wasm-0.7.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9591101a79240713de52bd2f320f538dddfa1de1ff067a93318c9e22f4cd84c8", size = 3960909, upload-time = "2026-09-12T00:15:59.822Z" }, + { url = "https://files.pythonhosted.org/packages/83/92/2d4fc2a4434c3deb2da74776618fc598a84d0a840f4bfbb928df623a34ac/hyperlight_sandbox_backend_wasm-0.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:fa2c15690592dd9c08433a680bf4eeb7bac415a50231c74e1a78c30d053e3e31", size = 3366641, upload-time = "2026-09-12T00:16:01.631Z" }, ] [[package]] name = "hyperlight-sandbox-python-guest" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/3a/da46883ad721b08286b883cd3e4ffbb27a2f9361429d3e595fa60f4f9c7e/hyperlight_sandbox_python_guest-0.6.0.tar.gz", hash = "sha256:7a0bdfe3f8fd1bd953bf94eb6d5b7c7601d49bc84b6c4a4d0dcd13713c81b7c8", size = 21562971, upload-time = "2026-08-26T18:11:16.553Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/5a/c780fd545374371176cb1bc02a5e4ec2bafbf4bae14794e4b0a738be3ca8/hyperlight_sandbox_python_guest-0.7.0.tar.gz", hash = "sha256:c64df2516370ead8efc64a2e19c8520838c5200340a05076e10da7116c028a9f", size = 21628728, upload-time = "2026-09-12T00:16:28.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/22/8cd4b789340c7d1b6806ad0ae0e7ae9090982a13b76dc558046bfab09df2/hyperlight_sandbox_python_guest-0.6.0-py3-none-any.whl", hash = "sha256:b92bfd7f2fe33cec5332872fd6509ea167a7738c28a6995a5672259ebfd9834a", size = 21728017, upload-time = "2026-08-26T18:11:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/91/81/fde74f43f899c7100246a973e2c87e258e9c170a969418142143ec175a8b/hyperlight_sandbox_python_guest-0.7.0-py3-none-any.whl", hash = "sha256:9eff31341b61830b9b699dbd4fba65f5d62e878050db5db331f688ee0e561367", size = 21789808, upload-time = "2026-09-12T00:16:23.975Z" }, ] [[package]] From 3256b99eb0aa08a1917e4ec5d0e4dcd19ddf1af3 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 18 Sep 2026 10:21:00 +0200 Subject: [PATCH 5/5] Python: forward Hyperlight output limits --- .../_execute_code_tool.py | 8 ++- .../hyperlight/test_hyperlight_codeact.py | 57 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index 8a0fb0dab2a..541a41e84d3 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -109,8 +109,6 @@ def filesystem_enabled(self) -> bool: return self.workspace_root is not None or bool(self.file_mounts) def cache_key(self) -> tuple[Any, ...]: - # Output limits are invocation-scoped and do not change sandbox construction, - # so they intentionally do not participate in the shared runtime cache key. return ( self.backend, self.module, @@ -121,6 +119,9 @@ def cache_key(self) -> tuple[Any, ...]: self.workspace_signature, tuple((mount.mount_path, str(mount.host_path), mount.path_signature) for mount in self.file_mounts), tuple((allowed_domain.target, allowed_domain.methods) for allowed_domain in self.allowed_domains), + self.max_output_files, + self.max_output_file_bytes, + self.max_output_total_bytes, ) @@ -1459,6 +1460,9 @@ def _create_sandbox() -> Any: module_path=config.module_path, input_dir=input_dir_handle.name if input_dir_handle is not None else None, output_dir=output_dir_handle.name if output_dir_handle is not None else None, + max_file_size=f"{config.max_output_file_bytes}B", + max_total_size=f"{config.max_output_total_bytes}B", + max_file_count=config.max_output_files, ) except ImportError as exc: raise RuntimeError( diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index b9ed18feceb..082ca86154d 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -275,9 +275,15 @@ def __init__( module_path: str | None = None, heap_size: str | None = None, stack_size: str | None = None, + max_file_size: str | None = None, + max_total_size: str | None = None, + max_file_count: int | None = None, ) -> None: self.input_dir = input_dir self.output_dir = output_dir + self.max_file_size = max_file_size + self.max_total_size = max_total_size + self.max_file_count = max_file_count self.registered_tools: dict[str, Any] = {} self.allowed_domains: list[tuple[str, list[str] | None]] = [] self.restore_calls: list[Any] = [] @@ -2245,8 +2251,12 @@ def __init__( backend: str = "wasm", module: str | None = None, module_path: str | None = None, + max_file_size: str | None = None, + max_total_size: str | None = None, + max_file_count: int | None = None, ) -> None: del input_dir, output_dir, backend, module, module_path + del max_file_size, max_total_size, max_file_count self.allowed_domains: list[tuple[str, list[str] | None]] = [] _FakeStrictNetworkSandbox.instances.append(self) @@ -2371,7 +2381,7 @@ async def test_provider_forwards_output_limits_to_run_tool_and_serializable_stat json.dumps(state) -async def test_output_limits_are_invocation_scoped_when_registry_is_shared( +async def test_output_limits_are_isolated_when_registry_is_shared( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2399,7 +2409,50 @@ async def test_output_limits_are_invocation_scoped_when_registry_is_shared( _assert_bounded_output_error(rejected, "per-file output limit") assert [_decode_content_bytes(item) for item in accepted if item.type == "data"] == [b"data"] - assert len(_FakeSandbox.instances) == 1 + assert len(_FakeSandbox.instances) == 2 + assert [ + (sandbox.max_file_size, sandbox.max_total_size, sandbox.max_file_count) for sandbox in _FakeSandbox.instances + ] == [("4B", "10B", 20), ("3B", "10B", 20)] + + +async def test_default_output_limits_are_forwarded_to_hyperlight_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + execute_code = HyperlightExecuteCodeTool() + + try: + await execute_code.invoke(arguments={"code": "None"}) + finally: + _close_execute_code_registry(execute_code) + + sandbox = _FakeSandbox.instances[0] + assert (sandbox.max_file_size, sandbox.max_total_size, sandbox.max_file_count) == ( + "5242880B", + "20971520B", + 20, + ) + + +async def test_custom_output_limits_are_forwarded_to_hyperlight_sandbox( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + execute_code = HyperlightExecuteCodeTool( + max_output_file_bytes=7, + max_output_total_bytes=11, + max_output_files=3, + ) + + try: + await execute_code.invoke(arguments={"code": "None"}) + finally: + _close_execute_code_registry(execute_code) + + sandbox = _FakeSandbox.instances[0] + assert (sandbox.max_file_size, sandbox.max_total_size, sandbox.max_file_count) == ("7B", "11B", 3) def test_execute_code_tool_uses_finite_default_output_limits() -> None: