Skip to content

Commit 534f130

Browse files
authored
Merge branch 'main' into issue-3621
2 parents 87cea32 + b2cbbea commit 534f130

File tree

8 files changed

+2096
-775
lines changed

8 files changed

+2096
-775
lines changed

docs/agents.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,11 @@ It also takes an optional `event_stream_handler` argument that you can use to ga
125125
The example below shows how to stream events and text output. You can also [stream structured output](output.md#streaming-structured-output).
126126

127127
!!! note
128-
As the `run_stream()` method will consider the first output matching the [output type](output.md#structured-output) to be the final output,
129-
it will stop running the agent graph and will not execute any tool calls made by the model after this "final" output.
128+
The `run_stream()` method will consider the first output that matches the [output type](output.md#structured-output) to be the final output of the agent run, even when the model generates tool calls after this "final" output.
130129

131-
If you want to always run the agent graph to completion and stream all events from the model's streaming response and the agent's execution of tools,
130+
These "dangling" tool calls will not be executed unless the agent's [`end_strategy`][pydantic_ai.agent.Agent.end_strategy] is set to `'exhaustive'`, and even then their results will not be sent back to the model as the agent run will already be considered completed.
131+
132+
If you want to always keep running the agent when it performs tool calls, and stream all events from the model's streaming response and the agent's execution of tools,
132133
use [`agent.run_stream_events()`][pydantic_ai.agent.AbstractAgent.run_stream_events] or [`agent.iter()`][pydantic_ai.agent.AbstractAgent.iter] instead, as described in the following sections.
133134

134135
```python {title="run_stream_event_stream_handler.py"}

docs/output.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,15 @@ print(repr(result.output))
306306

307307
_(This example is complete, it can be run "as is")_
308308

309+
##### Parallel Output Tool Calls
310+
311+
When the model calls other tools in parallel with an output tool, you can control how tool calls are executed by setting the agent's [`end_strategy`][pydantic_ai.agent.Agent.end_strategy]:
312+
313+
- `'early'` (default): Output tools are executed first. Once a valid final result is found, remaining function and output tool calls are skipped
314+
- `'exhaustive'`: Output tools are executed first, then all function tools are executed. The first valid output tool result becomes the final output
315+
316+
The `'exhaustive'` strategy is useful when tools have important side effects (like logging, sending notifications, or updating metrics) that should always execute.
317+
309318
#### Native Output
310319

311320
Native Output mode uses a model's native "Structured Outputs" feature (aka "JSON Schema response format"), where the model is forced to only output text matching the provided JSON schema. Note that this is not supported by all models, and sometimes comes with restrictions. For example, Gemini cannot use tools at the same time as structured output, and attempting to do so will result in an error.

docs/tools-advanced.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,13 @@ Async functions are run on the event loop, while sync functions are offloaded to
381381
!!! note "Limiting tool executions"
382382
You can cap tool executions within a run using [`UsageLimits(tool_calls_limit=...)`](agents.md#usage-limits). The counter increments only after a successful tool invocation. Output tools (used for [structured output](output.md)) are not counted in the `tool_calls` metric.
383383

384+
#### Output Tool Calls
385+
386+
When a model calls an [output tool](output.md#tool-output) in parallel with other tools, the agent's [`end_strategy`][pydantic_ai.agent.Agent.end_strategy] parameter controls how these tool calls are executed.
387+
The `'exhaustive'` strategy ensures all tools are executed even after a final result is found, which is useful when tools have side effects (like logging, sending notifications, or updating metrics) that should always execute.
388+
389+
For more information of how `end_strategy` works with both function tools and output tools, see the [Output Tool](output.md#parallel-output-tool-calls) docs.
390+
384391
## See Also
385392

386393
- [Function Tools](tools.md) - Basic tool concepts and registration

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,6 @@
6060
S = TypeVar('S')
6161
NoneType = type(None)
6262
EndStrategy = Literal['early', 'exhaustive']
63-
"""The strategy for handling multiple tool calls when a final result is found.
64-
65-
- `'early'`: Stop processing other tool calls once a final result is found
66-
- `'exhaustive'`: Process all tool calls even after finding a final result
67-
"""
6863
DepsT = TypeVar('DepsT')
6964
OutputT = TypeVar('OutputT')
7065

@@ -865,35 +860,56 @@ async def process_tool_calls( # noqa: C901
865860

866861
# First, we handle output tool calls
867862
for call in tool_calls_by_kind['output']:
868-
if final_result:
869-
if final_result.tool_call_id == call.tool_call_id:
870-
part = _messages.ToolReturnPart(
871-
tool_name=call.tool_name,
872-
content='Final result processed.',
873-
tool_call_id=call.tool_call_id,
874-
)
875-
else:
876-
yield _messages.FunctionToolCallEvent(call)
877-
part = _messages.ToolReturnPart(
878-
tool_name=call.tool_name,
879-
content='Output tool not used - a final result was already processed.',
880-
tool_call_id=call.tool_call_id,
881-
)
882-
yield _messages.FunctionToolResultEvent(part)
883-
863+
# `final_result` can be passed into `process_tool_calls` from `Agent.run_stream`
864+
# when streaming and there's already a final result
865+
if final_result and final_result.tool_call_id == call.tool_call_id:
866+
part = _messages.ToolReturnPart(
867+
tool_name=call.tool_name,
868+
content='Final result processed.',
869+
tool_call_id=call.tool_call_id,
870+
)
871+
output_parts.append(part)
872+
# Early strategy is chosen and final result is already set
873+
elif ctx.deps.end_strategy == 'early' and final_result:
874+
yield _messages.FunctionToolCallEvent(call)
875+
part = _messages.ToolReturnPart(
876+
tool_name=call.tool_name,
877+
content='Output tool not used - a final result was already processed.',
878+
tool_call_id=call.tool_call_id,
879+
)
880+
yield _messages.FunctionToolResultEvent(part)
884881
output_parts.append(part)
882+
# Early strategy is chosen and final result is not yet set
883+
# Or exhaustive strategy is chosen
885884
else:
886885
try:
887886
result_data = await tool_manager.handle_call(call)
888887
except exceptions.UnexpectedModelBehavior as e:
889-
ctx.state.increment_retries(
890-
ctx.deps.max_result_retries, error=e, model_settings=ctx.deps.model_settings
891-
)
892-
raise e # pragma: lax no cover
888+
# If we already have a valid final result, don't fail the entire run
889+
# This allows exhaustive strategy to complete successfully when at least one output tool is valid
890+
if final_result:
891+
# If output tool fails when we already have a final result, skip it without retrying
892+
yield _messages.FunctionToolCallEvent(call)
893+
part = _messages.ToolReturnPart(
894+
tool_name=call.tool_name,
895+
content='Output tool not used - output failed validation.',
896+
tool_call_id=call.tool_call_id,
897+
)
898+
output_parts.append(part)
899+
yield _messages.FunctionToolResultEvent(part)
900+
else:
901+
# No valid result yet, so this is a real failure
902+
ctx.state.increment_retries(
903+
ctx.deps.max_result_retries, error=e, model_settings=ctx.deps.model_settings
904+
)
905+
raise e # pragma: lax no cover
893906
except ToolRetryError as e:
894-
ctx.state.increment_retries(
895-
ctx.deps.max_result_retries, error=e, model_settings=ctx.deps.model_settings
896-
)
907+
# If we already have a valid final result, don't increment retries for invalid output tools
908+
# This allows the run to succeed if at least one output tool returned a valid result
909+
if not final_result:
910+
ctx.state.increment_retries(
911+
ctx.deps.max_result_retries, error=e, model_settings=ctx.deps.model_settings
912+
)
897913
yield _messages.FunctionToolCallEvent(call)
898914
output_parts.append(e.tool_retry)
899915
yield _messages.FunctionToolResultEvent(e.tool_retry)
@@ -904,7 +920,10 @@ async def process_tool_calls( # noqa: C901
904920
tool_call_id=call.tool_call_id,
905921
)
906922
output_parts.append(part)
907-
final_result = result.FinalResult(result_data, call.tool_name, call.tool_call_id)
923+
924+
# In both `early` and `exhaustive` modes, use the first output tool's result as the final result
925+
if not final_result:
926+
final_result = result.FinalResult(result_data, call.tool_name, call.tool_call_id)
908927

909928
# Then, we handle function tool calls
910929
calls_to_run: list[_messages.ToolCallPart] = []

pydantic_ai_slim/pydantic_ai/agent/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,11 @@ class Agent(AbstractAgent[AgentDepsT, OutputDataT]):
116116

117117
_name: str | None
118118
end_strategy: EndStrategy
119-
"""Strategy for handling tool calls when a final result is found."""
119+
"""The strategy for handling multiple tool calls when a final result is found.
120+
121+
- `'early'` (default): Output tools are executed first. Once a valid final result is found, remaining function and output tool calls are skipped
122+
- `'exhaustive'`: Output tools are executed first, then all function tools are executed. The first valid output tool result becomes the final output
123+
"""
120124

121125
model_settings: ModelSettings | None
122126
"""Optional model request settings to use for this agents's runs, by default.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ exclude_lines = [
307307
'except ImportError as _import_error:',
308308
'$\s*pass$',
309309
'assert False',
310+
'@pytest\.mark\.skip',
311+
'@pytest\.mark\.xfail',
310312
]
311313

312314
[tool.logfire]

0 commit comments

Comments
 (0)