Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
35 changes: 30 additions & 5 deletions docs/specs/004-python-function-calling-loop.md

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions python/packages/core/agent_framework/_agent_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, NoReturn, cast

from pydantic import BaseModel
Expand Down Expand Up @@ -397,14 +398,37 @@ def _write_back_message_list(
return result


def _argument_value_to_wire(value: Any) -> Any:
"""Recursively encode simple Enum scalars before the existing JSON-safe projection."""
if isinstance(value, Enum):
# IntEnum and str-backed enums already follow make_json_safe's scalar behavior.
if isinstance(value, (str, int, float, bool)):
return make_json_safe(value)
from ._tools import _is_simple_enum_member # pyright: ignore[reportPrivateUsage]

if not _is_simple_enum_member(value):
raise TypeError("agent-hooks arguments require a simple JSON scalar Enum without custom member state.")
return value.value
if isinstance(value, BaseModel):
return _argument_value_to_wire(value.model_dump())
if isinstance(value, dict):
value_as_dict = cast("dict[Any, Any]", value)
return {str(key): _argument_value_to_wire(item) for key, item in value_as_dict.items()}
if isinstance(value, list):
return [_argument_value_to_wire(item) for item in cast("list[Any]", value)]
if isinstance(value, tuple):
return [_argument_value_to_wire(item) for item in cast("tuple[Any, ...]", value)]
return make_json_safe(value)


def _arguments_to_wire(arguments: Any) -> dict[str, Any]:
"""Project tool-call arguments as the spec's ``args`` object."""
if arguments is None:
return {}
if isinstance(arguments, BaseModel):
return {str(key): make_json_safe(item) for key, item in arguments.model_dump().items()}
return {str(key): _argument_value_to_wire(item) for key, item in arguments.model_dump().items()}
if isinstance(arguments, Mapping):
return {str(key): make_json_safe(item) for key, item in cast("Mapping[Any, Any]", arguments).items()}
return {str(key): _argument_value_to_wire(item) for key, item in cast("Mapping[Any, Any]", arguments).items()}
if isinstance(arguments, str):
with contextlib.suppress(ValueError):
parsed = json.loads(arguments)
Expand Down
267 changes: 225 additions & 42 deletions python/packages/core/agent_framework/_tools.py

Large diffs are not rendered by default.

21 changes: 19 additions & 2 deletions python/packages/core/agent_framework/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
_SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY, # pyright: ignore[reportPrivateUsage]
FunctionTool,
_argument_authority_token, # pyright: ignore[reportPrivateUsage]
_argument_comparison_token, # pyright: ignore[reportPrivateUsage]
_contains_typed_approval_token, # pyright: ignore[reportPrivateUsage]
tool,
)
from ._types import Content, Message
Expand Down Expand Up @@ -2791,12 +2793,27 @@ def _current_arguments(self, context: FunctionInvocationContext) -> dict[str, An
return context.arguments.model_dump()
return dict(context.arguments)

def _approval_arguments(self, context: FunctionInvocationContext) -> dict[str, Any]:
"""Encode newly supported scalars only when the existing policy signature is unchanged."""
arguments = self._current_arguments(context)
if not _contains_typed_approval_token(_argument_comparison_token(arguments), include_primitive_enums=False):
return arguments
encoded = cast(
dict[str, Any],
_strict_json_value(arguments, path="approval invocation.arguments", canonical=False),
)
if self._signature_from_parts(context.function.name, arguments) != self._signature_from_parts(
context.function.name, encoded
):
raise ValueError("Policy approval arguments cannot retain their signature in JSON.")
return encoded

def _build_function_call_content(self, context: FunctionInvocationContext) -> Content:
"""Reconstruct the current function call as Content for approval requests."""
return Content.from_function_call(
call_id=self._get_call_id(context),
name=context.function.name,
arguments=self._current_arguments(context),
arguments=self._approval_arguments(context),
id=self._get_approval_id(context),
)

Expand All @@ -2816,7 +2833,7 @@ def _signature_from_parts(self, name: str | None, arguments: dict[str, Any]) ->
return hashlib.sha256(payload).hexdigest()

def _call_body_signature(self, context: FunctionInvocationContext) -> str:
return self._signature_from_parts(context.function.name, self._current_arguments(context))
return self._signature_from_parts(context.function.name, self._approval_arguments(context))

def _resolved_arguments(self, context: FunctionInvocationContext) -> dict[str, Any]:
if isinstance(context.arguments, BaseModel):
Expand Down
Loading
Loading