Skip to content

Commit 927a29c

Browse files
authored
Fix potential infinite tool call loop by resetting tool_choice after … (#263)
# Fix potential infinite tool call loop by resetting tool_choice after tool execution ## Summary This PR fixes an issue where setting `tool_choice` to "required" or a specific function name could cause models to get stuck in an infinite tool call loop. When `tool_choice` is set to force tool usage, this setting persists across model invocations. This PR automatically resets `tool_choice` to "auto" after tool execution, allowing the model to decide whether to make additional tool calls in subsequent turns. Unlike using `tool_use_behavior="stop_on_first_tool"`, this approach lets the model continue processing tool results while preventing forced repeated tool calls. ## Test plan - Added tests to verify tool_choice reset behavior for both agent and run_config settings - Added integration test to verify the solution prevents infinite loops - All tests pass ## Checks - [x] I've added new tests for the fix - [x] I've updated the relevant documentation (added comment in code) - [x] I've run `make lint` and `make format` - [x] I've made sure tests pass
2 parents 13abb68 + 07a4af1 commit 927a29c

File tree

3 files changed

+213
-2
lines changed

3 files changed

+213
-2
lines changed

Diff for: docs/agents.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,11 @@ Supplying a list of tools doesn't always mean the LLM will use a tool. You can f
142142

143143
!!! note
144144

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.
145+
To prevent infinite loops, the framework automatically resets `tool_choice` to "auto" after a tool call in the following scenarios:
146+
147+
1. When `tool_choice` is set to a specific function name (any string that's not "auto", "required", or "none")
148+
2. When `tool_choice` is set to "required" AND there is only one tool available
149+
150+
This targeted reset mechanism allows the model to decide whether to make additional tool calls in subsequent turns while avoiding infinite loops in these specific cases.
151+
152+
If you want the Agent to completely stop after a tool call (rather than continuing with auto mode), you can set [`Agent.tool_use_behavior="stop_on_first_tool"`] which will directly use the tool output as the final response without further LLM processing.

Diff for: src/agents/_run_impl.py

+44-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import dataclasses
45
import inspect
56
from collections.abc import Awaitable
67
from dataclasses import dataclass
@@ -47,10 +48,11 @@
4748
)
4849
from .lifecycle import RunHooks
4950
from .logger import logger
51+
from .model_settings import ModelSettings
5052
from .models.interface import ModelTracing
5153
from .run_context import RunContextWrapper, TContext
5254
from .stream_events import RunItemStreamEvent, StreamEvent
53-
from .tool import ComputerTool, FunctionTool, FunctionToolResult
55+
from .tool import ComputerTool, FunctionTool, FunctionToolResult, Tool
5456
from .tracing import (
5557
SpanError,
5658
Trace,
@@ -206,6 +208,29 @@ async def execute_tools_and_side_effects(
206208
new_step_items.extend([result.run_item for result in function_results])
207209
new_step_items.extend(computer_results)
208210

211+
# Reset tool_choice to "auto" after tool execution to prevent infinite loops
212+
if processed_response.functions or processed_response.computer_actions:
213+
tools = agent.tools
214+
215+
if (
216+
run_config.model_settings and
217+
cls._should_reset_tool_choice(run_config.model_settings, tools)
218+
):
219+
# update the run_config model settings with a copy
220+
new_run_config_settings = dataclasses.replace(
221+
run_config.model_settings,
222+
tool_choice="auto"
223+
)
224+
run_config = dataclasses.replace(run_config, model_settings=new_run_config_settings)
225+
226+
if cls._should_reset_tool_choice(agent.model_settings, tools):
227+
# Create a modified copy instead of modifying the original agent
228+
new_model_settings = dataclasses.replace(
229+
agent.model_settings,
230+
tool_choice="auto"
231+
)
232+
agent = dataclasses.replace(agent, model_settings=new_model_settings)
233+
209234
# Second, check if there are any handoffs
210235
if run_handoffs := processed_response.handoffs:
211236
return await cls.execute_handoffs(
@@ -296,6 +321,24 @@ async def execute_tools_and_side_effects(
296321
next_step=NextStepRunAgain(),
297322
)
298323

324+
@classmethod
325+
def _should_reset_tool_choice(cls, model_settings: ModelSettings, tools: list[Tool]) -> bool:
326+
if model_settings is None or model_settings.tool_choice is None:
327+
return False
328+
329+
# for specific tool choices
330+
if (
331+
isinstance(model_settings.tool_choice, str) and
332+
model_settings.tool_choice not in ["auto", "required", "none"]
333+
):
334+
return True
335+
336+
# for one tool and required tool choice
337+
if model_settings.tool_choice == "required":
338+
return len(tools) == 1
339+
340+
return False
341+
299342
@classmethod
300343
def process_model_response(
301344
cls,

Diff for: tests/test_tool_choice_reset.py

+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import pytest
2+
3+
from agents import Agent, ModelSettings, Runner, Tool
4+
from agents._run_impl import RunImpl
5+
6+
from .fake_model import FakeModel
7+
from .test_responses import (
8+
get_function_tool,
9+
get_function_tool_call,
10+
get_text_message,
11+
)
12+
13+
14+
class TestToolChoiceReset:
15+
16+
def test_should_reset_tool_choice_direct(self):
17+
"""
18+
Test the _should_reset_tool_choice method directly with various inputs
19+
to ensure it correctly identifies cases where reset is needed.
20+
"""
21+
# Case 1: tool_choice = None should not reset
22+
model_settings = ModelSettings(tool_choice=None)
23+
tools1: list[Tool] = [get_function_tool("tool1")]
24+
# Cast to list[Tool] to fix type checking issues
25+
assert not RunImpl._should_reset_tool_choice(model_settings, tools1)
26+
27+
# Case 2: tool_choice = "auto" should not reset
28+
model_settings = ModelSettings(tool_choice="auto")
29+
assert not RunImpl._should_reset_tool_choice(model_settings, tools1)
30+
31+
# Case 3: tool_choice = "none" should not reset
32+
model_settings = ModelSettings(tool_choice="none")
33+
assert not RunImpl._should_reset_tool_choice(model_settings, tools1)
34+
35+
# Case 4: tool_choice = "required" with one tool should reset
36+
model_settings = ModelSettings(tool_choice="required")
37+
assert RunImpl._should_reset_tool_choice(model_settings, tools1)
38+
39+
# Case 5: tool_choice = "required" with multiple tools should not reset
40+
model_settings = ModelSettings(tool_choice="required")
41+
tools2: list[Tool] = [get_function_tool("tool1"), get_function_tool("tool2")]
42+
assert not RunImpl._should_reset_tool_choice(model_settings, tools2)
43+
44+
# Case 6: Specific tool choice should reset
45+
model_settings = ModelSettings(tool_choice="specific_tool")
46+
assert RunImpl._should_reset_tool_choice(model_settings, tools1)
47+
48+
@pytest.mark.asyncio
49+
async def test_required_tool_choice_with_multiple_runs(self):
50+
"""
51+
Test scenario 1: When multiple runs are executed with tool_choice="required"
52+
Ensure each run works correctly and doesn't get stuck in infinite loop
53+
Also verify that tool_choice remains "required" between runs
54+
"""
55+
# Set up our fake model with responses for two runs
56+
fake_model = FakeModel()
57+
fake_model.add_multiple_turn_outputs([
58+
[get_text_message("First run response")],
59+
[get_text_message("Second run response")]
60+
])
61+
62+
# Create agent with a custom tool and tool_choice="required"
63+
custom_tool = get_function_tool("custom_tool")
64+
agent = Agent(
65+
name="test_agent",
66+
model=fake_model,
67+
tools=[custom_tool],
68+
model_settings=ModelSettings(tool_choice="required"),
69+
)
70+
71+
# First run should work correctly and preserve tool_choice
72+
result1 = await Runner.run(agent, "first run")
73+
assert result1.final_output == "First run response"
74+
assert agent.model_settings.tool_choice == "required", "tool_choice should stay required"
75+
76+
# Second run should also work correctly with tool_choice still required
77+
result2 = await Runner.run(agent, "second run")
78+
assert result2.final_output == "Second run response"
79+
assert agent.model_settings.tool_choice == "required", "tool_choice should stay required"
80+
81+
@pytest.mark.asyncio
82+
async def test_required_with_stop_at_tool_name(self):
83+
"""
84+
Test scenario 2: When using required tool_choice with stop_at_tool_names behavior
85+
Ensure it correctly stops at the specified tool
86+
"""
87+
# Set up fake model to return a tool call for second_tool
88+
fake_model = FakeModel()
89+
fake_model.set_next_output([
90+
get_function_tool_call("second_tool", "{}")
91+
])
92+
93+
# Create agent with two tools and tool_choice="required" and stop_at_tool behavior
94+
first_tool = get_function_tool("first_tool", return_value="first tool result")
95+
second_tool = get_function_tool("second_tool", return_value="second tool result")
96+
97+
agent = Agent(
98+
name="test_agent",
99+
model=fake_model,
100+
tools=[first_tool, second_tool],
101+
model_settings=ModelSettings(tool_choice="required"),
102+
tool_use_behavior={"stop_at_tool_names": ["second_tool"]},
103+
)
104+
105+
# Run should stop after using second_tool
106+
result = await Runner.run(agent, "run test")
107+
assert result.final_output == "second tool result"
108+
109+
@pytest.mark.asyncio
110+
async def test_specific_tool_choice(self):
111+
"""
112+
Test scenario 3: When using a specific tool choice name
113+
Ensure it doesn't cause infinite loops
114+
"""
115+
# Set up fake model to return a text message
116+
fake_model = FakeModel()
117+
fake_model.set_next_output([get_text_message("Test message")])
118+
119+
# Create agent with specific tool_choice
120+
tool1 = get_function_tool("tool1")
121+
tool2 = get_function_tool("tool2")
122+
tool3 = get_function_tool("tool3")
123+
124+
agent = Agent(
125+
name="test_agent",
126+
model=fake_model,
127+
tools=[tool1, tool2, tool3],
128+
model_settings=ModelSettings(tool_choice="tool1"), # Specific tool
129+
)
130+
131+
# Run should complete without infinite loops
132+
result = await Runner.run(agent, "first run")
133+
assert result.final_output == "Test message"
134+
135+
@pytest.mark.asyncio
136+
async def test_required_with_single_tool(self):
137+
"""
138+
Test scenario 4: When using required tool_choice with only one tool
139+
Ensure it doesn't cause infinite loops
140+
"""
141+
# Set up fake model to return a tool call followed by a text message
142+
fake_model = FakeModel()
143+
fake_model.add_multiple_turn_outputs([
144+
# First call returns a tool call
145+
[get_function_tool_call("custom_tool", "{}")],
146+
# Second call returns a text message
147+
[get_text_message("Final response")]
148+
])
149+
150+
# Create agent with a single tool and tool_choice="required"
151+
custom_tool = get_function_tool("custom_tool", return_value="tool result")
152+
agent = Agent(
153+
name="test_agent",
154+
model=fake_model,
155+
tools=[custom_tool],
156+
model_settings=ModelSettings(tool_choice="required"),
157+
)
158+
159+
# Run should complete without infinite loops
160+
result = await Runner.run(agent, "first run")
161+
assert result.final_output == "Final response"

0 commit comments

Comments
 (0)