Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
prepend_agent_framework_to_user_agent,
)
from ._tools import (
SKIP_PARSING,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
Expand Down Expand Up @@ -258,6 +259,7 @@
"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",
Expand Down
77 changes: 73 additions & 4 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,31 @@
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 the ``result_parser`` keyword argument to ``invoke``, the configured
``result_parser`` (and the default :meth:`FunctionTool.parse_result`) are bypassed
and the wrapped function's return value is returned unchanged.

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.invoke(result_parser=...)`` meaning "do not parse the result"."""

# region Helpers


Expand Down Expand Up @@ -508,31 +533,63 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any:
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,
result_parser: _SkipParsingSentinel,
**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,
result_parser: None = None,
**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,
result_parser: _SkipParsingSentinel | None = None,
**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.

Pass the module-level :data:`SKIP_PARSING` sentinel as ``result_parser`` to
bypass parsing entirely and receive the wrapped function's raw return value.
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.
result_parser: Per-call override. Pass :data:`SKIP_PARSING` to skip parsing
and return the wrapped function's raw value. When omitted (the default),
the tool's configured ``result_parser`` (or :meth:`parse_result`) is used.
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
``result_parser=SKIP_PARSING`` is supplied.

Raises:
TypeError: If arguments is not mapping-like or fails schema checks.
Expand All @@ -544,6 +601,7 @@ async def invoke(
from ._types import Content
from .observability import OBSERVABILITY_SETTINGS

skip_parsing = result_parser is SKIP_PARSING
parser = self.result_parser or FunctionTool.parse_result
Comment thread
eavanvalkenburg marked this conversation as resolved.
Outdated

parameter_names = set(self.parameters().get("properties", {}).keys())
Expand Down Expand Up @@ -616,6 +674,10 @@ async def invoke(
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:
Expand Down Expand Up @@ -671,6 +733,13 @@ async def invoke(
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:
Expand Down
148 changes: 148 additions & 0 deletions python/packages/core/tests/core/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic import BaseModel

from agent_framework import (
SKIP_PARSING,
Content,
FunctionTool,
tool,
Expand Down Expand Up @@ -1298,6 +1299,153 @@ def __len__(self) -> int:
assert len(normalized) == 2
assert normalized[0] is bundled
assert normalized[1] is standalone
# region SKIP_PARSING sentinel


async def test_invoke_skip_parsing_returns_native_value() -> None:
"""invoke(result_parser=SKIP_PARSING) 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"}, result_parser=SKIP_PARSING)

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(result_parser=SKIP_PARSING)

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}, result_parser=SKIP_PARSING)
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 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(result_parser=SKIP_PARSING)
assert raw == {"a": 1}
assert parser_calls == []

# Sanity: omitting result_parser still applies the configured parser.
parsed = await make_dict.invoke()
assert parsed[0].type == "text"
assert parsed[0].text == "PARSED"


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}, result_parser=SKIP_PARSING)


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"}, result_parser=SKIP_PARSING, 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={}, result_parser=SKIP_PARSING)


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", result_parser=SKIP_PARSING)

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
6 changes: 6 additions & 0 deletions python/packages/hyperlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,9 @@ codeact = HyperlightCodeActProvider(
- `allowed_domains` accepts a single string target such as `"github.com"` to
allow all backend-supported methods, an explicit `(target, method_or_methods)`
tuple such as `("github.com", "GET")`, or an `AllowedDomain` named tuple.
- Tools registered with the sandbox return their native Python value
(`dict`, `list`, primitives, or custom objects) directly to the guest via the
Hyperlight FFI. Any `result_parser` configured on a `FunctionTool` is
intended for LLM-facing consumers and does not run on the sandbox path —
apply formatting inside the tool function itself if you need it for
in-sandbox consumers.
Loading
Loading