diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 0f2bdfc5ab9..d32843f4204 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -73,6 +73,10 @@ agent_framework/ - **`FunctionTool`** - Wraps Python functions as tools with JSON schema generation - **`@tool`** decorator - Converts functions to tools - **`use_function_invocation()`** - Decorator to add automatic function calling to chat clients +- **`_normalize_tool_description_format` / `_format_tool_parameters`** - Private configuration normalizer and + structured parameter formatter shared by Hyperlight and Monty descriptions. They validate and detach compact/JSON + settings, return detached parameter data, and fall back to full JSON Schema when compact data cannot preserve + constraints. They do not change `FunctionTool.parameters()` or render runtime-specific text. ### Vector stores diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a9f878c05af..5d249350a9f 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -402,6 +402,91 @@ def _annotation_includes_function_invocation_context(annotation: Any) -> bool: ) +_ToolParameterFormat: TypeAlias = Literal["compact", "json"] +_ToolDescriptionFormat: TypeAlias = _ToolParameterFormat | Mapping[str, _ToolParameterFormat] +_NormalizedToolDescriptionFormat: TypeAlias = _ToolParameterFormat | dict[str, _ToolParameterFormat] +_TOOL_DESCRIPTION_FORMAT_ERROR = "tool_description_format must be 'compact', 'json', or a tool-name mapping." + + +def _normalize_tool_description_format( # pyright: ignore[reportUnusedFunction] + value: object, +) -> _NormalizedToolDescriptionFormat: + """Validate and detach model-facing tool parameter description settings.""" + if isinstance(value, str): + if value not in ("compact", "json"): + raise ValueError(_TOOL_DESCRIPTION_FORMAT_ERROR) + return value + if not isinstance(value, Mapping): + raise TypeError(_TOOL_DESCRIPTION_FORMAT_ERROR) + + normalized: dict[str, _ToolParameterFormat] = {} + for name, choice in cast(Mapping[object, object], value).items(): + if not isinstance(name, str): + raise TypeError("tool_description_format mapping keys must be strings.") + if not isinstance(choice, str): + raise TypeError(f"tool_description_format[{name!r}] must be a string ('compact' or 'json').") + if choice not in ("compact", "json"): + raise ValueError(f"tool_description_format[{name!r}] must be 'compact' or 'json'; got {choice!r}.") + normalized[name] = choice + return normalized + + +def _format_tool_parameters( # pyright: ignore[reportUnusedFunction] + parameters: dict[str, Any], + *, + parameter_format: _ToolParameterFormat, +) -> tuple[_ToolParameterFormat, dict[str, Any]]: + """Return the effective format and detached parameter data for tool descriptions. + + Compact data maps parameter names to scalar type, requiredness, and optional + description, enum, and default metadata. Schemas with unrepresented constraints + retain their full JSON Schema so callers can explain the fallback to the model. + """ + if parameter_format not in ("compact", "json"): + raise ValueError("parameter_format must be 'compact' or 'json'.") + + if parameter_format == "json": + return "json", copy.deepcopy(parameters) + + properties = parameters.get("properties") + required = parameters.get("required", []) + if ( + parameters.get("type") != "object" + or parameters.keys() - {"type", "properties", "required", "title", "description"} + or not isinstance(properties, dict) + or not isinstance(required, list) + ): + return "json", copy.deepcopy(parameters) + + property_schemas = cast(dict[object, Any], properties) + required_names = cast(list[object], required) + if not all(isinstance(name, str) and name in property_schemas for name in required_names): + return "json", copy.deepcopy(parameters) + + compact: dict[str, Any] = {} + for name, property_schema in property_schemas.items(): + if not isinstance(name, str) or not isinstance(property_schema, dict): + return "json", copy.deepcopy(parameters) + + schema = cast(dict[str, Any], property_schema) + if schema.get("type") not in ("string", "integer", "number", "boolean", "null") or schema.keys() - { + "type", + "title", + "description", + "enum", + "default", + }: + return "json", copy.deepcopy(parameters) + + compact[name] = { + "type": schema["type"], + "required": name in required_names, + **{key: copy.deepcopy(schema[key]) for key in ("description", "enum", "default") if key in schema}, + } + + 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 4b0eb1c81a9..3071e191513 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 contextvars import ContextVar @@ -21,6 +22,8 @@ from agent_framework._middleware import FunctionInvocationContext from agent_framework._tools import ( _auto_invoke_function, + _format_tool_parameters, + _normalize_tool_description_format, _parse_annotation, _parse_inputs, _try_execute_function_call_groups, @@ -31,6 +34,182 @@ # region FunctionTool and tool decorator tests +def test_normalize_tool_description_format_returns_detached_mapping(): + formats = {"lookup": "json"} + + normalized = _normalize_tool_description_format(formats) + formats.clear() + + assert normalized == {"lookup": "json"} + + +@pytest.mark.parametrize( + ("value", "error_type"), + [ + ("yaml", ValueError), + (None, TypeError), + ({1: "json"}, TypeError), + ({"lookup": 1}, TypeError), + ({"lookup": "yaml"}, ValueError), + ], +) +def test_normalize_tool_description_format_rejects_invalid_values(value, error_type): + with pytest.raises(error_type, match="tool_description_format"): + _normalize_tool_description_format(value) + + +def test_format_tool_parameters_compact_preserves_scalar_metadata(): + schema = { + "type": "object", + "title": "InventoryInput", + "properties": { + "partNumber": {"type": "string", "description": "Part identifier", "title": "Part Number"}, + "units": {"type": "integer", "default": 1}, + "currency": {"type": "string", "enum": ["EUR", "USD"], "default": "EUR"}, + "price": {"type": "number"}, + "available": {"type": "boolean", "default": False}, + "empty": {"type": "null", "default": None}, + }, + "required": ["partNumber", "empty"], + } + original = copy.deepcopy(schema) + + effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact") + + assert effective_format == "compact" + assert parameters == { + "partNumber": {"type": "string", "required": True, "description": "Part identifier"}, + "units": {"type": "integer", "required": False, "default": 1}, + "currency": {"type": "string", "required": False, "enum": ["EUR", "USD"], "default": "EUR"}, + "price": {"type": "number", "required": False}, + "available": {"type": "boolean", "required": False, "default": False}, + "empty": {"type": "null", "required": True, "default": None}, + } + parameters["currency"]["enum"].append("GBP") + assert schema == original + + +@pytest.mark.parametrize("required", [[], ["value"]]) +def test_format_tool_parameters_does_not_infer_requiredness_from_defaults(required): + schema = { + "type": "object", + "properties": {"value": {"type": "string", "default": "default"}}, + "required": required, + } + + effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact") + + assert effective_format == "compact" + assert parameters["value"]["required"] == ("value" in required) + assert parameters["value"]["default"] == "default" + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "object", "properties": {}}, + {"type": "object", "properties": {}, "required": [], "title": "EmptyInput"}, + ], +) +def test_format_tool_parameters_empty_object(schema): + assert _format_tool_parameters(schema, parameter_format="compact") == ("compact", {}) + + +@pytest.mark.parametrize( + "property_schema", + [ + {"type": "object", "properties": {"nested": {"type": "string"}}, "required": ["nested"]}, + {"type": "array", "items": {"type": "integer"}}, + {"$ref": "#/$defs/Address"}, + {"anyOf": [{"type": "string"}, {"type": "null"}]}, + {"type": ["string", "null"]}, + {"type": "string", "minLength": 1}, + {"type": "number", "minimum": 0}, + {"type": "integer", "exclusiveMaximum": 10}, + {"type": "string", "pattern": "^[A-Z]+$"}, + {"type": "string", "format": "date-time"}, + {"type": "string", "const": "fixed"}, + {"type": "string", "x-custom-keyword": {"constraint": "custom"}}, + {"type": "string", "allOf": [{"maxLength": 10}]}, + {"description": "An unconstrained parameter"}, + True, + False, + ], +) +def test_format_tool_parameters_compact_falls_back_for_rich_properties(property_schema): + schema = {"type": "object", "properties": {"value": property_schema}, "required": ["value"]} + + effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact") + + assert effective_format == "json" + assert parameters == schema + + +@pytest.mark.parametrize( + "extra", + [ + {"additionalProperties": False}, + {"additionalProperties": {"type": "string"}}, + {"$defs": {"Address": {"type": "string"}}}, + {"oneOf": [{"required": ["value"]}, {"required": ["other"]}]}, + {"dependentRequired": {"value": ["other"]}}, + {"patternProperties": {"^x-": {"type": "integer"}}}, + {"minProperties": 1}, + {"x-custom-keyword": "preserve"}, + {"required": ["unlisted"]}, + {"required": "value"}, + {"required": [1]}, + {"properties": []}, + {"properties": {1: {"type": "string"}}}, + {"properties": {"value": {"type": "string", "required": True}}}, + {"type": "array"}, + ], +) +def test_format_tool_parameters_compact_falls_back_for_root_constraints(extra): + schema = {"type": "object", "properties": {"value": {"type": "string"}}, **extra} + + effective_format, parameters = _format_tool_parameters(schema, parameter_format="compact") + + assert effective_format == "json" + assert parameters == schema + + +@pytest.mark.parametrize("parameter_format", ["compact", "json"]) +def test_format_tool_parameters_full_schema_result_is_detached(parameter_format): + schema = { + "type": "object", + "properties": {"value": {"$ref": "#/$defs/Value"}}, + "$defs": {"Value": {"type": "string", "enum": ["one", "two"]}}, + "required": ["value"], + "additionalProperties": False, + } + original = copy.deepcopy(schema) + + effective_format, parameters = _format_tool_parameters(schema, parameter_format=parameter_format) + + assert effective_format == "json" + assert parameters == original + parameters["$defs"]["Value"]["enum"].append("three") + assert schema == original + + +def test_format_tool_parameters_json_preserves_simple_schema(): + schema = {"type": "object", "properties": {"value": {"type": "string", "title": "Value"}}} + + assert _format_tool_parameters(schema, parameter_format="json") == ("json", schema) + + +@pytest.mark.parametrize("schema", [{}, {"properties": {"value": {"type": "string"}}}]) +def test_format_tool_parameters_does_not_treat_unconstrained_schema_as_empty(schema): + assert _format_tool_parameters(schema, parameter_format="compact") == ("json", schema) + + +@pytest.mark.parametrize("parameter_format", ["other", "", None]) +def test_format_tool_parameters_rejects_unknown_format(parameter_format): + with pytest.raises(ValueError, match="parameter_format"): + _format_tool_parameters({"type": "object", "properties": {}}, parameter_format=parameter_format) + + async def test_sequential_function_invocation_runs_calls_in_model_order() -> None: execution_order: list[str] = [] diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md index 02332e100f4..f64930b9b3f 100644 --- a/python/packages/hyperlight/README.md +++ b/python/packages/hyperlight/README.md @@ -118,6 +118,44 @@ codeact = HyperlightCodeActProvider( ) ``` +### Sandbox tool parameter descriptions + +Both `HyperlightExecuteCodeTool` and `HyperlightCodeActProvider` accept the +keyword-only `tool_description_format` option. The default, `"compact"`, includes +each parameter's scalar type, required/optional status, description, enum values, +and default when present. Use `"json"` to include the complete JSON Schema: + +```python +execute_code = HyperlightExecuteCodeTool( + tools=[compute], + tool_description_format="json", +) + +codeact = HyperlightCodeActProvider( + tools=[compute], + tool_description_format={"compute": "json", "send_email": "compact"}, +) +``` + +A string applies to every registered tool. A mapping selects formats by exact, +case-sensitive tool name; missing names use `"compact"`. Mappings are copied at +construction and when creating run-scoped tools, and entries for unregistered +tools are retained for later registration. + +Compact mode automatically falls back to full JSON Schema, with an explanatory +note, when a schema cannot be represented faithfully (for example, nested objects, +arrays, references, or additional constraints). No schema details are discarded. +Only `"compact"` and `"json"` are accepted; `None` is not supported. + +Tool parameter schemas are model-visible metadata, just as they are for direct +function calling. Do not put credentials, tenant identifiers, or other secrets in +parameter descriptions, enum values, defaults, or custom schema fields. + +This option affects `HyperlightExecuteCodeTool.description`, or the injected +run tool's `.description` when using `HyperlightCodeActProvider`. It does not +change the short CodeAct instructions, the `execute_code` input schema, sandbox +execution, or runtime caching. + ### Output attachment limits Files written under `/output` are returned as inline data attachments. Hyperlight 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..541a41e84d3 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -21,7 +21,13 @@ 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 @@ -103,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, @@ -115,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, ) @@ -1453,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( @@ -1506,12 +1516,20 @@ def _build_sandbox() -> tuple[Any, Any]: 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: _ToolDescriptionFormat = "compact", approval_mode: ApprovalMode | None = None, workspace_root: str | Path | None = None, file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, @@ -1524,6 +1542,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 +1554,7 @@ def __init__( input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._state_lock = threading.RLock() + 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) @@ -1571,6 +1591,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 +1713,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 +1739,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..081608ebbe2 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py @@ -2,24 +2,55 @@ from __future__ import annotations +import json from collections.abc import Sequence from agent_framework import FunctionTool +from agent_framework._tools import ( + _format_tool_parameters, # pyright: ignore[reportPrivateUsage] + _NormalizedToolDescriptionFormat, # 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: _NormalizedToolDescriptionFormat = "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 +138,7 @@ def build_execute_code_description( workspace_enabled: bool, mounted_paths: Sequence[str], allowed_domains: Sequence[AllowedDomain], + tool_description_format: _NormalizedToolDescriptionFormat = "compact", ) -> str: """Build the dynamic execute_code tool description for standalone usage.""" filesystem_text = _format_filesystem_capabilities( @@ -126,7 +158,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..119e7fbcdc4 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py @@ -8,7 +8,7 @@ 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, @@ -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: _ToolDescriptionFormat = "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/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/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index c9abad2af7b..082ca86154d 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 @@ -273,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] = [] @@ -1880,6 +1888,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) @@ -2027,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) @@ -2153,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: @@ -2181,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: 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..640150a89b4 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -105,6 +105,36 @@ 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`. + +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 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..a4da33b364f 100644 --- a/python/packages/monty/agent_framework_monty/_execute_code_tool.py +++ b/python/packages/monty/agent_framework_monty/_execute_code_tool.py @@ -20,7 +20,13 @@ 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 @@ -186,6 +192,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 +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: _ToolDescriptionFormat = "compact", ) -> None: super().__init__( name=EXECUTE_CODE_TOOL_NAME, @@ -208,6 +221,9 @@ def __init__( input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._default_approval_mode: ApprovalMode = approval_mode or "never_require" + self._tool_description_format: _NormalizedToolDescriptionFormat = _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 +246,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 +324,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 +335,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 +349,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..c7e6ad9c6a3 100644 --- a/python/packages/monty/agent_framework_monty/_instructions.py +++ b/python/packages/monty/agent_framework_monty/_instructions.py @@ -4,24 +4,55 @@ from __future__ import annotations +import json from collections.abc import Sequence from agent_framework import FunctionTool +from agent_framework._tools import ( + _format_tool_parameters, # pyright: ignore[reportPrivateUsage] + _NormalizedToolDescriptionFormat, # 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: _NormalizedToolDescriptionFormat = "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 +87,10 @@ def build_codeact_instructions( tools: Sequence[FunctionTool], tools_visible_to_model: bool, mounts: Sequence[FileMount] = (), + tool_description_format: _NormalizedToolDescriptionFormat = "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 +131,10 @@ def build_execute_code_description( *, tools: Sequence[FunctionTool], mounts: Sequence[FileMount] = (), + tool_description_format: _NormalizedToolDescriptionFormat = "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..81192a038ee 100644 --- a/python/packages/monty/agent_framework_monty/_provider.py +++ b/python/packages/monty/agent_framework_monty/_provider.py @@ -10,7 +10,7 @@ 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 @@ -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: _ToolDescriptionFormat = "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]) diff --git a/python/uv.lock b/python/uv.lock index 047cbf03701..fef775eefbe 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]]