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
11 changes: 11 additions & 0 deletions src/agents/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,17 @@ def __agents_bind_function_tool__(
return bound_invoker

async def __call__(self, ctx: ToolContext[Any], input: str) -> Any:
# Validate the context up front. A non-context value (most commonly None) would
# otherwise blow up deep in the invocation with a cryptic AttributeError, so fail
# fast here with an actionable message before any tool logic runs. The base
# RunContextWrapper is accepted because agent-as-tool invokers run with one; the
# message points to ToolContext since that is what function tools need.
if not isinstance(ctx, RunContextWrapper):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject base contexts for regular function tools

For a normal @function_tool, passing a plain RunContextWrapper is still the wrong direct-call context, but this guard lets it through because ToolContext subclasses RunContextWrapper. In that scenario _on_invoke_tool_impl still dereferences ctx.tool_name, and with the default failure handler _on_handled_error then dereferences context.run_config, so callers get the same masked AttributeError instead of the new clear TypeError; only agent-as-tool invokers should accept the base wrapper.

Useful? React with 👍 / 👎.

raise TypeError(
f"on_invoke_tool requires a ToolContext, got {type(ctx).__name__}. "
"Construct one with ToolContext(context=..., tool_name=..., "
"tool_call_id=..., tool_arguments=...) or invoke the tool through Runner."
)
try:
return await self._invoke_tool_impl(ctx, input)
except Exception as e:
Expand Down
21 changes: 21 additions & 0 deletions tests/test_function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,27 @@ async def test_simple_function():
)


@pytest.mark.asyncio
async def test_on_invoke_tool_rejects_non_tool_context():
tool = function_tool(simple_function)

# A non-ToolContext (most commonly None) should fail fast with a clear TypeError
# instead of a cryptic AttributeError raised deep inside the invocation path.
with pytest.raises(TypeError, match="on_invoke_tool requires a ToolContext, got NoneType"):
await tool.on_invoke_tool(cast(Any, None), '{"a": 1, "b": 2}')

# The error message names the offending type to help developers spot the mistake.
with pytest.raises(TypeError, match="got int"):
await tool.on_invoke_tool(cast(Any, 123), '{"a": 1, "b": 2}')

# A valid ToolContext is unaffected by the guard.
result = await tool.on_invoke_tool(
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments='{"a": 1, "b": 2}'),
'{"a": 1, "b": 2}',
)
assert result == 3


@pytest.mark.asyncio
async def test_sync_function_runs_via_to_thread(monkeypatch: pytest.MonkeyPatch) -> None:
calls = {"to_thread": 0, "func": 0}
Expand Down