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
@@ -125,6 +125,7 @@ from ._telemetry import (
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from ._tools import (
|
||||
SKIP_PARSING,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
FunctionTool,
|
||||
@@ -258,6 +259,7 @@ __all__ = [
|
||||
"GROUP_INDEX_KEY",
|
||||
"GROUP_KIND_KEY",
|
||||
"GROUP_TOKEN_COUNT_KEY",
|
||||
"SKIP_PARSING",
|
||||
"SUMMARIZED_BY_SUMMARY_ID_KEY",
|
||||
"SUMMARY_OF_GROUP_IDS_KEY",
|
||||
"SUMMARY_OF_MESSAGE_IDS_KEY",
|
||||
|
||||
@@ -94,6 +94,33 @@ ApprovalMode: TypeAlias = Literal["always_require", "never_require"]
|
||||
ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
|
||||
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
|
||||
|
||||
|
||||
class _SkipParsingSentinel:
|
||||
"""Sentinel signaling that :meth:`FunctionTool.invoke` should return the raw value.
|
||||
|
||||
When passed as ``result_parser`` to :class:`FunctionTool` (or the ``@tool`` decorator),
|
||||
the default :meth:`FunctionTool.parse_result` is bypassed and the wrapped function's
|
||||
return value is returned unchanged from :meth:`FunctionTool.invoke`. Callers may also
|
||||
request the raw value on a per-call basis by passing ``skip_parsing=True`` to
|
||||
:meth:`FunctionTool.invoke`.
|
||||
|
||||
Use the module-level ``SKIP_PARSING`` singleton — do not instantiate this class.
|
||||
"""
|
||||
|
||||
_instance: ClassVar[_SkipParsingSentinel | None] = None
|
||||
|
||||
def __new__(cls) -> _SkipParsingSentinel:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SKIP_PARSING"
|
||||
|
||||
|
||||
SKIP_PARSING: Final[_SkipParsingSentinel] = _SkipParsingSentinel()
|
||||
"""Sentinel for ``FunctionTool(result_parser=...)`` meaning "do not parse the result"."""
|
||||
|
||||
# region Helpers
|
||||
|
||||
|
||||
@@ -279,7 +306,7 @@ class FunctionTool(SerializationMixin):
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
func: Callable[..., Any] | None = None,
|
||||
input_model: type[BaseModel] | Mapping[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the FunctionTool.
|
||||
@@ -327,9 +354,11 @@ class FunctionTool(SerializationMixin):
|
||||
result_parser: An optional callable with signature ``Callable[[Any], str]`` that
|
||||
overrides the default result parsing behavior. When provided, this callable
|
||||
is used to convert the raw function return value to a string instead of the
|
||||
built-in :meth:`parse_result` logic. Depending on your function, it may be
|
||||
easiest to just do the serialization directly in the function body rather
|
||||
than providing a custom ``result_parser``.
|
||||
built-in :meth:`parse_result` logic. Pass the :data:`SKIP_PARSING` sentinel
|
||||
instead of a callable to opt out of parsing entirely; in that case
|
||||
:meth:`invoke` returns the wrapped function's raw return value. Depending
|
||||
on your function, it may be easiest to just do the serialization directly
|
||||
in the function body rather than providing a custom ``result_parser``.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
# Core attributes (formerly from BaseTool)
|
||||
@@ -508,31 +537,65 @@ class FunctionTool(SerializationMixin):
|
||||
self.invocation_exception_count += 1
|
||||
raise
|
||||
|
||||
@overload
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
arguments: BaseModel | Mapping[str, Any] | None = None,
|
||||
context: FunctionInvocationContext | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
skip_parsing: Literal[True],
|
||||
**kwargs: Any,
|
||||
) -> list[Content]:
|
||||
) -> Any: ...
|
||||
|
||||
@overload
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
arguments: BaseModel | Mapping[str, Any] | None = None,
|
||||
context: FunctionInvocationContext | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
skip_parsing: Literal[False] = False,
|
||||
**kwargs: Any,
|
||||
) -> list[Content]: ...
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
*,
|
||||
arguments: BaseModel | Mapping[str, Any] | None = None,
|
||||
context: FunctionInvocationContext | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
skip_parsing: bool = False,
|
||||
**kwargs: Any,
|
||||
) -> list[Content] | Any:
|
||||
"""Run the AI function with the provided arguments as a Pydantic model.
|
||||
|
||||
The raw return value of the wrapped function is automatically parsed into a
|
||||
``list[Content]`` using :meth:`parse_result` or the custom ``result_parser``
|
||||
if one was provided. Every result — text, rich media, or serialized objects —
|
||||
is represented uniformly as Content items.
|
||||
configured on the tool. Every result — text, rich media, or serialized
|
||||
objects — is represented uniformly as Content items.
|
||||
|
||||
Parsing can be skipped in two ways: configure the tool with
|
||||
``result_parser=SKIP_PARSING`` to always skip parsing, or pass
|
||||
``skip_parsing=True`` per call. Either way the wrapped function's raw value
|
||||
is returned. This is intended for callers (e.g. sandboxed runtimes) that
|
||||
consume the value from Python directly and would otherwise undo the
|
||||
``Content`` wrapping.
|
||||
|
||||
Keyword Args:
|
||||
arguments: A mapping or model instance containing the arguments for the function.
|
||||
context: Explicit function invocation context carrying runtime kwargs.
|
||||
tool_call_id: Optional tool call identifier used for telemetry and tracing.
|
||||
skip_parsing: When ``True``, bypass parsing and return the wrapped function's
|
||||
raw value instead of a ``list[Content]``. Defaults to ``False``.
|
||||
kwargs: Direct function argument values. When provided, every keyword
|
||||
must match a declared tool parameter. Runtime data must be passed
|
||||
via ``context``.
|
||||
|
||||
Returns:
|
||||
A list of Content items representing the tool output.
|
||||
``list[Content]`` by default. The raw function return value (``Any``) when
|
||||
``skip_parsing=True`` (or the tool was constructed with
|
||||
``result_parser=SKIP_PARSING``).
|
||||
|
||||
Raises:
|
||||
TypeError: If arguments is not mapping-like or fails schema checks.
|
||||
@@ -544,7 +607,9 @@ class FunctionTool(SerializationMixin):
|
||||
from ._types import Content
|
||||
from .observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
parser = self.result_parser or FunctionTool.parse_result
|
||||
configured_parser = self.result_parser
|
||||
skip_parsing = skip_parsing or configured_parser is SKIP_PARSING
|
||||
parser = configured_parser if callable(configured_parser) else FunctionTool.parse_result
|
||||
|
||||
parameter_names = set(self.parameters().get("properties", {}).keys())
|
||||
direct_argument_kwargs = (
|
||||
@@ -616,6 +681,10 @@ class FunctionTool(SerializationMixin):
|
||||
logger.debug(f"Function arguments: {observable_kwargs}")
|
||||
res = self.__call__(**call_kwargs)
|
||||
result = await res if inspect.isawaitable(res) else res
|
||||
if skip_parsing:
|
||||
logger.info(f"Function {self.name} succeeded.")
|
||||
logger.debug(f"Function result: {type(result).__name__}")
|
||||
return result
|
||||
try:
|
||||
parsed = parser(result)
|
||||
except Exception:
|
||||
@@ -671,6 +740,13 @@ class FunctionTool(SerializationMixin):
|
||||
logger.error(f"Function failed. Error: {exception}")
|
||||
raise
|
||||
else:
|
||||
if skip_parsing:
|
||||
logger.info(f"Function {self.name} succeeded.")
|
||||
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
|
||||
result_str = str(result)
|
||||
span.set_attribute(OtelAttr.TOOL_RESULT, result_str)
|
||||
logger.debug(f"Function result: {result_str}")
|
||||
return result
|
||||
try:
|
||||
parsed = parser(result)
|
||||
except Exception:
|
||||
@@ -1067,7 +1143,7 @@ def tool(
|
||||
max_invocations: int | None = None,
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
|
||||
) -> FunctionTool: ...
|
||||
|
||||
|
||||
@@ -1083,7 +1159,7 @@ def tool(
|
||||
max_invocations: int | None = None,
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
|
||||
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
|
||||
|
||||
|
||||
@@ -1098,7 +1174,7 @@ def tool(
|
||||
max_invocations: int | None = None,
|
||||
max_invocation_exceptions: int | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | None = None,
|
||||
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
|
||||
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
|
||||
"""Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically.
|
||||
|
||||
|
||||
@@ -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