Skip to content

Commit 401e260

Browse files
dsfacciniclaude
andauthored
fix: unknown tool calls no longer exhaust global retry counter (#4940)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 943970a commit 401e260

5 files changed

Lines changed: 64 additions & 7 deletions

File tree

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1436,9 +1436,8 @@ async def process_tool_calls( # noqa: C901
14361436
else:
14371437
calls_to_run.extend(tool_calls_by_kind['function'])
14381438

1439-
# Then, we handle unknown tool calls
1439+
# Unknown tools use per-tool retry handling via ModelRetry in ToolManager
14401440
if tool_calls_by_kind['unknown']:
1441-
ctx.state.increment_retries(ctx.deps.max_result_retries)
14421441
calls_to_run.extend(tool_calls_by_kind['unknown'])
14431442

14441443
calls_to_run_results: dict[str, DeferredToolResult] = {}
@@ -1489,6 +1488,7 @@ async def process_tool_calls( # noqa: C901
14891488
else:
14901489
validated = await tool_manager.validate_tool_call(call)
14911490
except exceptions.UnexpectedModelBehavior:
1491+
ctx.state.check_incomplete_tool_call()
14921492
yield _messages.FunctionToolCallEvent(call, args_valid=False)
14931493
raise
14941494
validated_calls[call.tool_call_id] = validated

tests/test_agent.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3511,7 +3511,7 @@ def empty(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
35113511
agent = Agent(FunctionModel(empty))
35123512

35133513
with capture_run_messages() as messages:
3514-
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum retries \(1\) for output validation'):
3514+
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 1"):
35153515
agent.run_sync('Hello')
35163516
assert messages == snapshot(
35173517
[
@@ -3607,7 +3607,7 @@ def empty(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
36073607
agent = Agent(FunctionModel(empty), retries=num_retries)
36083608

36093609
with capture_run_messages() as messages:
3610-
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum retries \(2\) for output validation'):
3610+
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 2"):
36113611
agent.run_sync('Hello')
36123612
assert messages == snapshot(
36133613
[
@@ -9149,6 +9149,44 @@ def test_override_resets_after_context(self):
91499149
assert result.output == 'max_tokens=100 temperature=None'
91509150

91519151

9152+
def test_unknown_tool_with_valid_tool_does_not_exhaust_retries():
9153+
"""Unknown tool calls should not increment the global retry counter.
9154+
9155+
When the model returns both an unknown tool and a valid tool in the same
9156+
response, the unknown tool is handled via per-tool retries (ModelRetry)
9157+
downstream. The global retry counter should only reflect output validation
9158+
retries, not unknown-tool retries, so valid tools keep working.
9159+
9160+
We set retries=2 (per-tool max for unknown tools) and output_retries=1
9161+
(global max for output validation) to isolate the bug: per-tool retries
9162+
allow 2 rounds of the unknown tool, but the old code's global increment
9163+
would exhaust output_retries after just 2 rounds.
9164+
"""
9165+
call_count = 0
9166+
9167+
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
9168+
nonlocal call_count
9169+
call_count += 1
9170+
if call_count <= 2:
9171+
return ModelResponse(
9172+
parts=[
9173+
ToolCallPart('nonexistent_tool', '{}'),
9174+
ToolCallPart('valid_tool', '{"x": 1}'),
9175+
]
9176+
)
9177+
return ModelResponse(parts=[TextPart('done')])
9178+
9179+
agent = Agent(FunctionModel(model_function), retries=2, output_retries=1)
9180+
9181+
@agent.tool_plain
9182+
def valid_tool(x: int) -> str:
9183+
return f'result={x}'
9184+
9185+
result = agent.run_sync('Hello')
9186+
assert result.output == 'done'
9187+
assert call_count == 3
9188+
9189+
91529190
# endregion
91539191

91549192

tests/test_streaming.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ async def ret_a(x: str) -> str: # pragma: no cover
770770
return x
771771

772772
with capture_run_messages() as messages:
773-
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum retries \(0\) for output validation'):
773+
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 0"):
774774
async with agent.run_stream('hello'):
775775
pass
776776

@@ -2629,6 +2629,22 @@ def known_tool(x: int) -> int:
26292629
timestamp=IsNow(tz=timezone.utc),
26302630
),
26312631
),
2632+
FunctionToolCallEvent(
2633+
part=ToolCallPart(
2634+
tool_name='known_tool',
2635+
args={'x': 5},
2636+
tool_call_id=IsStr(),
2637+
),
2638+
args_valid=True,
2639+
),
2640+
FunctionToolCallEvent(
2641+
part=ToolCallPart(
2642+
tool_name='unknown_tool',
2643+
args={'arg': 'value'},
2644+
tool_call_id=IsStr(),
2645+
),
2646+
args_valid=False,
2647+
),
26322648
]
26332649
)
26342650

tests/test_ui.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,8 +531,11 @@ async def stream_function(
531531
'<response>',
532532
"<tool-call name='unknown_tool'>None",
533533
"</tool-call name='unknown_tool'>",
534-
"<error type='UnexpectedModelBehavior'>Exceeded maximum retries (1) for output validation</error>",
535534
'</response>',
535+
'<request>',
536+
"<function-tool-call name='unknown_tool'>None</function-tool-call>",
537+
"<error type='UnexpectedModelBehavior'>Tool 'unknown_tool' exceeded max retries count of 1</error>",
538+
'</request>',
536539
'</stream>',
537540
]
538541
)

tests/test_vercel_ai.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2261,7 +2261,7 @@ async def stream_function(
22612261
'toolName': 'unknown_tool',
22622262
},
22632263
{'type': 'tool-input-available', 'toolCallId': IsStr(), 'toolName': 'unknown_tool', 'input': {}},
2264-
{'type': 'error', 'errorText': 'Exceeded maximum retries (1) for output validation'},
2264+
{'type': 'error', 'errorText': "Tool 'unknown_tool' exceeded max retries count of 1"},
22652265
{'type': 'finish-step'},
22662266
{'type': 'finish', 'finishReason': 'error'},
22672267
'[DONE]',

0 commit comments

Comments
 (0)