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
90 changes: 82 additions & 8 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2164,6 +2164,40 @@ def _finalize_function_result(
return function_result


def _tool_declares_parameter(tool: FunctionTool, name: str) -> bool:
"""Return whether the tool's own schema declares a parameter with this name.

Schema-supplied tools declare through their JSON ``properties``; decorated
tools through their resolved input model. A ``**kwargs`` catch-all does not
declare anything: the model never agreed to such a parameter by name.
"""
if tool._schema_supplied: # pyright: ignore[reportPrivateUsage]
properties = tool._input_schema_cached.get("properties") if tool._input_schema_cached else None # pyright: ignore[reportPrivateUsage]
return isinstance(properties, Mapping) and name in properties
return tool.input_model is not None and name in tool.input_model.model_fields


def _function_call_payload_is_not_an_object(function_call: Content) -> bool:
"""Return whether the model sent a payload that is not a JSON object.

`Content.parse_arguments()` wraps such a payload under a single "raw" key.
That is the right answer for a caller that wants to see what the model sent,
but not for a function call: the tool would run with an argument it never
declared, and the model would not learn that its call was malformed. The
wrapped mapping still reaches the function middleware, which can repair the
arguments the way it repairs any other invalid arguments; when it stays
unrepaired, the innermost handler answers the call instead of running it.
"""
arguments = function_call.arguments
if isinstance(arguments, str) and arguments:
try:
decoded = json.loads(arguments)
except (json.JSONDecodeError, TypeError):
decoded = None
return not isinstance(decoded, dict)
return False


async def _auto_invoke_function(
function_call_content: Content,
custom_args: dict[str, Any] | None = None,
Expand Down Expand Up @@ -2239,7 +2273,25 @@ async def _auto_invoke_function(
approval_response = function_call_content
function_call_content = approved_function_call

parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
# A payload that is not a JSON object still reaches middleware as the raw
# parsed mapping it wraps, so middleware has the same chance to repair it
# that it has for any other invalid arguments. If it is still unrepaired,
# the innermost handler answers it before the tool runs.
payload_is_not_an_object = _function_call_payload_is_not_an_object(function_call_content)
parsed_args = dict(function_call_content.parse_arguments() or {})
wrapped_raw_value = parsed_args.get("raw") if payload_is_not_an_object and set(parsed_args) == {"raw"} else None

def _unrepaired_wrapper(arguments: Any) -> bool:
"""Return whether the arguments are still the wrapped malformed payload.

The wrapper counts as repaired only when middleware replaced it. Keeping
the original ``raw`` entry - which an argument-injecting middleware does
- leaves the call malformed, while a ``raw`` entry the tool itself
declares is ordinary arguments and never counts as the wrapper.
"""
if wrapped_raw_value is None or _tool_declares_parameter(tool, "raw"):
return False
return isinstance(arguments, Mapping) and cast("Mapping[str, Any]", arguments).get("raw") == wrapped_raw_value

# Filter out internal framework kwargs before passing to tools.
# conversation_id is an internal tracking ID that should not be forwarded to tools.
Expand Down Expand Up @@ -2278,6 +2330,15 @@ async def _auto_invoke_function(
# No middleware - execute directly
direct_context = None
try:
if _unrepaired_wrapper(args):
# Without middleware there is nothing that could repair the
# wrapped payload, so answer it before the tool runs. A tool
# that declares ``raw`` receives the payload as ordinary
# arguments instead.
raise _FunctionArgumentValidationError(
f"Invalid arguments for '{tool.name}': arguments are not a JSON object",
redacted_message=f"Invalid arguments for '{tool.name}'.",
)
if getattr(tool, "_context_parameter_name", None):
direct_context = FunctionInvocationContext(
function=tool,
Expand Down Expand Up @@ -2312,13 +2373,17 @@ async def _auto_invoke_function(
return _function_execution_error_result(function_call_content, tool.name, exc, config, direct_context)
# Execute through middleware pipeline if available
arguments_prepared = False
try:
args = tool._prepare_arguments(args) # pyright: ignore[reportPrivateUsage]
arguments_prepared = True
except _FunctionArgumentValidationError:
# Invalid provider arguments are intentionally exposed to middleware so
# it has a supported opportunity to repair them before final validation.
pass
if not payload_is_not_an_object:
try:
args = tool._prepare_arguments(args) # pyright: ignore[reportPrivateUsage]
arguments_prepared = True
except _FunctionArgumentValidationError:
# Invalid provider arguments are intentionally exposed to middleware so
# it has a supported opportunity to repair them before final validation.
pass
# A wrapped malformed payload skips preparation: the model would silently
# drop the wrapper as an extra field, and middleware would never see what
# the model actually sent.

middleware_context = FunctionInvocationContext(
function=tool,
Expand Down Expand Up @@ -2361,6 +2426,15 @@ async def _auto_invoke_function(
async def final_function_handler(context_obj: Any) -> Any:
nonlocal final_handler_started
final_handler_started = True
if _unrepaired_wrapper(context_obj.arguments):
# Middleware left the wrapped payload in place - an argument-injecting
# middleware keeps its ``raw`` entry with the original value - so the
# arguments are still not arguments: answer it here, immediately
# before the tool runs.
raise _FunctionArgumentValidationError(
f"Invalid arguments for '{tool.name}': arguments are not a JSON object",
redacted_message=f"Invalid arguments for '{tool.name}'.",
)
return await tool.invoke(
arguments=context_obj.arguments,
context=context_obj,
Expand Down
Loading
Loading