mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
66e02c10e3
commit
58ff4ad3a9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user