enhancement: Add tool_name to ToolContext to support shared tool handlers (#1043)
This adds a `tool_name` field to `ToolContext`, which gets passed into the `on_invoke_tool` handler. Helpful for scenarios where we dynamically register multiple tools that all share a single generic handler e.g.in multi-agent setups. As such, by including the name of the tool that was invoked, the handler can now easily branch logic or route requests accordingly. Resolves #1030 All tests pass. and here is a script to test it out https://gist.github.com/vrtnis/ca354244f7a5ecd9a73c0a2d34cb194b --------- Co-authored-by: Kazuhiro Sera <seratch@openai.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# `Tool context`
|
||||
|
||||
::: agents.tool_context
|
||||
+1
-1
@@ -180,7 +180,7 @@ Sometimes, you don't want to use a Python function as a tool. You can directly c
|
||||
- `name`
|
||||
- `description`
|
||||
- `params_json_schema`, which is the JSON schema for the arguments
|
||||
- `on_invoke_tool`, which is an async function that receives the context and the arguments as a JSON string, and must return the tool output as a string.
|
||||
- `on_invoke_tool`, which is an async function that receives a [`ToolContext`][agents.tool_context.ToolContext] and the arguments as a JSON string, and must return the tool output as a string.
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
|
||||
@@ -92,6 +92,7 @@ plugins:
|
||||
- ref/lifecycle.md
|
||||
- ref/items.md
|
||||
- ref/run_context.md
|
||||
- ref/tool_context.md
|
||||
- ref/usage.md
|
||||
- ref/exceptions.md
|
||||
- ref/guardrail.md
|
||||
|
||||
@@ -548,7 +548,11 @@ class RunImpl:
|
||||
func_tool: FunctionTool, tool_call: ResponseFunctionToolCall
|
||||
) -> Any:
|
||||
with function_span(func_tool.name) as span_fn:
|
||||
tool_context = ToolContext.from_agent_context(context_wrapper, tool_call.call_id)
|
||||
tool_context = ToolContext.from_agent_context(
|
||||
context_wrapper,
|
||||
tool_call.call_id,
|
||||
tool_call=tool_call,
|
||||
)
|
||||
if config.trace_include_sensitive_data:
|
||||
span_fn.span_data.input = tool_call.arguments
|
||||
try:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
|
||||
from .run_context import RunContextWrapper, TContext
|
||||
|
||||
@@ -8,16 +10,26 @@ def _assert_must_pass_tool_call_id() -> str:
|
||||
raise ValueError("tool_call_id must be passed to ToolContext")
|
||||
|
||||
|
||||
def _assert_must_pass_tool_name() -> str:
|
||||
raise ValueError("tool_name must be passed to ToolContext")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext(RunContextWrapper[TContext]):
|
||||
"""The context of a tool call."""
|
||||
|
||||
tool_name: str = field(default_factory=_assert_must_pass_tool_name)
|
||||
"""The name of the tool being invoked."""
|
||||
|
||||
tool_call_id: str = field(default_factory=_assert_must_pass_tool_call_id)
|
||||
"""The ID of the tool call."""
|
||||
|
||||
@classmethod
|
||||
def from_agent_context(
|
||||
cls, context: RunContextWrapper[TContext], tool_call_id: str
|
||||
cls,
|
||||
context: RunContextWrapper[TContext],
|
||||
tool_call_id: str,
|
||||
tool_call: Optional[ResponseFunctionToolCall] = None,
|
||||
) -> "ToolContext":
|
||||
"""
|
||||
Create a ToolContext from a RunContextWrapper.
|
||||
@@ -26,4 +38,5 @@ class ToolContext(RunContextWrapper[TContext]):
|
||||
base_values: dict[str, Any] = {
|
||||
f.name: getattr(context, f.name) for f in fields(RunContextWrapper) if f.init
|
||||
}
|
||||
return cls(tool_call_id=tool_call_id, **base_values)
|
||||
tool_name = tool_call.name if tool_call is not None else _assert_must_pass_tool_name()
|
||||
return cls(tool_name=tool_name, tool_call_id=tool_call_id, **base_values)
|
||||
|
||||
+34
-15
@@ -26,7 +26,9 @@ async def test_argless_function():
|
||||
tool = function_tool(argless_function)
|
||||
assert tool.name == "argless_function"
|
||||
|
||||
result = await tool.on_invoke_tool(ToolContext(context=None, tool_call_id="1"), "")
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(context=None, tool_name=tool.name, tool_call_id="1"), ""
|
||||
)
|
||||
assert result == "ok"
|
||||
|
||||
|
||||
@@ -39,11 +41,13 @@ async def test_argless_with_context():
|
||||
tool = function_tool(argless_with_context)
|
||||
assert tool.name == "argless_with_context"
|
||||
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), "")
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_name=tool.name, tool_call_id="1"), "")
|
||||
assert result == "ok"
|
||||
|
||||
# Extra JSON should not raise an error
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), '{"a": 1}')
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), '{"a": 1}'
|
||||
)
|
||||
assert result == "ok"
|
||||
|
||||
|
||||
@@ -56,15 +60,19 @@ async def test_simple_function():
|
||||
tool = function_tool(simple_function, failure_error_function=None)
|
||||
assert tool.name == "simple_function"
|
||||
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), '{"a": 1}')
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), '{"a": 1}'
|
||||
)
|
||||
assert result == 6
|
||||
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), '{"a": 1, "b": 2}')
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), '{"a": 1, "b": 2}'
|
||||
)
|
||||
assert result == 3
|
||||
|
||||
# Missing required argument should raise an error
|
||||
with pytest.raises(ModelBehaviorError):
|
||||
await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), "")
|
||||
await tool.on_invoke_tool(ToolContext(None, tool_name=tool.name, tool_call_id="1"), "")
|
||||
|
||||
|
||||
class Foo(BaseModel):
|
||||
@@ -92,7 +100,9 @@ async def test_complex_args_function():
|
||||
"bar": Bar(x="hello", y=10),
|
||||
}
|
||||
)
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), valid_json)
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), valid_json
|
||||
)
|
||||
assert result == "6 hello10 hello"
|
||||
|
||||
valid_json = json.dumps(
|
||||
@@ -101,7 +111,9 @@ async def test_complex_args_function():
|
||||
"bar": Bar(x="hello", y=10),
|
||||
}
|
||||
)
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), valid_json)
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), valid_json
|
||||
)
|
||||
assert result == "3 hello10 hello"
|
||||
|
||||
valid_json = json.dumps(
|
||||
@@ -111,12 +123,16 @@ async def test_complex_args_function():
|
||||
"baz": "world",
|
||||
}
|
||||
)
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), valid_json)
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), valid_json
|
||||
)
|
||||
assert result == "3 hello10 world"
|
||||
|
||||
# Missing required argument should raise an error
|
||||
with pytest.raises(ModelBehaviorError):
|
||||
await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), '{"foo": {"a": 1}}')
|
||||
await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), '{"foo": {"a": 1}}'
|
||||
)
|
||||
|
||||
|
||||
def test_function_config_overrides():
|
||||
@@ -176,7 +192,9 @@ async def test_manual_function_tool_creation_works():
|
||||
assert tool.params_json_schema[key] == value
|
||||
assert tool.strict_json_schema
|
||||
|
||||
result = await tool.on_invoke_tool(ToolContext(None, tool_call_id="1"), '{"data": "hello"}')
|
||||
result = await tool.on_invoke_tool(
|
||||
ToolContext(None, tool_name=tool.name, tool_call_id="1"), '{"data": "hello"}'
|
||||
)
|
||||
assert result == "hello_done"
|
||||
|
||||
tool_not_strict = FunctionTool(
|
||||
@@ -191,7 +209,8 @@ async def test_manual_function_tool_creation_works():
|
||||
assert "additionalProperties" not in tool_not_strict.params_json_schema
|
||||
|
||||
result = await tool_not_strict.on_invoke_tool(
|
||||
ToolContext(None, tool_call_id="1"), '{"data": "hello", "bar": "baz"}'
|
||||
ToolContext(None, tool_name=tool_not_strict.name, tool_call_id="1"),
|
||||
'{"data": "hello", "bar": "baz"}',
|
||||
)
|
||||
assert result == "hello_done"
|
||||
|
||||
@@ -202,7 +221,7 @@ async def test_function_tool_default_error_works():
|
||||
raise ValueError("test")
|
||||
|
||||
tool = function_tool(my_func)
|
||||
ctx = ToolContext(None, tool_call_id="1")
|
||||
ctx = ToolContext(None, tool_name=tool.name, tool_call_id="1")
|
||||
|
||||
result = await tool.on_invoke_tool(ctx, "")
|
||||
assert "Invalid JSON" in str(result)
|
||||
@@ -226,7 +245,7 @@ async def test_sync_custom_error_function_works():
|
||||
return f"error_{error.__class__.__name__}"
|
||||
|
||||
tool = function_tool(my_func, failure_error_function=custom_sync_error_function)
|
||||
ctx = ToolContext(None, tool_call_id="1")
|
||||
ctx = ToolContext(None, tool_name=tool.name, tool_call_id="1")
|
||||
|
||||
result = await tool.on_invoke_tool(ctx, "")
|
||||
assert result == "error_ModelBehaviorError"
|
||||
@@ -250,7 +269,7 @@ async def test_async_custom_error_function_works():
|
||||
return f"error_{error.__class__.__name__}"
|
||||
|
||||
tool = function_tool(my_func, failure_error_function=custom_sync_error_function)
|
||||
ctx = ToolContext(None, tool_call_id="1")
|
||||
ctx = ToolContext(None, tool_name=tool.name, tool_call_id="1")
|
||||
|
||||
result = await tool.on_invoke_tool(ctx, "")
|
||||
assert result == "error_ModelBehaviorError"
|
||||
|
||||
@@ -16,7 +16,7 @@ class DummyContext:
|
||||
|
||||
|
||||
def ctx_wrapper() -> ToolContext[DummyContext]:
|
||||
return ToolContext(context=DummyContext(), tool_call_id="1")
|
||||
return ToolContext(context=DummyContext(), tool_name="dummy", tool_call_id="1")
|
||||
|
||||
|
||||
@function_tool
|
||||
|
||||
Reference in New Issue
Block a user