Skip to content

Commit effca7a

Browse files
committed
Introduce tool_use_behavior on agents
1 parent b09a5bf commit effca7a

12 files changed

+532
-26
lines changed

docs/agents.md

+13
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,16 @@ robot_agent = pirate_agent.clone(
130130
instructions="Write like a robot",
131131
)
132132
```
133+
134+
## Forcing tool use
135+
136+
Supplying a list of tools doesn't always mean the LLM will use a tool. You can force tool use by setting [`ModelSettings.tool_choice`][agents.model_settings.ModelSettings.tool_choice]. Valid values are:
137+
138+
1. `auto`, which allows the LLM to decide whether or not to use a tool.
139+
2. `required`, which requires the LLM to use a tool (but it can intelligently decide which tool).
140+
3. `none`, which requires the LLM to _not_ use a tool.
141+
4. Setting a specific string e.g. `my_tool`, which requires the LLM to use that specific tool.
142+
143+
!!! note
144+
145+
If requiring tool use, you should consider setting [`Agent.tool_use_behavior`] to stop the Agent from running when a tool output is produced. Otherwise, the Agent might run in an infinite loop, where the LLM produces a tool call , and the tool result is sent to the LLM, and this infinite loops because the LLM is always forced to use a tool.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from typing import Any, Literal
5+
6+
from pydantic import BaseModel
7+
8+
from agents import (
9+
Agent,
10+
FunctionToolResult,
11+
ModelSettings,
12+
RunContextWrapper,
13+
Runner,
14+
ToolsToFinalOutputFunction,
15+
ToolsToFinalOutputResult,
16+
function_tool,
17+
)
18+
19+
"""
20+
This example shows how to force the agent to use a tool. It uses `ModelSettings(tool_choice="required")`
21+
to force the agent to use any tool.
22+
23+
You can run it with 3 options:
24+
1. `default`: The default behavior, which is to send the tool output to the LLM. In this case,
25+
`tool_choice` is not set, because otherwise it would result in an infinite loop - the LLM would
26+
call the tool, the tool would run and send the results to the LLM, and that would repeat
27+
(because the model is forced to use a tool every time.)
28+
2. `first_tool_result`: The first tool result is used as the final output.
29+
3. `custom`: A custom tool use behavior function is used. The custom function receives all the tool
30+
results, and chooses to use the first tool result to generate the final output.
31+
32+
Usage:
33+
python examples/agent_patterns/forcing_tool_use.py -t default
34+
python examples/agent_patterns/forcing_tool_use.py -t first_tool
35+
python examples/agent_patterns/forcing_tool_use.py -t custom
36+
"""
37+
38+
39+
class Weather(BaseModel):
40+
city: str
41+
temperature_range: str
42+
conditions: str
43+
44+
45+
@function_tool
46+
def get_weather(city: str) -> Weather:
47+
print("[debug] get_weather called")
48+
return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind")
49+
50+
51+
async def custom_tool_use_behavior(
52+
context: RunContextWrapper[Any], results: list[FunctionToolResult]
53+
) -> ToolsToFinalOutputResult:
54+
weather: Weather = results[0].output
55+
return ToolsToFinalOutputResult(
56+
is_final_output=True, final_output=f"{weather.city} is {weather.conditions}."
57+
)
58+
59+
60+
async def main(tool_use_behavior: Literal["default", "first_tool", "custom"] = "default"):
61+
if tool_use_behavior == "default":
62+
behavior: Literal["run_llm_again", "stop_on_first_tool"] | ToolsToFinalOutputFunction = (
63+
"run_llm_again"
64+
)
65+
elif tool_use_behavior == "first_tool":
66+
behavior = "stop_on_first_tool"
67+
elif tool_use_behavior == "custom":
68+
behavior = custom_tool_use_behavior
69+
70+
agent = Agent(
71+
name="Weather agent",
72+
instructions="You are a helpful agent.",
73+
tools=[get_weather],
74+
tool_use_behavior=behavior,
75+
model_settings=ModelSettings(
76+
tool_choice="required" if tool_use_behavior != "default" else None
77+
),
78+
)
79+
80+
result = await Runner.run(agent, input="What's the weather in Tokyo?")
81+
print(result.final_output)
82+
83+
84+
if __name__ == "__main__":
85+
import argparse
86+
87+
parser = argparse.ArgumentParser()
88+
parser.add_argument(
89+
"-t",
90+
"--tool-use-behavior",
91+
type=str,
92+
required=True,
93+
choices=["default", "first_tool", "custom"],
94+
help="The behavior to use for tool use. Default will cause tool outputs to be sent to the model. "
95+
"first_tool_result will cause the first tool result to be used as the final output. "
96+
"custom will use a custom tool use behavior function.",
97+
)
98+
args = parser.parse_args()
99+
asyncio.run(main(args.tool_use_behavior))

examples/basic/tools.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import asyncio
2+
3+
from pydantic import BaseModel
4+
5+
from agents import Agent, Runner, function_tool
6+
7+
8+
class Weather(BaseModel):
9+
city: str
10+
temperature_range: str
11+
conditions: str
12+
13+
14+
@function_tool
15+
def get_weather(city: str) -> Weather:
16+
print("[debug] get_weather called")
17+
return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.")
18+
19+
20+
agent = Agent(
21+
name="Hello world",
22+
instructions="You are a helpful agent.",
23+
tools=[get_weather],
24+
)
25+
26+
27+
async def main():
28+
result = await Runner.run(agent, input="What's the weather in Tokyo?")
29+
print(result.final_output)
30+
# The weather in Tokyo is sunny.
31+
32+
33+
if __name__ == "__main__":
34+
asyncio.run(main())

src/agents/__init__.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from openai import AsyncOpenAI
66

77
from . import _config
8-
from .agent import Agent
8+
from .agent import Agent, ToolsToFinalOutputFunction, ToolsToFinalOutputResult
99
from .agent_output import AgentOutputSchema
1010
from .computer import AsyncComputer, Button, Computer, Environment
1111
from .exceptions import (
@@ -57,6 +57,7 @@
5757
ComputerTool,
5858
FileSearchTool,
5959
FunctionTool,
60+
FunctionToolResult,
6061
Tool,
6162
WebSearchTool,
6263
default_tool_error_function,
@@ -136,6 +137,8 @@ def enable_verbose_stdout_logging():
136137

137138
__all__ = [
138139
"Agent",
140+
"ToolsToFinalOutputFunction",
141+
"ToolsToFinalOutputResult",
139142
"Runner",
140143
"Model",
141144
"ModelProvider",
@@ -189,6 +192,7 @@ def enable_verbose_stdout_logging():
189192
"AgentUpdatedStreamEvent",
190193
"StreamEvent",
191194
"FunctionTool",
195+
"FunctionToolResult",
192196
"ComputerTool",
193197
"FileSearchTool",
194198
"Tool",

src/agents/_run_impl.py

+75-10
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import inspect
5+
from collections.abc import Awaitable
46
from dataclasses import dataclass
5-
from typing import TYPE_CHECKING, Any
7+
from typing import TYPE_CHECKING, Any, cast
68

79
from openai.types.responses import (
810
ResponseComputerToolCall,
@@ -25,7 +27,7 @@
2527
from openai.types.responses.response_input_param import ComputerCallOutput
2628
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
2729

28-
from .agent import Agent
30+
from .agent import Agent, ToolsToFinalOutputResult
2931
from .agent_output import AgentOutputSchema
3032
from .computer import AsyncComputer, Computer
3133
from .exceptions import AgentsException, ModelBehaviorError, UserError
@@ -48,7 +50,7 @@
4850
from .models.interface import ModelTracing
4951
from .run_context import RunContextWrapper, TContext
5052
from .stream_events import RunItemStreamEvent, StreamEvent
51-
from .tool import ComputerTool, FunctionTool
53+
from .tool import ComputerTool, FunctionTool, FunctionToolResult
5254
from .tracing import (
5355
SpanError,
5456
Trace,
@@ -70,6 +72,8 @@ class QueueCompleteSentinel:
7072

7173
QUEUE_COMPLETE_SENTINEL = QueueCompleteSentinel()
7274

75+
_NOT_FINAL_OUTPUT = ToolsToFinalOutputResult(is_final_output=False, final_output=None)
76+
7377

7478
@dataclass
7579
class ToolRunHandoff:
@@ -199,7 +203,7 @@ async def execute_tools_and_side_effects(
199203
config=run_config,
200204
),
201205
)
202-
new_step_items.extend(function_results)
206+
new_step_items.extend([result.run_item for result in function_results])
203207
new_step_items.extend(computer_results)
204208

205209
# Second, check if there are any handoffs
@@ -216,6 +220,30 @@ async def execute_tools_and_side_effects(
216220
run_config=run_config,
217221
)
218222

223+
# Third, we'll check if the tool use should result in a final output
224+
check_tool_use = await cls._check_for_final_output_from_tools(
225+
agent=agent,
226+
tool_results=function_results,
227+
context_wrapper=context_wrapper,
228+
config=run_config,
229+
)
230+
231+
if check_tool_use.is_final_output:
232+
# If the output type is str, then let's just stringify it
233+
if not agent.output_type or agent.output_type is str:
234+
check_tool_use.final_output = str(check_tool_use.final_output)
235+
236+
return await cls.execute_final_output(
237+
agent=agent,
238+
original_input=original_input,
239+
new_response=new_response,
240+
pre_step_items=pre_step_items,
241+
new_step_items=new_step_items,
242+
final_output=check_tool_use.final_output,
243+
hooks=hooks,
244+
context_wrapper=context_wrapper,
245+
)
246+
219247
# Now we can check if the model also produced a final output
220248
message_items = [item for item in new_step_items if isinstance(item, MessageOutputItem)]
221249

@@ -355,10 +383,10 @@ async def execute_function_tool_calls(
355383
hooks: RunHooks[TContext],
356384
context_wrapper: RunContextWrapper[TContext],
357385
config: RunConfig,
358-
) -> list[RunItem]:
386+
) -> list[FunctionToolResult]:
359387
async def run_single_tool(
360388
func_tool: FunctionTool, tool_call: ResponseFunctionToolCall
361-
) -> str:
389+
) -> Any:
362390
with function_span(func_tool.name) as span_fn:
363391
if config.trace_include_sensitive_data:
364392
span_fn.span_data.input = tool_call.arguments
@@ -404,10 +432,14 @@ async def run_single_tool(
404432
results = await asyncio.gather(*tasks)
405433

406434
return [
407-
ToolCallOutputItem(
408-
output=str(result),
409-
raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, str(result)),
410-
agent=agent,
435+
FunctionToolResult(
436+
tool=tool_run.function_tool,
437+
output=result,
438+
run_item=ToolCallOutputItem(
439+
output=result,
440+
raw_item=ItemHelpers.tool_call_output_item(tool_run.tool_call, str(result)),
441+
agent=agent,
442+
),
411443
)
412444
for tool_run, result in zip(tool_runs, results)
413445
]
@@ -646,6 +678,39 @@ def stream_step_result_to_queue(
646678
if event:
647679
queue.put_nowait(event)
648680

681+
@classmethod
682+
async def _check_for_final_output_from_tools(
683+
cls,
684+
*,
685+
agent: Agent[TContext],
686+
tool_results: list[FunctionToolResult],
687+
context_wrapper: RunContextWrapper[TContext],
688+
config: RunConfig,
689+
) -> ToolsToFinalOutputResult:
690+
"""Returns (i, final_output)."""
691+
if not tool_results:
692+
return _NOT_FINAL_OUTPUT
693+
694+
if agent.tool_use_behavior == "run_llm_again":
695+
return _NOT_FINAL_OUTPUT
696+
elif agent.tool_use_behavior == "stop_on_first_tool":
697+
return ToolsToFinalOutputResult(
698+
is_final_output=True, final_output=tool_results[0].output
699+
)
700+
elif callable(agent.tool_use_behavior):
701+
if inspect.iscoroutinefunction(agent.tool_use_behavior):
702+
return await cast(
703+
Awaitable[ToolsToFinalOutputResult],
704+
agent.tool_use_behavior(context_wrapper, tool_results),
705+
)
706+
else:
707+
return cast(
708+
ToolsToFinalOutputResult, agent.tool_use_behavior(context_wrapper, tool_results)
709+
)
710+
else:
711+
logger.error(f"Invalid tool_use_behavior: {agent.tool_use_behavior}")
712+
raise UserError(f"Invalid tool_use_behavior: {agent.tool_use_behavior}")
713+
649714

650715
class TraceCtxManager:
651716
"""Creates a trace only if there is no current trace, and manages the trace lifecycle."""

0 commit comments

Comments
 (0)