Python: Hyperlight: thread-confine sandbox, skip parsing on host callbacks, schema/tool cleanup (#5424)

* improved parsing of tool call results and tweaks

* Address PR review: skip_parsing flag, broader registry close, comment fix

- FunctionTool.invoke now takes a boolean skip_parsing flag instead of the
  SKIP_PARSING sentinel; the sentinel is still accepted as result_parser at
  construction time to opt out of parsing for every call. The two paths are
  equivalent.
- _SandboxRegistry.close now invokes any sandbox close/shutdown hook on the
  entry's own worker thread (PyO3 unsendable), then shuts the worker down,
  then cleans up the per-entry temporary directories.
- Clarified the _SandboxWorker.shutdown comment to describe the actual
  ThreadPoolExecutor.shutdown(wait=False, cancel_futures=False) semantics.
- Hyperlight host callback uses skip_parsing=True (the new flag).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Drop redundant 'is not SKIP_PARSING' guard that mypy 1.x flags

After callable(configured_parser) the sentinel is already excluded; the extra
identity check tripped mypy's non-overlapping identity warning.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fixed sandbox working on copy of tool

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-04-23 10:14:51 +02:00
committed by GitHub
Unverified
parent 66e02c10e3
commit 58ff4ad3a9
6 changed files with 576 additions and 92 deletions
@@ -8,6 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from pydantic import BaseModel
from agent_framework import (
SKIP_PARSING,
Content,
FunctionTool,
tool,
@@ -1300,4 +1301,165 @@ def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None
assert normalized[1] is standalone
# region SKIP_PARSING sentinel & skip_parsing
async def test_invoke_skip_parsing_returns_native_value() -> None:
"""invoke(skip_parsing=True) returns the wrapped function's raw value."""
@tool
def get_weather(city: str) -> dict[str, Any]:
"""Get the weather."""
return {"city": city, "temperature_c": 21.5, "conditions": "partly cloudy"}
raw = await get_weather.invoke(arguments={"city": "Seattle"}, skip_parsing=True)
assert isinstance(raw, dict)
assert raw == {"city": "Seattle", "temperature_c": 21.5, "conditions": "partly cloudy"}
async def test_invoke_skip_parsing_passes_through_custom_objects() -> None:
"""skip_parsing must not call str()/repr() on the result."""
class Custom: # noqa: B903
def __init__(self, value: int) -> None:
self.value = value
@tool
def make() -> Custom:
"""Make a custom object."""
return Custom(42)
raw = await make.invoke(skip_parsing=True)
assert isinstance(raw, Custom)
assert raw.value == 42
async def test_invoke_skip_parsing_awaits_async_functions() -> None:
@tool
async def slow(x: int) -> int:
"""Async tool."""
return x * 2
raw = await slow.invoke(arguments={"x": 21}, skip_parsing=True)
assert raw == 42
async def test_invoke_skip_parsing_bypasses_configured_result_parser() -> None:
"""The tool's own result_parser is bypassed when skip_parsing=True is requested."""
parser_calls: list[Any] = []
def parser(value: Any) -> str:
parser_calls.append(value)
return "PARSED"
@tool(result_parser=parser)
def make_dict() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1}
raw = await make_dict.invoke(skip_parsing=True)
assert raw == {"a": 1}
assert parser_calls == []
# Sanity: omitting skip_parsing still applies the configured parser.
parsed = await make_dict.invoke()
assert parsed[0].type == "text"
assert parsed[0].text == "PARSED"
async def test_constructor_skip_parsing_sentinel_returns_raw_by_default() -> None:
"""Constructing a tool with result_parser=SKIP_PARSING makes invoke return the raw value."""
@tool(result_parser=SKIP_PARSING)
def make_dict() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1}
raw = await make_dict.invoke()
assert raw == {"a": 1}
async def test_invoke_skip_parsing_validates_arguments() -> None:
"""Argument validation is shared with the default path."""
@tool
def adder(x: int, y: int) -> int:
"""Add."""
return x + y
with pytest.raises(TypeError):
await adder.invoke(arguments={"x": "not-an-int", "y": 1}, skip_parsing=True)
async def test_invoke_skip_parsing_rejects_unexpected_runtime_kwargs() -> None:
@tool
async def echo(message: str) -> str:
"""Echo."""
return message
with pytest.raises(TypeError, match="Unexpected keyword argument"):
await echo.invoke(arguments={"message": "hi"}, skip_parsing=True, api_token="secret")
async def test_invoke_skip_parsing_raises_for_declaration_only_tool() -> None:
declared = FunctionTool(name="dummy", description="declaration only")
from agent_framework.exceptions import ToolException
with pytest.raises(ToolException):
await declared.invoke(arguments={}, skip_parsing=True)
async def test_invoke_skip_parsing_records_telemetry(span_exporter: InMemorySpanExporter) -> None:
"""skip_parsing participates in OTEL spans and records str(raw) as TOOL_RESULT."""
@tool(name="raw_tool", description="raw tool")
def returns_dict(x: int) -> dict[str, int]:
"""Returns a dict."""
return {"value": x}
span_exporter.clear()
raw = await returns_dict.invoke(arguments={"x": 5}, tool_call_id="raw_call", skip_parsing=True)
assert raw == {"value": 5}
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes[OtelAttr.TOOL_NAME] == "raw_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "raw_call"
assert span.attributes[OtelAttr.TOOL_RESULT] == "{'value': 5}"
async def test_invoke_default_path_records_parsed_telemetry(
span_exporter: InMemorySpanExporter,
) -> None:
"""Regression: omitting skip_parsing still records the parsed result in telemetry."""
def parser(value: Any) -> str:
return f"parsed:{value}"
@tool(name="parsed_tool", description="parsed", result_parser=parser)
def returns_int() -> int:
"""Returns an int."""
return 7
span_exporter.clear()
parsed = await returns_int.invoke(tool_call_id="parsed_call")
assert parsed[0].text == "parsed:7"
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].attributes[OtelAttr.TOOL_RESULT] == "parsed:7"
def test_skip_parsing_is_singleton() -> None:
"""SKIP_PARSING is a singleton; instantiation returns the same object."""
from agent_framework._tools import _SkipParsingSentinel
assert _SkipParsingSentinel() is SKIP_PARSING
assert repr(SKIP_PARSING) == "SKIP_PARSING"
# endregion