Files
wehub-resource-sync 9201ef759e
CI / lint (push) Waiting to run
CI / mypy (push) Waiting to run
CI / docs (push) Waiting to run
CI / test on 3.10 (standard) (push) Waiting to run
CI / test on 3.11 (standard) (push) Waiting to run
CI / test on 3.12 (standard) (push) Waiting to run
CI / test on 3.10 (all-extras) (push) Waiting to run
CI / test on 3.11 (all-extras) (push) Waiting to run
CI / test on 3.12 (all-extras) (push) Waiting to run
CI / test on 3.13 (all-extras) (push) Waiting to run
CI / test on 3.14 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (standard) (push) Waiting to run
CI / test on 3.14 (standard) (push) Waiting to run
CI / test on 3.14 (all-extras) (push) Waiting to run
CI / test on 3.10 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.11 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.12 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.13 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.14 (pydantic-ai-slim) (push) Waiting to run
CI / test on 3.10 (pydantic-evals) (push) Waiting to run
CI / test on 3.11 (pydantic-evals) (push) Waiting to run
CI / test on 3.12 (pydantic-evals) (push) Waiting to run
CI / test on 3.13 (pydantic-evals) (push) Waiting to run
CI / test on 3.10 (lowest-versions) (push) Waiting to run
CI / test on 3.11 (lowest-versions) (push) Waiting to run
CI / test on 3.12 (lowest-versions) (push) Waiting to run
CI / test on 3.13 (lowest-versions) (push) Waiting to run
CI / test on 3.14 (lowest-versions) (push) Waiting to run
CI / test examples on 3.11 (push) Waiting to run
CI / test examples on 3.12 (push) Waiting to run
CI / test examples on 3.13 (push) Waiting to run
CI / test examples on 3.14 (push) Waiting to run
CI / coverage (push) Blocked by required conditions
CI / check (push) Blocked by required conditions
CI / deploy-docs (push) Blocked by required conditions
CI / deploy-docs-preview (push) Blocked by required conditions
CI / build release artifacts (push) Blocked by required conditions
CI / publish to PyPI (push) Blocked by required conditions
CI / Send tweet (push) Blocked by required conditions
Harness Compat / harness compat (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 13:27:52 +08:00

1901 lines
92 KiB
Python

from __future__ import annotations as _annotations
import asyncio
import dataclasses
import inspect
import time
from asyncio import Task
from collections import deque
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Sequence
from contextlib import asynccontextmanager, contextmanager
from contextvars import ContextVar
from copy import deepcopy
from dataclasses import field, replace
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeGuard, cast
from opentelemetry.trace import Tracer
from typing_extensions import TypeVar, assert_never
from pydantic_ai._history_processor import HistoryProcessor
from pydantic_ai._instrumentation import DEFAULT_INSTRUMENTATION_VERSION, time_to_first_chunk_ctx
from pydantic_ai._tool_execution import process_tool_calls
from pydantic_ai._utils import cancel_and_drain, dataclasses_no_defaults_repr, fill_run_metadata, now_utc
from pydantic_ai._uuid import uuid7
from pydantic_ai.capabilities.abstract import AbstractCapability
from pydantic_ai.models import ModelRequestContext
from pydantic_ai.native_tools import AbstractNativeTool
from pydantic_ai.native_tools._tool_search import ToolSearchTool
from pydantic_ai.tool_manager import ToolManager
from pydantic_ai.toolsets._tool_search import parse_discovered_tools
from pydantic_graph import BaseNode, End, Graph, GraphBuilder, GraphRunContext
from pydantic_graph.basenode import NodeRunEndT
from . import _enqueue, _output, _system_prompt, exceptions, messages as _messages, models, result, usage as _usage
from ._deferred_capabilities import parse_loaded_capabilities
from ._instructions import normalize_toolset_instructions
from ._run_context import set_current_run_context
from .exceptions import ToolRetryError
from .output import OutputDataT, OutputSpec
from .settings import ModelSettings
from .tools import (
AgentNativeTool,
DeferredToolResult,
DeferredToolResults,
RunContext,
ToolDefinition,
)
if TYPE_CHECKING:
from datetime import datetime
from .agent import Agent
from .models.instrumented import InstrumentationSettings
__all__ = (
'GraphAgentState',
'GraphAgentDeps',
'UserPromptNode',
'ModelRequestNode',
'CallToolsNode',
'build_run_context',
'capture_run_messages',
'HistoryProcessor',
'resolve_conversation_id',
'process_tool_calls',
)
T = TypeVar('T')
S = TypeVar('S')
NoneType = type(None)
EndStrategy = Literal['early', 'graceful', 'exhaustive']
"""How to handle function tool calls a model requests alongside a result that ends the run.
The final result usually comes from an output tool call, but with
[`NativeOutput`][pydantic_ai.output.NativeOutput], [`PromptedOutput`][pydantic_ai.output.PromptedOutput],
or image output it comes from the text or image the model returns in the same response.
- `'early'`: Output tools run in the order the model emitted them and the run ends at the first one
that succeeds; function tools are not executed. If every output tool fails, function tools run so
the model can correct on the next round. Likewise, if the response contains a valid structured
output (`NativeOutput`/`PromptedOutput` text, or an image) alongside function tool calls, that output
ends the run and the function tools are skipped. Plain, unstructured text output (`str` or
`TextOutput`) does *not* skip tools this way — the model isn't told its text is final, so its
preamble shouldn't silently cancel a tool call; the function tools run and the run continues.
- `'graceful'` (default): Tools run in the order the model emitted them — function tools that precede
an output tool complete before it. Output tools run in order and the first success wins; subsequent
output tools are skipped (their side effects don't run). If a function tool raises
[`ModelRetry`][pydantic_ai.exceptions.ModelRetry], the output result is suppressed and the retry is
surfaced to the model instead.
- `'exhaustive'`: Every tool runs (in parallel by default); the first valid output by emission order
becomes the final result. As with `'graceful'`, a function tool's
[`ModelRetry`][pydantic_ai.exceptions.ModelRetry] suppresses the output result. Use `sequential=True`
on a tool (including via [`ToolOutput`][pydantic_ai.output.ToolOutput]) to make it a barrier that
doesn't overlap with others.
Under `'graceful'` and `'exhaustive'`, a structured output (`NativeOutput`/`PromptedOutput` text, or an
image) returned alongside function tool calls does *not* end the run early: the function tools run and
the run continues, so their results can inform the model's eventual output. Only `'early'` skips them.
The default changed from `'early'` to `'graceful'` in v2. Set `end_strategy='early'` to keep the v1
behavior where the run ends the instant an output tool succeeds.
"""
DepsT = TypeVar('DepsT')
OutputT = TypeVar('OutputT')
async def _cancel_task(task: Task[Any]) -> None:
# `cancel()` is a documented no-op on an already-finished task, so there's no need to guard it.
task.cancel()
try:
await task
except BaseException:
# Called while another stream error is already propagating; await only
# to finish cleanup and retrieve the task exception, not replace it.
pass
NEW_CONVERSATION: Literal['new'] = 'new'
"""Sentinel value for `conversation_id` that forces a fresh conversation, ignoring any
`conversation_id` present in `message_history`. See `resolve_conversation_id`."""
def resolve_conversation_id(
explicit: str | None,
message_history: Sequence[_messages.ModelMessage] | None,
) -> str:
"""Resolve the `conversation_id` to use for an agent run.
Priority:
1. `explicit == 'new'` → fresh UUID7 (forks a conversation off the supplied history).
2. Explicit string → used as-is.
3. Most recent non-`None` `conversation_id` on `message_history` (scanned from the end).
4. Fresh UUID7.
A fresh UUID7 is intentionally distinct from the run's `run_id`, so callers can
treat the two identifiers as independent.
"""
if explicit == NEW_CONVERSATION:
return str(uuid7())
if explicit is not None:
return explicit
if message_history:
for message in reversed(message_history):
if (cid := message.conversation_id) is not None:
return cid
return str(uuid7())
@dataclasses.dataclass(kw_only=True)
class GraphAgentState:
"""State kept across the execution of the agent graph."""
message_history: list[_messages.ModelMessage] = dataclasses.field(default_factory=list[_messages.ModelMessage])
usage: _usage.RunUsage = dataclasses.field(default_factory=_usage.RunUsage)
output_retries_used: int = 0
run_step: int = 0
run_id: str = dataclasses.field(default_factory=lambda: str(uuid7()))
conversation_id: str = dataclasses.field(default_factory=lambda: str(uuid7()))
"""The unique identifier of the conversation this run belongs to.
Resolved from the `conversation_id` argument to `Agent.run` (etc.), the most recent
`conversation_id` on `message_history`, or a freshly generated UUID7. See the
`Agent.iter` docstring for the resolution priority.
"""
metadata: dict[str, Any] | None = None
last_max_tokens: int | None = None
"""Last-resolved `max_tokens` from model settings, used only in error messages."""
last_model_request_parameters: models.ModelRequestParameters | None = None
"""Last-resolved model request parameters, used for OTel span attributes."""
pending_messages: list[_enqueue.PendingMessage] = dataclasses.field(default_factory=list[_enqueue.PendingMessage])
"""Internal: queue used by [`PendingMessageDrainCapability`][pydantic_ai.capabilities._pending_messages.PendingMessageDrainCapability]
for messages enqueued via [`enqueue`][pydantic_ai.tools.RunContext.enqueue] or [`AgentRun.enqueue`][pydantic_ai.run.AgentRun.enqueue]."""
mcp_tool_defs_cache: dict[str, dict[str, ToolDefinition]] = dataclasses.field(
default_factory=dict[str, dict[str, ToolDefinition]]
)
"""Per-run cache of durable-execution MCP toolset tool definitions, keyed by toolset `id`.
Shared by reference into every `RunContext` this run (see `build_run_context`), where it is
exposed as the private `_mcp_tool_defs_cache` field. Recreated per run and reconstructed
identically on durable replay/recovery, which is what keeps the Temporal/DBOS MCP wrappers'
`get_tools` scheduling replay-deterministic."""
def check_incomplete_tool_call(self) -> None:
"""Raise `IncompleteToolCall` if the last model response was truncated mid-tool-call."""
if (
self.message_history
and isinstance(model_response := self.message_history[-1], _messages.ModelResponse)
and model_response.finish_reason == 'length'
and model_response.parts
and isinstance(tool_call := model_response.parts[-1], _messages.ToolCallPart)
):
try:
tool_call.args_as_dict(raise_if_invalid=True)
except Exception:
raise exceptions.IncompleteToolCall(
f'Model token limit ({self.last_max_tokens or "provider default"}) exceeded while generating a tool call, resulting in incomplete arguments. Increase the `max_tokens` model setting, or simplify the prompt to result in a shorter response that will fit within the limit.'
)
def consume_output_retry(
self,
max_output_retries: int,
error: BaseException | None = None,
) -> None:
"""Record one unit of output-retry budget consumption.
Raises `UnexpectedModelBehavior` when `output_retries_used` would exceed
`max_output_retries`. Called for `ModelRetry`s from output validators (text path)
and for `ToolRetryError`s from output-tool dispatch / empty-or-non-actionable
responses; per-tool retry limits are still enforced separately by
`ToolManager._check_max_retries`.
"""
self.output_retries_used += 1
if self.output_retries_used > max_output_retries:
self.check_incomplete_tool_call()
message = f'Exceeded maximum output retries ({max_output_retries})'
raise exceptions.UnexpectedModelBehavior(message) from error
@dataclasses.dataclass(kw_only=True)
class GraphAgentDeps(Generic[DepsT, OutputDataT]):
"""Dependencies/config passed to the agent graph."""
user_deps: DepsT
prompt: str | Sequence[_messages.UserContent] | None
new_message_index: int
resumed_request: _messages.ModelRequest | None
resumed_request_index: int | None
model: models.Model
get_model_settings: Callable[[RunContext[DepsT]], ModelSettings | None]
usage_limits: _usage.UsageLimits
max_output_retries: int
end_strategy: EndStrategy
get_instructions: Callable[[RunContext[DepsT]], Awaitable[list[_messages.InstructionPart] | None]]
output_schema: _output.OutputSchema[OutputDataT]
output_validators: list[_output.OutputValidator[DepsT, OutputDataT]]
validation_context: Any | Callable[[RunContext[DepsT]], Any]
root_capability: AbstractCapability[DepsT]
capabilities: dict[str, AbstractCapability[DepsT]]
# Invariant: these two sets are shared by reference into every `RunContext` this run (their
# identity survives `replace(ctx, ...)`, which shallow-copies) and are only ever mutated in
# place — never reassigned. The per-step refresh and the `load_capability` tool body rely on
# that shared identity. Reassigning either (here, or by passing it to a `replace(ctx, ...=...)`)
# would silently break in-step capability loads / tool reveals.
loaded_capability_ids: set[str]
discovered_tool_names: set[str]
native_tools: list[AgentNativeTool[DepsT]] = dataclasses.field(repr=False)
tool_manager: ToolManager[DepsT]
tracer: Tracer
instrumentation_settings: InstrumentationSettings | None
agent: Agent[DepsT, Any] | None = None
class AgentNode(BaseNode[GraphAgentState, GraphAgentDeps[DepsT, Any], result.FinalResult[NodeRunEndT]]):
"""The base class for all agent nodes.
Using subclass of `BaseNode` for all nodes reduces the amount of boilerplate of generics everywhere
"""
def is_agent_node(
node: BaseNode[GraphAgentState, GraphAgentDeps[T, Any], result.FinalResult[S]] | End[result.FinalResult[S]],
) -> TypeGuard[AgentNode[T, S]]:
"""Check if the provided node is an instance of `AgentNode`.
Usage:
if is_agent_node(node):
# `node` is an AgentNode
...
This method preserves the generic parameters on the narrowed type, unlike `isinstance(node, AgentNode)`.
"""
return isinstance(node, AgentNode)
@dataclasses.dataclass
class UserPromptNode(AgentNode[DepsT, NodeRunEndT]):
"""The node that handles the user prompt and instructions."""
user_prompt: str | Sequence[_messages.UserContent] | None
_: dataclasses.KW_ONLY
deferred_tool_results: DeferredToolResults | None = None
instructions: str | None = None
instructions_functions: list[_system_prompt.SystemPromptRunner[DepsT]] = dataclasses.field(
default_factory=list[_system_prompt.SystemPromptRunner[DepsT]]
)
system_prompts: tuple[str, ...] = dataclasses.field(default_factory=tuple)
system_prompt_functions: list[_system_prompt.SystemPromptRunner[DepsT]] = dataclasses.field(
default_factory=list[_system_prompt.SystemPromptRunner[DepsT]]
)
system_prompt_dynamic_functions: dict[str, _system_prompt.SystemPromptRunner[DepsT]] = dataclasses.field(
default_factory=dict[str, _system_prompt.SystemPromptRunner[DepsT]]
)
async def run( # noqa: C901
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> ModelRequestNode[DepsT, NodeRunEndT] | CallToolsNode[DepsT, NodeRunEndT]:
try:
ctx_messages = get_captured_run_messages()
except LookupError:
messages: list[_messages.ModelMessage] = []
else:
if ctx_messages.used:
messages = []
else:
messages = ctx_messages.messages
ctx_messages.used = True
# Replace the `capture_run_messages` list with the message history
messages[:] = _clean_message_history(ctx.state.message_history)
# Use the `capture_run_messages` list as the message history so that new messages are added to it
ctx.state.message_history = messages
ctx.deps.new_message_index = len(messages)
if self.deferred_tool_results is not None:
return await self._handle_deferred_tool_results(self.deferred_tool_results, messages, ctx)
next_message: _messages.ModelRequest | None = None
is_resuming_without_prompt = False
run_context: RunContext[DepsT] | None = None
if messages and (last_message := messages[-1]):
if isinstance(last_message, _messages.ModelRequest) and self.user_prompt is None:
# Drop last message from history and reuse its parts
messages.pop()
next_message = _messages.ModelRequest(
parts=last_message.parts,
run_id=last_message.run_id,
conversation_id=last_message.conversation_id,
metadata=last_message.metadata,
)
is_resuming_without_prompt = True
# Extract `UserPromptPart` content from the popped message and add to `ctx.deps.prompt`
user_prompt_parts = [part for part in last_message.parts if isinstance(part, _messages.UserPromptPart)]
if user_prompt_parts:
if len(user_prompt_parts) == 1:
ctx.deps.prompt = user_prompt_parts[0].content
else:
combined_content: list[_messages.UserContent] = []
for part in user_prompt_parts:
if isinstance(part.content, str):
combined_content.append(part.content)
else:
combined_content.extend(part.content)
ctx.deps.prompt = combined_content
elif isinstance(last_message, _messages.ModelResponse):
if self.user_prompt is None:
# Align with the upcoming request step so we don't resolve dynamic toolsets twice.
run_context = replace(
build_run_context(ctx),
run_step=ctx.state.run_step + 1,
retry=ctx.state.output_retries_used,
max_retries=ctx.deps.tool_manager.default_max_retries,
)
ctx.deps.tool_manager = await ctx.deps.tool_manager.for_run_step(run_context)
if last_message.tool_calls:
# Pending tool calls must be processed before any new ModelRequest, regardless
# of instructions. Instructions will be applied by ModelRequestNode.run() on
# the subsequent request after tool results are collected.
return CallToolsNode[DepsT, NodeRunEndT](last_message)
instruction_parts = await _get_instructions(ctx, run_context)
if not instruction_parts:
# No pending tool calls and no instructions — nothing new to send to the model.
return CallToolsNode[DepsT, NodeRunEndT](last_message)
elif last_message.tool_calls:
raise exceptions.UserError(
'Cannot provide a new user prompt when the message history contains unprocessed tool calls.'
)
if not run_context:
run_context = build_run_context(ctx)
if messages:
await self._reevaluate_dynamic_prompts(messages, run_context)
if next_message:
await self._reevaluate_dynamic_prompts([next_message], run_context)
else:
parts: list[_messages.ModelRequestPart] = []
if not messages:
parts.extend(await self._sys_parts(run_context))
if self.user_prompt is not None:
parts.append(_messages.UserPromptPart(self.user_prompt))
next_message = _messages.ModelRequest(parts=parts)
return ModelRequestNode[DepsT, NodeRunEndT](
request=next_message, is_resuming_without_prompt=is_resuming_without_prompt
)
async def _handle_deferred_tool_results(
self,
deferred_tool_results: DeferredToolResults,
messages: list[_messages.ModelMessage],
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
) -> CallToolsNode[DepsT, NodeRunEndT]:
if not messages:
raise exceptions.UserError('Tool call results were provided, but the message history is empty.')
last_model_request: _messages.ModelRequest | None = None
last_model_response: _messages.ModelResponse | None = None
for message in reversed(messages):
if isinstance(message, _messages.ModelRequest):
last_model_request = message
elif isinstance(message, _messages.ModelResponse): # pragma: no branch
last_model_response = message
break
if not last_model_response:
raise exceptions.UserError(
'Tool call results were provided, but the message history does not contain a `ModelResponse`.'
)
if not last_model_response.tool_calls:
raise exceptions.UserError(
'Tool call results were provided, but the message history does not contain any unprocessed tool calls.'
)
tool_call_results: dict[str, DeferredToolResult | Literal['skip']] = {}
tool_call_results.update(deferred_tool_results.to_tool_call_results())
if last_model_request:
for part in last_model_request.parts:
if isinstance(part, _messages.ToolReturnPart | _messages.RetryPromptPart):
if part.tool_call_id in tool_call_results:
raise exceptions.UserError(
f'Tool call {part.tool_call_id!r} was already executed and its result cannot be overridden.'
)
tool_call_results[part.tool_call_id] = 'skip'
# Skip ModelRequestNode and go directly to CallToolsNode
return CallToolsNode[DepsT, NodeRunEndT](
last_model_response,
tool_call_results=tool_call_results,
tool_call_metadata=deferred_tool_results.metadata or None,
user_prompt=self.user_prompt,
)
async def _reevaluate_dynamic_prompts(
self, messages: list[_messages.ModelMessage], run_context: RunContext[DepsT]
) -> None:
"""Reevaluate any `SystemPromptPart` with dynamic_ref in the provided messages by running the associated runner function."""
# Only proceed if there's at least one dynamic runner.
if self.system_prompt_dynamic_functions:
for msg in messages:
if isinstance(msg, _messages.ModelRequest):
reevaluated_message_parts: list[_messages.ModelRequestPart] = []
for part in msg.parts:
if isinstance(part, _messages.SystemPromptPart) and part.dynamic_ref:
# Look up the runner by its ref
if runner := self.system_prompt_dynamic_functions.get( # pragma: lax no cover
part.dynamic_ref
):
# To enable dynamic system prompt refs in future runs, use a placeholder string
updated_part_content = await runner.run(run_context)
part = _messages.SystemPromptPart(
updated_part_content or '', dynamic_ref=part.dynamic_ref
)
reevaluated_message_parts.append(part)
# Replace message parts with reevaluated ones to prevent mutating parts list
if reevaluated_message_parts != msg.parts:
msg.parts = reevaluated_message_parts
async def _sys_parts(self, run_context: RunContext[DepsT]) -> list[_messages.SystemPromptPart]:
"""Build the initial system-prompt messages for the conversation."""
return await _system_prompt.resolve_system_prompts(
self.system_prompts, self.system_prompt_functions, run_context
)
__repr__ = dataclasses_no_defaults_repr
async def _get_instructions(
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
run_context: RunContext[DepsT],
) -> list[_messages.InstructionPart] | None:
"""Combine base instructions (from agent/capabilities) with toolset instructions.
Toolset instructions are fetched from the current tool manager's toolset,
which reflects any changes from for_run_step.
"""
parts: list[_messages.InstructionPart] = []
base = await ctx.deps.get_instructions(run_context)
if base:
parts.extend(base)
toolset_result = await ctx.deps.tool_manager.toolset.get_instructions(run_context)
parts.extend(normalize_toolset_instructions(toolset_result))
return parts or None
async def _prepare_request_parameters(
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
instruction_parts: list[_messages.InstructionPart] | None,
) -> models.ModelRequestParameters:
"""Build tools and create an agent model."""
output_schema = ctx.deps.output_schema
prompted_output_template = (
output_schema.template if isinstance(output_schema, _output.StructuredTextOutputSchema) else None
)
# `tool_manager.tool_defs` already reflects the `prepare_tools`/`prepare_output_tools`
# capability hooks — they're dispatched at `get_tools()` time via `PreparedToolset`
# wrappers in `Agent._get_toolset`, so the filtered/modified defs are baked into
# `ToolManager.tools` (and execution lookups) as well as the model's request parameters.
function_tools: list[ToolDefinition] = []
output_tools: list[ToolDefinition] = []
for tool_def in ctx.deps.tool_manager.tool_defs:
if tool_def.kind == 'output':
output_tools.append(tool_def)
else:
function_tools.append(tool_def)
run_context = build_run_context(ctx)
raw_native_tools: list[AgentNativeTool[DepsT]] = list(ctx.deps.native_tools)
# resolve dynamic native tools
native_tools: list[AbstractNativeTool] = []
if raw_native_tools:
for tool in raw_native_tools:
if isinstance(tool, AbstractNativeTool):
native_tools.append(tool)
else:
t = tool(run_context)
if inspect.isawaitable(t):
t = await t
if t is not None:
native_tools.append(t)
# Drop the auto-injected `ToolSearchTool` native tool when the search corpus is empty —
# the toolset has nothing to manage, so emitting the native tool would waste a tool slot
# and surface an inert native tool in `ModelRequestParameters` snapshots. Filtering
# here (at MRP-construction time) keeps the request shape honest before
# `prepare_request` runs. Non-optional `ToolSearchTool` instances (user-passed) are
# preserved so the request still fails loudly on unsupported models.
has_tool_search_corpus = any(t.with_native == ToolSearchTool.kind for t in function_tools)
if not has_tool_search_corpus:
# Confine the corpus-empty drop to `ToolSearchTool`: other optional native tools
# (e.g. a hypothetical `WebSearchTool(optional=True)`) don't have a corpus and
# shouldn't be dropped here — they only get dropped on the unsupported-on-this-model
# path in `Model.prepare_request`.
native_tools = [t for t in native_tools if not (isinstance(t, ToolSearchTool) and t.optional)]
return models.ModelRequestParameters(
function_tools=function_tools,
native_tools=native_tools,
output_mode=output_schema.mode,
output_tools=output_tools,
output_object=output_schema.object_def,
prompted_output_template=prompted_output_template,
allow_text_output=output_schema.allows_text,
allow_image_output=output_schema.allows_image,
instruction_parts=instruction_parts,
)
@dataclasses.dataclass
class _SkipStreamedResponse(models.StreamedResponse):
"""Minimal StreamedResponse for SkipModelRequest — yields no events.
These properties implement the StreamedResponse ABC but are never accessed:
the streaming skip path always resolves via the _run_result shortcut in
StreamedRunResult, so the AgentStream wrapping this response is discarded.
"""
_response: _messages.ModelResponse = field(repr=False)
@property
def model_name(self) -> str: # pragma: no cover
return self._response.model_name or ''
@property
def provider_name(self) -> str | None: # pragma: no cover
return None
@property
def provider_url(self) -> str | None: # pragma: no cover
return None
@property
def timestamp(self) -> datetime: # pragma: no cover
return self._response.timestamp
async def close_stream(self) -> None: # pragma: no cover
# _SkipStreamedResponse is produced by short-circuit paths that never
# open a connection; there is nothing to close.
pass
async def _get_event_iterator(self) -> AsyncIterator[_messages.ModelResponseStreamEvent]:
return
yield # pragma: no cover
def get(self) -> _messages.ModelResponse: # pragma: no cover
return self._response
@dataclasses.dataclass
class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
"""The node that makes a request to the model using the last message in state.message_history."""
request: _messages.ModelRequest
is_resuming_without_prompt: bool = False
_result: CallToolsNode[DepsT, NodeRunEndT] | ModelRequestNode[DepsT, NodeRunEndT] | None = field(
repr=False, init=False, default=None
)
_did_stream: bool = field(repr=False, init=False, default=False)
last_request_context: ModelRequestContext | None = field(repr=False, init=False, default=None)
async def run(
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> CallToolsNode[DepsT, NodeRunEndT] | ModelRequestNode[DepsT, NodeRunEndT]:
if self._result is not None:
return self._result
if self._did_stream:
# `self._result` gets set when exiting the `stream` contextmanager, so hitting this
# means that the stream was started but not finished before `run()` was called
raise exceptions.AgentRunError('You must finish streaming before calling run()') # pragma: no cover
return await self._make_request(ctx)
@asynccontextmanager
async def stream(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, T]],
) -> AsyncGenerator[result.AgentStream[DepsT, T]]:
assert not self._did_stream, 'stream() should only be called once per node'
try:
model, model_settings, model_request_parameters, message_history, run_context = await self._prepare_request(
ctx
)
except exceptions.SkipModelRequest as e:
# SkipModelRequest in stream path: yield an empty stream and finish handling
# new_message_index wasn't updated in _prepare_request, fix it here
ctx.deps.new_message_index = _first_new_message_index(
ctx.state.message_history,
ctx.state.run_id,
resumed_request=ctx.deps.resumed_request,
resumed_request_index=ctx.deps.resumed_request_index,
)
self._did_stream = True
ctx.state.usage.requests += 1
# instruction_parts=None is fine here: the model isn't called, we just need MRP for the wrapper
skip_mrp = await _prepare_request_parameters(ctx, instruction_parts=None)
skip_sr = _SkipStreamedResponse(model_request_parameters=skip_mrp, _response=e.response)
agent_stream = self._build_agent_stream(ctx, skip_sr, skip_mrp)
yield agent_stream
await self._finish_handling(ctx, e.response)
assert self._result is not None
return
# Cooperative hand-off between this coroutine and the wrap_model_request task:
# 1. The task runs capability middleware, then calls _streaming_handler which opens the stream.
# 2. _streaming_handler sets stream_ready once the stream is open, then waits on stream_done.
# 3. This coroutine waits for stream_ready (or early task completion), yields the stream
# to the caller, and sets stream_done when the caller is finished consuming it.
# 4. The handler resumes, the stream context manager closes, and the task completes.
stream_ready = asyncio.Event()
stream_done = asyncio.Event()
agent_stream_holder: list[result.AgentStream[DepsT, T]] = []
_handler_response: _messages.ModelResponse | None = None
async def _streaming_handler(
req_ctx: ModelRequestContext,
) -> _messages.ModelResponse:
nonlocal _handler_response
# Stamp the request-issue instant so the instrumentation capability can record
# `gen_ai.client.operation.time_to_first_chunk` (TTFT). `StreamedResponse` records
# the first-chunk instant; the delta is the client-side time to first token.
request_start = time.perf_counter()
with set_current_run_context(run_context):
async with req_ctx.model.request_stream(
req_ctx.messages, req_ctx.model_settings, req_ctx.model_request_parameters, run_context
) as sr:
self._did_stream = True
ctx.state.usage.requests += 1
agent_stream = self._build_agent_stream(ctx, sr, req_ctx.model_request_parameters)
agent_stream_holder.append(agent_stream)
stream_ready.set()
try:
await stream_done.wait()
finally:
# Report TTFT in a `finally` so it also lands when the consumer raises
# mid-iteration and `_cancel_task(wrap_task)` injects CancelledError at
# the `wait()` above, mirroring `InstrumentedModel.request_stream`. On
# that cancelled path `finish` is never reached today (no metrics of any
# kind are recorded), so this is symmetry rather than an observable fix.
time_to_first_chunk_ctx.set(sr.time_to_first_chunk(request_start))
response = sr.get()
_handler_response = response
return response
wrap_request_context = ModelRequestContext(
model=model,
messages=message_history,
model_settings=model_settings,
model_request_parameters=model_request_parameters,
)
wrap_task = asyncio.create_task(
ctx.deps.root_capability.wrap_model_request(
run_context,
request_context=wrap_request_context,
handler=_streaming_handler,
)
)
# Wait for handler to start or wrap to complete (short-circuit).
# If outer cancellation arrives during this wait, drain both tasks before re-raising
# so the user's `wrap_model_request` cleanup runs instead of orphaning.
ready_waiter = asyncio.create_task(stream_ready.wait())
try:
await asyncio.wait({ready_waiter, wrap_task}, return_when=asyncio.FIRST_COMPLETED)
except BaseException:
# `BaseException` to also catch `CancelledError`. Handoff hasn't completed,
# so both tasks are still ours; drain them so cleanup runs before we re-raise.
#
# Unblock `_streaming_handler` before draining: if wrap_task's model
# absorbed the CancelledError (e.g. Temporal's cooperative cancellation),
# the handler is parked on `stream_done.wait()`. Setting stream_done lets
# it exit so cancel_and_drain's gather can complete. Harmless no-op when
# the task was actually cancelled — it's already unwinding. See #6422.
stream_done.set()
await cancel_and_drain(ready_waiter, wrap_task)
raise
else:
# Handoff succeeded: `wrap_task` is owned by the rest of the streaming
# lifecycle below. Only the throwaway readiness waiter is ours to clean up.
await cancel_and_drain(ready_waiter)
if wrap_task.done() and not stream_ready.is_set():
# wrap_model_request completed without calling handler — short-circuited or raised SkipModelRequest
try:
result_or_exc: _messages.ModelResponse | Exception
try:
result_or_exc = wrap_task.result()
except Exception as e:
result_or_exc = e
model_response = await self._resolve_wrap_result(ctx, run_context, wrap_request_context, result_or_exc)
except exceptions.ModelRetry as e:
self._did_stream = True
# Don't increment usage.requests — handler was never called (short-circuit)
run_context = build_run_context(ctx)
await self._build_retry_node(ctx, e)
# Must still yield from @asynccontextmanager — yield an empty stream
dummy_sr = _SkipStreamedResponse(
model_request_parameters=model_request_parameters,
_response=_messages.ModelResponse(parts=[]),
)
yield self._build_agent_stream(ctx, dummy_sr, model_request_parameters)
return
self._did_stream = True
ctx.state.usage.requests += 1
skip_sr = _SkipStreamedResponse(model_request_parameters=model_request_parameters, _response=model_response)
agent_stream = self._build_agent_stream(ctx, skip_sr, model_request_parameters)
yield agent_stream
self.last_request_context = wrap_request_context
await self._finish_handling(ctx, model_response)
assert self._result is not None
return
# Normal path: handler was called, stream is ready
stream_error: BaseException | None = None
try:
yield agent_stream_holder[0]
except BaseException as exc:
stream_error = exc
raise
finally:
stream_done.set()
if stream_error is not None:
await _cancel_task(wrap_task)
# Capture the partial response so `capture_run_messages` and `all_messages()`
# include what was streamed before the interruption. State is forced to
# 'interrupted' since the run did not complete normally — `sr._cancelled`
# may be False here (downstream exception rather than explicit cancel).
# We append directly rather than via `_append_response` to skip the usage-limit
# check; raising `UsageLimitExceeded` here would mask `stream_error`.
if agent_stream_holder: # pragma: no branch
partial_response = replace(
agent_stream_holder[0].response,
state='interrupted',
run_id=ctx.state.run_id,
conversation_id=ctx.state.conversation_id,
)
ctx.state.usage.incr(partial_response.usage)
ctx.state.message_history.append(partial_response)
else:
try:
try:
model_response = await wrap_task
except exceptions.ModelRetry:
raise # Propagate to outer handler
except Exception as e:
model_response = await ctx.deps.root_capability.on_model_request_error(
run_context, request_context=wrap_request_context, error=e
)
except exceptions.ModelRetry as e:
# Don't increment usage.requests — _streaming_handler already did
# In the normal streaming path the handler was always called (that's
# how the stream was created), so _handler_response is always set.
assert _handler_response is not None
self._append_response(ctx, _handler_response)
await self._build_retry_node(ctx, e)
else:
self.last_request_context = wrap_request_context
await self._finish_handling(ctx, model_response)
assert self._result is not None
@staticmethod
def _build_agent_stream(
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, T]],
stream_response: models.StreamedResponse,
model_request_parameters: models.ModelRequestParameters,
) -> result.AgentStream[DepsT, T]:
"""Build an AgentStream from the given stream response and context."""
return result.AgentStream[DepsT, T](
_raw_stream_response=stream_response,
_output_schema=ctx.deps.output_schema,
_model_request_parameters=model_request_parameters,
_output_validators=ctx.deps.output_validators,
_run_ctx=build_run_context(ctx),
_usage_limits=ctx.deps.usage_limits,
_tool_manager=ctx.deps.tool_manager,
_root_capability=ctx.deps.root_capability,
_metadata_getter=lambda: ctx.state.metadata,
)
async def _make_request(
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> CallToolsNode[DepsT, NodeRunEndT] | ModelRequestNode[DepsT, NodeRunEndT]:
if self._result is not None:
return self._result # pragma: no cover
try:
model, model_settings, model_request_parameters, message_history, run_context = await self._prepare_request(
ctx
)
except exceptions.SkipModelRequest as e:
# new_message_index wasn't updated in _prepare_request, fix it here
ctx.deps.new_message_index = _first_new_message_index(
ctx.state.message_history,
ctx.state.run_id,
resumed_request=ctx.deps.resumed_request,
resumed_request_index=ctx.deps.resumed_request_index,
)
ctx.state.usage.requests += 1
return await self._finish_handling(ctx, e.response)
_handler_response: _messages.ModelResponse | None = None
async def model_handler(req_ctx: ModelRequestContext) -> _messages.ModelResponse:
nonlocal _handler_response
with set_current_run_context(run_context):
response = await req_ctx.model.request(
req_ctx.messages, req_ctx.model_settings, req_ctx.model_request_parameters
)
response = _narrow_tool_call_parts(response, req_ctx.model_request_parameters)
_handler_response = response
return response
request_context = ModelRequestContext(
model=model,
messages=message_history,
model_settings=model_settings,
model_request_parameters=model_request_parameters,
)
try:
try:
model_response = await ctx.deps.root_capability.wrap_model_request(
run_context,
request_context=request_context,
handler=model_handler,
)
except exceptions.SkipModelRequest as e:
model_response = e.response
except exceptions.ModelRetry:
raise # Propagate to outer handler
except Exception as e:
model_response = await ctx.deps.root_capability.on_model_request_error(
run_context, request_context=request_context, error=e
)
except exceptions.ModelRetry as e:
# ModelRetry from wrap_model_request or on_model_request_error — retry the model request.
# If the handler was called, preserve the response in history for context.
if _handler_response is not None:
ctx.state.usage.requests += 1
self._append_response(ctx, _handler_response)
return await self._build_retry_node(ctx, e)
self.last_request_context = request_context
ctx.state.usage.requests += 1
return await self._finish_handling(ctx, model_response)
async def _prepare_request(
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> tuple[
models.Model,
ModelSettings | None,
models.ModelRequestParameters,
list[_messages.ModelMessage],
RunContext[DepsT],
]:
self.request.timestamp = now_utc()
if not self.is_resuming_without_prompt:
self.request.run_id = self.request.run_id or ctx.state.run_id
self.request.conversation_id = self.request.conversation_id or ctx.state.conversation_id
ctx.state.message_history.append(self.request)
ctx.state.run_step += 1
_refresh_loaded_capability_ids(ctx)
_refresh_discovered_tool_names(ctx)
run_context = build_run_context(ctx)
run_context = replace(
run_context,
retry=ctx.state.output_retries_used,
max_retries=ctx.deps.tool_manager.default_max_retries,
)
# This will raise errors for any tool name conflicts.
# Note: for_run_step may already have been called by UserPromptNode for the
# resume-without-prompt path; ToolManager.for_run_step is a no-op for the same step.
ctx.deps.tool_manager = await ctx.deps.tool_manager.for_run_step(run_context)
# Fetch instructions now that dynamic toolsets have been resolved by for_run_step.
instruction_parts = await _get_instructions(ctx, run_context)
if instruction_parts:
instruction_parts = _messages.InstructionPart.sorted(instruction_parts) or None
self.request.instructions = _messages.InstructionPart.join(instruction_parts) if instruction_parts else None
# Validate after instructions are resolved; self.request was appended above so [:-1] is prior history
if not ctx.state.message_history[:-1] and not self.request.parts and not self.request.instructions:
raise exceptions.UserError('No message history, user prompt, or instructions provided')
model_request_parameters = await _prepare_request_parameters(ctx, instruction_parts)
model_settings = ctx.deps.get_model_settings(run_context) or ModelSettings()
run_context.model_settings = model_settings
request_context = ModelRequestContext(
model=ctx.deps.model,
messages=ctx.state.message_history[:],
model_settings=model_settings,
model_request_parameters=model_request_parameters,
)
messages_before_processing = len(request_context.messages)
self.last_request_context = request_context
request_context = await ctx.deps.root_capability.before_model_request(
run_context,
request_context,
)
self.last_request_context = request_context
model = request_context.model
messages = request_context.messages
model_settings = request_context.model_settings
model_request_parameters = request_context.model_request_parameters
if len(messages) == 0:
raise exceptions.UserError('Processed history cannot be empty.')
if not isinstance(messages[-1], _messages.ModelRequest):
raise exceptions.UserError('Processed history must end with a `ModelRequest`.')
# Fill in framework metadata the history processors may have left unset on a new `ModelRequest`.
fill_run_metadata(messages[-1], run_id=ctx.state.run_id, conversation_id=ctx.state.conversation_id)
if self.is_resuming_without_prompt:
# No separate user-prompt request this run: the trailing request that arrived via
# `message_history` *is* the request being sent, so it's prior context, not new. Track it
# two ways so `_first_new_message_index` can exclude it however capabilities/processors
# mutate the list: by object (identity/value, survives reordering and removal) and by
# position (survives an in-place rebuild that changes its fields). It's the last message
# here, before the model output is appended, so its index is `len(messages) - 1`.
ctx.deps.resumed_request = self.request
ctx.deps.resumed_request_index = len(messages) - 1
elif ctx.deps.resumed_request_index is not None:
# Later steps (e.g. a tool-call loop) may prepend/truncate/rebuild messages ahead of the
# resumed request, shifting it. Translate the pinned index by the net count change; drop
# it (falling back to object/value matching, then run_id) if processing removed the
# resumed request itself. The object reference is left untouched — it still points at the
# step-1 request, so identity/value matching keeps working across steps.
shifted = ctx.deps.resumed_request_index - (messages_before_processing - len(messages))
ctx.deps.resumed_request_index = shifted if shifted >= 0 else None
# `ctx.state.message_history` is the same list used by `capture_run_messages`, so we should replace its contents, not the reference
ctx.state.message_history[:] = messages
# Update the new message index to ensure `result.new_messages()` returns the correct messages
ctx.deps.new_message_index = _first_new_message_index(
messages,
ctx.state.run_id,
resumed_request=ctx.deps.resumed_request,
resumed_request_index=ctx.deps.resumed_request_index,
)
# Merge possible consecutive trailing `ModelRequest`s into one, with tool call parts before user parts,
# but don't store it in the message history on state. This is just for the benefit of model classes that want clear user/assistant boundaries.
# See `tests/test_tools.py::test_parallel_tool_return_with_deferred` for an example where this is necessary.
#
# Run a first pass so `prepare_messages` sees a normalized history.
messages = _clean_message_history(messages)
# Hand off to the model class for any history shapes the active provider can't
# ship on the wire — currently typed `NativeToolSearch*Part` instances translated
# to local-shape `ToolSearch*Part` when the profile doesn't support `ToolSearchTool`.
#
# Lives on `Model.prepare_messages` rather than inline here for two reasons:
# 1. The translation depends on `self.profile`, which is per-model state.
# 2. `FallbackModel` defers the decision until it's picked an underlying model — so
# each candidate runs `prepare_messages` itself with its own profile when chosen.
prepared = model.prepare_messages(messages)
# If `prepare_messages` produced a new list (e.g. tool-search synthesis split a
# `ModelResponse(call+return)` into `ModelResponse(call) + ModelRequest(return)`
# adjacent to an existing `ModelRequest`), re-run cleanup so consecutive same-role
# messages are merged. The default `prepare_messages` returns the input list
# unchanged, so the identity check skips the redundant second pass.
if prepared is not messages:
messages = _clean_message_history(prepared)
else:
messages = prepared
ctx.state.last_max_tokens = model_settings.get('max_tokens') if model_settings else None
ctx.state.last_model_request_parameters = model_request_parameters
usage = ctx.state.usage
if ctx.deps.usage_limits.count_tokens_before_request:
# Copy to avoid modifying the original usage object with the counted usage
usage = deepcopy(usage)
counted_usage = await model.count_tokens(messages, model_settings, model_request_parameters)
usage.incr(counted_usage)
ctx.deps.usage_limits.check_before_request(usage)
return model, model_settings or None, model_request_parameters, messages, run_context
async def _finish_handling(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
response: _messages.ModelResponse,
) -> CallToolsNode[DepsT, NodeRunEndT] | ModelRequestNode[DepsT, NodeRunEndT]:
fill_run_metadata(response, run_id=ctx.state.run_id, conversation_id=ctx.state.conversation_id)
run_context = build_run_context(ctx)
assert self.last_request_context is not None, 'last_request_context must be set before _finish_handling'
request_context = self.last_request_context
run_context.model_settings = request_context.model_settings
try:
response = await ctx.deps.root_capability.after_model_request(
run_context, request_context=request_context, response=response
)
except exceptions.ModelRetry as e:
# Hook rejected the response — append it to history (model DID respond) and retry
self._append_response(ctx, response)
return await self._build_retry_node(ctx, e)
# Append the model response to state.message_history
self._append_response(ctx, response)
# Set the `_result` attribute since we can't use `return` in an async iterator
self._result = CallToolsNode(response)
return self._result
async def _resolve_wrap_result(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
run_context: RunContext[DepsT],
request_context: ModelRequestContext,
result_or_exc: _messages.ModelResponse | Exception,
) -> _messages.ModelResponse:
"""Resolve a wrap_model_request result, handling SkipModelRequest and errors.
Returns ModelResponse on success.
Raises ModelRetry if the result or on_model_request_error raises it.
"""
if isinstance(result_or_exc, Exception):
exc = result_or_exc
if isinstance(exc, exceptions.SkipModelRequest):
return exc.response
if isinstance(exc, exceptions.ModelRetry):
raise exc
return await ctx.deps.root_capability.on_model_request_error(
run_context, request_context=request_context, error=exc
)
return result_or_exc
@staticmethod
def _append_response(
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[Any, Any]],
response: _messages.ModelResponse,
) -> None:
"""Append a model response to history, updating usage tracking."""
fill_run_metadata(response, run_id=ctx.state.run_id, conversation_id=ctx.state.conversation_id)
ctx.state.usage.incr(response.usage)
if ctx.deps.usage_limits: # pragma: no branch
ctx.deps.usage_limits.check_tokens(ctx.state.usage)
ctx.state.message_history.append(response)
async def _build_retry_node(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
error: exceptions.ModelRetry,
) -> ModelRequestNode[DepsT, NodeRunEndT]:
"""Build a retry ModelRequestNode from a ModelRetry exception.
Increments the retry counter and creates a new request with a RetryPromptPart.
"""
ctx.state.consume_output_retry(ctx.deps.max_output_retries, error=error)
m = _messages.RetryPromptPart(content=error.message)
retry_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=[m]))
self._result = retry_node
return retry_node
__repr__ = dataclasses_no_defaults_repr
@dataclasses.dataclass
class CallToolsNode(AgentNode[DepsT, NodeRunEndT]):
"""The node that processes a model response, and decides whether to end the run or make a new request."""
model_response: _messages.ModelResponse
tool_call_results: dict[str, DeferredToolResult | Literal['skip']] | None = None
tool_call_metadata: dict[str, dict[str, Any]] | None = None
"""Metadata for deferred tool calls, keyed by `tool_call_id`."""
user_prompt: str | Sequence[_messages.UserContent] | None = None
"""Optional user prompt to include alongside tool call results.
This prompt is only sent to the model when the `model_response` contains tool calls.
If the `model_response` has final output instead, this user prompt is ignored.
The user prompt will be appended after all tool return parts in the next model request.
"""
_events_iterator: AsyncIterator[_messages.HandleResponseEvent] | None = field(default=None, init=False, repr=False)
_next_node: ModelRequestNode[DepsT, NodeRunEndT] | End[result.FinalResult[NodeRunEndT]] | None = field(
default=None, init=False, repr=False
)
_stream_error: BaseException | None = field(default=None, init=False, repr=False)
async def run(
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> ModelRequestNode[DepsT, NodeRunEndT] | End[result.FinalResult[NodeRunEndT]]:
async with self.stream(ctx):
pass
if self._next_node is not None:
return self._next_node
# If the stream raised an error that was caught by an external consumer
# (e.g. UIEventStream.transform_stream), _next_node will not have been set.
# Re-raise the original error instead of a confusing assertion.
if self._stream_error is not None:
raise self._stream_error.with_traceback(self._stream_error.__traceback__)
raise exceptions.AgentRunError('the stream should set `self._next_node` before it ends') # pragma: no cover
@asynccontextmanager
async def stream(
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> AsyncGenerator[AsyncIterator[_messages.HandleResponseEvent]]:
"""Process the model response and yield events for the start and end of each function tool call."""
stream = self._run_stream(ctx)
yield stream
# Run the stream to completion if it was not finished:
async for _event in stream:
pass
async def _run_stream( # noqa: C901
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> AsyncIterator[_messages.HandleResponseEvent]:
if self._events_iterator is None:
# Ensure that the stream is only run once
output_schema = ctx.deps.output_schema
async def _run_stream() -> AsyncIterator[_messages.HandleResponseEvent]: # noqa: C901
is_empty = not self.model_response.parts
is_thinking_only = not is_empty and all(
isinstance(p, _messages.ThinkingPart) for p in self.model_response.parts
)
if is_empty or is_thinking_only:
# No actionable output was returned by the model.
# Don't retry if the token limit was exceeded, possibly during thinking.
if self.model_response.finish_reason == 'length':
raise exceptions.UnexpectedModelBehavior(
f'Model token limit ({ctx.state.last_max_tokens or "provider default"}) exceeded before any response was generated. Increase the `max_tokens` model setting, or simplify the prompt to result in a shorter response that will fit within the limit.'
)
# Check for content filter on empty response
if is_empty and self.model_response.finish_reason == 'content_filter':
details = self.model_response.provider_details or {}
body = _messages.ModelMessagesTypeAdapter.dump_json([self.model_response]).decode()
if reason := details.get('finish_reason'):
message = f"Content filter triggered. Finish reason: '{reason}'"
elif reason := details.get('block_reason'):
message = f"Content filter triggered. Block reason: '{reason}'"
elif refusal := details.get('refusal'):
message = f'Content filter triggered. Refusal: {refusal!r}'
else: # pragma: no cover
message = 'Content filter triggered.'
raise exceptions.ContentFilterError(message, body=body)
# If the output type allows `None`, an empty or thinking-only response is a valid result:
# both signal that the model has no text output to give. Some models emit only thinking
# after completing the task via a tool call, and forcing a retry just makes them produce
# unnecessary follow-up text.
if output_schema.allows_none:
run_context = _build_output_run_context(ctx)
try:
result_data = await _output.run_none_process_hooks(
capability=ctx.deps.root_capability,
run_context=run_context,
schema=output_schema,
output_validators=ctx.deps.output_validators,
)
self._next_node = self._handle_final_result(
ctx, result.FinalResult(cast(NodeRunEndT, result_data)), []
)
except ToolRetryError as e:
ctx.state.consume_output_retry(ctx.deps.max_output_retries, error=e)
self._next_node = ModelRequestNode[DepsT, NodeRunEndT](
_messages.ModelRequest(parts=[e.tool_retry])
)
return
# For empty or thinking-only responses, fall through to the normal retry prompt
# below. That prompt is built from the output schema and available tools, so it
# tells the model which kinds of output are actually valid (text, tool call,
# and/or image) rather than assuming text is always an option.
text = ''
compaction_text = ''
tool_calls: list[_messages.ToolCallPart] = []
files: list[_messages.BinaryContent] = []
for part in self.model_response.parts:
if isinstance(part, _messages.TextPart):
text += part.content
elif isinstance(part, _messages.ToolCallPart):
tool_calls.append(part)
elif isinstance(part, _messages.FilePart):
files.append(part.content)
elif isinstance(part, _messages.NativeToolCallPart):
# Text parts before a native tool call are essentially thoughts,
# not part of the final result output, so we reset the accumulated text.
# The part itself was already surfaced through `PartStartEvent` / `PartDeltaEvent`.
text = ''
elif isinstance(part, _messages.NativeToolReturnPart):
# Already surfaced through `PartStartEvent` / `PartDeltaEvent`.
pass
elif isinstance(part, _messages.ThinkingPart):
pass
elif isinstance(part, _messages.CompactionPart):
if part.content:
compaction_text += part.content
else:
assert_never(part)
# Use compaction content as text fallback when the response has no other
# actionable text (e.g. Anthropic pause_after_compaction=True)
if not text and compaction_text:
text = compaction_text
try:
# We generally prioritize at least executing tool calls if they are present.
# This accounts for cases like Anthropic returns that might contain a text response
# and a tool call response, where the text response just indicates the tool call will happen.
# The exception is `end_strategy='early'`: if the response also carries a valid non-tool
# output (schema-validated text, or an image) alongside plain function tool calls, that
# output is already the final result, so `_handle_tool_calls` skips those tools and ends the
# run — matching the way `'early'` skips function tools once an output tool call succeeds.
# (Output tool calls and deferred tool calls are left to normal processing, so a co-emitted
# one still wins/surfaces rather than being preempted by the text.)
alternatives: list[str] = []
if tool_calls:
response_output = (text, files) if ctx.deps.end_strategy == 'early' else None
async for event in self._handle_tool_calls(ctx, tool_calls, response_output=response_output):
yield event
return
elif output_schema.toolset:
alternatives.append('include your response in a tool call')
elif ctx.deps.tool_manager.tools is None or ctx.deps.tool_manager.tools:
# tools is None when the tool manager is unprepared (e.g. UserPromptNode
# skips to CallToolsNode, bypassing for_run_step); in that case we
# default to suggesting tools to be safe
alternatives.append('call a tool')
if output_schema.allows_image:
if image := next((file for file in files if isinstance(file, _messages.BinaryImage)), None):
self._next_node = await self._handle_image_response(ctx, image)
return
alternatives.append('return an image')
if text_processor := output_schema.text_processor:
if text:
self._next_node = await self._handle_text_response(ctx, text, text_processor)
return
alternatives.insert(0, 'return text')
# handle responses with only parts that don't constitute output.
# This can happen with models that support thinking mode when they don't provide
# actionable output alongside their thinking content. so we tell the model to try again.
m = _messages.RetryPromptPart(
content=f'Please {" or ".join(alternatives)}.',
)
raise ToolRetryError(m)
except ToolRetryError as e:
ctx.state.consume_output_retry(ctx.deps.max_output_retries, error=e)
self._next_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=[e.tool_retry]))
self._events_iterator = _run_stream()
try:
async for event in self._events_iterator:
yield event
except BaseException as e:
self._stream_error = e
raise
async def _handle_tool_calls(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
tool_calls: list[_messages.ToolCallPart],
*,
response_output: tuple[str, list[_messages.BinaryContent]] | None = None,
) -> AsyncIterator[_messages.HandleResponseEvent]:
run_context = build_run_context(ctx)
run_context = replace(
run_context,
retry=ctx.state.output_retries_used,
max_retries=ctx.deps.tool_manager.default_max_retries,
)
# This will raise errors for any tool name conflicts
ctx.deps.tool_manager = await ctx.deps.tool_manager.for_run_step(run_context)
# Under `end_strategy='early'`, `response_output` holds the response's `(text, files)`. If it carries a
# valid non-tool output (schema-validated text, or an image) and every co-emitted tool call is a plain
# function tool, that output is the final result and the tools are recorded as skipped.
#
# We check the tool kinds here (rather than letting `process_tool_calls` sort it out) for two reasons:
# output and deferred (external/unapproved) tool calls must go through normal processing, and
# `_process_response_output` runs the output validators, so we only want to invoke it once we know the
# response output can actually win. `for_run_step` above populated the tool defs used here.
#
# The precedence is deliberate: calling an output tool is an explicit "finish the run" signal, and a
# deferred call may need an external result or human approval — whereas the model's text may just be
# supporting prose (it doesn't know we might treat that text as final), so text must not silently
# cancel either. A co-emitted output tool call therefore still produces the final result, and a
# co-emitted deferred call is still surfaced, rather than being preempted by the text.
final_result: result.FinalResult[NodeRunEndT] | None = None
if response_output is not None and all(
(tool_def := ctx.deps.tool_manager.get_tool_def(call.tool_name)) is None or tool_def.kind == 'function'
for call in tool_calls
):
text, files = response_output
final_result = await self._process_response_output(ctx, text=text, files=files)
output_parts: list[_messages.ModelRequestPart] = []
output_final_result: deque[result.FinalResult[NodeRunEndT]] = deque(maxlen=1)
try:
# When `final_result` is set (schema-validated text or image output already won under
# `end_strategy='early'`), `process_tool_calls` records the tool calls as skipped rather than
# executing them.
async for event in process_tool_calls(
tool_manager=ctx.deps.tool_manager,
tool_calls=tool_calls,
tool_call_results=self.tool_call_results,
tool_call_metadata=self.tool_call_metadata,
final_result=final_result,
ctx=ctx,
output_parts=output_parts,
output_final_result=output_final_result,
):
yield event
except BaseException:
# Capture the partial tool returns collected so far. State is 'interrupted'
# so `capture_run_messages` consumers can detect partial state. The user prompt
# is intentionally omitted: this request was never sent to the model.
if output_parts:
ctx.state.message_history.append(
_messages.ModelRequest(
parts=list(output_parts),
run_id=ctx.state.run_id,
conversation_id=ctx.state.conversation_id,
timestamp=now_utc(),
state='interrupted',
)
)
raise
if output_final_result:
final_result = output_final_result[0]
self._next_node = self._handle_final_result(ctx, final_result, output_parts)
else:
# Add user prompt if provided, after all tool return parts
if self.user_prompt is not None:
output_parts.append(_messages.UserPromptPart(self.user_prompt))
self._next_node = ModelRequestNode[DepsT, NodeRunEndT](_messages.ModelRequest(parts=output_parts))
async def _process_response_output(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
*,
text: str,
files: list[_messages.BinaryContent],
) -> result.FinalResult[NodeRunEndT] | None:
"""Build the response's non-tool output result (an image, or schema-validated text), or `None`.
Used under `end_strategy='early'` to decide whether a response that also contains function tool calls
already carries a final result. Images take precedence over text, matching the order the no-tool-calls
path handles them in.
Only text that's validated against a schema can preempt tool calls — i.e. the object output processor
used by [`NativeOutput`][pydantic_ai.output.NativeOutput],
[`PromptedOutput`][pydantic_ai.output.PromptedOutput], and a bare structured type (auto mode). There
the model was told to produce the final output as its text, so text that validates is a deliberate
final result. Plain, unstructured text output (`str`, [`TextOutput`][pydantic_ai.output.TextOutput], or
a `str` fallback in a larger schema) accepts *any* text, so the model's preamble — which it emits with
no signal that we'd treat it as final — must not silently win and skip the tools.
Returns `None` when the response carries no usable output — e.g. schema-validated text or an image that
fails validation — so the caller runs the tool calls instead. Unlike a failed output *tool* call, this
doesn't consume an output retry or surface a retry prompt: running the tools is the correction.
"""
output_schema = ctx.deps.output_schema
try:
if output_schema.allows_image:
if image := next((file for file in files if isinstance(file, _messages.BinaryImage)), None):
return await self._process_image_response(ctx, image)
if (
(text_processor := output_schema.text_processor)
and isinstance(text_processor, _output.BaseObjectOutputProcessor)
and text
):
return await self._process_text_response(ctx, text, text_processor)
except ToolRetryError:
return None
return None
async def _handle_text_response(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
text: str,
text_processor: _output.BaseOutputProcessor[NodeRunEndT],
) -> ModelRequestNode[DepsT, NodeRunEndT] | End[result.FinalResult[NodeRunEndT]]:
return self._handle_final_result(ctx, await self._process_text_response(ctx, text, text_processor), [])
async def _process_text_response(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
text: str,
text_processor: _output.BaseOutputProcessor[NodeRunEndT],
) -> result.FinalResult[NodeRunEndT]:
run_context = _build_output_run_context(ctx)
schema = ctx.deps.output_schema
result_data = await _output.run_output_with_hooks(
text_processor,
text=text,
run_context=run_context,
capability=ctx.deps.root_capability,
schema=schema,
output_validators=ctx.deps.output_validators,
)
return result.FinalResult(result_data)
async def _handle_image_response(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
image: _messages.BinaryImage,
) -> ModelRequestNode[DepsT, NodeRunEndT] | End[result.FinalResult[NodeRunEndT]]:
return self._handle_final_result(ctx, await self._process_image_response(ctx, image), [])
async def _process_image_response(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
image: _messages.BinaryImage,
) -> result.FinalResult[NodeRunEndT]:
run_context = _build_output_run_context(ctx)
schema = ctx.deps.output_schema
result_data = await _output.run_image_process_hooks(
image,
capability=ctx.deps.root_capability,
run_context=run_context,
schema=schema,
output_validators=ctx.deps.output_validators,
)
return result.FinalResult(result_data)
def _handle_final_result(
self,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
final_result: result.FinalResult[NodeRunEndT],
tool_responses: list[_messages.ModelRequestPart],
) -> End[result.FinalResult[NodeRunEndT]]:
messages = ctx.state.message_history
# To allow this message history to be used in a future run without dangling tool calls,
# append a new ModelRequest using the tool returns and retries
if tool_responses:
messages.append(
_messages.ModelRequest(
parts=tool_responses,
run_id=ctx.state.run_id,
conversation_id=ctx.state.conversation_id,
timestamp=now_utc(),
)
)
return End(final_result)
__repr__ = dataclasses_no_defaults_repr
@dataclasses.dataclass
class SetFinalResult(AgentNode[DepsT, NodeRunEndT]):
"""A node that immediately ends the graph run after a streaming response produced a final result."""
final_result: result.FinalResult[NodeRunEndT]
async def run(
self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
) -> End[result.FinalResult[NodeRunEndT]]:
return End(self.final_result)
def build_run_context(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]]) -> RunContext[DepsT]:
"""Build a `RunContext` object from the current agent graph run context."""
run_context = RunContext[DepsT](
deps=ctx.deps.user_deps,
agent=ctx.deps.agent,
model=ctx.deps.model,
usage=ctx.state.usage,
usage_limits=ctx.deps.usage_limits,
prompt=ctx.deps.prompt,
messages=ctx.state.message_history,
validation_context=None,
tracer=ctx.deps.tracer,
trace_include_content=ctx.deps.instrumentation_settings is not None
and ctx.deps.instrumentation_settings.include_content,
instrumentation_version=ctx.deps.instrumentation_settings.version
if ctx.deps.instrumentation_settings
else DEFAULT_INSTRUMENTATION_VERSION,
run_step=ctx.state.run_step,
run_id=ctx.state.run_id,
conversation_id=ctx.state.conversation_id,
metadata=ctx.state.metadata,
tool_manager=ctx.deps.tool_manager,
capabilities=ctx.deps.capabilities,
loaded_capability_ids=ctx.deps.loaded_capability_ids,
discovered_tool_names=ctx.deps.discovered_tool_names,
pending_messages=ctx.state.pending_messages,
_mcp_tool_defs_cache=ctx.state.mcp_tool_defs_cache,
)
validation_context = build_validation_context(ctx.deps.validation_context, run_context)
# Only `validation_context` may be passed to `replace`: it shallow-copies, preserving the shared
# identity of the mutable members passed by reference above — `loaded_capability_ids`,
# `discovered_tool_names`, `pending_messages`, `_mcp_tool_defs_cache` (see the invariant on
# `GraphAgentDeps.loaded_capability_ids`). Never add any of them as a `replace` kwarg — forking the
# object would silently break in-step capability loads / tool reveals / message enqueues / tool-defs caching.
run_context = replace(run_context, validation_context=validation_context)
return run_context
def _refresh_loaded_capability_ids(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]]) -> None:
"""Refresh the history-derived loaded capability ids from the current graph state."""
# The `load_capability` tool (and therefore any `LoadCapability*` history parts) only exists
# when a deferred capability is configured — the same condition that injects the loader. Without
# one, the set can never change during the run, so the seeded value stays in sync without rescanning.
# (`discovered_tool_names` has no equally-cheap guard: tool search is auto-injected and its trigger
# is "deferred tools exist", which isn't known without resolving toolsets, so its refresh stays
# unconditional.)
if not any(capability.defer_loading is True for capability in ctx.deps.capabilities.values()):
return
loaded_capability_ids = parse_loaded_capabilities(ctx.state.message_history)
# Mutate in place (not reassign): this set is shared by reference with the run's `RunContext`
# copies made via `replace(ctx, ...)`, so clear + update keeps them all in sync.
ctx.deps.loaded_capability_ids.clear()
ctx.deps.loaded_capability_ids.update(loaded_capability_ids)
def _refresh_discovered_tool_names(ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]]) -> None:
"""Refresh the history-derived discovered tool names from the current graph state."""
discovered_tool_names = parse_discovered_tools(ctx.state.message_history)
# Mutate in place (not reassign), for the same shared-by-reference reason as the set above.
ctx.deps.discovered_tool_names.clear()
ctx.deps.discovered_tool_names.update(discovered_tool_names)
def build_validation_context(
validation_ctx: Any | Callable[[RunContext[DepsT]], Any],
run_context: RunContext[DepsT],
) -> Any:
"""Build a Pydantic validation context, potentially from the current agent run context."""
if callable(validation_ctx):
fn = cast(Callable[[RunContext[DepsT]], Any], validation_ctx)
return fn(run_context)
else:
return validation_ctx
def _build_output_run_context(
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, Any]],
) -> RunContext[DepsT]:
"""Build a RunContext with global output retry info for output validation.
Starts from `tool_manager.ctx` (when available) so per-tool retry counts
(`ctx.retries[name]`) populated by `for_run_step` propagate to output hooks
like `prepare_output_tools` and output validators. Then overrides `retry`
and `max_retries` with the **output** budget (`max_output_retries`),
distinct from the tool budget on `tool_manager.ctx`.
"""
base = ctx.deps.tool_manager.ctx if ctx.deps.tool_manager.ctx is not None else build_run_context(ctx)
return replace(
base,
retry=ctx.state.output_retries_used,
max_retries=ctx.deps.max_output_retries,
)
@dataclasses.dataclass
class _RunMessages:
messages: list[_messages.ModelMessage]
used: bool = False
_messages_ctx_var: ContextVar[_RunMessages] = ContextVar('var')
@contextmanager
def capture_run_messages() -> Generator[list[_messages.ModelMessage]]:
"""Context manager to access the messages used in a [`run`][pydantic_ai.agent.AbstractAgent.run], [`run_sync`][pydantic_ai.agent.AbstractAgent.run_sync], or [`run_stream`][pydantic_ai.agent.AbstractAgent.run_stream] call.
Useful when a run may raise an exception, see [model errors](../agent.md#model-errors) for more information.
Examples:
```python
from pydantic_ai import Agent, capture_run_messages
agent = Agent('test')
with capture_run_messages() as messages:
try:
result = agent.run_sync('foobar')
except Exception:
print(messages)
raise
```
!!! note
If you call `run`, `run_sync`, or `run_stream` more than once within a single `capture_run_messages` context,
`messages` will represent the messages exchanged during the first call only.
Contexts can be nested: each `capture_run_messages` context captures the runs for which it is the
innermost active context. A run started inside a nested context is captured by that nested context,
not by any enclosing one, so wrapping a nested agent run (e.g. inside a tool) in its own
`capture_run_messages` lets you inspect that inner run's messages independently.
If a run is interrupted by an exception or cancellation while streaming a response or executing
tool calls, the partial [`ModelResponse`][pydantic_ai.messages.ModelResponse] or
[`ModelRequest`][pydantic_ai.messages.ModelRequest] is still captured here with
`state='interrupted'`, so consumers can detect and inspect partial state.
"""
messages: list[_messages.ModelMessage] = []
# Always push a fresh context so nested `capture_run_messages` contexts each capture their own runs,
# rather than sharing (and overwriting) the enclosing context's messages.
token = _messages_ctx_var.set(_RunMessages(messages))
try:
yield messages
finally:
_messages_ctx_var.reset(token)
def get_captured_run_messages() -> _RunMessages:
return _messages_ctx_var.get()
def build_agent_graph(
name: str | None,
deps_type: type[DepsT],
output_type: OutputSpec[OutputT],
) -> Graph[
GraphAgentState,
GraphAgentDeps[DepsT, OutputT],
UserPromptNode[DepsT, OutputT],
result.FinalResult[OutputT],
]:
"""Build the execution [Graph][pydantic_graph.Graph] for a given agent."""
g = GraphBuilder(
name=name or 'Agent',
state_type=GraphAgentState,
deps_type=GraphAgentDeps[DepsT, OutputT],
input_type=UserPromptNode[DepsT, OutputT],
output_type=result.FinalResult[OutputT],
auto_instrument=False,
)
g.add(
g.edge_from(g.start_node).to(UserPromptNode[DepsT, OutputT]),
g.node(UserPromptNode[DepsT, OutputT]),
g.node(ModelRequestNode[DepsT, OutputT]),
g.node(CallToolsNode[DepsT, OutputT]),
g.node(
SetFinalResult[DepsT, OutputT],
),
)
return g.build(validate_graph_structure=False)
def _narrow_tool_call_parts(
response: _messages.ModelResponse, model_request_parameters: models.ModelRequestParameters
) -> _messages.ModelResponse:
"""Promote each base `ToolCallPart` in the response to its typed subclass via `ToolDefinition.tool_kind`.
Lives here rather than in each model adapter so adapter authors emit base
`ToolCallPart`s freely and the framework owns the typed-identity translation. Streaming
parts are typed up-front by `ModelResponsePartsManager` via the same lookup; this
function handles the non-streaming `Model.request()` return path. Either path produces
the same typed end state — `isinstance(part, ToolSearchCallPart)` is true from the
moment the call is emitted by the model.
"""
tool_kind_by_name: dict[str, _messages.ToolPartKind] = {
td.name: td.tool_kind for td in model_request_parameters.function_tools if td.tool_kind
}
if not tool_kind_by_name:
return response
changed = False
new_parts: list[_messages.ModelResponsePart] = []
for part in response.parts:
if (
isinstance(part, _messages.ToolCallPart)
and part.tool_kind is None
and (tool_kind := tool_kind_by_name.get(part.tool_name)) is not None
):
promoted = _messages.ToolCallPart.narrow_type(part, tool_kind=tool_kind)
new_parts.append(promoted)
changed = True
else:
new_parts.append(part)
return replace(response, parts=new_parts) if changed else response
def _first_run_id_index(messages: list[_messages.ModelMessage], run_id: str) -> int:
"""Return the index of the first message for the current run, or len(messages) if none are found."""
for index, message in enumerate(messages):
if message.run_id == run_id:
return index
return len(messages)
def _first_new_message_index(
messages: list[_messages.ModelMessage],
run_id: str,
*,
resumed_request: _messages.ModelRequest | None,
resumed_request_index: int | None,
) -> int:
"""Return the first index that should be included in `new_messages()`.
When resuming from `message_history` without a new user prompt, the trailing
`ModelRequest` is prior context even though the framework stamps it with the current
`run_id` for adapter bookkeeping, so it must be excluded. A capability or history processor
can mutate the message list before this runs, so the resumed request is located by trying
progressively looser fallbacks, each robust to a different kind of mutation:
1. Object identity (`is`) — survives reordering, insertion, and removal of *other* messages.
2. Value match (`_is_same_request`) — survives loss of identity (e.g. a deep-copying
processor) as long as the request's fields are unchanged.
3. Position (`resumed_request_index`, pinned while preparing the request) — survives an
in-place rebuild that changes the request's fields (e.g. system-prompt reinjection),
which defeats both matches above.
Falling back to the first message carrying the current `run_id` is the last resort. Note the
layers cover different *single* mutations: a rebuild that also shifts the request's position
by adding/removing messages after it on the same step defeats all three, and detection falls
back to `run_id` (which includes the resumed request); this is rarer than any layer's own
blind spot and no built-in capability triggers it.
"""
if resumed_request is not None:
for index, message in enumerate(messages):
if message is resumed_request:
return index + 1
for index in range(len(messages) - 1, -1, -1):
if _is_same_request(messages[index], resumed_request):
return index + 1
if resumed_request_index is not None and 0 <= resumed_request_index < len(messages):
return resumed_request_index + 1
return _first_run_id_index(messages, run_id)
def _is_same_request(message: _messages.ModelMessage, request: _messages.ModelRequest) -> bool:
if not isinstance(message, _messages.ModelRequest):
return False
if message is request: # pragma: no cover
return True
# Intentionally excludes `run_id`: the resumed request may not have `run_id` set yet when
# this comparison is performed.
return (
message.parts == request.parts
and message.timestamp == request.timestamp
and message.instructions == request.instructions
and message.metadata == request.metadata
)
def _clean_message_history(messages: list[_messages.ModelMessage]) -> list[_messages.ModelMessage]:
"""Clean the message history by merging consecutive messages."""
clean_messages: list[_messages.ModelMessage] = []
for message in messages:
last_message = clean_messages[-1] if len(clean_messages) > 0 else None
if isinstance(message, _messages.ModelRequest):
if (
last_message
and isinstance(last_message, _messages.ModelRequest)
# Requests can only be merged if they have the same instructions
and (
not last_message.instructions
or not message.instructions
or last_message.instructions == message.instructions
)
# We intentionally don't block merging when `conversation_id` or `metadata` differ,
# nor try to preserve them across the merge. These fields are only bookkeeping for
# callers; they're never part of what gets sent to the model. Refusing to merge on a
# mismatch would leave two consecutive requests where the model expects one, breaking
# providers (and provider-side conversation state) that require a single request per
# turn -- a real regression -- just to preserve fields the model request node never reads.
):
parts = [*last_message.parts, *message.parts]
parts.sort(
# Tool return parts always need to be at the start
key=lambda x: 0 if isinstance(x, _messages.ToolReturnPart | _messages.RetryPromptPart) else 1
)
merged_message = _messages.ModelRequest(
parts=parts,
instructions=last_message.instructions or message.instructions,
timestamp=message.timestamp or last_message.timestamp,
)
clean_messages[-1] = merged_message
else:
clean_messages.append(message)
elif isinstance(message, _messages.ModelResponse): # pragma: no branch
# Interrupted responses are preserved as-is. Stream cancellation can
# leave incomplete tool calls, but filtering or synthesizing tool
# returns is a separate run-resumption semantics decision.
if (
last_message
and isinstance(last_message, _messages.ModelResponse)
# Responses can only be merged if they didn't really come from an API
and last_message.provider_response_id is None
and last_message.provider_name is None
and last_message.model_name is None
and message.provider_response_id is None
and message.provider_name is None
and message.model_name is None
):
merged_message = replace(last_message, parts=[*last_message.parts, *message.parts])
clean_messages[-1] = merged_message
else:
clean_messages.append(message)
return clean_messages