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

5502 lines
220 KiB
Python

from __future__ import annotations as _annotations
import asyncio
import datetime
import gc
import json
import re
import threading
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator
from contextlib import asynccontextmanager
from copy import deepcopy
from dataclasses import replace
from datetime import timezone
from typing import Any, cast
from unittest.mock import MagicMock
import pytest
from pydantic import BaseModel
from pydantic_core import ErrorDetails
from pydantic_ai import (
Agent,
AgentRunResult,
AgentRunResultEvent,
AgentStreamEvent,
ExternalToolset,
FinalResultEvent,
FunctionToolCallEvent,
FunctionToolResultEvent,
ImageUrl,
ModelMessage,
ModelRequest,
ModelRequestContext,
ModelResponse,
OutputToolCallEvent,
OutputToolResultEvent,
PartDeltaEvent,
PartEndEvent,
PartStartEvent,
RetryPromptPart,
RunContext,
TextPart,
TextPartDelta,
ToolCallPart,
ToolReturnPart,
UnexpectedModelBehavior,
UserError,
UserPromptPart,
_utils,
capture_run_messages,
models,
)
from pydantic_ai._agent_graph import GraphAgentState
from pydantic_ai._output import TextOutputProcessor, TextOutputSchema
from pydantic_ai.agent import AgentRun
from pydantic_ai.capabilities import AbstractCapability, CombinedCapability, WrapModelRequestHandler
from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, DeltaToolCalls, FunctionModel
from pydantic_ai.models.test import TestModel, TestStreamedResponse as ModelTestStreamedResponse
from pydantic_ai.models.wrapper import CompletedStreamedResponse
from pydantic_ai.output import NativeOutput, PromptedOutput, TextOutput, ToolOutput
from pydantic_ai.result import AgentStream, FinalResult, RunUsage, StreamedRunResult, StreamedRunResultSync
from pydantic_ai.tool_manager import ToolManager
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolApproved, ToolDefinition, ToolDenied
from pydantic_ai.usage import RequestUsage
from pydantic_graph import End
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsInt, IsNow, IsStr, message_part
pytestmark = pytest.mark.anyio
class Foo(BaseModel):
a: int
b: str
async def test_streamed_text_response():
m = TestModel()
test_agent = Agent(m)
assert test_agent.name is None
@test_agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
async with test_agent.run_stream('Hello') as result:
assert test_agent.name == 'test_agent'
assert isinstance(result.run_id, str)
assert not result.is_complete
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=timezone.utc), tool_call_id=IsStr()
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.usage == snapshot(
RunUsage(
requests=2,
input_tokens=103,
output_tokens=5,
tool_calls=1,
)
)
response = await result.get_output()
assert response == snapshot('{"ret_a":"a-apple"}')
assert result.is_complete
assert result.timestamp == IsNow(tz=timezone.utc)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=timezone.utc), tool_call_id=IsStr()
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=52, output_tokens=11),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.usage == snapshot(
RunUsage(
requests=2,
input_tokens=103,
output_tokens=11,
tool_calls=1,
)
)
def test_streamed_text_sync_response():
m = TestModel()
test_agent = Agent(m)
assert test_agent.name is None
@test_agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
result = test_agent.run_stream_sync('Hello')
assert test_agent.name == 'test_agent'
assert isinstance(result.run_id, str)
assert not result.is_complete
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=timezone.utc), tool_call_id=IsStr()
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()
assert result.usage == snapshot(
RunUsage(
requests=2,
input_tokens=103,
output_tokens=5,
tool_calls=1,
)
)
response = result.get_output()
assert response == snapshot('{"ret_a":"a-apple"}')
assert result.is_complete
assert result.timestamp == IsNow(tz=timezone.utc)
assert result.response == snapshot(
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=52, output_tokens=11),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
)
)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=timezone.utc), tool_call_id=IsStr()
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=52, output_tokens=11),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.usage == snapshot(
RunUsage(
requests=2,
input_tokens=103,
output_tokens=11,
tool_calls=1,
)
)
async def test_run_stream_sync_rejects_running_event_loop():
"""`run_stream_sync` drives its own event loop, so it must refuse to run inside an existing one."""
agent = Agent(TestModel())
with pytest.raises(RuntimeError, match=r'from within an async context or a running event loop; use `run_stream`'):
agent.run_stream_sync('Hello')
def test_run_stream_sync_rejects_disabled_threads():
"""When threads are disabled (e.g. emscripten or Temporal), the dedicated-thread portal can't be used."""
agent = Agent(TestModel())
with _utils.disable_threads():
with pytest.raises(RuntimeError, match=r'runs on a dedicated event-loop thread.*use `run_stream`'):
agent.run_stream_sync('Hello')
def test_run_stream_sync_tears_down_on_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch):
"""A Ctrl-C while blocked on the portal cancels the run instead of leaking tasks/sockets (#5975)."""
agent = Agent(TestModel())
result = agent.run_stream_sync('Hello')
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
assert bridge._finalizer.alive # pyright: ignore[reportPrivateUsage]
# Simulate the interrupt landing in the calling thread while it's blocked on the portal: the first
# `portal.call` (the `get_output` below) raises, later ones (the teardown) behave normally.
portal = bridge._portal # pyright: ignore[reportPrivateUsage]
original_call = portal.call
calls = 0
def interrupt_first_call(*args: Any, **kwargs: Any) -> Any:
nonlocal calls
calls += 1
if calls == 1:
raise KeyboardInterrupt
return original_call(*args, **kwargs)
monkeypatch.setattr(portal, 'call', interrupt_first_call)
# Enter the `with` block too, so its `__exit__` also calls `shutdown()` — the interrupt teardown
# already ran it once, so this exercises the idempotent (already-disarmed) shutdown path.
with pytest.raises(KeyboardInterrupt):
with result:
result.get_output()
# The run was torn down as part of handling the interrupt: the finalizer is disarmed and the portal
# thread is stopped, so no pending tasks or sockets are left running until GC.
assert not bridge._finalizer.alive # pyright: ignore[reportPrivateUsage]
with pytest.raises(RuntimeError): # the portal has stopped, so it rejects further calls
original_call(asyncio.sleep, 0)
def test_run_stream_sync_keyboard_interrupt_closes_open_stream(monkeypatch: pytest.MonkeyPatch):
"""A Ctrl-C mid-stream tears down the still-open model stream instead of leaking it (#5975).
The pre-portal implementation pumped each item via a separate `loop.run_until_complete(anext(...))`
on the caller's loop, so a `KeyboardInterrupt` unwound the caller while leaving the run's tasks
pending and its model connection open on that loop until GC. Here the model stream is still open
(mid-stream) when the interrupt lands; teardown must close it, which we observe via its `finally`.
"""
stream_closed = threading.Event()
async def stream_function(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[str]:
try:
# The final-result event lets `run_stream_sync` return here with the generator suspended at
# this `yield` — i.e. the model stream (and its notional connection) still open. The `finally`
# runs only when teardown closes it.
yield 'The cat sat on the mat.'
finally:
stream_closed.set()
agent = Agent(FunctionModel(stream_function=stream_function))
result = agent.run_stream_sync('Hello')
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
portal = bridge._portal # pyright: ignore[reportPrivateUsage]
assert not stream_closed.is_set() # the model stream is open and producing
# Simulate the interrupt landing while the caller is blocked on the portal: the first `portal.call`
# (the `get_output` below) raises, later ones (the teardown) behave normally.
original_call = portal.call
calls = 0
def interrupt_first_call(*args: Any, **kwargs: Any) -> Any:
nonlocal calls
calls += 1
if calls == 1:
raise KeyboardInterrupt
return original_call(*args, **kwargs)
monkeypatch.setattr(portal, 'call', interrupt_first_call)
with pytest.raises(KeyboardInterrupt):
with result:
result.get_output()
# The interrupt teardown ran the still-open model stream's `finally` on the portal thread, so the
# connection was closed rather than left pending on the loop until GC — the leak #5975 reported.
assert stream_closed.wait(timeout=5)
def test_run_stream_sync_keyboard_interrupt_mid_iteration_closes_receive_stream(monkeypatch: pytest.MonkeyPatch):
"""A Ctrl-C *while iterating* a sync stream closes its receive stream too, leaking nothing (#5975).
The interrupt stops the portal before `stream_sync`'s on-loop `aclose` can run, so the synchronous
`close()` fallback in its `finally` is what actually closes the receive stream. Without it, the
orphaned `MemoryObjectReceiveStream` warns from `__del__` at GC (escalated to an error by pytest's
unraisable-exception handling), so this test fails if that fallback regresses.
"""
agent = Agent(TestModel(custom_output_text='The cat sat on the mat.'))
with agent.run_stream_sync('Hello') as result:
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
portal = bridge._portal # pyright: ignore[reportPrivateUsage]
stream = result.stream_text(delta=True, debounce_by=None)
assert next(stream) # pump running, receive stream open
original_call = portal.call
calls = 0
def interrupt_first_call(*args: Any, **kwargs: Any) -> Any:
nonlocal calls
calls += 1
if calls == 1:
raise KeyboardInterrupt
return original_call(*args, **kwargs)
monkeypatch.setattr(portal, 'call', interrupt_first_call)
# The interrupt propagates through the `stream_sync` generator's `finally`, which closes the
# receive stream synchronously even though the portal is now gone.
with pytest.raises(KeyboardInterrupt):
next(stream)
del stream
gc.collect() # surface any unclosed `MemoryObjectReceiveStream` now, not at session teardown
def test_run_stream_sync_early_break_tears_down_pump():
"""Abandoning a sync stream early unblocks and closes the pump without surfacing an error."""
agent = Agent(TestModel(custom_output_text='The cat sat on the mat.'))
with agent.run_stream_sync('Hello') as result:
stream = result.stream_text(delta=True, debounce_by=None)
assert next(stream) # pull one chunk while the pump still has more to send
# `stream_text` is typed `Iterator` but is a generator at runtime; closing it abandons the stream,
# closing the receive end the pump is sending into.
cast(Any, stream).close()
async def test_run_stream_early_break_during_debounce_closes_cleanly():
"""Breaking out of a debounced `stream_text()` mid-chunk must not raise from stream teardown.
`stream_text()`/`stream_output()` debounce via `group_by_temporal`, which prefetches the next item in
a background task. Abandoning the stream with an early `break` while that prefetch is parked in an
in-flight `anext` on the model source used to make the run's `aclose()` raise
`RuntimeError: aclose(): asynchronous generator is already running`; `PeekableAsyncStream` now
serializes source access so `aclose()` waits for the prefetch to release the source first.
"""
async def stream_function(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[str]:
while True: # `while True` (not a bounded loop) so teardown mid-loop leaves no uncovered exit branch
yield 'chunk '
await asyncio.sleep(0.2) # keep a chunk in-flight (prefetched) when we break
agent = Agent(FunctionModel(stream_function=stream_function))
# Consume one chunk (default debounce spawns the prefetch task), then abandon the still-suspended
# stream by leaving the `async with`. Tearing down while the prefetch is mid-`anext` must not raise.
# (A single `anext` rather than `async for ...: break` avoids an uncovered loop-exit branch; keeping
# `stream` referenced stops it being finalized early, which would cancel the prefetch and hide the bug.)
async with agent.run_stream('hello') as result:
stream = result.stream_text(delta=True)
assert await anext(stream)
def test_run_stream_sync_rejects_already_entered_result():
"""Passing an already-entered `StreamedRunResult` (the old constructor arg) raises a clear error."""
with pytest.raises(TypeError, match='now takes the `run_stream\\(\\)` context manager'):
StreamedRunResultSync(cast(Any, object.__new__(StreamedRunResult)))
async def test_streamed_structured_response():
m = TestModel()
agent = Agent(m, output_type=tuple[str, str], name='fig_jam')
async with agent.run_stream('') as result:
assert agent.name == 'fig_jam'
assert not result.is_complete
response = await result.get_output()
assert response == snapshot(('a', 'a'))
assert result.is_complete
assert result.response == snapshot(
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'response': ['a', 'a']},
tool_call_id='pyd_ai_tool_call_id__final_result',
)
],
usage=RequestUsage(input_tokens=50),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
)
)
async def test_structured_response_iter():
async def text_stream(_messages: list[ModelMessage], agent_info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert agent_info.output_tools is not None
assert len(agent_info.output_tools) == 1
name = agent_info.output_tools[0].name
json_data = json.dumps({'response': [1, 2, 3, 4]})
yield {0: DeltaToolCall(name=name)}
yield {0: DeltaToolCall(json_args=json_data[:15])}
yield {0: DeltaToolCall(json_args=json_data[15:])}
agent = Agent(FunctionModel(stream_function=text_stream), output_type=list[int])
chunks: list[list[int]] = []
async with agent.run_stream('') as result:
async for structured_response in result.stream_response(debounce_by=None):
response_data = await result.validate_response_output(
structured_response, allow_partial=structured_response.state == 'incomplete'
)
chunks.append(response_data)
assert chunks == snapshot([[1], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]])
async with agent.run_stream('Hello') as result:
with pytest.raises(UserError, match=r'stream_text\(\) can only be used with text responses'):
async for _ in result.stream_text():
pass
async def test_streamed_text_stream():
m = TestModel(custom_output_text='The cat sat on the mat.')
agent = Agent(m)
async with agent.run_stream('Hello') as result:
# typehint to test (via static typing) that the stream type is correctly inferred
chunks: list[str] = [c async for c in result.stream_text()]
# one chunk with `stream_text()` due to group_by_temporal
assert chunks == snapshot(['The cat sat on the mat.'])
assert result.is_complete
async with agent.run_stream('Hello') as result:
# typehint to test (via static typing) that the stream type is correctly inferred
chunks: list[str] = [c async for c in result.stream_output()]
# two chunks with `stream()` due to not-final vs. final
assert chunks == snapshot(['The cat sat on the mat.', 'The cat sat on the mat.'])
assert result.is_complete
async with agent.run_stream('Hello') as result:
assert [c async for c in result.stream_text(debounce_by=None)] == snapshot(
[
'The ',
'The cat ',
'The cat sat ',
'The cat sat on ',
'The cat sat on the ',
'The cat sat on the mat.',
]
)
async with agent.run_stream('Hello') as result:
# with stream_text, there is no need to do partial validation, so we only get the final message once:
assert [c async for c in result.stream_text(delta=False, debounce_by=None)] == snapshot(
['The ', 'The cat ', 'The cat sat ', 'The cat sat on ', 'The cat sat on the ', 'The cat sat on the mat.']
)
async with agent.run_stream('Hello') as result:
assert [c async for c in result.stream_text(delta=True, debounce_by=None)] == snapshot(
['The ', 'cat ', 'sat ', 'on ', 'the ', 'mat.']
)
def upcase(text: str) -> str:
return text.upper()
async with agent.run_stream('Hello', output_type=TextOutput(upcase)) as result:
assert [c async for c in result.stream_output(debounce_by=None)] == snapshot(
[
'THE ',
'THE CAT ',
'THE CAT SAT ',
'THE CAT SAT ON ',
'THE CAT SAT ON THE ',
'THE CAT SAT ON THE MAT.',
'THE CAT SAT ON THE MAT.',
]
)
async with agent.run_stream('Hello') as result:
assert [c async for c in result.stream_response(debounce_by=None)] == snapshot(
[
ModelResponse(
parts=[TextPart(content='The ')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat ')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat ')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on ')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the ')],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
state='complete',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_streamed_text_stream_sync():
m = TestModel(custom_output_text='The cat sat on the mat.')
agent = Agent(m)
result = agent.run_stream_sync('Hello')
# typehint to test (via static typing) that the stream type is correctly inferred
chunks: list[str] = [c for c in result.stream_text()]
# one chunk with `stream_text()` due to group_by_temporal
assert chunks == snapshot(['The cat sat on the mat.'])
assert result.is_complete
result = agent.run_stream_sync('Hello')
# typehint to test (via static typing) that the stream type is correctly inferred
chunks: list[str] = [c for c in result.stream_output()]
# two chunks with `stream()` due to not-final vs. final
assert chunks == snapshot(['The cat sat on the mat.', 'The cat sat on the mat.'])
assert result.is_complete
result = agent.run_stream_sync('Hello')
assert [c for c in result.stream_text(debounce_by=None)] == snapshot(
[
'The ',
'The cat ',
'The cat sat ',
'The cat sat on ',
'The cat sat on the ',
'The cat sat on the mat.',
]
)
result = agent.run_stream_sync('Hello')
# with stream_text, there is no need to do partial validation, so we only get the final message once:
assert [c for c in result.stream_text(delta=False, debounce_by=None)] == snapshot(
['The ', 'The cat ', 'The cat sat ', 'The cat sat on ', 'The cat sat on the ', 'The cat sat on the mat.']
)
result = agent.run_stream_sync('Hello')
assert [c for c in result.stream_text(delta=True, debounce_by=None)] == snapshot(
['The ', 'cat ', 'sat ', 'on ', 'the ', 'mat.']
)
def upcase(text: str) -> str:
return text.upper()
result = agent.run_stream_sync('Hello', output_type=TextOutput(upcase))
assert [c for c in result.stream_output(debounce_by=None)] == snapshot(
[
'THE ',
'THE CAT ',
'THE CAT SAT ',
'THE CAT SAT ON ',
'THE CAT SAT ON THE ',
'THE CAT SAT ON THE MAT.',
'THE CAT SAT ON THE MAT.',
]
)
result = agent.run_stream_sync('Hello')
assert [c for c in result.stream_response(debounce_by=None)] == snapshot(
[
ModelResponse(
parts=[TextPart(content='The ')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat ')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat ')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on ')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the ')],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
state='complete',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_plain_response():
call_index = 0
async def text_stream(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[str]:
nonlocal call_index
call_index += 1
yield 'hello '
yield 'world'
agent = Agent(FunctionModel(stream_function=text_stream), output_type=tuple[str, str])
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(1\)'):
async with agent.run_stream(''):
pass
assert call_index == 2
async def test_stream_output_type_union_data_before_kind():
"""A valid union envelope streamed with `data` before `kind` must not crash mid-stream.
While `kind` is still a partial trailing string (e.g. `'App'`), envelope validation must
fail (so the chunk is skipped) rather than reach the union processor's `kind` lookup.
Streaming manifestation of https://github.com/pydantic/pydantic-ai/issues/5844.
"""
class Apple(BaseModel):
color: str
class Banana(BaseModel):
length: float
async def text_stream(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[str]:
# `data` first, so that `kind` is the trailing partial string while streaming.
for char in '{"result": {"data": {"color": "red"}, "kind": "Apple"}}':
yield char
agent = Agent(FunctionModel(stream_function=text_stream), output_type=PromptedOutput([Apple, Banana]))
async with agent.run_stream('What fruit is it?') as result:
async for _ in result.stream_output(debounce_by=None):
pass
assert await result.get_output() == snapshot(Apple(color='red'))
async def test_call_tool():
async def stream_structured_function(
messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
assert agent_info.function_tools is not None
assert len(agent_info.function_tools) == 1
name = agent_info.function_tools[0].name
part = message_part(messages, UserPromptPart)
json_string = json.dumps({'x': part.content})
yield {0: DeltaToolCall(name=name)}
yield {0: DeltaToolCall(json_args=json_string[:3])}
yield {0: DeltaToolCall(json_args=json_string[3:])}
else:
part = message_part(messages, ToolReturnPart, message_index=-1)
assert agent_info.output_tools is not None
assert len(agent_info.output_tools) == 1
name = agent_info.output_tools[0].name
json_data = json.dumps({'response': [part.content, 2]})
yield {0: DeltaToolCall(name=name)}
yield {0: DeltaToolCall(json_args=json_data[:5])}
yield {0: DeltaToolCall(json_args=json_data[5:])}
agent = Agent(FunctionModel(stream_function=stream_structured_function), output_type=tuple[str, int])
@agent.tool_plain
async def ret_a(x: str) -> str:
assert x == 'hello'
return f'{x} world'
async with agent.run_stream('hello') as result:
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args='{"x": "hello"}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=50, output_tokens=5),
model_name='function::stream_structured_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a',
content='hello world',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert await result.get_output() == snapshot(('hello world', 2))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args='{"x": "hello"}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=50, output_tokens=5),
model_name='function::stream_structured_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a',
content='hello world',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"response": ["hello world", 2]}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=50, output_tokens=7),
model_name='function::stream_structured_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_empty_response():
async def stream_structured_function(
messages: list[ModelMessage], _: AgentInfo
) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
yield {}
else:
yield 'ok here is text'
agent = Agent(FunctionModel(stream_function=stream_structured_function))
async with agent.run_stream('hello') as result:
response = await result.get_output()
assert response == snapshot('ok here is text')
messages = result.all_messages()
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=50),
model_name='function::stream_structured_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='ok here is text')],
usage=RequestUsage(input_tokens=50, output_tokens=4),
model_name='function::stream_structured_function',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_run_stream_allows_none_output_empty_response():
"""`run_stream()` with `output_type=str | None` should return `None` on an empty model response."""
async def empty_stream(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
yield {}
agent = Agent(FunctionModel(stream_function=empty_stream), output_type=str | None)
async with agent.run_stream('hello') as result:
assert await result.get_output() is None
assert result.is_complete
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=50),
model_name='function::empty_stream',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_call_tool_wrong_name():
async def stream_structured_function(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
yield {0: DeltaToolCall(name='foobar', json_args='{}')}
agent = Agent(
FunctionModel(stream_function=stream_structured_function),
output_type=tuple[str, int],
retries={'tools': 0, 'output': 0},
)
@agent.tool_plain
async def ret_a(x: str) -> str: # pragma: no cover
return x
with capture_run_messages() as messages:
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 0"):
async with agent.run_stream('hello'):
pass
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function::stream_structured_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_invalid_output_tool_args_get_output():
"""Regression test for https://github.com/pydantic/pydantic-ai/issues/3638."""
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None and len(info.output_tools) == 1
yield {0: DeltaToolCall(name=info.output_tools[0].name)}
yield {0: DeltaToolCall(json_args='{"response": ["hello", "not_an_int"]}')}
agent = Agent(FunctionModel(stream_function=stream_fn), output_type=tuple[str, int])
with pytest.raises(UnexpectedModelBehavior, match='retries are not supported in `run_stream'):
async with agent.run_stream('hello') as result:
await result.get_output()
async def test_invalid_output_tool_args_stream_output():
"""Regression test for https://github.com/pydantic/pydantic-ai/issues/3638."""
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None and len(info.output_tools) == 1
yield {0: DeltaToolCall(name=info.output_tools[0].name)}
yield {0: DeltaToolCall(json_args='{"response": ["hello", "not_an_int"]}')}
agent = Agent(FunctionModel(stream_function=stream_fn), output_type=tuple[str, int])
with pytest.raises(UnexpectedModelBehavior, match='retries are not supported in `run_stream'):
async with agent.run_stream('hello') as result:
async for _ in result.stream_output(debounce_by=None):
pass
class TestPartialOutput:
"""Tests for `ctx.partial_output` flag in output validators and output functions."""
# NOTE: When changing tests in this class:
# 1. Follow the existing order
# 2. Update tests in `tests/test_agent.py::TestPartialOutput` as well
async def test_output_validator_text(self):
"""Test that output validators receive correct value for `partial_output` with text output."""
call_log: list[tuple[str, bool]] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
for chunk in ['Hello', ' ', 'world', '!']:
yield chunk
agent = Agent(FunctionModel(stream_function=sf))
@agent.output_validator
def validate_output(ctx: RunContext, output: str) -> str:
call_log.append((output, ctx.partial_output))
return output
async with agent.run_stream('test') as result:
text_parts = [text_part async for text_part in result.stream_text(debounce_by=None)]
assert text_parts[-1] == 'Hello world!'
assert call_log == snapshot(
[
('Hello', True),
('Hello ', True),
('Hello world', True),
('Hello world!', True),
('Hello world!', False),
]
)
async def test_output_validator_structured(self):
"""Test that output validators receive correct value for `partial_output` with structured output."""
call_log: list[tuple[Foo, bool]] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 42')}
yield {0: DeltaToolCall(json_args=', "b": "f')}
yield {0: DeltaToolCall(json_args='oo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=Foo)
@agent.output_validator
def validate_output(ctx: RunContext, output: Foo) -> Foo:
call_log.append((output, ctx.partial_output))
return output
async with agent.run_stream('test') as result:
outputs = [output async for output in result.stream_output(debounce_by=None)]
assert outputs[-1] == Foo(a=42, b='foo')
assert call_log == snapshot(
[
(Foo(a=42, b='f'), True),
(Foo(a=42, b='foo'), True),
(Foo(a=42, b='foo'), False),
]
)
async def test_output_function_text(self):
"""Test that output functions receive correct value for `partial_output` with text output."""
call_log: list[tuple[str, bool]] = []
def process_output(ctx: RunContext, text: str) -> str:
call_log.append((text, ctx.partial_output))
return text.upper()
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
for chunk in ['Hello', ' ', 'world', '!']:
yield chunk
agent = Agent(FunctionModel(stream_function=sf), output_type=TextOutput(process_output))
async with agent.run_stream('test') as result:
outputs = [output async for output in result.stream_output(debounce_by=None)]
assert outputs[-1] == 'HELLO WORLD!'
assert call_log == snapshot(
[
('Hello', True),
('Hello ', True),
('Hello world', True),
('Hello world!', True),
('Hello world!', False),
]
)
async def test_output_function_structured(self):
"""Test that output functions receive correct value for `partial_output` with structured output."""
call_log: list[tuple[Foo, bool]] = []
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
call_log.append((foo, ctx.partial_output))
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21')}
yield {0: DeltaToolCall(json_args=', "b": "f')}
yield {0: DeltaToolCall(json_args='oo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=process_foo)
async with agent.run_stream('test') as result:
outputs = [output async for output in result.stream_output(debounce_by=None)]
assert outputs[-1] == Foo(a=42, b='FOO')
assert call_log == snapshot(
[
(Foo(a=21, b='f'), True),
(Foo(a=21, b='foo'), True),
(Foo(a=21, b='foo'), False),
]
)
async def test_output_function_structured_get_output(self):
"""Test that output functions receive correct value for `partial_output` with `get_output()`.
When using only `get_output()` without streaming, the output processor is called only once
with `partial_output=False` (final validation), since the user doesn't see partial results.
"""
call_log: list[tuple[Foo, bool]] = []
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
call_log.append((foo, ctx.partial_output))
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21, "b": "foo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=ToolOutput(process_foo, name='my_output'))
async with agent.run_stream('test') as result:
output = await result.get_output()
assert output == Foo(a=42, b='FOO')
assert call_log == snapshot([(Foo(a=21, b='foo'), False)])
async def test_output_function_structured_stream_output_only(self):
"""Test that output functions receive correct value for `partial_output` with `stream_output()`.
When using only `stream_output()`, the LAST yielded output should have `partial_output=False` (final validation).
"""
call_log: list[tuple[Foo, bool]] = []
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
call_log.append((foo, ctx.partial_output))
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21, "b": "foo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=ToolOutput(process_foo, name='my_output'))
async with agent.run_stream('test') as result:
outputs = [output async for output in result.stream_output()]
assert outputs[-1] == Foo(a=42, b='FOO')
assert call_log == snapshot(
[
(Foo(a=21, b='foo'), True),
(Foo(a=21, b='foo'), False),
],
)
async def test_stream_output_partial_then_final_validation(self):
"""Test that stream_output() calls validators with partial_output=True during streaming, then False at the end.
This verifies the critical invariant: output validators/functions are called multiple times with
partial_output=True as chunks arrive, followed by exactly one call with partial_output=False
for final validation. The final yield may have the same content as the last partial yield,
but the validation semantics differ (partial validation may accept incomplete data).
"""
call_log: list[tuple[Foo, bool]] = []
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
call_log.append((foo, ctx.partial_output))
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21')}
yield {0: DeltaToolCall(json_args=', "b": "f')}
yield {0: DeltaToolCall(json_args='oo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=ToolOutput(process_foo, name='my_output'))
async with agent.run_stream('test') as result:
outputs = [output async for output in result.stream_output(debounce_by=None)]
assert outputs[-1] == Foo(a=42, b='FOO')
# Verify the pattern: multiple True calls, exactly one False call at the end
partial_output_flags = [partial for _, partial in call_log]
assert partial_output_flags[-1] is False, 'Last call must have partial_output=False'
assert all(flag is True for flag in partial_output_flags[:-1]), (
'All calls except last must have partial_output=True'
)
assert len([f for f in partial_output_flags if f is False]) == 1, 'Exactly one partial_output=False call'
# The full call log shows progressive partial outputs followed by final validation
assert call_log == snapshot(
[
(Foo(a=21, b='f'), True),
(Foo(a=21, b='foo'), True),
(Foo(a=21, b='foo'), False), # Final validation - same content, different validation mode
]
)
# NOTE: When changing tests in this class:
# 1. Follow the existing order
# 2. Update tests in `tests/test_agent.py::TestPartialOutput` as well
class TestStreamingCachedOutput:
async def test_output_function_structured_double_stream_output(self):
"""Test that calling `stream_output()` twice works correctly.
The first `stream_output()` should do validations and cache the result.
The second `stream_output()` should return cached results without re-validation.
"""
call_log: list[tuple[Foo, bool]] = []
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
call_log.append((foo, ctx.partial_output))
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21, "b": "foo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=ToolOutput(process_foo, name='my_output'))
async with agent.run_stream('test') as result:
outputs1 = [output async for output in result.stream_output()]
outputs2 = [output async for output in result.stream_output()]
assert outputs1[-1] == outputs2[-1] == Foo(a=42, b='FOO')
assert call_log == snapshot(
[
(Foo(a=21, b='foo'), True),
(Foo(a=21, b='foo'), False),
],
)
async def test_output_validator_text_double_stream_text(self):
"""Test that calling `stream_text()` twice works correctly with output validator.
The first `stream_text()` should do validations and cache the result.
The second `stream_text()` should return cached results without re-validation.
"""
call_log: list[tuple[str, bool]] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
for chunk in ['Hello', ' ', 'world', '!']:
yield chunk
agent = Agent(FunctionModel(stream_function=sf))
@agent.output_validator
def validate_output(ctx: RunContext, output: str) -> str:
call_log.append((output, ctx.partial_output))
return output
async with agent.run_stream('test') as result:
text_parts1 = [text async for text in result.stream_text(debounce_by=None)]
text_parts2 = [text async for text in result.stream_text(debounce_by=None)]
assert text_parts1[-1] == text_parts2[-1] == 'Hello world!'
assert call_log == snapshot(
[
('Hello', True),
('Hello ', True),
('Hello world', True),
('Hello world!', True),
('Hello world!', False),
],
)
async def test_output_function_structured_double_get_output(self):
"""Test that calling `get_output()` twice works correctly.
The first `get_output()` should do validation and cache the result.
The second `get_output()` should return cached results without re-validation.
"""
call_log: list[tuple[Foo, bool]] = []
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
call_log.append((foo, ctx.partial_output))
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21, "b": "foo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=ToolOutput(process_foo, name='my_output'))
async with agent.run_stream('test') as result:
output1 = await result.get_output()
output2 = await result.get_output()
assert output1 == output2 == Foo(a=42, b='FOO')
assert call_log == snapshot([(Foo(a=21, b='foo'), False)])
async def test_cached_output_mutation_does_not_affect_cache(self):
"""Test that mutating a returned cached output does not affect the cached value.
When the same output is retrieved multiple times from cache, each call should return
a deep copy, so mutations to one don't affect subsequent retrievals.
"""
def process_foo(ctx: RunContext, foo: Foo) -> Foo:
return Foo(a=foo.a * 2, b=foo.b.upper())
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name=info.output_tools[0].name, json_args='{"a": 21, "b": "foo"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=ToolOutput(process_foo, name='my_output'))
async with agent.run_stream('test') as result:
# Get the first output and mutate it
output1 = await result.get_output()
output1.a = 999
output1.b = 'MUTATED'
# Get the second output - should not be affected by mutation
output2 = await result.get_output()
# First output should have been mutated
assert output1 == Foo(a=999, b='MUTATED')
# Second output should be the original cached value (not mutated)
assert output2 == Foo(a=42, b='FOO')
class OutputType(BaseModel):
"""Result type used by multiple tests."""
value: str
class TestMultipleToolCalls:
"""Tests for scenarios where multiple tool calls are made in a single response."""
# NOTE: When changing tests in this class:
# 1. Follow the existing order
# 2. Update tests in `tests/test_agent.py::TestMultipleToolCalls` as well
async def test_early_strategy_stops_after_first_final_result(self):
"""Test that 'early' strategy stops processing regular tools after first final result."""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('final_result', '{"value": "final"}')}
yield {2: DeltaToolCall('regular_tool', '{"x": 1}')}
yield {3: DeltaToolCall('another_tool', '{"y": 2}')}
yield {4: DeltaToolCall('deferred_tool', '{"x": 3}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='early')
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
"""A regular tool that should not be called."""
tool_called.append('regular_tool')
return x
@agent.tool_plain
def another_tool(y: int) -> int: # pragma: no cover
"""Another tool that should not be called."""
tool_called.append('another_tool')
return y
async def defer(ctx: RunContext, tool_def: ToolDefinition) -> ToolDefinition | None:
return replace(tool_def, kind='external')
@agent.tool_plain(prepare=defer)
def deferred_tool(x: int) -> int: # pragma: no cover
return x + 1
async with agent.run_stream('test early strategy') as result:
response = await result.get_output()
assert response.value == snapshot('final')
messages = result.all_messages()
# Verify no tools were called after final result
assert tool_called == []
# Verify we got tool returns for all calls
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test early strategy', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args='{"value": "final"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='regular_tool', args='{"x": 1}', tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args='{"y": 2}', tool_call_id=IsStr()),
ToolCallPart(tool_name='deferred_tool', args='{"x": 3}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=13),
model_name='function::sf',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='regular_tool',
content='Tool not executed - a final result was already processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='another_tool',
content='Tool not executed - a final result was already processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize('output_mode', ['native', 'prompted'])
async def test_early_strategy_prefers_structured_text_output_over_tool_calls(self, output_mode: str):
"""Under 'early', valid native/prompted output text streamed alongside function tool calls is the
final result, so the function tools are skipped — matching the non-streaming behavior."""
tool_called: list[str] = []
async def sf(messages: list[ModelMessage], _info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
yield '{"value": "final"}'
yield {1: DeltaToolCall('regular_tool', '{"x": 1}')}
output_type = NativeOutput(OutputType) if output_mode == 'native' else PromptedOutput(OutputType)
agent = Agent(FunctionModel(stream_function=sf), output_type=output_type, end_strategy='early')
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
tool_called.append('regular_tool')
return x
async with agent.run_stream('test early structured output') as result:
output = await result.get_output()
messages = result.all_messages()
assert output == OutputType(value='final')
assert tool_called == []
assert isinstance(messages[-1], ModelRequest)
skipped = messages[-1].parts[0]
assert isinstance(skipped, ToolReturnPart)
assert skipped.tool_name == 'regular_tool'
assert skipped.content == 'Tool not executed - a final result was already processed.'
async def test_non_early_strategy_runs_tools_alongside_structured_text_output(self):
"""Under 'graceful', function tools streamed alongside structured text output still run. (In streaming
the text output is committed the instant it streams, so it remains the final result — unlike the
non-streaming graceful case, which continues the run and ends on the post-tool output.)"""
tool_called: list[str] = []
async def sf(messages: list[ModelMessage], _info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
yield '{"value": "final"}'
yield {1: DeltaToolCall('regular_tool', '{"x": 1}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=NativeOutput(OutputType), end_strategy='graceful')
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
async with agent.run_stream('test graceful structured output') as result:
output = await result.get_output()
assert output == OutputType(value='final')
assert tool_called == ['regular_tool']
async def test_early_strategy_does_not_call_additional_output_tools(self):
"""Test that 'early' strategy does not execute additional output tool functions."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
"""Process first output."""
output_tools_called.append('first')
return output
def process_second(output: OutputType) -> OutputType: # pragma: no cover
"""Process second output."""
output_tools_called.append('second')
return output
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('first_output', '{"value": "first"}')}
yield {2: DeltaToolCall('second_output', '{"value": "second"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='early',
)
async with agent.run_stream('test early output tools') as result:
response = await result.get_output()
# Verify the result came from the first output tool
assert isinstance(response, OutputType)
assert response.value == 'first'
# Verify only the first output tool was called
assert output_tools_called == ['first']
# Verify we got tool returns in the correct order
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test early output tools', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args='{"value": "second"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::stream_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_early_strategy_uses_first_final_result(self):
"""Test that 'early' strategy uses the first final result and ignores subsequent ones."""
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('final_result', '{"value": "first"}')}
yield {2: DeltaToolCall('final_result', '{"value": "second"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='early')
async with agent.run_stream('test multiple final results') as result:
response = await result.get_output()
assert response.value == snapshot('first')
messages = result.all_messages()
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test multiple final results', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args='{"value": "second"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::sf',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='final_result',
content='Output tool not used - a final result was already processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_early_strategy_with_final_result_in_middle(self):
"""Test that 'early' strategy stops at first final result, regardless of position."""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('regular_tool', '{"x": 1}')}
yield {2: DeltaToolCall('final_result', '{"value": "final"}')}
yield {3: DeltaToolCall('another_tool', '{"y": 2}')}
yield {4: DeltaToolCall('unknown_tool', '{"value": "???"}')}
yield {5: DeltaToolCall('deferred_tool', '{"x": 5}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='early')
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
"""A regular tool that should not be called."""
tool_called.append('regular_tool')
return x
@agent.tool_plain
def another_tool(y: int) -> int: # pragma: no cover
"""A tool that should not be called."""
tool_called.append('another_tool')
return y
async def defer(ctx: RunContext, tool_def: ToolDefinition) -> ToolDefinition | None:
return replace(tool_def, kind='external')
@agent.tool_plain(prepare=defer)
def deferred_tool(x: int) -> int: # pragma: no cover
return x + 1
async with agent.run_stream('test early strategy with final result in middle') as result:
response = await result.get_output()
assert response.value == snapshot('final')
messages = result.all_messages()
# Verify no tools were called
assert tool_called == []
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test early strategy with final result in middle',
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='regular_tool',
args='{"x": 1}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='final_result',
args='{"value": "final"}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='another_tool',
args='{"y": 2}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='unknown_tool',
args='{"value": "???"}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='deferred_tool',
args='{"x": 5}',
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=50, output_tokens=17),
model_name='function::sf',
timestamp=IsNow(tz=datetime.timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
ToolReturnPart(
tool_name='regular_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
ToolReturnPart(
tool_name='another_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
ToolReturnPart(
content='Tool not executed - a final result was already processed.',
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_early_strategy_with_external_tool_call(self):
"""Test that early strategy handles external tool calls correctly.
Streaming and non-streaming modes differ in how they choose the final result:
- Streaming: First tool call (in response order) that can produce a final result (output or deferred)
- Non-streaming: First output tool (if none called, all deferred tools become final result)
See https://github.com/pydantic/pydantic-ai/issues/3636#issuecomment-3618800480 for details.
"""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('external_tool')}
yield {2: DeltaToolCall('final_result', '{"value": "final"}')}
yield {3: DeltaToolCall('regular_tool', '{"x": 1}')}
agent = Agent(
FunctionModel(stream_function=sf),
output_type=[OutputType, DeferredToolRequests],
toolsets=[
ExternalToolset(
tool_defs=[
ToolDefinition(
name='external_tool',
kind='external',
)
]
)
],
end_strategy='early',
)
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
"""A regular tool that should not be called."""
tool_called.append('regular_tool')
return x
async with agent.run_stream('test early strategy with external tool call') as result:
response = await result.get_output()
assert response == snapshot(
DeferredToolRequests(
calls=[
ToolCallPart(
tool_name='external_tool',
tool_call_id=IsStr(),
)
]
)
)
messages = result.all_messages()
# Verify no tools were called
assert tool_called == []
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test early strategy with external tool call',
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='external_tool', tool_call_id=IsStr()),
ToolCallPart(
tool_name='final_result',
args='{"value": "final"}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='regular_tool',
args='{"x": 1}',
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=50, output_tokens=7),
model_name='function::sf',
timestamp=IsNow(tz=datetime.timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
ToolReturnPart(
tool_name='regular_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_early_strategy_with_deferred_tool_call(self):
"""Test that early strategy handles deferred tool calls correctly."""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('deferred_tool')}
yield {2: DeltaToolCall('regular_tool', '{"x": 1}')}
agent = Agent(
FunctionModel(stream_function=sf),
output_type=[str, DeferredToolRequests],
end_strategy='early',
)
@agent.tool_plain
def deferred_tool() -> int:
raise CallDeferred
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
async with agent.run_stream('test early strategy with external tool call') as result:
response = await result.get_output()
assert response == snapshot(
DeferredToolRequests(calls=[ToolCallPart(tool_name='deferred_tool', tool_call_id=IsStr())])
)
messages = result.all_messages()
# Verify regular tool was called
assert tool_called == ['regular_tool']
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test early strategy with external tool call',
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='deferred_tool', tool_call_id=IsStr()),
ToolCallPart(
tool_name='regular_tool',
args='{"x": 1}',
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=50, output_tokens=3),
model_name='function::sf',
timestamp=IsNow(tz=datetime.timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=1,
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_early_strategy_does_not_apply_to_tool_calls_without_final_tool(self):
"""Test that 'early' strategy does not apply to tool calls when no output tool is called."""
tool_called: list[str] = []
agent = Agent(TestModel(), output_type=OutputType, end_strategy='early')
@agent.tool_plain
def regular_tool(x: int) -> int:
"""A regular tool that should be called."""
tool_called.append('regular_tool')
return x
async with agent.run_stream('test early strategy with regular tool calls') as result:
response = await result.get_output()
assert response.value == snapshot('a')
messages = result.all_messages()
# Verify the regular tool was executed
assert tool_called == ['regular_tool']
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test early strategy with regular tool calls',
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='regular_tool',
args={'x': 0},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=57),
model_name='test',
timestamp=IsNow(tz=datetime.timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=0,
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'value': 'a'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=58, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=datetime.timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_graceful_strategy_executes_function_tools_but_skips_output_tools(self):
"""Test that 'graceful' strategy executes function tools but skips remaining output tools."""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('final_result', '{"value": "first"}')}
yield {2: DeltaToolCall('regular_tool', '{"x": 42}')}
yield {3: DeltaToolCall('another_tool', '{"y": 2}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='graceful')
@agent.tool_plain
def regular_tool(x: int) -> int:
"""A regular tool that should be called."""
tool_called.append('regular_tool')
return x
@agent.tool_plain
def another_tool(y: int) -> int:
"""Another tool that should be called."""
tool_called.append('another_tool')
return y
async with agent.run_stream('test graceful strategy') as result:
response = await result.get_output()
assert response.value == snapshot('first')
messages = result.all_messages()
# Verify all function tools were called
assert sorted(tool_called) == sorted(['regular_tool', 'another_tool'])
# Verify we got tool returns in the correct order
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test graceful strategy', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='regular_tool', args='{"x": 42}', tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args='{"y": 2}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=10),
model_name='function::sf',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='regular_tool',
content=42,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='another_tool', content=2, tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_graceful_strategy_does_not_call_additional_output_tools(self):
"""Test that 'graceful' strategy does not execute additional output tool functions."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
"""Process first output."""
output_tools_called.append('first')
return output
def process_second(output: OutputType) -> OutputType: # pragma: no cover
"""Process second output."""
output_tools_called.append('second')
return output
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('first_output', '{"value": "first"}')}
yield {2: DeltaToolCall('second_output', '{"value": "second"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='graceful',
)
async with agent.run_stream('test graceful output tools') as result:
response = await result.get_output()
# Verify the result came from the first output tool
assert isinstance(response, OutputType)
assert response.value == 'first'
# Verify only the first output tool was called
assert output_tools_called == ['first']
# Verify we got tool returns in the correct order
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test graceful output tools', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args='{"value": "second"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::stream_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_graceful_strategy_uses_first_final_result(self):
"""Test that 'graceful' strategy uses the first final result and ignores subsequent ones."""
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('final_result', '{"value": "first"}')}
yield {2: DeltaToolCall('final_result', '{"value": "second"}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='graceful')
async with agent.run_stream('test multiple final results') as result:
response = await result.get_output()
assert response.value == snapshot('first')
messages = result.all_messages()
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test multiple final results', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args='{"value": "second"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::sf',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='final_result',
content='Output tool not used - a final result was already processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_graceful_strategy_with_final_result_in_middle(self):
"""Test that 'graceful' strategy executes function tools but skips output and deferred tools."""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('regular_tool', '{"x": 1}')}
yield {2: DeltaToolCall('final_result', '{"value": "final"}')}
yield {3: DeltaToolCall('another_tool', '{"y": 2}')}
yield {4: DeltaToolCall('unknown_tool', '{"value": "???"}')}
yield {5: DeltaToolCall('deferred_tool', '{"x": 5}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='graceful')
@agent.tool_plain
def regular_tool(x: int) -> int:
"""A regular tool that should be called."""
tool_called.append('regular_tool')
return x
@agent.tool_plain
def another_tool(y: int) -> int:
"""Another tool that should be called."""
tool_called.append('another_tool')
return y
async def defer(ctx: RunContext, tool_def: ToolDefinition) -> ToolDefinition | None:
return replace(tool_def, kind='external')
@agent.tool_plain(prepare=defer)
def deferred_tool(x: int) -> int: # pragma: no cover
tool_called.append('deferred_tool')
return x + 1
async with agent.run_stream('test graceful strategy with final result in middle') as result:
response = await result.get_output()
assert response.value == snapshot('final')
messages = result.all_messages()
# Verify function tools were called but deferred tools were not
assert sorted(tool_called) == sorted(['regular_tool', 'another_tool'])
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test graceful strategy with final result in middle',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='regular_tool',
args='{"x": 1}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='final_result',
args='{"value": "final"}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='another_tool',
args='{"y": 2}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='unknown_tool',
args='{"value": "???"}',
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='deferred_tool',
args='{"x": 5}',
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=50, output_tokens=17),
model_name='function::sf',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=1,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='another_tool',
content=2,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
RetryPromptPart(
content="Unknown tool name: 'unknown_tool'. Available tools: 'another_tool', 'deferred_tool', 'final_result', 'regular_tool'",
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_exhaustive_strategy_executes_all_tools(self):
"""Test that 'exhaustive' strategy executes all tools while using first final result."""
tool_called: list[str] = []
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('regular_tool', '{"x": 42}')}
yield {2: DeltaToolCall('final_result', '{"value": "first"}')}
yield {3: DeltaToolCall('another_tool', '{"y": 2}')}
yield {4: DeltaToolCall('final_result', '{"value": "second"}')}
yield {5: DeltaToolCall('unknown_tool', '{"value": "???"}')}
yield {6: DeltaToolCall('deferred_tool', '{"x": 4}')}
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy='exhaustive')
@agent.tool_plain
def regular_tool(x: int) -> int:
"""A regular tool that should be called."""
tool_called.append('regular_tool')
return x
@agent.tool_plain
def another_tool(y: int) -> int:
"""Another tool that should be called."""
tool_called.append('another_tool')
return y
async def defer(ctx: RunContext, tool_def: ToolDefinition) -> ToolDefinition | None:
return replace(tool_def, kind='external')
@agent.tool_plain(prepare=defer)
def deferred_tool(x: int) -> int: # pragma: no cover
return x + 1
async with agent.run_stream('test exhaustive strategy') as result:
response = await result.get_output()
assert response.value == snapshot('first')
messages = result.all_messages()
# Verify the result came from the first final tool
assert response.value == 'first'
# Verify all regular tools were called
assert sorted(tool_called) == sorted(['regular_tool', 'another_tool'])
# Verify we got tool returns in the correct order
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test exhaustive strategy', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='regular_tool', args='{"x": 42}', tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args='{"y": 2}', tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args='{"value": "second"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='unknown_tool', args='{"value": "???"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='deferred_tool', args='{"x": 4}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=21),
model_name='function::sf',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=42,
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
ToolReturnPart(
tool_name='another_tool',
content=2,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Output tool processed, but its value will not be the final result of the agent run.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
RetryPromptPart(
content="Unknown tool name: 'unknown_tool'. Available tools: 'another_tool', 'deferred_tool', 'final_result', 'regular_tool'",
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_exhaustive_strategy_calls_all_output_tools(self):
"""Test that 'exhaustive' strategy executes all output tool functions."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
"""Process first output."""
output_tools_called.append('first')
return output
def process_second(output: OutputType) -> OutputType:
"""Process second output."""
output_tools_called.append('second')
return output
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('first_output', '{"value": "first"}')}
yield {2: DeltaToolCall('second_output', '{"value": "second"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
)
async with agent.run_stream('test exhaustive output tools') as result:
response = await result.get_output()
# Verify the result came from the first output tool
assert isinstance(response, OutputType)
assert response.value == 'first'
# Verify both output tools were called
assert sorted(output_tools_called) == ['first', 'second']
# Verify we got tool returns in the correct order
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test exhaustive output tools', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args='{"value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args='{"value": "second"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::stream_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool processed, but its value will not be the final result of the agent run.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.xfail(reason='See https://github.com/pydantic/pydantic-ai/issues/3393')
async def test_exhaustive_strategy_invalid_first_valid_second_output(self):
"""Test that exhaustive strategy uses the second valid output when the first is invalid."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
"""Process first output - will be invalid."""
output_tools_called.append('first')
raise ModelRetry('First output validation failed')
def process_second(output: OutputType) -> OutputType:
"""Process second output - will be valid."""
output_tools_called.append('second')
return output
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('first_output', '{"value": "invalid"}')}
yield {2: DeltaToolCall('second_output', '{"value": "valid"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
)
async with agent.run_stream('test invalid first valid second') as result:
response = await result.get_output()
# Verify the result came from the second output tool (first was invalid)
assert isinstance(response, OutputType)
assert response.value == snapshot('valid')
# Verify both output tools were called
assert sorted(output_tools_called) == ['first', 'second']
# Verify we got appropriate messages
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test invalid first valid second', timestamp=IsNow(tz=timezone.utc))],
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args='{"value": "invalid"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args='{"value": "valid"}', tool_call_id=IsStr()),
],
model_name='function:stream_function:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='First output validation failed',
tool_name='first_output',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_exhaustive_strategy_valid_first_invalid_second_output(self):
"""Test that exhaustive strategy uses the first valid output even when the second is invalid."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
"""Process first output - will be valid."""
output_tools_called.append('first')
return output
def process_second(output: OutputType) -> OutputType:
"""Process second output - will be invalid."""
output_tools_called.append('second')
raise ModelRetry('Second output validation failed')
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('first_output', '{"value": "valid"}')}
yield {2: DeltaToolCall('second_output', '{"value": "invalid"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
retries={'output': 0}, # No retries - model must succeed first try
)
async with agent.run_stream('test valid first invalid second') as result:
response = await result.get_output()
# Verify the result came from the first output tool (second was invalid, but we ignore it)
assert isinstance(response, OutputType)
assert response.value == snapshot('valid')
# Verify both output tools were called
assert sorted(output_tools_called) == ['first', 'second']
# Verify we got appropriate messages
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test valid first invalid second', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args='{"value": "valid"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args='{"value": "invalid"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::stream_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool not used - output function execution failed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_exhaustive_strategy_with_tool_retry_and_final_result(self):
"""Test that exhaustive strategy doesn't increment retries when `final_result` exists and `ToolRetryError` occurs."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
"""Process first output - will be valid."""
output_tools_called.append('first')
return output
def process_second(output: OutputType) -> OutputType:
"""Process second output - will raise ModelRetry."""
output_tools_called.append('second')
raise ModelRetry('Second output validation failed')
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('first_output', '{"value": "valid"}')}
yield {2: DeltaToolCall('second_output', '{"value": "invalid"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
retries={'output': 1}, # Allow 1 retry so ToolRetryError is raised
)
async with agent.run_stream('test exhaustive with tool retry') as result:
response = await result.get_output()
# Verify the result came from the first output tool
assert isinstance(response, OutputType)
assert response.value == 'valid'
# Verify both output tools were called
assert sorted(output_tools_called) == ['first', 'second']
# Verify we got appropriate messages
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test exhaustive with tool retry', timestamp=IsNow(tz=datetime.timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args='{"value": "valid"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args='{"value": "invalid"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::stream_function',
timestamp=IsNow(tz=datetime.timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
RetryPromptPart(
content='Second output validation failed',
tool_name='second_output',
tool_call_id=IsStr(),
timestamp=IsNow(tz=datetime.timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.xfail(reason='See https://github.com/pydantic/pydantic-ai/issues/3638')
async def test_exhaustive_raises_unexpected_model_behavior(self):
"""Test that exhaustive strategy raises `UnexpectedModelBehavior` when all outputs have validation errors."""
def process_output(output: OutputType) -> OutputType: # pragma: no cover
"""A tool that should not be called."""
assert False
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
# Missing 'value' field will cause validation error
yield {1: DeltaToolCall('output_tool', '{"invalid_field": "invalid"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(process_output, name='output_tool'),
],
end_strategy='exhaustive',
)
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries \\(1\\)'):
async with agent.run_stream('test') as result:
await result.get_output()
@pytest.mark.xfail(reason='See https://github.com/pydantic/pydantic-ai/issues/3638')
async def test_multiple_final_result_are_validated_correctly(self):
"""Tests that if multiple final results are returned, but one fails validation, the other is used."""
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
assert info.output_tools is not None
yield {1: DeltaToolCall('final_result', '{"bad_value": "first"}')}
yield {2: DeltaToolCall('final_result', '{"value": "second"}')}
agent = Agent(FunctionModel(stream_function=stream_function), output_type=OutputType, end_strategy='early')
async with agent.run_stream('test multiple final results') as result:
response = await result.get_output()
messages = result.new_messages()
# Verify the result came from the second final tool
assert response.value == snapshot('second')
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test multiple final results', timestamp=IsNow(tz=timezone.utc))],
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args='{"bad_value": "first"}', tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args='{"value": "second"}', tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=50, output_tokens=8),
model_name='function::stream_function',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
ErrorDetails(
type='missing',
loc=('value',),
msg='Field required',
input={'bad_value': 'first'},
)
],
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
),
],
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_sequential_tool_is_a_per_tool_barrier(self):
"""A `sequential=True` tool runs alone; other tools parallelize around it (streaming path)."""
active = 0
barrier_ran_alone = True
async def stream_function(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
yield {0: DeltaToolCall(name='parallel_a')}
yield {1: DeltaToolCall(name='parallel_b')}
yield {2: DeltaToolCall(name='barrier')}
yield {3: DeltaToolCall(name='parallel_c')}
else:
yield 'done'
agent = Agent(FunctionModel(stream_function=stream_function))
async def track() -> str:
nonlocal active
active += 1
await asyncio.sleep(0.02)
active -= 1
return 'ok'
@agent.tool_plain
async def parallel_a() -> str:
return await track()
@agent.tool_plain
async def parallel_b() -> str:
return await track()
@agent.tool_plain(sequential=True)
async def barrier() -> str:
nonlocal barrier_ran_alone
if active != 0:
barrier_ran_alone = False # pragma: no cover
await asyncio.sleep(0.02)
return 'barrier'
@agent.tool_plain
async def parallel_c() -> str:
return await track()
async with agent.run_stream('test') as result:
await result.get_output()
assert barrier_ran_alone
async def test_outer_cancellation_cancels_pending_tools(self):
"""Outer cancellation during streamed tool execution cancels still-pending tool tasks."""
first_done = asyncio.Event()
pending_started = asyncio.Event()
pending_cancelled = asyncio.Event()
async def stream_function(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
yield {0: DeltaToolCall(name='fast_tool')}
yield {1: DeltaToolCall(name='slow_tool')}
else:
yield 'done' # pragma: no cover
agent = Agent(FunctionModel(stream_function=stream_function))
@agent.tool_plain
async def fast_tool() -> str:
first_done.set()
return 'done'
@agent.tool_plain
async def slow_tool() -> str:
pending_started.set()
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
pending_cancelled.set()
raise
return 'done' # pragma: no cover
async def run() -> None:
async with agent.run_stream('test') as result:
await result.get_output() # pragma: no cover
task = asyncio.create_task(run())
await asyncio.wait_for(first_done.wait(), timeout=1)
await asyncio.wait_for(pending_started.wait(), timeout=1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert pending_cancelled.is_set()
async def test_graceful_runs_function_tools_before_output(self):
"""Streaming commits the output as it streams, but `graceful` still runs the function tools
the model emitted alongside it (their side effects happen)."""
called: list[str] = []
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name='tool_a')}
yield {1: DeltaToolCall(name='tool_b')}
yield {2: DeltaToolCall('final_result', '{"value": "done"}')}
agent = Agent(FunctionModel(stream_function=stream_function), output_type=OutputType, end_strategy='graceful')
@agent.tool_plain
def tool_a() -> str:
called.append('tool_a')
return 'a'
@agent.tool_plain
def tool_b() -> str:
called.append('tool_b')
return 'b'
async with agent.run_stream('test') as result:
output = await result.get_output()
assert output.value == 'done'
assert sorted(called) == ['tool_a', 'tool_b']
async def test_graceful_interleaved_outputs_and_function_tools(self):
"""Graceful streaming with outputs and function tools interleaved: the first streamed output
wins, later outputs are skipped, and the function tools still run."""
called: list[str] = []
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name='tool_a')}
yield {1: DeltaToolCall('first_output', '{"value": "a"}')}
yield {2: DeltaToolCall(name='tool_b')}
yield {3: DeltaToolCall('second_output', '{"value": "b"}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(OutputType, name='first_output'),
ToolOutput(OutputType, name='second_output'),
],
end_strategy='graceful',
)
@agent.tool_plain
def tool_a() -> str:
called.append('tool_a')
return 'a'
@agent.tool_plain
def tool_b() -> str:
called.append('tool_b')
return 'b'
async with agent.run_stream('test') as result:
output = await result.get_output()
assert output.value == 'a'
assert sorted(called) == ['tool_a', 'tool_b']
async def test_exhaustive_tool_output_sequential_barrier(self):
"""`ToolOutput(sequential=True)` under streaming: the output is committed as it streams, so
(unlike the non-streaming path) it isn't held behind the function tool; the function tool
still runs."""
events: list[str] = []
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name='tool_a')}
yield {1: DeltaToolCall('do_output', '{"value": "done"}')}
def do_output(output: OutputType) -> OutputType:
events.append('output')
return output
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=ToolOutput(do_output, name='do_output', sequential=True),
end_strategy='exhaustive',
)
@agent.tool_plain
async def tool_a() -> str:
await asyncio.sleep(0.02)
events.append('tool_a')
return 'a'
async with agent.run_stream('test') as result:
output = await result.get_output()
assert output.value == 'done'
assert 'tool_a' in events
async def test_early_output_failure_raises_when_streaming(self):
"""The non-streaming `early` fallback (run function tools when every output fails) has no
streaming equivalent: a streamed output that fails validation raises, since `run_stream()`
can't retry outputs."""
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
assert info.output_tools is not None
yield {0: DeltaToolCall('regular_tool', '{"x": 1}')}
yield {1: DeltaToolCall('bad_output', '{"value": "x"}')}
def bad_output(output: OutputType) -> OutputType:
if output.value == 'x':
raise ModelRetry('bad')
return output # pragma: no cover
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=ToolOutput(bad_output, name='bad_output'),
end_strategy='early',
)
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
return x
with pytest.raises(UnexpectedModelBehavior, match='retries are not supported in `run_stream\\(\\)`'):
async with agent.run_stream('test') as result:
await result.get_output()
async def test_early_multiple_outputs_and_function_tools(self):
"""Early streaming with several output tools: the first streamed output wins, later outputs
are skipped, and function tools are stubbed (not run) once an output succeeds."""
called: list[str] = []
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
assert info.output_tools is not None
yield {0: DeltaToolCall('first_output', '{"value": "a"}')}
yield {1: DeltaToolCall('second_output', '{"value": "b"}')}
yield {2: DeltaToolCall('regular_tool', '{"x": 1}')}
agent = Agent(
FunctionModel(stream_function=stream_function),
output_type=[
ToolOutput(OutputType, name='first_output'),
ToolOutput(OutputType, name='second_output'),
],
end_strategy='early',
)
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
called.append('regular_tool')
return x
async with agent.run_stream('test') as result:
output = await result.get_output()
assert output.value == 'a'
assert called == []
async def test_graceful_function_tool_retry_does_not_suppress_committed_output(self):
"""Retry-wins doesn't apply when streaming: the output is committed as it streams, so a
function tool's `ModelRetry` in the same response can't revoke it (`graceful`)."""
rounds = 0
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
nonlocal rounds
assert info.output_tools is not None
rounds += 1
yield {0: DeltaToolCall('flaky_tool', '{"x": 1}')}
yield {1: DeltaToolCall('final_result', '{"value": "committed"}')}
agent = Agent(FunctionModel(stream_function=stream_function), output_type=OutputType, end_strategy='graceful')
@agent.tool_plain
def flaky_tool(x: int) -> int:
raise ModelRetry('not yet')
async with agent.run_stream('test') as result:
output = await result.get_output()
# The streamed output is committed and not suppressed, so the run ends in a single round.
assert output.value == 'committed'
assert rounds == 1
async def test_exhaustive_function_tool_retry_does_not_suppress_committed_output(self):
"""Retry-wins is also exempt under `exhaustive` streaming: the committed output stands."""
rounds = 0
async def stream_function(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
nonlocal rounds
assert info.output_tools is not None
rounds += 1
yield {0: DeltaToolCall('flaky_tool', '{"x": 1}')}
yield {1: DeltaToolCall('final_result', '{"value": "committed"}')}
agent = Agent(FunctionModel(stream_function=stream_function), output_type=OutputType, end_strategy='exhaustive')
@agent.tool_plain
def flaky_tool(x: int) -> int:
raise ModelRetry('not yet')
async with agent.run_stream('test') as result:
output = await result.get_output()
assert output.value == 'committed'
assert rounds == 1
# NOTE: When changing tests in this class:
# 1. Follow the existing order
# 2. Update tests in `tests/test_agent.py::TestMultipleToolCalls` as well
# The retry-wins tests (a function-tool `ModelRetry` suppressing an output result) have no
# streaming counterpart: under `run_stream` the streamed output is committed as soon as it's
# detected, so retry-wins doesn't apply (see `docs/output.md`).
async def test_custom_output_type_default_str() -> None:
agent = Agent('test')
async with agent.run_stream('test') as result:
response = await result.get_output()
assert response == snapshot('success (no tool calls)')
assert result.response == snapshot(
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
)
)
async with agent.run_stream('test', output_type=OutputType) as result:
response = await result.get_output()
assert response == snapshot(OutputType(value='a'))
async def test_custom_output_type_default_structured() -> None:
agent = Agent('test', output_type=OutputType)
async with agent.run_stream('test') as result:
response = await result.get_output()
assert response == snapshot(OutputType(value='a'))
async with agent.run_stream('test', output_type=str) as result:
response = await result.get_output()
assert response == snapshot('success (no tool calls)')
async def test_iter_stream_output():
m = TestModel(custom_output_text='The cat sat on the mat.')
agent = Agent(m)
@agent.output_validator
def output_validator_simple(data: str) -> str:
# Make a substitution in the validated results
return re.sub('cat sat', 'bat sat', data)
run: AgentRun
stream: AgentStream | None = None
messages: list[str] = []
stream_usage: RunUsage | None = None
async with agent.iter('Hello') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for chunk in stream.stream_output(debounce_by=None):
messages.append(chunk)
stream_usage = deepcopy(stream.usage)
assert stream is not None
assert stream.response == snapshot(
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
)
)
assert run.next_node == End(data=FinalResult(output='The bat sat on the mat.', tool_name=None, tool_call_id=None))
assert run.usage == stream_usage == RunUsage(requests=1, input_tokens=51, output_tokens=7)
assert messages == snapshot(
[
'',
'The ',
'The cat ',
'The bat sat ',
'The bat sat on ',
'The bat sat on the ',
'The bat sat on the mat.',
'The bat sat on the mat.',
]
)
async def test_streamed_run_result_metadata_available() -> None:
agent = Agent(TestModel(custom_output_text='stream metadata'), metadata={'env': 'stream'})
async with agent.run_stream('stream metadata prompt') as result:
assert await result.get_output() == 'stream metadata'
assert result.metadata == {'env': 'stream'}
async def test_agent_stream_metadata_available() -> None:
agent = Agent(
TestModel(custom_output_text='agent stream metadata'),
metadata=lambda ctx: {'prompt': ctx.prompt},
)
captured_stream: AgentStream | None = None
async with agent.iter('agent stream prompt') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
captured_stream = stream
async for _ in stream.stream_text(debounce_by=None):
pass
assert captured_stream is not None
assert captured_stream.metadata == {'prompt': 'agent stream prompt'}
def test_agent_stream_metadata_falls_back_to_run_context() -> None:
response_message = ModelResponse(parts=[TextPart('fallback metadata')], model_name='test')
stream_response = ModelTestStreamedResponse(
model_request_parameters=models.ModelRequestParameters(),
_model_name='test',
_structured_response=response_message,
_messages=[],
_provider_name='test',
)
run_ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
metadata={'source': 'run-context'},
)
output_schema = TextOutputSchema[str](
text_processor=TextOutputProcessor(),
allows_deferred_tools=False,
allows_image=False,
allows_none=False,
)
stream = AgentStream(
_raw_stream_response=stream_response,
_output_schema=output_schema,
_model_request_parameters=models.ModelRequestParameters(),
_output_validators=[],
_run_ctx=run_ctx,
_usage_limits=None,
_tool_manager=ToolManager(toolset=MagicMock()),
_root_capability=CombinedCapability([]),
)
assert stream.metadata == {'source': 'run-context'}
def _make_run_result(*, metadata: dict[str, Any] | None) -> AgentRunResult[str]:
state = GraphAgentState(metadata=metadata)
response_message = ModelResponse(parts=[TextPart('final')], model_name='test')
state.message_history.append(response_message)
return AgentRunResult('final', _state=state)
def test_streamed_run_result_metadata_prefers_run_result_state() -> None:
run_result = _make_run_result(metadata={'from': 'run-result'})
streamed = StreamedRunResult(
all_messages=run_result.all_messages(),
new_message_index=0,
run_result=run_result,
)
assert streamed.metadata == {'from': 'run-result'}
def test_streamed_run_result_metadata_none_without_sources() -> None:
run_result = _make_run_result(metadata=None)
streamed = StreamedRunResult(all_messages=[], new_message_index=0, run_result=run_result)
assert streamed.metadata is None
def test_streamed_run_result_metadata_none_without_run_or_stream() -> None:
streamed = StreamedRunResult(all_messages=[], new_message_index=0, stream_response=None, on_complete=None)
assert streamed.metadata is None
def test_streamed_run_result_sync_exposes_metadata() -> None:
run_result = _make_run_result(metadata={'sync': 'metadata'})
streamed = StreamedRunResult(
all_messages=run_result.all_messages(),
new_message_index=0,
run_result=run_result,
)
@asynccontextmanager
async def run_stream_cm() -> AsyncGenerator[StreamedRunResult[None, str]]:
yield streamed
with StreamedRunResultSync(run_stream_cm()) as sync_result:
assert sync_result.metadata == {'sync': 'metadata'}
async def test_iter_stream_response():
m = TestModel(custom_output_text='The cat sat on the mat.')
agent = Agent(m)
@agent.output_validator
def output_validator_simple(data: str) -> str:
# Make a substitution in the validated results
return re.sub('cat sat', 'bat sat', data)
run: AgentRun
stream: AgentStream
messages: list[ModelResponse] = []
async with agent.iter('Hello') as run:
assert isinstance(run.run_id, str)
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for chunk in stream.stream_response(debounce_by=None):
messages.append(chunk)
incomplete_texts = [
'',
'',
'The ',
'The cat ',
'The cat sat ',
'The cat sat on ',
'The cat sat on the ',
'The cat sat on the mat.',
'The cat sat on the mat.',
]
assert messages == [
*(
ModelResponse(
parts=[TextPart(content=text)],
usage=RequestUsage(input_tokens=IsInt(), output_tokens=IsInt()),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
state='incomplete',
)
for text in incomplete_texts
),
ModelResponse(
parts=[TextPart(content='The cat sat on the mat.')],
usage=RequestUsage(input_tokens=IsInt(), output_tokens=IsInt()),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
),
]
# Note: as you can see above, the output validator is not applied to the streamed responses, just the final result:
assert run.result is not None
assert run.result.output == 'The bat sat on the mat.'
async def test_stream_iter_structured_validator() -> None:
class NotOutputType(BaseModel):
not_value: str
agent = Agent[object, OutputType | NotOutputType]('test', output_type=OutputType | NotOutputType)
@agent.output_validator
def output_validator(data: OutputType | NotOutputType) -> OutputType | NotOutputType:
assert isinstance(data, OutputType)
return OutputType(value=data.value + ' (validated)')
outputs: list[OutputType] = []
async with agent.iter('test') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for output in stream.stream_output(debounce_by=None):
outputs.append(output)
assert outputs == snapshot([OutputType(value='a (validated)'), OutputType(value='a (validated)')])
async def test_unknown_tool_call_events():
"""Test that unknown tool calls emit both FunctionToolCallEvent and FunctionToolResultEvent during streaming."""
def call_mixed_tools(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
"""Mock function that calls both known and unknown tools."""
return ModelResponse(
parts=[
ToolCallPart('unknown_tool', {'arg': 'value'}),
ToolCallPart('known_tool', {'x': 5}),
]
)
agent = Agent(FunctionModel(call_mixed_tools))
@agent.tool_plain
def known_tool(x: int) -> int:
return x * 2
event_parts: list[Any] = []
try:
async with agent.iter('test') as agent_run:
async for node in agent_run: # pragma: no branch
if Agent.is_call_tools_node(node):
async with node.stream(agent_run.ctx) as event_stream:
async for event in event_stream:
event_parts.append(event)
except UnexpectedModelBehavior:
pass
assert event_parts == snapshot(
[
FunctionToolCallEvent(
part=ToolCallPart(
tool_name='unknown_tool',
args={'arg': 'value'},
tool_call_id=IsStr(),
),
args_valid=False,
),
FunctionToolCallEvent(
part=ToolCallPart(
tool_name='known_tool',
args={'x': 5},
tool_call_id=IsStr(),
),
args_valid=True,
),
FunctionToolResultEvent(
part=RetryPromptPart(
content="Unknown tool name: 'unknown_tool'. Available tools: 'known_tool'",
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='known_tool',
content=10,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
),
FunctionToolCallEvent(
part=ToolCallPart(
tool_name='unknown_tool',
args={'arg': 'value'},
tool_call_id=IsStr(),
),
args_valid=False,
),
]
)
async def test_output_tool_success_events():
"""Test that a successful output tool call emits `OutputToolCallEvent` and `OutputToolResultEvent`."""
def call_final_result(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart('final_result', {'value': 'hello'})])
agent = Agent(FunctionModel(call_final_result), output_type=OutputType)
events: list[Any] = []
async with agent.iter('test') as agent_run:
async for node in agent_run:
if Agent.is_call_tools_node(node):
async with node.stream(agent_run.ctx) as event_stream:
async for event in event_stream:
events.append(event)
assert agent_run.result is not None
assert agent_run.result.output == snapshot(OutputType(value='hello'))
assert events == snapshot(
[
OutputToolCallEvent(
part=ToolCallPart(
tool_name='final_result',
args={'value': 'hello'},
tool_call_id=IsStr(),
),
args_valid=True,
),
OutputToolResultEvent(
part=ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
]
)
async def test_output_tool_events():
"""Test that output tools emit events during streaming for both validation failure and success."""
def call_final_result_with_bad_data(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
"""Mock function that calls final_result tool with invalid data."""
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('final_result', {'bad_value': 'invalid'}), # Invalid field name
ToolCallPart('final_result', {'value': 'valid'}), # Valid field name
]
)
agent = Agent(FunctionModel(call_final_result_with_bad_data), output_type=OutputType)
events: list[Any] = []
async with agent.iter('test') as agent_run:
async for node in agent_run:
if Agent.is_call_tools_node(node):
async with node.stream(agent_run.ctx) as event_stream:
async for event in event_stream:
events.append(event)
assert events == snapshot(
[
OutputToolCallEvent(
part=ToolCallPart(
tool_name='final_result',
args={'bad_value': 'invalid'},
tool_call_id=IsStr(),
),
args_valid=False,
),
OutputToolResultEvent(
part=RetryPromptPart(
content=[
ErrorDetails(
type='missing',
loc=('value',),
msg='Field required',
input={'bad_value': 'invalid'},
),
],
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
OutputToolCallEvent(
part=ToolCallPart(
tool_name='final_result',
args={'value': 'valid'},
tool_call_id=IsStr(),
),
args_valid=True,
),
OutputToolResultEvent(
part=ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
]
)
def _tool_call_and_return_ids_from_messages(messages: list[ModelMessage]) -> tuple[set[str], set[str]]:
call_ids: set[str] = set()
return_ids: set[str] = set()
for message in messages:
for part in message.parts:
if isinstance(part, ToolCallPart):
call_ids.add(part.tool_call_id)
elif isinstance(part, ToolReturnPart):
return_ids.add(part.tool_call_id)
return call_ids, return_ids
async def test_output_tool_event_history_has_no_dangling_call():
"""Regression test for #2640: event-reconstructed history should not have a dangling output tool call.
Every `OutputToolCallEvent` seen on the event stream should have a matching
`OutputToolResultEvent`, and the tool_call_ids should match those in `all_messages()`.
"""
def call_final_result(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart('final_result', {'value': 'hello'})])
agent = Agent(FunctionModel(call_final_result), output_type=OutputType)
events: list[Any] = []
async with agent.iter('test') as agent_run:
async for node in agent_run:
if Agent.is_call_tools_node(node):
async with node.stream(agent_run.ctx) as handle_stream:
async for event in handle_stream:
events.append(event)
call_ids_from_events = {e.part.tool_call_id for e in events if isinstance(e, OutputToolCallEvent)}
return_ids_from_events = {e.part.tool_call_id for e in events if isinstance(e, OutputToolResultEvent)}
# No dangling calls: every call seen on the event stream has a matching result.
assert call_ids_from_events == return_ids_from_events
assert None not in call_ids_from_events
# And the event-stream view matches `all_messages()`.
assert agent_run.result is not None
call_ids_from_messages, return_ids_from_messages = _tool_call_and_return_ids_from_messages(
agent_run.result.all_messages()
)
assert call_ids_from_events == call_ids_from_messages
assert return_ids_from_events == return_ids_from_messages
async def test_output_function_model_retry_in_stream():
"""`ModelRetry` from a `ToolOutput` function during `run_stream()` must surface as
`UnexpectedModelBehavior` (caused by `ModelRetry`), not propagate as `ToolRetryError`.
Regression test for an earlier version of `ToolManager.execute_output_tool_call` that
unconditionally wrapped `ModelRetry` from the output function as `ToolRetryError`,
which `result.validate_response_output` doesn't catch in the streaming path.
"""
async def stream_final_result(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert info.output_tools is not None
yield {0: DeltaToolCall(name='final_result', json_args='{"value": "anything"}')}
def reject(value: str) -> str:
raise ModelRetry('please try again')
agent = Agent(
FunctionModel(stream_function=stream_final_result),
output_type=ToolOutput(reject, name='final_result'),
retries={'output': 0},
)
with pytest.raises(UnexpectedModelBehavior) as exc_info:
async with agent.run_stream('test') as result:
await result.get_output()
# The cause must be ModelRetry, not ToolRetryError — `validate_response_output`
# only catches `(ValidationError, ModelRetry)` in the streaming path.
assert isinstance(exc_info.value.__cause__, ModelRetry)
async def test_stream_structured_output():
class CityLocation(BaseModel):
city: str
country: str | None = None
m = TestModel(custom_output_text='{"city": "Mexico City", "country": "Mexico"}')
agent = Agent(m, output_type=PromptedOutput(CityLocation))
async with agent.run_stream('') as result:
assert not result.is_complete
assert [c async for c in result.stream_output(debounce_by=None)] == snapshot(
[
CityLocation(city='Mexico '),
CityLocation(city='Mexico City'),
CityLocation(city='Mexico City'),
CityLocation(city='Mexico City', country='Mexico'),
CityLocation(city='Mexico City', country='Mexico'),
]
)
assert result.is_complete
async def test_iter_stream_structured_output():
class CityLocation(BaseModel):
city: str
country: str | None = None
m = TestModel(custom_output_text='{"city": "Mexico City", "country": "Mexico"}')
agent = Agent(m, output_type=PromptedOutput(CityLocation))
async with agent.iter('') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
assert [c async for c in stream.stream_output(debounce_by=None)] == snapshot(
[
CityLocation(city='Mexico '),
CityLocation(city='Mexico City'),
CityLocation(city='Mexico City'),
CityLocation(city='Mexico City', country='Mexico'),
CityLocation(city='Mexico City', country='Mexico'),
]
)
async def test_iter_stream_output_tool_dont_hit_retry_limit():
class CityLocation(BaseModel):
city: str
country: str | None = None
async def text_stream(_messages: list[ModelMessage], agent_info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
"""Stream partial JSON data that will initially fail validation."""
assert agent_info.output_tools is not None
assert len(agent_info.output_tools) == 1
name = agent_info.output_tools[0].name
yield {0: DeltaToolCall(name=name)}
yield {0: DeltaToolCall(json_args='{"c')}
yield {0: DeltaToolCall(json_args='ity":')}
yield {0: DeltaToolCall(json_args=' "Mex')}
yield {0: DeltaToolCall(json_args='ico City",')}
yield {0: DeltaToolCall(json_args=' "cou')}
yield {0: DeltaToolCall(json_args='ntry": "Mexico"}')}
agent = Agent(FunctionModel(stream_function=text_stream), output_type=CityLocation)
async with agent.iter('Generate city info') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
assert [c async for c in stream.stream_output(debounce_by=None)] == snapshot(
[
CityLocation(city='Mex'),
CityLocation(city='Mexico City'),
CityLocation(city='Mexico City'),
CityLocation(city='Mexico City', country='Mexico'),
CityLocation(city='Mexico City', country='Mexico'),
]
)
def test_function_tool_event_tool_call_id_properties():
"""Ensure that the `tool_call_id` property on function tool events mirrors the underlying part's ID."""
# Prepare a ToolCallPart with a fixed ID
call_part = ToolCallPart(tool_name='sample_tool', args={'a': 1}, tool_call_id='call_id_123')
call_event = FunctionToolCallEvent(part=call_part, args_valid=True)
# The event should expose the same `tool_call_id` as the part
assert call_event.tool_call_id == call_part.tool_call_id == 'call_id_123'
# Prepare a ToolReturnPart with a fixed ID
return_part = ToolReturnPart(tool_name='sample_tool', content='ok', tool_call_id='return_id_456')
result_event = FunctionToolResultEvent(part=return_part)
# The event should expose the same `tool_call_id` as the result part
assert result_event.tool_call_id == return_part.tool_call_id == 'return_id_456'
async def test_tool_raises_call_deferred():
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests])
@agent.tool_plain()
def my_tool(x: int) -> int:
raise CallDeferred
async with agent.run_stream('Hello') as result:
assert not result.is_complete
assert isinstance(result.run_id, str)
assert isinstance(result.conversation_id, str)
assert [c async for c in result.stream_output(debounce_by=None)] == snapshot(
[DeferredToolRequests(calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr())])]
)
assert await result.get_output() == snapshot(
DeferredToolRequests(calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr())])
)
responses = [c async for c in result.stream_response(debounce_by=None)]
assert responses == snapshot(
[
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
)
]
)
assert await result.validate_response_output(responses[0]) == snapshot(
DeferredToolRequests(calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr())])
)
assert result.usage == snapshot(RunUsage(requests=1, input_tokens=51, output_tokens=0))
assert result.timestamp == IsNow(tz=timezone.utc)
assert result.is_complete
async def test_tool_raises_approval_required():
async def llm(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls | str]:
if len(messages) == 1:
yield {0: DeltaToolCall(name='my_tool', json_args='{"x": 1}', tool_call_id='my_tool')}
else:
yield 'Done!'
agent = Agent(FunctionModel(stream_function=llm), output_type=[str, DeferredToolRequests])
@agent.tool
def my_tool(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x * 42
async with agent.run_stream('Hello') as result:
assert not result.is_complete
messages = result.all_messages()
output = await result.get_output()
assert output == snapshot(
DeferredToolRequests(approvals=[ToolCallPart(tool_name='my_tool', args='{"x": 1}', tool_call_id=IsStr())])
)
assert result.is_complete
async with agent.run_stream(
message_history=messages,
deferred_tool_results=DeferredToolResults(approvals={'my_tool': ToolApproved(override_args={'x': 2})}),
) as result:
assert not result.is_complete
output = await result.get_output()
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{"x": 1}', tool_call_id='my_tool')],
usage=RequestUsage(input_tokens=50, output_tokens=3),
model_name='function::llm',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content=84,
tool_call_id='my_tool',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Done!')],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function::llm',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert output == snapshot('Done!')
assert result.is_complete
async def test_deferred_tool_iter():
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests])
async def prepare_tool(ctx: RunContext, tool_def: ToolDefinition) -> ToolDefinition:
return replace(tool_def, kind='external')
@agent.tool_plain(prepare=prepare_tool)
def my_tool(x: int) -> int:
return x + 1 # pragma: no cover
@agent.tool_plain(requires_approval=True)
def my_other_tool(x: int) -> int:
return x + 1 # pragma: no cover
outputs: list[str | DeferredToolRequests] = []
events: list[Any] = []
async with agent.iter('test') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for event in stream:
events.append(event)
async for output in stream.stream_output(debounce_by=None):
outputs.append(output)
if agent.is_call_tools_node(node):
async with node.stream(run.ctx) as stream:
async for event in stream:
events.append(event)
assert outputs == snapshot(
[
DeferredToolRequests(
calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr())],
approvals=[ToolCallPart(tool_name='my_other_tool', args={'x': 0}, tool_call_id=IsStr())],
),
DeferredToolRequests(
calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_tool')],
approvals=[
ToolCallPart(
tool_name='my_other_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_other_tool'
)
],
),
]
)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr()),
),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_tool'),
next_part_kind='tool-call',
),
PartStartEvent(
index=1,
part=ToolCallPart(
tool_name='my_other_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_other_tool'
),
previous_part_kind='tool-call',
),
PartEndEvent(
index=1,
part=ToolCallPart(
tool_name='my_other_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_other_tool'
),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='my_other_tool', args={'x': 0}, tool_call_id=IsStr()), args_valid=True
),
]
)
async def test_tool_raises_call_deferred_approval_required_iter():
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests])
@agent.tool_plain
def my_tool(x: int) -> int:
raise CallDeferred
@agent.tool_plain
def my_other_tool(x: int) -> int:
raise ApprovalRequired
events: list[Any] = []
async with agent.iter('test') as run:
async for node in run:
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for event in stream:
events.append(event)
if agent.is_call_tools_node(node):
async with node.stream(run.ctx) as stream:
async for event in stream:
events.append(event)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_tool'),
next_part_kind='tool-call',
),
PartStartEvent(
index=1,
part=ToolCallPart(
tool_name='my_other_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_other_tool'
),
previous_part_kind='tool-call',
),
PartEndEvent(
index=1,
part=ToolCallPart(
tool_name='my_other_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_other_tool'
),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='my_other_tool', args={'x': 0}, tool_call_id=IsStr()), args_valid=True
),
]
)
assert run.result is not None
assert run.result.output == snapshot(
DeferredToolRequests(
calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id=IsStr())],
approvals=[ToolCallPart(tool_name='my_other_tool', args={'x': 0}, tool_call_id=IsStr())],
)
)
async def test_run_event_stream_handler():
m = TestModel()
test_agent = Agent(m)
assert test_agent.name is None
@test_agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
events: list[AgentStreamEvent] = []
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]):
async for event in stream:
events.append(event)
result = await test_agent.run('Hello', event_stream_handler=event_stream_handler)
assert result.output == snapshot('{"ret_a":"a-apple"}')
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id='pyd_ai_tool_call_id__ret_a'),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='ret_a',
content='a-apple',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='{"ret_a":')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='"a-apple"}')),
PartEndEvent(index=0, part=TextPart(content='{"ret_a":"a-apple"}')),
]
)
async def test_event_stream_handler_propagates_tool_error():
"""When a tool raises during streaming with event_stream_handler and the error
is suppressed by the handler, the _stream_error re-raise path in run() should
propagate the original error — not an internal AssertionError about _next_node."""
m = TestModel()
test_agent = Agent(m)
@test_agent.tool_plain
async def failing_tool(x: str) -> str:
raise RuntimeError('tool execution failed')
events: list[AgentStreamEvent] = []
async def handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]):
# Suppress the error to simulate UIEventStream.transform_stream behavior,
# which catches exceptions and doesn't re-raise them.
try:
async for event in stream:
events.append(event)
except RuntimeError:
pass
with pytest.raises(RuntimeError, match='tool execution failed'):
await test_agent.run('Hello', event_stream_handler=handler)
# Events up to the tool call should still have been emitted
assert any(isinstance(e, FunctionToolCallEvent) for e in events)
def test_run_sync_event_stream_handler():
m = TestModel()
test_agent = Agent(m)
assert test_agent.name is None
@test_agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
events: list[AgentStreamEvent] = []
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]):
async for event in stream:
events.append(event)
result = test_agent.run_sync('Hello', event_stream_handler=event_stream_handler)
assert result.output == snapshot('{"ret_a":"a-apple"}')
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id='pyd_ai_tool_call_id__ret_a'),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='ret_a',
content='a-apple',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='{"ret_a":')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='"a-apple"}')),
PartEndEvent(index=0, part=TextPart(content='{"ret_a":"a-apple"}')),
]
)
async def test_run_stream_event_stream_handler():
m = TestModel()
test_agent = Agent(m)
assert test_agent.name is None
@test_agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
events: list[AgentStreamEvent] = []
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]):
async for event in stream:
events.append(event)
async with test_agent.run_stream('Hello', event_stream_handler=event_stream_handler) as result:
assert [c async for c in result.stream_output(debounce_by=None)] == snapshot(
['{"ret_a":', '{"ret_a":"a-apple"}', '{"ret_a":"a-apple"}']
)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id='pyd_ai_tool_call_id__ret_a'),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='ret_a',
content='a-apple',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
]
)
async def test_run_event_stream_handler_does_not_need_to_consume_stream():
agent = Agent(TestModel(custom_output_text='hello world this is a long answer'))
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]) -> None:
return # never reads the stream
result = await agent.run('Hello', event_stream_handler=event_stream_handler)
assert result.output == 'hello world this is a long answer'
async def test_run_stream_event_stream_handler_does_not_need_to_consume_stream():
agent = Agent(TestModel(custom_output_text='hello world this is a long answer'))
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]) -> None:
return # never reads the stream
async with agent.run_stream('Hello', event_stream_handler=event_stream_handler) as result:
output = await result.get_output()
assert output == 'hello world this is a long answer'
async def test_run_event_stream_handler_unconsumed_still_executes_tool_calls():
"""A handler that ignores the stream must not stop the agent from acting on the model's reply.
The reply (including tool calls) is built by iterating the stream, so a handler that returns
without consuming it used to silently drop the tool call.
"""
tool_calls: list[int] = []
agent = Agent(TestModel())
@agent.tool_plain
def record(x: int) -> str:
tool_calls.append(x)
return 'ok'
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]) -> None:
return # never reads the stream
await agent.run('go', event_stream_handler=event_stream_handler)
assert tool_calls == [0]
async def test_run_stream_event_stream_handler_unconsumed_still_executes_tool_calls():
"""Same as the `agent.run()` case, but for `agent.run_stream()` (exercises the `CallToolsNode` path)."""
tool_calls: list[int] = []
agent = Agent(TestModel())
@agent.tool_plain
def record(x: int) -> str:
tool_calls.append(x)
return 'ok'
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]) -> None:
return # never reads the stream
async with agent.run_stream('go', event_stream_handler=event_stream_handler) as result:
await result.get_output()
assert tool_calls == [0]
async def test_run_event_stream_handler_interrupted_does_not_drain():
"""A handler interrupted before returning (cancellation/`break`) must not trigger the drain.
The drain only runs when the handler returns normally; re-running it on an interrupted handler
would consume a stream the caller asked to stop, reintroducing the cancellation hang from #5313.
The stream is unbounded, so a drain that ran after the interrupt would never terminate.
"""
pulled = 0
async def counting_stream(_messages: list[ModelMessage], _: AgentInfo) -> AsyncIterator[str]:
nonlocal pulled
while True:
pulled += 1
yield 'hello'
agent = Agent(FunctionModel(stream_function=counting_stream))
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]) -> None:
raise asyncio.CancelledError # interrupted before consuming the stream
with pytest.raises(asyncio.CancelledError):
await agent.run('Hello', event_stream_handler=event_stream_handler)
# Only the single lookahead the run makes before invoking the handler; the post-handler
# drain was skipped (otherwise this unbounded stream would have been pulled forever).
assert pulled == 1
async def test_stream_tool_returning_user_content():
m = TestModel()
agent = Agent(m)
assert agent.name is None
@agent.tool_plain
async def get_image() -> ImageUrl:
return ImageUrl(url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg')
events: list[AgentStreamEvent] = []
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]):
async for event in stream:
events.append(event)
await agent.run('Hello', event_stream_handler=event_stream_handler)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='get_image', args={}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='get_image', args={}, tool_call_id='pyd_ai_tool_call_id__get_image'),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='get_image', args={}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='get_image',
content=ImageUrl(
url='https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg'
),
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(
index=0,
delta=TextPartDelta(
content_delta='{"get_image":{"url":"https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg","'
),
),
PartDeltaEvent(
index=0,
delta=TextPartDelta(
content_delta='force_download":false,"vendor_metadata":null,"kind":"image-url","media_type":"image/jpeg","identifier":"bd38f5"}}'
),
),
PartEndEvent(
index=0,
part=TextPart(
content='{"get_image":{"url":"https://t3.ftcdn.net/jpg/00/85/79/92/360_F_85799278_0BBGV9OAdQDTLnKwAPBCcg1J7QtiieJY.jpg","force_download":false,"vendor_metadata":null,"kind":"image-url","media_type":"image/jpeg","identifier":"bd38f5"}}'
),
),
]
)
async def test_run_stream_events():
m = TestModel()
test_agent = Agent(m)
assert test_agent.name is None
@test_agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
async with test_agent.run_stream_events('Hello') as event_stream:
events = [event async for event in event_stream]
assert test_agent.name == 'test_agent'
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id='pyd_ai_tool_call_id__ret_a'),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr()), args_valid=True
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='ret_a',
content='a-apple',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='{"ret_a":')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='"a-apple"}')),
PartEndEvent(index=0, part=TextPart(content='{"ret_a":"a-apple"}')),
AgentRunResultEvent(result=AgentRunResult(output='{"ret_a":"a-apple"}')),
]
)
def test_structured_response_sync_validation():
async def text_stream(_messages: list[ModelMessage], agent_info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
assert agent_info.output_tools is not None
assert len(agent_info.output_tools) == 1
name = agent_info.output_tools[0].name
json_data = json.dumps({'response': [1, 2, 3, 4]})
yield {0: DeltaToolCall(name=name)}
yield {0: DeltaToolCall(json_args=json_data[:15])}
yield {0: DeltaToolCall(json_args=json_data[15:])}
agent = Agent(FunctionModel(stream_function=text_stream), output_type=list[int])
chunks: list[list[int]] = []
result = agent.run_stream_sync('')
for structured_response in result.stream_response(debounce_by=None):
response_data = result.validate_response_output(
structured_response, allow_partial=structured_response.state == 'incomplete'
)
chunks.append(response_data)
assert chunks == snapshot([[1], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]])
async def test_get_output_after_stream_output():
"""Verify that we don't get duplicate messages in history when using tool output and `get_output` is called after `stream_output`."""
m = TestModel()
agent = Agent(m, output_type=bool)
async with agent.run_stream('Hello') as result:
outputs: list[bool] = []
async for o in result.stream_output():
outputs.append(o)
o = await result.get_output()
outputs.append(o)
assert outputs == snapshot([False, False, False])
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'response': False},
tool_call_id='pyd_ai_tool_call_id__final_result',
)
],
usage=RequestUsage(input_tokens=51),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='pyd_ai_tool_call_id__final_result',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize('delta', [True, False])
@pytest.mark.parametrize('debounce_by', [None, 0.1])
async def test_stream_text_early_break_cleanup(delta: bool, debounce_by: float | None):
"""Breaking out of `stream_text()` triggers proper async generator cleanup.
Regression test for https://github.com/pydantic/pydantic-ai/issues/4204
The `aclosing` wrapper in `_stream_response_text` ensures `aclose()` propagates
through the nested generator chain so cleanup happens in the same async context,
preventing `RuntimeError: async generator raised StopAsyncIteration`.
Tests both `group_by_temporal` code paths:
- `debounce_by=None`: simple pass-through iterator
- `debounce_by=0.1`: asyncio.Task-based buffering with pending task cancellation
"""
cleanup_called = False
async def sf(_: list[ModelMessage], _info: AgentInfo) -> AsyncIterator[str]:
nonlocal cleanup_called
try:
for chunk in ['Hello', ' ', 'world', '!', ' More', ' text']:
yield chunk
finally:
# Confirms aclose() propagated synchronously, not deferred to GC.
cleanup_called = True
agent = Agent(FunctionModel(stream_function=sf))
async with agent.run_stream('test') as result:
await anext(result.stream_text(delta=delta, debounce_by=debounce_by))
assert cleanup_called, 'stream function cleanup should have been called by aclosing propagation'
async def test_args_validator_failure_events():
"""Test that failed validation emits args_valid=False, retries with error message, then succeeds."""
validator_calls = 0
def my_validator(ctx: RunContext[int], x: int, y: int) -> None:
nonlocal validator_calls
validator_calls += 1
if validator_calls == 1:
raise ModelRetry('Validation failed: x must be positive')
agent = Agent(
TestModel(call_tools=['add_numbers']),
deps_type=int,
)
@agent.tool(args_validator=my_validator, retries=2)
def add_numbers(ctx: RunContext[int], x: int, y: int) -> int:
"""Add two numbers."""
return x + y
events: list[Any] = []
async with agent.run_stream_events('call add_numbers with x=1 and y=2', deps=42) as event_stream:
async for event in event_stream:
events.append(event)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id=IsStr()),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id=IsStr()),
args_valid=False,
),
FunctionToolResultEvent(
part=RetryPromptPart(
content='Validation failed: x must be positive',
tool_name='add_numbers',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
),
PartStartEvent(
index=0,
part=ToolCallPart(tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id=IsStr()),
),
PartEndEvent(
index=0,
part=ToolCallPart(tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id=IsStr()),
),
FunctionToolCallEvent(
part=ToolCallPart(tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id=IsStr()),
args_valid=True,
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='add_numbers',
content=0,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='{"add_nu')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='mbers":0}')),
PartEndEvent(index=0, part=TextPart(content='{"add_numbers":0}')),
AgentRunResultEvent(result=AgentRunResult(output='{"add_numbers":0}')),
]
)
async def test_args_validator_event_args_valid_field():
"""Test that FunctionToolCallEvent has args_valid field set correctly."""
def my_validator(ctx: RunContext[int], x: int, y: int) -> None:
pass # Always succeeds
agent = Agent(
TestModel(call_tools=['add_numbers']),
deps_type=int,
)
@agent.tool(args_validator=my_validator)
def add_numbers(ctx: RunContext[int], x: int, y: int) -> int:
"""Add two numbers."""
return x + y
events: list[Any] = []
async with agent.run_stream_events('call add_numbers with x=1 and y=2', deps=42) as event_stream:
async for event in event_stream:
events.append(event)
assert events == snapshot(
[
PartStartEvent(
index=0,
part=ToolCallPart(
tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id='pyd_ai_tool_call_id__add_numbers'
),
),
PartEndEvent(
index=0,
part=ToolCallPart(
tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id='pyd_ai_tool_call_id__add_numbers'
),
),
FunctionToolCallEvent(
part=ToolCallPart(
tool_name='add_numbers', args={'x': 0, 'y': 0}, tool_call_id='pyd_ai_tool_call_id__add_numbers'
),
args_valid=True,
),
FunctionToolResultEvent(
part=ToolReturnPart(
tool_name='add_numbers',
content=0,
tool_call_id='pyd_ai_tool_call_id__add_numbers',
timestamp=IsDatetime(),
)
),
PartStartEvent(index=0, part=TextPart(content='')),
FinalResultEvent(tool_name=None, tool_call_id=None),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='{"add_nu')),
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='mbers":0}')),
PartEndEvent(index=0, part=TextPart(content='{"add_numbers":0}')),
AgentRunResultEvent(result=AgentRunResult(output='{"add_numbers":0}')),
]
)
async def test_args_validator_event_args_valid_no_custom_validator():
"""Test that args_valid=True when no custom validator but schema validation passes."""
agent = Agent(
TestModel(call_tools=['add_numbers']),
deps_type=int,
)
@agent.tool
def add_numbers(ctx: RunContext[int], x: int, y: int) -> int:
"""Add two numbers."""
return x + y
events: list[Any] = []
async with agent.run_stream_events('call add_numbers with x=1 and y=2', deps=42) as event_stream:
async for event in event_stream:
events.append(event)
tool_call_events: list[FunctionToolCallEvent] = [e for e in events if isinstance(e, FunctionToolCallEvent)]
assert len(tool_call_events) >= 1
add_number_events = [e for e in tool_call_events if e.part.tool_name == 'add_numbers']
assert add_number_events, 'Should have events for add_numbers'
for event in add_number_events:
assert event.args_valid is True
async def test_schema_validation_failure_args_valid_false():
"""Test that args_valid=False when Pydantic schema validation fails (no custom validator)."""
def return_invalid_args(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: # pragma: no cover
"""Return a tool call with invalid arguments (wrong type)."""
return ModelResponse(parts=[ToolCallPart(tool_name='add_numbers', args={'x': 'not_an_int', 'y': 2})])
async def stream_invalid_args(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
"""Stream a tool call with invalid arguments."""
yield {0: DeltaToolCall(name='add_numbers')}
yield {0: DeltaToolCall(json_args='{"x": "not_an_int", "y": 2}')}
agent = Agent(FunctionModel(return_invalid_args, stream_function=stream_invalid_args), deps_type=int)
@agent.tool
def add_numbers(ctx: RunContext[int], x: int, y: int) -> int: # pragma: no cover
"""Add two numbers."""
return x + y
events: list[Any] = []
try:
async with agent.run_stream_events('call add_numbers', deps=42) as event_stream:
async for event in event_stream: # pragma: no branch
events.append(event)
except UnexpectedModelBehavior:
pass # Expected when max retries exceeded
tool_call_events: list[FunctionToolCallEvent] = [e for e in events if isinstance(e, FunctionToolCallEvent)]
assert len(tool_call_events) >= 1
first_event = tool_call_events[0]
assert first_event.part.tool_name == 'add_numbers'
assert first_event.args_valid is False
async def test_args_validator_run_stream_event_handler():
"""Test that args_valid is correctly set on FunctionToolCallEvent when using run_stream()."""
def my_validator(ctx: RunContext[int], x: int, y: int) -> None:
pass # Always succeeds
agent = Agent(
TestModel(call_tools=['add_numbers']),
deps_type=int,
)
@agent.tool(args_validator=my_validator)
def add_numbers(ctx: RunContext[int], x: int, y: int) -> int:
"""Add two numbers."""
return x + y
events: list[AgentStreamEvent] = []
async def handler(ctx: RunContext[int], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
events.append(event)
async with agent.run_stream('call add_numbers', deps=42, event_stream_handler=handler) as result:
await result.get_output()
tool_call_events = [e for e in events if isinstance(e, FunctionToolCallEvent)]
assert tool_call_events
for event in tool_call_events:
assert event.args_valid is True
async def test_event_ordering_call_before_result():
"""Test that FunctionToolCallEvent is emitted before FunctionToolResultEvent for each tool call."""
def my_validator(ctx: RunContext, x: int) -> None:
pass
agent = Agent(TestModel(call_tools=['my_tool']))
@agent.tool(args_validator=my_validator)
def my_tool(ctx: RunContext, x: int) -> int:
"""A tool."""
return x * 2
events: list[Any] = []
async with agent.run_stream_events('test') as event_stream:
async for event in event_stream:
events.append(event)
call_ids_seen: set[str] = set()
result_ids_seen: set[str] = set()
for event in events:
if isinstance(event, FunctionToolCallEvent):
call_ids_seen.add(event.tool_call_id)
assert event.tool_call_id not in result_ids_seen, (
f'FunctionToolResultEvent for {event.tool_call_id} appeared before FunctionToolCallEvent'
)
elif isinstance(event, FunctionToolResultEvent):
result_id = event.part.tool_call_id
result_ids_seen.add(result_id)
assert result_id in call_ids_seen, (
f'FunctionToolResultEvent for {result_id} appeared without prior FunctionToolCallEvent'
)
assert call_ids_seen
assert result_ids_seen
async def test_args_valid_true_for_presupplied_tool_approved():
"""Test that args_valid=True when re-running with ToolApproved (validation runs upfront with approval context)."""
def my_validator(ctx: RunContext[int], x: int) -> None:
pass
agent = Agent(
TestModel(),
deps_type=int,
output_type=[str, DeferredToolRequests],
)
@agent.tool(args_validator=my_validator)
def my_tool(ctx: RunContext[int], x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired()
return x * 42
# First run: tool requires approval
result = await agent.run('Hello', deps=42)
assert isinstance(result.output, DeferredToolRequests)
tool_call_id = result.output.approvals[0].tool_call_id
# Second run with ToolApproved: collect events
messages = result.all_messages()
events: list[Any] = []
async with agent.run_stream_events(
message_history=messages,
deferred_tool_results=DeferredToolResults(approvals={tool_call_id: ToolApproved()}),
deps=42,
) as event_stream:
async for event in event_stream:
events.append(event)
# The FunctionToolCallEvent for the pre-supplied result should have args_valid=True
tool_call_events = [e for e in events if isinstance(e, FunctionToolCallEvent) and e.part.tool_name == 'my_tool']
assert tool_call_events
assert tool_call_events[0].args_valid is True
async def test_args_valid_none_for_tool_denied():
"""Test that args_valid=None for ToolDenied and the denial message appears in the result event."""
def my_validator(ctx: RunContext[int], x: int) -> None:
pass
agent = Agent(
TestModel(),
deps_type=int,
output_type=[str, DeferredToolRequests],
)
@agent.tool(args_validator=my_validator)
def my_tool(ctx: RunContext[int], x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired()
return x # pragma: no cover
# First run: tool requires approval
result = await agent.run('Hello', deps=42)
assert isinstance(result.output, DeferredToolRequests)
tool_call_id = result.output.approvals[0].tool_call_id
# Second run with ToolDenied
messages = result.all_messages()
events: list[Any] = []
async with agent.run_stream_events(
message_history=messages,
deferred_tool_results=DeferredToolResults(approvals={tool_call_id: ToolDenied('User denied this tool call')}),
deps=42,
) as event_stream:
async for event in event_stream:
events.append(event)
# FunctionToolCallEvent should have args_valid=None (pre-supplied result, no upfront validation)
tool_call_events = [e for e in events if isinstance(e, FunctionToolCallEvent) and e.part.tool_name == 'my_tool']
assert tool_call_events
assert tool_call_events[0].args_valid is None
# FunctionToolResultEvent should contain the denial message
result_events = [e for e in events if isinstance(e, FunctionToolResultEvent) and e.part.tool_name == 'my_tool']
assert result_events
assert result_events[0].part.content == 'User denied this tool call'
async def test_deferred_tool_validation_event_in_stream():
"""Test that deferred (requires_approval) tools emit FunctionToolCallEvent with correct args_valid."""
def my_validator(ctx: RunContext, x: int) -> None:
pass
agent = Agent(
TestModel(),
output_type=[str, DeferredToolRequests],
)
@agent.tool(args_validator=my_validator)
def my_tool(ctx: RunContext, x: int) -> int:
raise ApprovalRequired()
events: list[Any] = []
async with agent.run_stream_events('test') as event_stream:
async for event in event_stream:
events.append(event)
tool_call_events = [e for e in events if isinstance(e, FunctionToolCallEvent) and e.part.tool_name == 'my_tool']
assert tool_call_events
# TestModel generates valid args (x=0 by default), so validation passes
assert tool_call_events[0].args_valid is True
# region: Stream cancellation tests
async def test_run_stream_cancel():
agent = Agent(TestModel())
async with agent.run_stream('Hello') as result:
assert not result.cancelled
# Consume one chunk to start the stream
async for _ in result.stream_text(delta=True, debounce_by=None): # pragma: no branch
break
await result.cancel()
assert result.cancelled
# StreamedResponse.get() sets state='interrupted' when _cancelled is True
assert result.response.state == 'interrupted'
async def test_run_stream_cancel_all_messages_includes_interrupted_response():
"""After cancelling a stream, all_messages() should include the interrupted ModelResponse."""
agent = Agent(TestModel())
async with agent.run_stream('Hello') as result:
# Consume one chunk to start the stream
async for _ in result.stream_text(delta=True, debounce_by=None): # pragma: no branch
break
await result.cancel()
assert result.cancelled
assert result.response.state == 'interrupted'
# The interrupted ModelResponse must appear in all_messages()
msgs = result.all_messages()
assert msgs == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success ')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
async def test_run_stream_cancel_guard_suppresses_transport_error():
"""When cancel() is called mid-stream and iteration continues, _stream_cancel_guard
suppresses the simulated transport error and the stream ends gracefully."""
agent = Agent(TestModel())
async with agent.run_stream('Hello') as result:
chunks: list[str] = []
async for text in result.stream_text(delta=True, debounce_by=None):
chunks.append(text)
if not result.cancelled: # pragma: no branch
await result.cancel()
# Don't break: let the loop call anext() again, which resumes
# the generator into the _cancelled check and exercises the
# _stream_cancel_guard suppression branch.
assert result.cancelled
assert result.response.state == 'interrupted'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success ')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
async def test_run_stream_cancel_after_complete():
agent = Agent(TestModel())
async with agent.run_stream('Hello') as result:
assert not result.is_complete
await result.get_output()
assert result.is_complete
assert result.response.state == 'complete'
# A defensive cancel() after the stream is fully consumed records the
# flag but must not downgrade response.state to 'interrupted'.
await result.cancel()
assert result.cancelled
assert result.response.state == 'complete'
async def test_completed_streamed_response_cancel_noop():
response = ModelResponse(parts=[TextPart(content='done')], model_name='test')
streamed_response = CompletedStreamedResponse(models.ModelRequestParameters(), response)
await streamed_response.cancel()
await streamed_response.cancel()
assert streamed_response.cancelled
assert streamed_response.response is response
assert response.state == 'complete'
async def test_stream_response_state_incomplete_until_finished():
"""`response.state` reads `'incomplete'` mid-stream and flips to `'complete'` once iteration ends."""
agent = Agent(TestModel(custom_output_text='hello world'))
async with agent.run_stream('Hello') as result:
async for _ in result.stream_text(delta=True, debounce_by=None):
assert result.response.state == 'incomplete'
await result.get_output()
assert result.response.state == 'complete'
async def test_stream_response_yields_incomplete_then_complete():
"""`stream_response` yields `state='incomplete'` mid-stream; the trailing yield is `'complete'`."""
agent = Agent(TestModel(custom_output_text='hello world'))
async with agent.run_stream('Hello') as result:
states = [msg.state async for msg in result.stream_response(debounce_by=None)]
assert states[-1] == 'complete'
assert all(state == 'incomplete' for state in states[:-1])
async def test_stream_response_state_incomplete_after_early_break():
"""Breaking out of the stream early must not flip `state` to `'complete'`.
`aclose()` on the underlying async generator raises `GeneratorExit` at the
suspended `yield`, so `_finished` must stay `False` and the truncated
response must keep reporting `'incomplete'`.
"""
agent = Agent(TestModel(custom_output_text='hello world'))
async with agent.iter('Hello') as run:
async for node in run: # pragma: no branch
if agent.is_model_request_node(node):
async with node.stream(run.ctx) as stream:
async for _ in stream: # pragma: no branch
break
assert stream.response.state == 'incomplete'
return
async def test_run_stream_events_break_cleanup():
agent = Agent(TestModel())
async with agent.run_stream_events('Hello') as events:
await anext(events)
# __aexit__ closes the iterator and drains the background task; no task leak, no error.
def make_cleanup_signal_test_model(producer_started: asyncio.Event) -> type[TestModel]:
class CleanupSignalTestModel(TestModel):
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: models.ModelSettings | None,
model_request_parameters: models.ModelRequestParameters,
run_context: RunContext | None = None,
) -> AsyncGenerator[models.StreamedResponse]:
async with super().request_stream(
messages,
model_settings,
model_request_parameters,
run_context,
) as stream:
producer_started.set()
yield stream
return CleanupSignalTestModel
async def test_run_stream_events_unstarted_iterator_cleanup():
"""Entering and exiting the CM without advancing the iterator must not start the background task."""
producer_started = asyncio.Event()
cleanup_signal_test_model = make_cleanup_signal_test_model(producer_started)
agent = Agent(cleanup_signal_test_model(custom_output_text='hello'))
# `sleep(0)` yields to the event loop while each context is open, so an eager-start regression would
# get a chance to schedule its background task and set `producer_started` before we assert it didn't.
async with agent.run_stream_events(''):
await asyncio.sleep(0)
empty_context = agent.run_stream_events('')
await empty_context.__aexit__(None, None, None)
context = agent.run_stream_events('')
await context.__aenter__()
await asyncio.sleep(0)
await context.__aexit__(None, None, None)
await context.__aexit__(None, None, None)
reentered_context = agent.run_stream_events('')
await reentered_context.__aenter__()
await asyncio.sleep(0)
with pytest.raises(RuntimeError, match='cannot be entered more than once'):
await reentered_context.__aenter__()
await reentered_context.__aexit__(None, None, None)
assert not producer_started.is_set()
async def test_run_stream_events_first_iteration_starts_background_task():
producer_started = asyncio.Event()
cleanup_signal_test_model = make_cleanup_signal_test_model(producer_started)
agent = Agent(cleanup_signal_test_model(custom_output_text='hello'))
async with agent.run_stream_events('') as events:
# Time out the first iteration itself so a lazy-start regression fails fast instead of hanging here.
await asyncio.wait_for(anext(events), timeout=1.0)
assert producer_started.is_set()
async def test_run_stream_events_break_on_final_result_retrieves_late_producer_error():
"""Breaking on the documented final-result event must still retrieve background task errors."""
producer_finished = asyncio.Event()
async def stream_that_fails_after_final_result(
_messages: list[ModelMessage], agent_info: AgentInfo
) -> AsyncIterator[str]:
yield 'hello'
try:
raise RuntimeError('producer boom')
finally:
producer_finished.set()
loop = asyncio.get_running_loop()
previous_handler = loop.get_exception_handler()
handle_exception = MagicMock()
loop.set_exception_handler(handle_exception)
try:
agent = Agent(FunctionModel(stream_function=stream_that_fails_after_final_result))
async with agent.run_stream_events('') as events:
async for event in events: # pragma: no branch
if isinstance(event, FinalResultEvent):
# This mirrors the documented "stop once final result is known" pattern.
# The producer task can still finish with an exception before the CM exits.
await asyncio.wait_for(producer_finished.wait(), timeout=1.0)
await asyncio.sleep(0)
break
gc.collect()
await asyncio.sleep(0)
finally:
loop.set_exception_handler(previous_handler)
handle_exception.assert_not_called()
async def test_run_stream_events_external_task_cancellation():
"""When the outer task is cancelled, the CancelledError handler forwards cancellation to the producer."""
never = asyncio.Event()
async def blocking_stream(_messages: list[ModelMessage], agent_info: AgentInfo) -> AsyncIterator[str]:
yield 'hello'
await never.wait() # block forever so the consumer is still awaiting when we cancel
agent = Agent(FunctionModel(stream_function=blocking_stream))
async def consume() -> None:
async with agent.run_stream_events('') as stream:
async for _ in stream:
pass
task = asyncio.create_task(consume())
await asyncio.sleep(0.05) # let the task start and block on the stream
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def test_run_stream_events_managed_cancellation_waits_for_cleanup():
# Test for https://github.com/pydantic/pydantic-ai/issues/5132.
cleanup_finished = asyncio.Event()
first_event_seen = asyncio.Event()
class SlowCleanupTestModel(TestModel):
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: models.ModelSettings | None,
model_request_parameters: models.ModelRequestParameters,
run_context: RunContext | None = None,
) -> AsyncGenerator[models.StreamedResponse]:
async with super().request_stream(
messages,
model_settings,
model_request_parameters,
run_context,
) as stream:
try:
yield stream
finally:
await asyncio.sleep(0.2)
cleanup_finished.set()
agent = Agent(SlowCleanupTestModel(custom_output_text='hello'))
async def consume() -> None:
async with agent.run_stream_events('Hello') as stream:
await anext(stream)
first_event_seen.set()
await asyncio.sleep(10)
task = asyncio.create_task(consume())
await first_event_seen.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cleanup_finished.is_set()
async def test_stream_wrap_model_request_readiness_wait_cancels_wrapper_task_on_outer_cancellation():
"""Outer cancellation while waiting for streaming model wrapper readiness should clean up the wrapper task.
Target boundary: `ModelRequestNode.stream()` creates `wrap_task` and `ready_waiter`, then waits for
`asyncio.wait({ready_waiter, wrap_task}, return_when=asyncio.FIRST_COMPLETED)`. If the outer task is
cancelled while parked on that wait, the wrapper task must be drained; otherwise the user's
`wrap_model_request` cleanup never runs.
"""
cleanup_finished = asyncio.Event()
started = asyncio.Event()
never_finishes = asyncio.Future[ModelResponse]()
class WrapModelRequestCapability(AbstractCapability):
async def wrap_model_request(
self,
ctx: RunContext,
*,
request_context: ModelRequestContext,
handler: WrapModelRequestHandler,
) -> ModelResponse:
try:
started.set()
# Suspend before calling handler() so we sit inside the readiness wait at
# `_agent_graph.py:asyncio.wait({ready_waiter, wrap_task}, ...)`.
return await never_finishes
finally:
# Without the drain on the readiness wait, this finally never runs.
cleanup_finished.set()
agent = Agent(TestModel(), capabilities=[WrapModelRequestCapability()])
async def consume() -> None:
async with agent.run_stream_events('Hello') as stream:
async for _ in stream:
pass
task = asyncio.create_task(consume())
await asyncio.wait_for(started.wait(), timeout=1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cleanup_finished.is_set()
# endregion