mirror of
https://github.com/pydantic/pydantic-ai.git
synced 2026-10-12 02:21:31 +00:00
7367 lines
287 KiB
Python
7367 lines
287 KiB
Python
from __future__ import annotations as _annotations
|
|
|
|
import asyncio
|
|
import contextvars
|
|
import datetime
|
|
import gc
|
|
import json
|
|
import re
|
|
import sys
|
|
import threading
|
|
import warnings
|
|
import weakref
|
|
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Awaitable, Callable, Generator
|
|
from contextlib import asynccontextmanager
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass, field, replace
|
|
from datetime import datetime as _datetime
|
|
from types import TracebackType
|
|
from typing import Any, Literal, cast
|
|
from unittest.mock import MagicMock
|
|
|
|
import anyio
|
|
import httpx2
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
from pydantic_core import ErrorDetails
|
|
|
|
from pydantic_ai import (
|
|
Agent,
|
|
AgentRunResult,
|
|
AgentRunResultEvent,
|
|
AgentStreamEvent,
|
|
DeferredToolRequests,
|
|
DeferredToolRequestsEvent,
|
|
DeferredToolResultsEvent,
|
|
ExternalToolset,
|
|
FinalResultEvent,
|
|
FunctionToolCallEvent,
|
|
FunctionToolResultEvent,
|
|
ImageUrl,
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelRequestContext,
|
|
ModelResponse,
|
|
ModelResponsePart,
|
|
ModelResponseStreamEvent,
|
|
OutputToolCallEvent,
|
|
OutputToolResultEvent,
|
|
PartDeltaEvent,
|
|
PartEndEvent,
|
|
PartStartEvent,
|
|
RetryPromptPart,
|
|
RunContext,
|
|
TextPart,
|
|
TextPartDelta,
|
|
ThinkingPart,
|
|
ToolAvailabilityDeltaEvent,
|
|
ToolAvailabilityDeltaPart,
|
|
ToolCallPart,
|
|
ToolCallPartDelta,
|
|
ToolReturn,
|
|
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._sync_stream import (
|
|
SyncStreamBridge,
|
|
_finalize_loop, # pyright: ignore[reportPrivateUsage]
|
|
_request_exit, # pyright: ignore[reportPrivateUsage]
|
|
_run_task_to_completion, # pyright: ignore[reportPrivateUsage]
|
|
)
|
|
from pydantic_ai._warnings import PydanticAIDeprecationWarning
|
|
from pydantic_ai.agent import AgentRun
|
|
from pydantic_ai.capabilities import (
|
|
AbstractCapability,
|
|
CombinedCapability,
|
|
HandleDeferredToolCalls,
|
|
WrapModelRequestHandler,
|
|
)
|
|
from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry
|
|
from pydantic_ai.models import CompletedStreamedResponse
|
|
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, DeltaToolCalls, FunctionModel
|
|
from pydantic_ai.models.test import TestModel, TestStreamedResponse as ModelTestStreamedResponse
|
|
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 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
|
|
|
|
|
|
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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
provider_name='test',
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=datetime.UTC), tool_call_id=IsStr()
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC)
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
provider_name='test',
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=datetime.UTC), tool_call_id=IsStr()
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
provider_name='test',
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=datetime.UTC), tool_call_id=IsStr()
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
provider_name='test',
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='ret_a', content='a-apple', timestamp=IsNow(tz=datetime.UTC), tool_call_id=IsStr()
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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 the caller's event loop, so it must refuse to run inside an existing one.
|
|
|
|
This is an in-process loop-state guard that a provider cassette cannot exercise.
|
|
"""
|
|
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_replaces_closed_event_loop(closed_event_loop: asyncio.AbstractEventLoop):
|
|
"""`run_stream_sync` must replace a closed thread-current event loop.
|
|
|
|
This uses `TestModel` rather than VCR because the failure occurs while scheduling the
|
|
stream owner task, before any model request is made.
|
|
"""
|
|
agent = Agent(TestModel(custom_output_text='success'))
|
|
with agent.run_stream_sync('Hello') as result:
|
|
assert result.get_output() == 'success'
|
|
|
|
replacement_loop = asyncio.get_event_loop()
|
|
assert replacement_loop is not closed_event_loop
|
|
assert not replacement_loop.is_closed()
|
|
|
|
with agent.run_stream_sync('Hello again') as result:
|
|
assert result.get_output() == 'success'
|
|
assert asyncio.get_event_loop() is replacement_loop
|
|
assert not asyncio.all_tasks(replacement_loop)
|
|
|
|
|
|
def test_run_stream_sync_creates_missing_event_loop(missing_event_loop: asyncio.AbstractEventLoop):
|
|
"""`run_stream_sync` must create and install an event loop when the thread has none.
|
|
|
|
This uses `TestModel` rather than VCR because the behavior occurs before any
|
|
model request is made.
|
|
"""
|
|
with Agent(TestModel(custom_output_text='success')).run_stream_sync('Hello') as result:
|
|
assert result.get_output() == 'success'
|
|
|
|
replacement_loop = asyncio.get_event_loop()
|
|
assert replacement_loop is not missing_event_loop
|
|
assert not replacement_loop.is_closed()
|
|
assert not asyncio.all_tasks(replacement_loop)
|
|
|
|
|
|
def test_run_stream_sync_works_with_disabled_threads():
|
|
"""`run_stream_sync` does not need a worker thread.
|
|
|
|
VCR cannot observe whether the synchronous bridge starts a thread.
|
|
"""
|
|
agent = Agent(TestModel())
|
|
with _utils.disable_threads():
|
|
with agent.run_stream_sync('Hello') as result:
|
|
assert result.get_output()
|
|
|
|
|
|
def _interrupt_next_loop_run(
|
|
bridge: SyncStreamBridge[Any], monkeypatch: pytest.MonkeyPatch
|
|
) -> Callable[[Awaitable[Any]], Any]:
|
|
"""Raise `KeyboardInterrupt` from the next blocking event-loop drive."""
|
|
loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
original_run_until_complete = loop.run_until_complete
|
|
calls = 0
|
|
|
|
def interrupt_first_run(awaitable: Awaitable[Any]) -> Any:
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
raise KeyboardInterrupt
|
|
return original_run_until_complete(awaitable)
|
|
|
|
monkeypatch.setattr(loop, 'run_until_complete', interrupt_first_run)
|
|
return original_run_until_complete
|
|
|
|
|
|
def test_run_stream_sync_tears_down_on_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch):
|
|
"""A Ctrl-C while driving the event loop cancels the run instead of leaking tasks or sockets (#5975).
|
|
|
|
VCR cannot reproduce the required in-process interrupt timing or inspect pending tasks.
|
|
"""
|
|
agent = Agent(TestModel())
|
|
result = agent.run_stream_sync('Hello')
|
|
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
|
|
assert bridge._finalizer.alive # pyright: ignore[reportPrivateUsage]
|
|
|
|
_interrupt_next_loop_run(bridge, monkeypatch)
|
|
|
|
# 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, leaving no pending owner task for GC.
|
|
assert not bridge._finalizer.alive # pyright: ignore[reportPrivateUsage]
|
|
assert bridge._owner_task.done() # pyright: ignore[reportPrivateUsage]
|
|
with pytest.raises(RuntimeError, match='already closed'):
|
|
bridge.call(lambda: None)
|
|
|
|
|
|
def test_sync_stream_bridge_call_propagates_keyboard_interrupt():
|
|
"""An interrupt raised by a bridge call propagates after the context manager exits.
|
|
|
|
VCR cannot inject a synchronous interrupt into the bridge call lifecycle.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
|
|
def interrupt() -> None:
|
|
raise KeyboardInterrupt
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
bridge.call(interrupt)
|
|
|
|
assert bridge._owner_task.done() # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_sync_stream_bridge_rejects_streaming_after_shutdown_before_creating_pump(monkeypatch: pytest.MonkeyPatch):
|
|
"""A closed bridge rejects iteration without creating another loop-bound task.
|
|
|
|
VCR cannot inspect the bridge's in-process task lifecycle.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
bridge.shutdown()
|
|
source = MagicMock()
|
|
task_context = MagicMock(side_effect=AssertionError('must not create a pump task'))
|
|
monkeypatch.setattr(bridge, '_task_context', task_context)
|
|
|
|
with pytest.raises(RuntimeError, match='already closed'):
|
|
next(bridge.stream_sync(source))
|
|
|
|
source.assert_not_called()
|
|
task_context.assert_not_called()
|
|
|
|
|
|
def test_sync_stream_bridge_interrupt_without_pump_preserves_original_error():
|
|
"""An interrupt after call completion propagates and leaves the event loop reusable.
|
|
|
|
Callback ordering is deterministic: completing the call queues `run_until_complete()`'s stop callback,
|
|
then the already-queued interrupt escapes before that stop callback runs. Shutdown must consume the stale
|
|
callback so it cannot stop the next unrelated loop drive. VCR cannot control this in-process ordering.
|
|
"""
|
|
cleanup_complete = False
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
nonlocal cleanup_complete
|
|
try:
|
|
yield object()
|
|
finally:
|
|
cleanup_complete = True
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
original_error = KeyboardInterrupt('original')
|
|
|
|
def interrupt() -> None:
|
|
raise original_error
|
|
|
|
def finish_then_interrupt() -> None:
|
|
# Finishing the call queues its `run_until_complete()` stop callback behind this interrupt.
|
|
loop.call_soon(interrupt)
|
|
|
|
with pytest.raises(KeyboardInterrupt) as exc_info:
|
|
bridge.call(finish_then_interrupt)
|
|
|
|
assert exc_info.value is original_error
|
|
assert cleanup_complete
|
|
assert bridge._owner_task.done() # pyright: ignore[reportPrivateUsage]
|
|
# A leftover stop callback would stop this drive before `sleep(0)` completes and raise `RuntimeError`.
|
|
assert loop.run_until_complete(asyncio.sleep(0)) is None
|
|
|
|
|
|
def test_sync_stream_bridge_task_drain_retries_multiple_early_stops(monkeypatch: pytest.MonkeyPatch):
|
|
"""Task draining tolerates multiple early stops without depending on their exception text.
|
|
|
|
VCR cannot replace the local event-loop driver or inject stale stop callbacks.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
task = loop.create_task(asyncio.sleep(0))
|
|
original_run_until_complete = loop.run_until_complete
|
|
calls = 0
|
|
|
|
def stop_twice(awaitable: Awaitable[object]) -> object:
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls <= 2:
|
|
raise RuntimeError(f'custom loop early stop {calls}')
|
|
return original_run_until_complete(awaitable)
|
|
|
|
with monkeypatch.context() as context:
|
|
context.setattr(loop, 'run_until_complete', stop_twice)
|
|
_run_task_to_completion(loop, task)
|
|
|
|
assert calls == 3
|
|
assert task.done()
|
|
loop.close()
|
|
|
|
|
|
def test_sync_stream_bridge_task_drain_propagates_error_after_completion(monkeypatch: pytest.MonkeyPatch):
|
|
"""A loop-driver error after the waiter completes is propagated instead of retried.
|
|
|
|
VCR cannot replace the local event-loop driver or exercise this defensive cleanup branch.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
task = loop.create_task(asyncio.sleep(0))
|
|
original_run_until_complete = loop.run_until_complete
|
|
error = RuntimeError('loop driver failed after completing the waiter')
|
|
|
|
def finish_then_fail(awaitable: Awaitable[object]) -> object:
|
|
original_run_until_complete(awaitable)
|
|
raise error
|
|
|
|
with monkeypatch.context() as context:
|
|
context.setattr(loop, 'run_until_complete', finish_then_fail)
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
_run_task_to_completion(loop, task)
|
|
|
|
assert exc_info.value is error
|
|
assert task.done()
|
|
loop.close()
|
|
|
|
|
|
def test_sync_stream_bridge_task_drain_propagates_error_while_loop_is_running(monkeypatch: pytest.MonkeyPatch):
|
|
"""A persistent loop-driver error is propagated instead of retried indefinitely.
|
|
|
|
VCR cannot replace the local event-loop driver or simulate another thread driving its loop.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
task = loop.create_task(asyncio.sleep(0))
|
|
original_run_until_complete = loop.run_until_complete
|
|
error = RuntimeError('event loop is already running')
|
|
|
|
def fail_to_drive_loop(awaitable: Awaitable[object]) -> object:
|
|
raise error
|
|
|
|
with monkeypatch.context() as context:
|
|
context.setattr(loop, 'run_until_complete', fail_to_drive_loop)
|
|
context.setattr(loop, 'is_running', lambda: True)
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
_run_task_to_completion(loop, task)
|
|
|
|
assert exc_info.value is error
|
|
pending_tasks = asyncio.all_tasks(loop)
|
|
for pending_task in pending_tasks:
|
|
pending_task.cancel()
|
|
original_run_until_complete(asyncio.gather(*pending_tasks, return_exceptions=True))
|
|
loop.close()
|
|
|
|
|
|
def test_sync_stream_bridge_interrupt_drains_pump_before_owner_exit():
|
|
"""A stale loop-stop callback cannot let owner cleanup overtake a cancelled stream pump.
|
|
|
|
VCR cannot control event-loop callback ordering or observe task cleanup order.
|
|
"""
|
|
source_cleaned = False
|
|
owner_saw_source_cleaned: bool | None = None
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
nonlocal owner_saw_source_cleaned
|
|
try:
|
|
yield object()
|
|
finally:
|
|
owner_saw_source_cleaned = source_cleaned
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
nonlocal source_cleaned
|
|
try:
|
|
yield 'first'
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
# Keep pump cleanup pending long enough for a stale stop callback to interrupt its first drain.
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
source_cleaned = True
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
stream = bridge.stream_sync(source)
|
|
assert next(stream) == 'first'
|
|
|
|
loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
|
|
def interrupt() -> None:
|
|
raise KeyboardInterrupt
|
|
|
|
def finish_then_interrupt() -> None:
|
|
# The call task finishes and queues its `run_until_complete()` stop callback behind this interrupt.
|
|
# The interrupt escapes first, leaving that stop callback queued for the pump drain in shutdown.
|
|
loop.call_soon(interrupt)
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
bridge.call(finish_then_interrupt)
|
|
|
|
assert source_cleaned
|
|
assert owner_saw_source_cleaned is True
|
|
cast(Generator[str, None, None], stream).close()
|
|
|
|
|
|
def test_sync_stream_bridge_rejects_use_from_another_thread():
|
|
"""Normal use never moves the caller-owned event loop to another thread.
|
|
|
|
VCR cannot exercise or observe the bridge's in-process thread affinity.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
errors: list[RuntimeError] = []
|
|
|
|
def use_bridge() -> None:
|
|
try:
|
|
bridge.call(lambda: None)
|
|
except RuntimeError as exc:
|
|
errors.append(exc)
|
|
|
|
thread = threading.Thread(target=use_bridge)
|
|
thread.start()
|
|
thread.join()
|
|
|
|
assert len(errors) == 1
|
|
assert str(errors[0]) == 'A synchronous stream must be used and closed on the thread where it was created.'
|
|
bridge.shutdown()
|
|
|
|
|
|
def test_sync_stream_bridge_defers_iterator_close_from_another_thread():
|
|
"""Closing a suspended iterator queues its pump cleanup back to the owner thread.
|
|
|
|
VCR cannot exercise or observe the bridge's in-process thread affinity.
|
|
"""
|
|
owner_thread_id = threading.get_ident()
|
|
cleanup_thread_id: int | None = None
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
nonlocal cleanup_thread_id
|
|
try:
|
|
yield 'first'
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cleanup_thread_id = threading.get_ident()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
stream = bridge.stream_sync(source)
|
|
assert next(stream) == 'first'
|
|
|
|
def close_stream() -> None:
|
|
cast(Generator[str, None, None], stream).close()
|
|
|
|
thread = threading.Thread(target=close_stream)
|
|
thread.start()
|
|
thread.join()
|
|
|
|
assert cleanup_thread_id is None
|
|
|
|
bridge.call(asyncio.sleep, 0)
|
|
bridge.call(asyncio.sleep, 0)
|
|
assert cleanup_thread_id == owner_thread_id
|
|
assert not bridge._pump_tasks # pyright: ignore[reportPrivateUsage]
|
|
bridge.shutdown()
|
|
|
|
|
|
def test_sync_stream_bridge_rejects_iterator_resume_from_another_thread():
|
|
"""Resuming a suspended iterator cannot move its pump cleanup to another thread.
|
|
|
|
VCR cannot exercise or observe the bridge's in-process thread affinity.
|
|
"""
|
|
owner_thread_id = threading.get_ident()
|
|
cleanup_thread_id: int | None = None
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
nonlocal cleanup_thread_id
|
|
try:
|
|
yield 'first'
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cleanup_thread_id = threading.get_ident()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
stream = bridge.stream_sync(source)
|
|
assert next(stream) == 'first'
|
|
errors: list[RuntimeError] = []
|
|
|
|
def resume_stream() -> None:
|
|
try:
|
|
next(stream)
|
|
except RuntimeError as exc:
|
|
errors.append(exc)
|
|
|
|
thread = threading.Thread(target=resume_stream)
|
|
thread.start()
|
|
thread.join()
|
|
|
|
assert len(errors) == 1
|
|
assert str(errors[0]) == 'A synchronous stream must be used and closed on the thread where it was created.'
|
|
assert cleanup_thread_id is None
|
|
|
|
bridge.shutdown()
|
|
assert cleanup_thread_id == owner_thread_id
|
|
|
|
|
|
def test_sync_stream_bridge_defers_iterator_gc_from_another_thread(monkeypatch: pytest.MonkeyPatch):
|
|
"""Foreign-thread iterator GC queues cleanup without emitting an unraisable exception.
|
|
|
|
VCR cannot control garbage collection or observe the bridge's in-process thread affinity.
|
|
"""
|
|
owner_thread_id = threading.get_ident()
|
|
cleanup_thread_id: int | None = None
|
|
unraisable: list[object] = []
|
|
# Flush any garbage a sibling test leaked into this xdist worker before arming the hook, so its
|
|
# finalizer warnings (e.g. an unclosed socket) run under the default hook instead of polluting
|
|
# our capture list and failing `assert not unraisable`.
|
|
gc.collect()
|
|
monkeypatch.setattr(sys, 'unraisablehook', unraisable.append)
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
nonlocal cleanup_thread_id
|
|
try:
|
|
yield 'first'
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cleanup_thread_id = threading.get_ident()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
stream = bridge.stream_sync(source)
|
|
assert next(stream) == 'first'
|
|
holder = [stream]
|
|
del stream
|
|
|
|
def drop_stream() -> None:
|
|
holder.clear()
|
|
gc.collect()
|
|
|
|
thread = threading.Thread(target=drop_stream)
|
|
thread.start()
|
|
thread.join()
|
|
|
|
assert not unraisable
|
|
assert cleanup_thread_id is None
|
|
|
|
bridge.call(asyncio.sleep, 0)
|
|
bridge.call(asyncio.sleep, 0)
|
|
assert cleanup_thread_id == owner_thread_id
|
|
assert not bridge._pump_tasks # pyright: ignore[reportPrivateUsage]
|
|
bridge.shutdown()
|
|
|
|
|
|
def test_sync_stream_bridge_closes_iterator_gc_after_shutdown(monkeypatch: pytest.MonkeyPatch):
|
|
"""Foreign-thread iterator GC closes its receive stream after wrapper shutdown.
|
|
|
|
VCR cannot control garbage collection or observe the bridge's in-process stream resources.
|
|
"""
|
|
original_loop = _utils.get_event_loop()
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
cleanup_thread_id: int | None = None
|
|
unraisable: list[object] = []
|
|
# Flush any garbage a sibling test leaked into this xdist worker before arming the hook, so its
|
|
# finalizer warnings (e.g. an unclosed socket) run under the default hook instead of polluting
|
|
# our capture list and failing `assert not unraisable`.
|
|
gc.collect()
|
|
monkeypatch.setattr(sys, 'unraisablehook', unraisable.append)
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
nonlocal cleanup_thread_id
|
|
try:
|
|
yield 'first'
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
cleanup_thread_id = threading.get_ident()
|
|
|
|
try:
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
stream = bridge.stream_sync(source)
|
|
assert next(stream) == 'first'
|
|
bridge.shutdown()
|
|
assert cleanup_thread_id == threading.get_ident()
|
|
holder = [stream]
|
|
del stream
|
|
|
|
def drop_stream() -> None:
|
|
holder.clear()
|
|
gc.collect()
|
|
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter('error', ResourceWarning)
|
|
thread = threading.Thread(target=drop_stream)
|
|
thread.start()
|
|
thread.join()
|
|
loop.close()
|
|
gc.collect()
|
|
finally:
|
|
if not loop.is_closed(): # pragma: no cover
|
|
loop.close()
|
|
asyncio.set_event_loop(original_loop)
|
|
|
|
assert not unraisable
|
|
|
|
|
|
def test_sync_stream_bridge_rejects_use_while_another_loop_runs():
|
|
"""A sync bridge never nests its owner loop inside another running event loop.
|
|
|
|
This is an in-process loop-state guard that a provider cassette cannot exercise.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
|
|
async def use_bridge() -> None:
|
|
with pytest.raises(RuntimeError, match='while an event loop is running'):
|
|
bridge.call(lambda: None)
|
|
|
|
asyncio.run(use_bridge())
|
|
bridge.shutdown()
|
|
|
|
|
|
def test_sync_stream_bridge_init_interrupt_cleans_owner():
|
|
"""An interrupt during context entry cancels the owner task without leaking its ready future.
|
|
|
|
VCR cannot inject the interrupt timing or inspect the bridge's pending futures.
|
|
"""
|
|
loop = _utils.get_event_loop()
|
|
|
|
def interrupt() -> None:
|
|
raise KeyboardInterrupt
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
loop.call_soon(interrupt)
|
|
await asyncio.sleep(1)
|
|
yield object() # pragma: no cover
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
|
|
|
|
def test_sync_stream_bridge_init_interrupt_after_entry_exits_context():
|
|
"""An interrupt after context entry still runs the context manager's cleanup.
|
|
|
|
VCR cannot inject an interrupt between context entry and result delivery.
|
|
"""
|
|
original_loop = _utils.get_event_loop()
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
exited = False
|
|
|
|
def interrupt() -> None:
|
|
raise KeyboardInterrupt
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
nonlocal exited
|
|
loop.call_soon(interrupt)
|
|
try:
|
|
yield object()
|
|
finally:
|
|
exited = True
|
|
|
|
try:
|
|
with pytest.raises(KeyboardInterrupt):
|
|
SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
assert loop.run_until_complete(asyncio.sleep(0)) is None
|
|
finally:
|
|
loop.close()
|
|
asyncio.set_event_loop(original_loop)
|
|
|
|
assert exited
|
|
|
|
|
|
# These synthetic paths have no external I/O, so 100 loop turns detects a real hang without inheriting
|
|
# CI-worker wall-clock variation.
|
|
class _LoopTurnWatchdog:
|
|
def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
self.forced_stop = False
|
|
self._loop = loop
|
|
self._turns = 0
|
|
self._handle = loop.call_soon(self._tick)
|
|
|
|
def _tick(self) -> None:
|
|
self._turns += 1
|
|
if self._turns >= 100:
|
|
self.forced_stop = True # pragma: no cover
|
|
self._loop.stop() # pragma: no cover
|
|
else:
|
|
self._handle = self._loop.call_soon(self._tick)
|
|
|
|
def cancel(self) -> None:
|
|
self._handle.cancel()
|
|
|
|
|
|
@pytest.fixture
|
|
def loop_turn_watchdog() -> Generator[Callable[[asyncio.AbstractEventLoop], _LoopTurnWatchdog], None, None]:
|
|
watchdogs: list[_LoopTurnWatchdog] = []
|
|
|
|
def arm(loop: asyncio.AbstractEventLoop) -> _LoopTurnWatchdog:
|
|
watchdog = _LoopTurnWatchdog(loop)
|
|
watchdogs.append(watchdog)
|
|
return watchdog
|
|
|
|
try:
|
|
yield arm
|
|
finally:
|
|
for watchdog in watchdogs:
|
|
watchdog.cancel()
|
|
|
|
|
|
@pytest.mark.parametrize('error_type', [KeyboardInterrupt, SystemExit])
|
|
def test_sync_stream_bridge_init_propagates_base_exception(
|
|
error_type: type[BaseException], loop_turn_watchdog: Callable[[asyncio.AbstractEventLoop], _LoopTurnWatchdog]
|
|
):
|
|
"""A base exception from `__aenter__` escapes immediately instead of hanging the event loop.
|
|
|
|
VCR cannot inject an in-process base exception into the context-manager entry protocol.
|
|
"""
|
|
original_loop = _utils.get_event_loop()
|
|
loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
error = error_type('entry failed')
|
|
|
|
class FailingContextManager:
|
|
async def __aenter__(self) -> object:
|
|
raise error
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_value: BaseException | None,
|
|
traceback: TracebackType | None,
|
|
) -> None:
|
|
pytest.fail('`__aexit__` must not be called when `__aenter__` fails') # pragma: no cover
|
|
|
|
watchdog = loop_turn_watchdog(loop)
|
|
try:
|
|
with pytest.raises(error_type) as exc_info:
|
|
SyncStreamBridge(FailingContextManager(), async_alternative='`async_method`')
|
|
watchdog.cancel()
|
|
assert exc_info.value is error
|
|
assert not watchdog.forced_stop
|
|
assert loop.run_until_complete(asyncio.sleep(0)) is None
|
|
finally:
|
|
loop.close()
|
|
asyncio.set_event_loop(original_loop)
|
|
|
|
|
|
def test_sync_stream_bridge_exit_error_propagates():
|
|
"""An error raised after context-manager cleanup is complete propagates to the sync caller.
|
|
|
|
VCR cannot make the in-process context manager fail during its exit protocol.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
try:
|
|
yield object()
|
|
finally:
|
|
raise RuntimeError('exit failed')
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
|
|
with pytest.raises(RuntimeError, match='exit failed'):
|
|
bridge.shutdown()
|
|
|
|
assert bridge._owner_task.done() # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_sync_stream_bridge_owner_cancellation_can_be_suppressed():
|
|
"""Owner cancellation follows the async context manager's suppression semantics.
|
|
|
|
VCR cannot control task cancellation or inspect the context manager's exception arguments.
|
|
"""
|
|
exit_type: type[BaseException] | None = None
|
|
|
|
class SuppressingContextManager:
|
|
async def __aenter__(self) -> object:
|
|
return object()
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_value: BaseException | None,
|
|
traceback: TracebackType | None,
|
|
) -> bool:
|
|
nonlocal exit_type
|
|
exit_type = exc_type
|
|
return True
|
|
|
|
bridge = SyncStreamBridge(SuppressingContextManager(), async_alternative='`async_method`')
|
|
bridge._owner_task.cancel() # pyright: ignore[reportPrivateUsage]
|
|
bridge._loop.run_until_complete(bridge._owner_task) # pyright: ignore[reportPrivateUsage]
|
|
|
|
assert exit_type is asyncio.CancelledError
|
|
bridge.shutdown()
|
|
|
|
|
|
def test_sync_stream_bridge_shutdown_accepts_prior_exit_request():
|
|
"""Shutdown remains idempotent if cleanup was already requested on the owner loop.
|
|
|
|
VCR cannot place the bridge into this internal cleanup state.
|
|
"""
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
bridge._exit_requested.set_result((None, None, None)) # pyright: ignore[reportPrivateUsage]
|
|
bridge.shutdown()
|
|
|
|
assert bridge._owner_task.done() # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
def test_sync_stream_bridge_finalizes_while_owner_loop_is_running():
|
|
"""The non-context-manager fallback can request cleanup from its running owner loop.
|
|
|
|
VCR cannot control garbage collection or inspect owner-task completion.
|
|
"""
|
|
result = Agent(TestModel()).run_stream_sync('Hello')
|
|
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
|
|
loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
owner_task = bridge._owner_task # pyright: ignore[reportPrivateUsage]
|
|
bridge_ref = weakref.ref(bridge)
|
|
holder = [result]
|
|
del result, bridge
|
|
|
|
async def drop_result() -> None:
|
|
holder.clear()
|
|
gc.collect()
|
|
await asyncio.sleep(0)
|
|
|
|
loop.run_until_complete(drop_result())
|
|
loop.run_until_complete(owner_task)
|
|
|
|
assert bridge_ref() is None
|
|
assert owner_task.done()
|
|
|
|
|
|
def test_sync_stream_bridge_finalizes_with_unclosed_iterator():
|
|
"""Dropping an active iterator releases its pump before the wrapper's GC cleanup runs.
|
|
|
|
VCR cannot control garbage collection or inspect the in-process pump tasks.
|
|
"""
|
|
result = Agent(TestModel(custom_output_text='The cat sat on the mat.')).run_stream_sync('Hello')
|
|
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
|
|
owner_task = bridge._owner_task # pyright: ignore[reportPrivateUsage]
|
|
stream = result.stream_text(delta=True, debounce_by=None)
|
|
assert next(stream)
|
|
pump_tasks = tuple(bridge._pump_tasks) # pyright: ignore[reportPrivateUsage]
|
|
bridge_ref = weakref.ref(bridge)
|
|
|
|
del stream, result, bridge
|
|
gc.collect()
|
|
|
|
assert bridge_ref() is None
|
|
assert owner_task.done()
|
|
assert all(task.done() for task in pump_tasks)
|
|
|
|
|
|
def test_sync_stream_bridge_running_loop_finalizer_drains_pumps():
|
|
"""Finalization on a running loop cancels pumps before releasing the owner task.
|
|
|
|
VCR cannot trigger this finalizer state or observe task cancellation order.
|
|
"""
|
|
|
|
async def run() -> None:
|
|
loop = asyncio.get_running_loop()
|
|
exit_requested: asyncio.Future[
|
|
tuple[type[BaseException] | None, BaseException | None, TracebackType | None]
|
|
] = loop.create_future()
|
|
|
|
async def wait_for_exit() -> None:
|
|
await exit_requested
|
|
|
|
async def wait_forever() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
owner_task = loop.create_task(wait_for_exit())
|
|
pump_task = loop.create_task(wait_forever())
|
|
pump_tasks = {pump_task}
|
|
|
|
_finalize_loop(loop, owner_task, exit_requested, pump_tasks, threading.get_ident())
|
|
await owner_task
|
|
|
|
assert pump_task.cancelled()
|
|
assert not pump_tasks
|
|
|
|
# A queued duplicate request is harmless after both cleanup signals are complete.
|
|
await _request_exit(owner_task, exit_requested, pump_tasks)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_sync_stream_bridge_gc_request_retrieves_owner_exit_error():
|
|
"""Best-effort GC cleanup consumes an error raised while the owner context exits.
|
|
|
|
VCR cannot inspect whether an in-process task exception was retrieved.
|
|
"""
|
|
|
|
async def run() -> None:
|
|
loop = asyncio.get_running_loop()
|
|
exit_requested: asyncio.Future[
|
|
tuple[type[BaseException] | None, BaseException | None, TracebackType | None]
|
|
] = loop.create_future()
|
|
|
|
async def fail_on_exit() -> None:
|
|
await exit_requested
|
|
raise RuntimeError('exit failed')
|
|
|
|
owner_task = loop.create_task(fail_on_exit())
|
|
await _request_exit(owner_task, exit_requested, set())
|
|
|
|
assert owner_task.done()
|
|
assert isinstance(owner_task.exception(), RuntimeError)
|
|
|
|
asyncio.run(run())
|
|
|
|
|
|
def test_sync_stream_bridge_finalizes_while_another_loop_is_running():
|
|
"""The non-context-manager fallback requests cleanup if another event loop is active.
|
|
|
|
VCR cannot control garbage collection while a second event loop is running.
|
|
"""
|
|
result = Agent(TestModel()).run_stream_sync('Hello')
|
|
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
|
|
owner_loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
owner_task = bridge._owner_task # pyright: ignore[reportPrivateUsage]
|
|
bridge_ref = weakref.ref(bridge)
|
|
holder = [result]
|
|
del result, bridge
|
|
|
|
async def drop_result() -> None:
|
|
holder.clear()
|
|
gc.collect()
|
|
|
|
try:
|
|
asyncio.run(drop_result())
|
|
owner_loop.run_until_complete(owner_task)
|
|
finally:
|
|
asyncio.set_event_loop(owner_loop)
|
|
|
|
assert bridge_ref() is None
|
|
assert owner_task.done()
|
|
|
|
|
|
def test_sync_stream_bridge_finalizes_on_another_thread():
|
|
"""The GC fallback never moves the stopped caller-owned loop to the finalizer thread.
|
|
|
|
VCR cannot control the finalizer thread or observe event-loop thread affinity.
|
|
"""
|
|
result = Agent(TestModel()).run_stream_sync('Hello')
|
|
bridge = result._bridge # pyright: ignore[reportPrivateUsage]
|
|
owner_loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
owner_task = bridge._owner_task # pyright: ignore[reportPrivateUsage]
|
|
bridge_ref = weakref.ref(bridge)
|
|
holder = [result]
|
|
del result, bridge
|
|
|
|
def drop_result() -> None:
|
|
holder.clear()
|
|
gc.collect()
|
|
|
|
thread = threading.Thread(target=drop_result)
|
|
thread.start()
|
|
thread.join()
|
|
|
|
assert bridge_ref() is None
|
|
assert not owner_task.done()
|
|
owner_loop.run_until_complete(owner_task)
|
|
assert owner_task.done()
|
|
|
|
|
|
def test_sync_stream_bridge_finalizer_ignores_closed_loop():
|
|
"""The GC fallback cannot drive a loop that its owner has already closed.
|
|
|
|
Once a caller closes the owner loop, async cleanup cannot safely run in-process. A provider cassette
|
|
cannot observe this local lifecycle guard. A pending Future stands in for the owner task so the guard
|
|
can be exercised without knowingly leaking a real task.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
exit_requested: asyncio.Future[tuple[type[BaseException] | None, BaseException | None, TracebackType | None]] = (
|
|
loop.create_future()
|
|
)
|
|
owner_task = cast(asyncio.Task[None], loop.create_future())
|
|
assert not owner_task.done()
|
|
loop.close()
|
|
|
|
_finalize_loop(loop, owner_task, exit_requested, set(), threading.get_ident())
|
|
|
|
assert not owner_task.done()
|
|
assert not exit_requested.done()
|
|
|
|
|
|
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 model stream is still open 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]
|
|
assert not stream_closed.is_set() # the model stream is open and producing
|
|
|
|
_interrupt_next_loop_run(bridge, monkeypatch)
|
|
|
|
with pytest.raises(KeyboardInterrupt):
|
|
with result:
|
|
result.get_output()
|
|
|
|
# The interrupt teardown ran the still-open model stream's `finally`, so the connection was closed
|
|
# rather than left pending on the loop until GC.
|
|
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).
|
|
|
|
Without cleanup, the orphaned `MemoryObjectReceiveStream` warns from `__del__` at GC, which pytest
|
|
escalates to an error.
|
|
"""
|
|
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]
|
|
stream = result.stream_text(delta=True, debounce_by=None)
|
|
assert next(stream) # pump running, receive stream open
|
|
|
|
_interrupt_next_loop_run(bridge, monkeypatch)
|
|
|
|
# The interrupt propagates through the `stream_sync` generator's `finally`, which closes the receive stream.
|
|
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(Generator[str, None, None], stream).close()
|
|
|
|
|
|
def test_sync_stream_bridge_early_close_cancels_waiting_pump():
|
|
"""Closing a sync iterator cancels a pump waiting indefinitely on its source.
|
|
|
|
VCR cannot inspect the pump task or deterministically hold its source pending.
|
|
"""
|
|
source_closed = False
|
|
wait_forever = asyncio.Event()
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
nonlocal source_closed
|
|
try:
|
|
yield 'first'
|
|
await wait_forever.wait()
|
|
yield 'unreachable' # pragma: no cover
|
|
finally:
|
|
source_closed = True
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
stream = bridge.stream_sync(source)
|
|
assert next(stream) == 'first'
|
|
cast(Generator[str, None, None], stream).close()
|
|
|
|
assert source_closed
|
|
assert not bridge._pump_tasks # pyright: ignore[reportPrivateUsage]
|
|
bridge.shutdown()
|
|
|
|
|
|
@pytest.mark.parametrize('error_type', [KeyboardInterrupt, SystemExit])
|
|
def test_sync_stream_bridge_pump_propagates_base_exception_without_hanging(
|
|
error_type: type[BaseException], loop_turn_watchdog: Callable[[asyncio.AbstractEventLoop], _LoopTurnWatchdog]
|
|
):
|
|
"""A base exception from a completed pump escapes without stranding the caller's event loop.
|
|
|
|
VCR cannot inject an in-process base exception into the iterator pump task.
|
|
"""
|
|
error = error_type('pump failed')
|
|
|
|
@asynccontextmanager
|
|
async def stream_context() -> AsyncGenerator[object]:
|
|
yield object()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
yield 'first'
|
|
raise error
|
|
|
|
bridge = SyncStreamBridge(stream_context(), async_alternative='`async_method`')
|
|
loop = bridge._loop # pyright: ignore[reportPrivateUsage]
|
|
stream = bridge.stream_sync(source)
|
|
watchdog = loop_turn_watchdog(loop)
|
|
|
|
with pytest.raises(error_type) as exc_info:
|
|
while True:
|
|
next(stream)
|
|
watchdog.cancel()
|
|
assert exc_info.value is error
|
|
assert not watchdog.forced_stop
|
|
assert bridge._owner_task.done() # pyright: ignore[reportPrivateUsage]
|
|
assert loop.run_until_complete(asyncio.sleep(0)) is None
|
|
|
|
|
|
def test_run_stream_sync_preserves_capability_contextvars():
|
|
"""Tasks driven by the sync bridge inherit context set inside the agent run.
|
|
|
|
VCR cannot observe in-process context-variable propagation between tasks.
|
|
"""
|
|
run_context: contextvars.ContextVar[str] = contextvars.ContextVar('run_context')
|
|
|
|
@dataclass
|
|
class Setter(AbstractCapability[Any]):
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
token = run_context.set('from-wrap-run')
|
|
try:
|
|
return await handler()
|
|
finally:
|
|
run_context.reset(token)
|
|
|
|
@dataclass
|
|
class Reader(AbstractCapability[Any]):
|
|
seen: list[str | None] = field(default_factory=lambda: [])
|
|
|
|
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
|
|
self.seen.append(run_context.get(None))
|
|
return node
|
|
|
|
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
|
|
self.seen.append(run_context.get(None))
|
|
return result
|
|
|
|
reader = Reader()
|
|
agent = Agent(TestModel(), capabilities=[Setter(), reader])
|
|
with agent.run_stream_sync('Hello') as result:
|
|
assert result.get_output()
|
|
|
|
assert reader.seen
|
|
assert all(value == 'from-wrap-run' for value in reader.seen)
|
|
|
|
|
|
def test_run_stream_sync_preserves_current_caller_contextvars():
|
|
"""Run-owned context merges into caller values changed after constructing the sync result.
|
|
|
|
VCR cannot observe caller context changes made between construction and consumption.
|
|
"""
|
|
caller_context: contextvars.ContextVar[str] = contextvars.ContextVar('caller_context')
|
|
seen: list[str | None] = []
|
|
agent = Agent(TestModel(custom_output_text='done'))
|
|
|
|
@agent.output_validator
|
|
def validate_output(output: str) -> str:
|
|
seen.append(caller_context.get(None))
|
|
return output
|
|
|
|
construction_token = caller_context.set('construction')
|
|
try:
|
|
result = agent.run_stream_sync('Hello')
|
|
consumption_token = caller_context.set('consumption')
|
|
try:
|
|
with result:
|
|
assert result.get_output() == 'done'
|
|
finally:
|
|
caller_context.reset(consumption_token)
|
|
finally:
|
|
caller_context.reset(construction_token)
|
|
|
|
assert seen == ['consumption']
|
|
|
|
|
|
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)
|
|
|
|
|
|
async def test_run_stream_cancel_during_debounce_from_another_task():
|
|
"""`cancel()` interrupts a debounced background pull without cancelling its caller.
|
|
|
|
A synthetic `PeekableAsyncStream` makes the second chunk wait deterministically; a recorded provider response cannot
|
|
guarantee that the debounced prefetch is still active when cancellation starts.
|
|
"""
|
|
pull_started = anyio.Event()
|
|
finalization_started = anyio.Event()
|
|
|
|
async def source() -> AsyncIterator[str]:
|
|
try:
|
|
yield 'chunk '
|
|
pull_started.set()
|
|
await anyio.sleep_forever()
|
|
finally:
|
|
finalization_started.set()
|
|
|
|
@dataclass
|
|
class CancellableStreamedResponse(models.StreamedResponse):
|
|
stream: _utils.PeekableAsyncStream[str, AsyncIterator[str]]
|
|
|
|
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
|
|
async for text in self.stream:
|
|
for event in self._parts_manager.handle_text_delta(vendor_part_id=0, content=text):
|
|
yield event
|
|
|
|
async def close_stream(self) -> None:
|
|
await self.stream.aclose()
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return 'cancellable'
|
|
|
|
@property
|
|
def provider_name(self) -> str:
|
|
return 'test'
|
|
|
|
@property
|
|
def provider_url(self) -> str:
|
|
return 'https://test.example.com'
|
|
|
|
@property
|
|
def timestamp(self) -> _datetime:
|
|
return _datetime(2024, 1, 1, tzinfo=datetime.UTC)
|
|
|
|
class CancellableModel(models.Model):
|
|
@property
|
|
def system(self) -> str:
|
|
return 'test'
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return 'cancellable'
|
|
|
|
async def request(
|
|
self,
|
|
messages: list[ModelMessage],
|
|
model_settings: models.ModelSettings | None,
|
|
model_request_parameters: models.ModelRequestParameters,
|
|
) -> ModelResponse:
|
|
raise AssertionError('Only streaming requests are expected') # pragma: no cover
|
|
|
|
@asynccontextmanager
|
|
async def request_stream(
|
|
self,
|
|
messages: list[ModelMessage],
|
|
model_settings: models.ModelSettings | None,
|
|
model_request_parameters: models.ModelRequestParameters,
|
|
run_context: RunContext[object] | None = None,
|
|
) -> AsyncGenerator[models.StreamedResponse]:
|
|
yield CancellableStreamedResponse(
|
|
model_request_parameters=model_request_parameters,
|
|
stream=_utils.PeekableAsyncStream(source()),
|
|
)
|
|
|
|
model = CancellableModel()
|
|
assert model.model_id == 'test:cancellable'
|
|
agent = Agent(model)
|
|
|
|
async with agent.run_stream('hello') as result:
|
|
stream = result.stream_text(delta=True)
|
|
with anyio.fail_after(1):
|
|
assert await anext(stream) == 'chunk '
|
|
await pull_started.wait()
|
|
|
|
cancel_finished = anyio.Event()
|
|
|
|
async def cancel() -> None:
|
|
await result.cancel()
|
|
cancel_finished.set()
|
|
|
|
async with anyio.create_task_group() as task_group:
|
|
task_group.start_soon(cancel)
|
|
with anyio.fail_after(1):
|
|
await cancel_finished.wait()
|
|
|
|
assert result.cancelled
|
|
assert finalization_started.is_set()
|
|
|
|
assert result.response.state == 'interrupted'
|
|
|
|
|
|
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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='ret_a',
|
|
content='hello world',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='ret_a',
|
|
content='hello world',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[],
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
state='interrupted',
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='another_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='deferred_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='second_output',
|
|
content='Output tool not used - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Output tool not used - a final result was already processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.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.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='another_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_name='unknown_tool',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='deferred_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.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.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content=1,
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.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.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.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.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content=42,
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='another_tool', content=2, tool_call_id=IsStr(), timestamp=IsNow(tz=datetime.UTC)
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='second_output',
|
|
content='Output tool not used - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Output tool not used - a final result was already processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content=1,
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='another_tool',
|
|
content=2,
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='deferred_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='regular_tool',
|
|
content=42,
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
tool_call_id=IsStr(),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='another_tool',
|
|
content=2,
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='deferred_tool',
|
|
content='Tool not executed - a final result was already processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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.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=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.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=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='second_output',
|
|
content='Final result processed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.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=datetime.UTC))],
|
|
timestamp=IsNow(tz=datetime.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.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.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='second_output',
|
|
content='Output tool not used - output function execution failed.',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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.UTC))],
|
|
timestamp=IsNow(tz=datetime.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.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.UTC),
|
|
),
|
|
RetryPromptPart(
|
|
content='Second output validation failed',
|
|
tool_name='second_output',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.UTC),
|
|
),
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.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=datetime.UTC),
|
|
),
|
|
ToolReturnPart(
|
|
tool_name='final_result',
|
|
content='Final result processed.',
|
|
timestamp=IsNow(tz=datetime.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=5)
|
|
await asyncio.wait_for(pending_started.wait(), timeout=5)
|
|
|
|
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=datetime.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=datetime.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=datetime.UTC),
|
|
),
|
|
),
|
|
FunctionToolResultEvent(
|
|
part=ToolReturnPart(
|
|
tool_name='known_tool',
|
|
content=10,
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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=datetime.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
|
|
),
|
|
DeferredToolRequestsEvent(
|
|
requests=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'
|
|
)
|
|
],
|
|
),
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
async def test_deferred_tool_requests_and_results_events():
|
|
"""`DeferredToolRequestsEvent` is emitted once per deferred batch; `DeferredToolResultsEvent` once when a handler resolves it."""
|
|
|
|
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
|
|
return requests.build_results(approve_all=True)
|
|
|
|
agent = Agent(
|
|
TestModel(),
|
|
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
|
|
)
|
|
|
|
@agent.tool_plain(requires_approval=True)
|
|
def my_tool(x: int) -> int:
|
|
return x + 1
|
|
|
|
events: list[Any] = []
|
|
|
|
async with agent.iter('test') as run:
|
|
async for node in run:
|
|
if agent.is_call_tools_node(node):
|
|
async with node.stream(run.ctx) as stream:
|
|
async for event in stream:
|
|
events.append(event)
|
|
|
|
event_kinds = [e.event_kind for e in events]
|
|
# `FunctionToolCallEvent` fires first (validation), then `DeferredToolRequestsEvent` for the batch,
|
|
# then `DeferredToolResultsEvent` once the handler resolves it, then `FunctionToolResultEvent`
|
|
# as the approved call executes.
|
|
assert event_kinds == snapshot(
|
|
[
|
|
'function_tool_call',
|
|
'deferred_tool_requests',
|
|
'deferred_tool_results',
|
|
'function_tool_result',
|
|
]
|
|
)
|
|
|
|
requests_event = next(e for e in events if e.event_kind == 'deferred_tool_requests')
|
|
assert isinstance(requests_event, DeferredToolRequestsEvent)
|
|
assert len(requests_event.requests.approvals) == 1
|
|
assert requests_event.requests.approvals[0].tool_name == 'my_tool'
|
|
|
|
results_event = next(e for e in events if e.event_kind == 'deferred_tool_results')
|
|
assert isinstance(results_event, DeferredToolResultsEvent)
|
|
assert len(results_event.results.approvals) == 1
|
|
|
|
|
|
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
|
|
),
|
|
DeferredToolRequestsEvent(
|
|
requests=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 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=datetime.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"}')),
|
|
]
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize('run_stream', [False, True])
|
|
@pytest.mark.parametrize('end_strategy', ['graceful', 'exhaustive'])
|
|
async def test_tool_availability_delta_event_stream_handler(
|
|
run_stream: bool, end_strategy: Literal['graceful', 'exhaustive']
|
|
) -> None:
|
|
"""A recorded tool reveal emits the same delta part through both streaming run APIs."""
|
|
agent = Agent(TestModel(), end_strategy=end_strategy)
|
|
|
|
@agent.tool_plain
|
|
def ret_a(x: str) -> ToolReturn[str]:
|
|
return ToolReturn(return_value=f'{x}-apple', tools=['hidden_tool'])
|
|
|
|
events: list[AgentStreamEvent] = []
|
|
|
|
async def event_stream_handler(ctx: RunContext, stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for event in stream:
|
|
events.append(event)
|
|
|
|
if run_stream:
|
|
async with agent.run_stream('Hello', event_stream_handler=event_stream_handler) as result:
|
|
await result.get_output()
|
|
else:
|
|
await agent.run('Hello', event_stream_handler=event_stream_handler)
|
|
|
|
assert [event for event in events if isinstance(event, ToolAvailabilityDeltaEvent)] == [
|
|
ToolAvailabilityDeltaEvent(
|
|
part=ToolAvailabilityDeltaPart(tools_added=['hidden_tool'], tool_call_id='pyd_ai_tool_call_id__ret_a')
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize('end_strategy', ['graceful', 'exhaustive'])
|
|
async def test_run_stream_usage_includes_tools_run_after_final_result(
|
|
end_strategy: Literal['graceful', 'exhaustive'],
|
|
) -> None:
|
|
"""Function tools run after the final result, and the agents they delegate to, count towards `result.usage`."""
|
|
|
|
async def sf(_: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
|
|
yield {0: DeltaToolCall('final_result', '{"value": "done"}')}
|
|
yield {1: DeltaToolCall('delegate', '{}')}
|
|
|
|
delegate_agent = Agent(TestModel())
|
|
agent = Agent(FunctionModel(stream_function=sf), output_type=OutputType, end_strategy=end_strategy)
|
|
|
|
@agent.tool
|
|
async def delegate(ctx: RunContext) -> str:
|
|
result = await delegate_agent.run('hi', usage=ctx.usage)
|
|
return result.output
|
|
|
|
usage = RunUsage()
|
|
async with agent.run_stream('go', usage=usage) as result:
|
|
await result.get_output()
|
|
assert result.usage == snapshot(RunUsage(requests=2, input_tokens=101, output_tokens=9, tool_calls=1))
|
|
assert result.usage == usage == snapshot(RunUsage(requests=2, input_tokens=101, output_tokens=9, tool_calls=1))
|
|
|
|
|
|
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=datetime.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=datetime.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
|
|
# The test asserts this unbounded stream is never pulled.
|
|
while True: # pragma: no cover
|
|
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)
|
|
|
|
# The continuation composite opens each segment's `request_stream` lazily, only once the
|
|
# consumer starts iterating, so a handler that raises before consuming never pulls the model
|
|
# stream at all. The key guarantee is that the post-handler drain was skipped (otherwise this
|
|
# unbounded stream would have been pulled forever).
|
|
assert pulled == 0
|
|
|
|
|
|
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=datetime.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=datetime.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=datetime.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.UTC),
|
|
)
|
|
],
|
|
timestamp=IsNow(tz=datetime.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=datetime.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=datetime.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_deferral_not_triggered_by_partial_args():
|
|
"""A deferring `args_validator` is consulted once, on the complete arguments.
|
|
|
|
Tool call arguments arrive in fragments while streaming, and a validator that saw those would
|
|
be able to request approval for a half-built call. Function tool arguments are only ever
|
|
validated once the model response is complete, so the validator sees the full arguments
|
|
(`ctx.partial_output` is `False`) and exactly one deferral reaches the deferred batch.
|
|
"""
|
|
validator_calls: list[tuple[int, int, bool]] = []
|
|
|
|
async def stream_args(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
|
|
yield {0: DeltaToolCall(name='add_numbers')}
|
|
yield {0: DeltaToolCall(json_args='{"x": 1,')}
|
|
yield {0: DeltaToolCall(json_args=' "y": 2}')}
|
|
|
|
def my_validator(ctx: RunContext, x: int, y: int) -> None:
|
|
validator_calls.append((x, y, ctx.partial_output))
|
|
raise ApprovalRequired()
|
|
|
|
agent = Agent(
|
|
FunctionModel(stream_function=stream_args),
|
|
output_type=[str, DeferredToolRequests],
|
|
)
|
|
|
|
@agent.tool(args_validator=my_validator)
|
|
def add_numbers(ctx: RunContext, x: int, y: int) -> int: # pragma: no cover
|
|
return x + y
|
|
|
|
events: list[Any] = []
|
|
async with agent.run_stream_events('add 1 and 2') as event_stream:
|
|
async for event in event_stream:
|
|
events.append(event)
|
|
|
|
assert validator_calls == snapshot([(1, 2, False)])
|
|
assert events == snapshot(
|
|
[
|
|
PartStartEvent(
|
|
index=0,
|
|
part=ToolCallPart(tool_name='add_numbers', tool_call_id=IsStr()),
|
|
),
|
|
PartDeltaEvent(
|
|
index=0,
|
|
delta=ToolCallPartDelta(args_delta='{"x": 1,', tool_call_id=IsStr()),
|
|
),
|
|
PartDeltaEvent(
|
|
index=0,
|
|
delta=ToolCallPartDelta(args_delta=' "y": 2}', tool_call_id=IsStr()),
|
|
),
|
|
PartEndEvent(
|
|
index=0,
|
|
part=ToolCallPart(
|
|
tool_name='add_numbers',
|
|
args='{"x": 1, "y": 2}',
|
|
tool_call_id=IsStr(),
|
|
),
|
|
),
|
|
FunctionToolCallEvent(
|
|
part=ToolCallPart(
|
|
tool_name='add_numbers',
|
|
args='{"x": 1, "y": 2}',
|
|
tool_call_id=IsStr(),
|
|
),
|
|
args_valid=True,
|
|
),
|
|
DeferredToolRequestsEvent(
|
|
requests=DeferredToolRequests(
|
|
approvals=[
|
|
ToolCallPart(
|
|
tool_name='add_numbers',
|
|
args='{"x": 1, "y": 2}',
|
|
tool_call_id=IsStr(),
|
|
)
|
|
]
|
|
)
|
|
),
|
|
AgentRunResultEvent(
|
|
result=AgentRunResult(
|
|
output=DeferredToolRequests(
|
|
approvals=[
|
|
ToolCallPart(
|
|
tool_name='add_numbers',
|
|
args='{"x": 1, "y": 2}',
|
|
tool_call_id=IsStr(),
|
|
)
|
|
]
|
|
)
|
|
)
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
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',
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _TransportErrorStream(models.StreamedResponse):
|
|
"""Streams one text delta, then raises `error` as an unwrapped transport error would."""
|
|
|
|
error: Exception
|
|
|
|
async def _get_event_iterator(self) -> AsyncIterator[Any]:
|
|
for event in self._parts_manager.handle_text_delta(vendor_part_id=0, content='x'):
|
|
yield event
|
|
raise self.error
|
|
|
|
async def close_stream(self) -> None:
|
|
pass
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return 'httpx2'
|
|
|
|
@property
|
|
def provider_name(self) -> str:
|
|
return 'httpx2'
|
|
|
|
@property
|
|
def provider_url(self) -> str | None:
|
|
return None
|
|
|
|
@property
|
|
def timestamp(self) -> _datetime:
|
|
return _datetime.now(tz=datetime.UTC)
|
|
|
|
|
|
async def test_stream_cancel_guard_suppresses_httpx2_transport_error():
|
|
stream = _TransportErrorStream(models.ModelRequestParameters(), httpx2.StreamClosed())
|
|
iterator = stream.__aiter__()
|
|
await iterator.__anext__()
|
|
await stream.cancel()
|
|
async for _ in iterator:
|
|
pass
|
|
|
|
assert stream.get().state == 'interrupted'
|
|
|
|
|
|
async def test_stream_cancel_guard_reraises_transport_error_when_not_cancelled():
|
|
"""A transport error the adapter didn't wrap still propagates when the stream wasn't cancelled."""
|
|
stream = _TransportErrorStream(models.ModelRequestParameters(), httpx2.ReadError('connection reset'))
|
|
with pytest.raises(httpx2.ReadError):
|
|
async for _ in stream:
|
|
pass
|
|
|
|
|
|
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_testmodel_stream_cancel_reports_interrupted():
|
|
"""Cancelling a `TestModel` sub-stream mid-iteration simulates the transport tear-down and reports interrupted.
|
|
|
|
Driven directly against `model.request_stream` (not the continuation composite, which tears segments
|
|
down via `close_stream` rather than `cancel`) so the stream's own `cancel()` makes the next chunk pull
|
|
raise `_StreamCancelled`, which the cancel-guard suppresses, leaving `get()` reporting `'interrupted'`.
|
|
"""
|
|
model = TestModel(custom_output_text='hello world')
|
|
params = models.ModelRequestParameters()
|
|
|
|
async with model.request_stream([ModelRequest(parts=[UserPromptPart('go')])], None, params) as stream:
|
|
iterator = stream.__aiter__()
|
|
await iterator.__anext__()
|
|
await stream.cancel()
|
|
async for _ in iterator: # the next pull raises `_StreamCancelled`, suppressed by the guard
|
|
pass
|
|
|
|
assert stream.get().state == 'interrupted'
|
|
|
|
|
|
async def test_stream_cancel_with_natural_drain_reports_interrupted():
|
|
"""A `cancel()` on a stream with no live connection still drains naturally but reports interrupted.
|
|
|
|
Mirrors a local model whose `close_stream()` has nothing to tear down: iteration reaches a natural
|
|
`StopAsyncIteration`, so the cancel-guard's else-branch runs but `_cancelled` keeps `_finished` unset,
|
|
and `get()` reports `'interrupted'` rather than `'complete'`.
|
|
"""
|
|
|
|
@dataclass
|
|
class _NaturalDrainStream(models.StreamedResponse):
|
|
async def _get_event_iterator(self) -> AsyncIterator[Any]:
|
|
for _ in range(2):
|
|
for event in self._parts_manager.handle_text_delta(vendor_part_id=0, content='x'):
|
|
yield event
|
|
|
|
async def close_stream(self) -> None:
|
|
pass # no live connection to tear down
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return 'drain'
|
|
|
|
@property
|
|
def provider_name(self) -> str | None:
|
|
return 'drain'
|
|
|
|
@property
|
|
def provider_url(self) -> str | None:
|
|
return None
|
|
|
|
@property
|
|
def timestamp(self) -> _datetime:
|
|
return _datetime.now(tz=datetime.UTC)
|
|
|
|
stream = _NaturalDrainStream(models.ModelRequestParameters())
|
|
iterator = stream.__aiter__()
|
|
await iterator.__anext__()
|
|
await stream.cancel()
|
|
async for _ in iterator: # drains to a natural completion while cancelled
|
|
pass
|
|
|
|
assert stream.get().state == 'interrupted'
|
|
|
|
|
|
async def test_stream_cancel_outranks_incomplete_state_hint():
|
|
"""A cancelled stream reports `'interrupted'` even when it stamped `state='incomplete'` mid-iteration.
|
|
|
|
OpenAI Responses stamps `state='incomplete'` on every `in_progress` event. That in-flight hint must
|
|
not outrank an explicit `cancel()` in `get()`, or a cancelled foreground stream would report
|
|
`'incomplete'` instead of `'interrupted'` — a regression of the cancellation-state feature that a VCR
|
|
test can't catch, since it hinges on `get()`'s internal state precedence rather than the request body.
|
|
"""
|
|
|
|
@dataclass
|
|
class _InProgressStream(models.StreamedResponse):
|
|
async def _get_event_iterator(self) -> AsyncIterator[Any]:
|
|
for _ in range(2):
|
|
self.state = 'incomplete' # mirror OpenAI Responses stamping on each `in_progress` event
|
|
for event in self._parts_manager.handle_text_delta(vendor_part_id=0, content='x'):
|
|
yield event
|
|
|
|
async def close_stream(self) -> None:
|
|
pass
|
|
|
|
@property
|
|
def model_name(self) -> str:
|
|
return 'in-progress'
|
|
|
|
@property
|
|
def provider_name(self) -> str | None:
|
|
return 'in-progress'
|
|
|
|
@property
|
|
def provider_url(self) -> str | None:
|
|
return None
|
|
|
|
@property
|
|
def timestamp(self) -> _datetime:
|
|
return _datetime.now(tz=datetime.UTC)
|
|
|
|
stream = _InProgressStream(models.ModelRequestParameters())
|
|
iterator = stream.__aiter__()
|
|
await iterator.__anext__()
|
|
await stream.cancel()
|
|
async for _ in iterator:
|
|
pass
|
|
|
|
assert stream.get().state == 'interrupted'
|
|
|
|
|
|
async def test_completed_streamed_response_cancel_noop():
|
|
response = ModelResponse(parts=[TextPart(content='done')], model_name='test')
|
|
streamed_response = CompletedStreamedResponse(response, model_request_parameters=models.ModelRequestParameters())
|
|
|
|
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_completed_streamed_response_metadata():
|
|
"""`CompletedStreamedResponse` forwards `model_name`/`provider_name`/`provider_url`/`timestamp`/`usage` to the response."""
|
|
ts = datetime.datetime(2026, 1, 15, 12, 0, 0, tzinfo=datetime.UTC)
|
|
response = ModelResponse(
|
|
parts=[TextPart(content='done')],
|
|
model_name='test-model',
|
|
provider_name='test-provider',
|
|
provider_url='https://test.example.com',
|
|
timestamp=ts,
|
|
usage=RequestUsage(input_tokens=10, output_tokens=20),
|
|
)
|
|
streamed_response = CompletedStreamedResponse(response, model_request_parameters=models.ModelRequestParameters())
|
|
|
|
assert streamed_response.model_name == 'test-model'
|
|
assert streamed_response.provider_name == 'test-provider'
|
|
assert streamed_response.provider_url == 'https://test.example.com'
|
|
assert streamed_response.timestamp == ts
|
|
assert streamed_response.usage == RequestUsage(input_tokens=10, output_tokens=20)
|
|
|
|
# `model_name` falls back to `''` when the response has none — matches the abstract `str` return type.
|
|
empty = CompletedStreamedResponse(
|
|
ModelResponse(parts=[TextPart(content='hi')]), model_request_parameters=models.ModelRequestParameters()
|
|
)
|
|
assert empty.model_name == ''
|
|
assert empty.provider_name is None
|
|
assert empty.provider_url is None
|
|
|
|
|
|
@pytest.fixture
|
|
def replay_mrp() -> models.ModelRequestParameters:
|
|
return models.ModelRequestParameters(
|
|
function_tools=[],
|
|
native_tools=[],
|
|
output_mode='text',
|
|
allow_text_output=True,
|
|
output_tools=[],
|
|
output_object=None,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def replay_timestamp() -> datetime.datetime:
|
|
return datetime.datetime(2026, 1, 15, 12, 0, 0, tzinfo=datetime.UTC)
|
|
|
|
|
|
@pytest.fixture
|
|
def replay_response(replay_timestamp: datetime.datetime) -> ModelResponse:
|
|
return ModelResponse(
|
|
parts=[
|
|
TextPart(content='Hello world'),
|
|
ThinkingPart(content='Let me think about this'),
|
|
ToolCallPart(tool_name='get_weather', args='{"city": "London"}', tool_call_id='call_1'),
|
|
],
|
|
model_name='test-model',
|
|
provider_name='test-provider',
|
|
provider_url='https://test.example.com',
|
|
timestamp=replay_timestamp,
|
|
usage=RequestUsage(input_tokens=10, output_tokens=20),
|
|
)
|
|
|
|
|
|
async def test_replay_streamed_response_cancel_noop(
|
|
replay_mrp: models.ModelRequestParameters,
|
|
) -> None:
|
|
"""`cancel()` on a replayed `CompletedStreamedResponse` is a no-op — events are replayed locally, no live connection to close."""
|
|
response = ModelResponse(parts=[TextPart(content='done')], model_name='test')
|
|
streamed_response = CompletedStreamedResponse(response, model_request_parameters=replay_mrp, replay_events=True)
|
|
|
|
await streamed_response.cancel()
|
|
await streamed_response.cancel()
|
|
|
|
assert streamed_response.cancelled
|
|
assert streamed_response.get() is response
|
|
|
|
|
|
async def test_replay_streamed_response_events(
|
|
replay_mrp: models.ModelRequestParameters, replay_response: ModelResponse
|
|
) -> None:
|
|
"""Verify that `CompletedStreamedResponse(replay_events=True)` replays all part types as stream events.
|
|
|
|
Each part is delivered as a single `PartStartEvent` carrying its full content, like a real
|
|
stream that emits the part in one chunk — deliberately with no follow-up `PartDeltaEvent`, so
|
|
reducing the stream reconstructs the response exactly instead of doubling the content.
|
|
"""
|
|
stream = CompletedStreamedResponse(replay_response, model_request_parameters=replay_mrp, replay_events=True)
|
|
events = [event async for event in stream]
|
|
|
|
assert events == snapshot(
|
|
[
|
|
PartStartEvent(index=0, part=TextPart(content='Hello world')),
|
|
FinalResultEvent(tool_name=None, tool_call_id=None),
|
|
PartEndEvent(
|
|
index=0,
|
|
part=TextPart(content='Hello world'),
|
|
next_part_kind='thinking',
|
|
),
|
|
PartStartEvent(index=1, part=ThinkingPart(content='Let me think about this'), previous_part_kind='text'),
|
|
PartEndEvent(
|
|
index=1,
|
|
part=ThinkingPart(content='Let me think about this'),
|
|
next_part_kind='tool-call',
|
|
),
|
|
PartStartEvent(
|
|
index=2,
|
|
part=ToolCallPart(tool_name='get_weather', args='{"city": "London"}', tool_call_id='call_1'),
|
|
previous_part_kind='thinking',
|
|
),
|
|
PartEndEvent(
|
|
index=2,
|
|
part=ToolCallPart(
|
|
tool_name='get_weather',
|
|
args='{"city": "London"}',
|
|
tool_call_id='call_1',
|
|
),
|
|
),
|
|
]
|
|
)
|
|
|
|
# Round-trip: a standard reducer applies `PartStartEvent.part` as the initial state and then
|
|
# each `PartDeltaEvent`. Synthesis must emit no deltas — a full-content start followed by a
|
|
# full-content delta would double the text/thinking/args — so reducing the starts alone must
|
|
# reconstruct `response.parts` exactly.
|
|
assert not any(isinstance(event, PartDeltaEvent) for event in events)
|
|
reduced = {event.index: event.part for event in events if isinstance(event, PartStartEvent)}
|
|
assert [reduced[index] for index in sorted(reduced)] == replay_response.parts
|
|
|
|
|
|
def test_completed_streamed_response_deprecated_positional_init(
|
|
replay_mrp: models.ModelRequestParameters, replay_response: ModelResponse
|
|
) -> None:
|
|
"""The pre-v3 positional `(model_request_parameters, response)` order still works, with a warning.
|
|
|
|
`CompletedStreamedResponse` was public under `pydantic_ai.models.wrapper` with that
|
|
signature before it moved to `pydantic_ai.models`.
|
|
"""
|
|
with pytest.warns(PydanticAIDeprecationWarning, match='pass the response first'):
|
|
stream = CompletedStreamedResponse(replay_mrp, replay_response) # pyright: ignore[reportDeprecated]
|
|
assert stream.get() is replay_response
|
|
assert stream.model_request_parameters is replay_mrp
|
|
|
|
|
|
async def test_completed_streamed_response_replay_events(
|
|
replay_mrp: models.ModelRequestParameters, replay_response: ModelResponse
|
|
) -> None:
|
|
"""`replay_events` is the primary keyword and accepts synthesized or captured events."""
|
|
stream = CompletedStreamedResponse(
|
|
model_request_parameters=replay_mrp,
|
|
response=replay_response,
|
|
replay_events=True,
|
|
)
|
|
replayed_events = [event async for event in stream]
|
|
assert replayed_events
|
|
|
|
buffered_stream = CompletedStreamedResponse(
|
|
model_request_parameters=replay_mrp,
|
|
response=replay_response,
|
|
replay_events=replayed_events,
|
|
)
|
|
assert [event async for event in buffered_stream] == replayed_events
|
|
|
|
|
|
@pytest.mark.parametrize('read_each', [False, True])
|
|
@pytest.mark.parametrize('details', [None, {}, {'initial': 1}], ids=['no-details', 'empty-details', 'details'])
|
|
async def test_replay_text_preserves_snapshots(read_each: bool, details: dict[str, Any] | None) -> None:
|
|
"""Replay preserves live reads and independent snapshots without a model request."""
|
|
start = TextPart('a', id='part', provider_name='first', provider_details=details)
|
|
response = ModelResponse(
|
|
parts=[TextPart('abc', id='part', provider_name='second', provider_details={**(details or {}), 'added': 2})]
|
|
)
|
|
events: list[ModelResponseStreamEvent] = [
|
|
PartStartEvent(index=7, part=start),
|
|
PartDeltaEvent(index=7, delta=TextPartDelta('b')),
|
|
PartDeltaEvent(index=7, delta=TextPartDelta('c')),
|
|
PartDeltaEvent(index=7, delta=TextPartDelta('', provider_name='second', provider_details={'added': 2})),
|
|
]
|
|
stream = CompletedStreamedResponse(
|
|
response, model_request_parameters=models.ModelRequestParameters(), replay_events=events
|
|
)
|
|
observed: list[ModelResponseStreamEvent] = []
|
|
snapshots: list[ModelResponse] = []
|
|
async for event in stream:
|
|
observed.append(event)
|
|
if event is events[1]:
|
|
start.provider_details = {'changed_after_delta': True}
|
|
if read_each:
|
|
snapshots.append(stream.get())
|
|
assert all(actual is expected for actual, expected in zip(observed, events, strict=True))
|
|
assert stream.get() == response
|
|
assert start.content == 'a'
|
|
if read_each:
|
|
assert [snapshot.text for snapshot in snapshots] == ['a', 'ab', 'abc', 'abc']
|
|
part = snapshots[1].parts[0]
|
|
assert isinstance(part, TextPart)
|
|
part.provider_details = {'changed_after_replay': True}
|
|
assert stream.get() == response
|
|
|
|
|
|
@pytest.mark.parametrize('custom_part', [False, True])
|
|
async def test_replay_text_preserves_subclasses(custom_part: bool) -> None:
|
|
"""Replay preserves user-defined part initialization and delta application."""
|
|
seen_lengths: list[int] = []
|
|
|
|
@dataclass
|
|
class CheckedPart(TextPart):
|
|
def __post_init__(self) -> None:
|
|
seen_lengths.append(len(self.content))
|
|
|
|
class CheckedDelta(TextPartDelta):
|
|
def apply(self, part: ModelResponsePart) -> TextPart:
|
|
assert isinstance(part, TextPart)
|
|
assert part.content == 'ab'
|
|
return replace(part, content=part.content.upper() + self.content_delta)
|
|
|
|
start = CheckedPart('a') if custom_part else TextPart('a')
|
|
expected = CheckedPart('abc') if custom_part else TextPart('ABc')
|
|
seen_lengths.clear()
|
|
events: list[ModelResponseStreamEvent] = [
|
|
PartStartEvent(index=0, part=start),
|
|
PartDeltaEvent(index=0, delta=TextPartDelta('b')),
|
|
PartDeltaEvent(index=0, delta=TextPartDelta('c') if custom_part else CheckedDelta('c')),
|
|
]
|
|
response = ModelResponse(parts=[expected])
|
|
stream = CompletedStreamedResponse(
|
|
response, model_request_parameters=models.ModelRequestParameters(), replay_events=events
|
|
)
|
|
async for _ in stream:
|
|
pass
|
|
assert stream.get() == response
|
|
assert start.content == 'a'
|
|
assert seen_lengths == ([2, 3] if custom_part else [])
|
|
|
|
|
|
async def test_replay_text_requires_start() -> None:
|
|
"""Replaying a malformed event list raises instead of inventing a missing part."""
|
|
stream = CompletedStreamedResponse(
|
|
ModelResponse(parts=[]),
|
|
model_request_parameters=models.ModelRequestParameters(),
|
|
replay_events=[PartDeltaEvent(index=7, delta=TextPartDelta('text'))],
|
|
)
|
|
with pytest.raises(AssertionError):
|
|
async for _ in stream:
|
|
pass
|
|
|
|
|
|
async def test_replay_text_cancel_preserves_buffered_content() -> None:
|
|
"""Canceling replay preserves partial text without a live transport."""
|
|
events: list[ModelResponseStreamEvent] = [
|
|
PartStartEvent(index=0, part=TextPart('a')),
|
|
PartDeltaEvent(index=0, delta=TextPartDelta('b')),
|
|
PartDeltaEvent(index=0, delta=TextPartDelta('c')),
|
|
]
|
|
stream = CompletedStreamedResponse(
|
|
ModelResponse(parts=[TextPart('abc')]),
|
|
model_request_parameters=models.ModelRequestParameters(),
|
|
replay_events=events,
|
|
)
|
|
iterator = aiter(stream)
|
|
await anext(iterator)
|
|
await anext(iterator)
|
|
await stream.cancel()
|
|
await stream.cancel()
|
|
assert stream.get().text == 'ab'
|
|
assert stream.get().state == 'interrupted'
|
|
assert [event async for event in iterator] == events[2:]
|
|
assert stream.get().text == 'abc'
|
|
assert stream.get().state == 'complete'
|
|
|
|
|
|
@pytest.mark.parametrize('events', [True, [PartStartEvent(index=0, part=TextPart(content='hi'))]])
|
|
async def test_completed_streamed_response_deprecated_events(
|
|
replay_mrp: models.ModelRequestParameters,
|
|
replay_response: ModelResponse,
|
|
events: bool | list[ModelResponseStreamEvent],
|
|
) -> None:
|
|
"""`events` remains as a deprecated alias for both supported value forms."""
|
|
with pytest.warns(PydanticAIDeprecationWarning, match='`events`'):
|
|
stream = CompletedStreamedResponse( # pyright: ignore[reportDeprecated]
|
|
replay_response,
|
|
model_request_parameters=replay_mrp,
|
|
events=events,
|
|
)
|
|
assert [event async for event in stream]
|
|
|
|
|
|
async def test_completed_streamed_response_replay_events_wins_over_deprecated_events(
|
|
replay_mrp: models.ModelRequestParameters, replay_response: ModelResponse
|
|
) -> None:
|
|
"""An explicit `replay_events` beats the deprecated `events` alias when both are passed."""
|
|
with pytest.warns(PydanticAIDeprecationWarning, match='`events`'):
|
|
stream = CompletedStreamedResponse( # pyright: ignore[reportCallIssue]
|
|
replay_response,
|
|
model_request_parameters=replay_mrp,
|
|
replay_events=True,
|
|
events=False,
|
|
)
|
|
assert [event async for event in stream]
|
|
|
|
|
|
def test_completed_streamed_response_deprecated_import_path() -> None:
|
|
"""The pre-v3 `pydantic_ai.models.wrapper` import path still works, with a warning.
|
|
|
|
The import is inside the test because triggering the module `__getattr__` shim *is*
|
|
the behavior under test.
|
|
"""
|
|
with pytest.warns(PydanticAIDeprecationWarning, match='moved'):
|
|
from pydantic_ai.models.wrapper import CompletedStreamedResponse as OldCompletedStreamedResponse
|
|
assert OldCompletedStreamedResponse is CompletedStreamedResponse
|
|
|
|
|
|
async def test_replay_streamed_response_buffered_aiter_idempotent(
|
|
replay_mrp: models.ModelRequestParameters, replay_response: ModelResponse
|
|
) -> None:
|
|
"""`__aiter__` is idempotent on the buffered path — second call returns the same iterator."""
|
|
buffered: list[ModelResponseStreamEvent] = [PartStartEvent(index=0, part=TextPart(content='hi'))]
|
|
stream = CompletedStreamedResponse(replay_response, model_request_parameters=replay_mrp, replay_events=buffered)
|
|
first = stream.__aiter__()
|
|
second = stream.__aiter__()
|
|
assert first is second
|
|
|
|
|
|
async def test_replay_streamed_response_get(
|
|
replay_mrp: models.ModelRequestParameters, replay_response: ModelResponse
|
|
) -> None:
|
|
"""`get()` returns the wrapped `ModelResponse`."""
|
|
stream = CompletedStreamedResponse(replay_response, model_request_parameters=replay_mrp, replay_events=True)
|
|
assert stream.get() is replay_response
|
|
|
|
|
|
async def test_replay_streamed_response_metadata(
|
|
replay_mrp: models.ModelRequestParameters,
|
|
replay_response: ModelResponse,
|
|
replay_timestamp: datetime.datetime,
|
|
) -> None:
|
|
"""Metadata properties delegate to the underlying response."""
|
|
stream = CompletedStreamedResponse(replay_response, model_request_parameters=replay_mrp, replay_events=True)
|
|
assert stream.usage == RequestUsage(input_tokens=10, output_tokens=20)
|
|
assert stream.model_name == 'test-model'
|
|
assert stream.provider_name == 'test-provider'
|
|
assert stream.provider_url == 'https://test.example.com'
|
|
assert stream.timestamp == replay_timestamp
|
|
|
|
|
|
async def test_replay_streamed_response_metadata_defaults(
|
|
replay_mrp: models.ModelRequestParameters,
|
|
) -> None:
|
|
"""When the response lacks provider info, `model_name` defaults to `''` and `provider_name`/`provider_url` to `None`."""
|
|
response = ModelResponse(parts=[TextPart(content='hi')])
|
|
stream = CompletedStreamedResponse(response, model_request_parameters=replay_mrp, replay_events=True)
|
|
assert stream.model_name == ''
|
|
assert stream.provider_name is None
|
|
assert stream.provider_url is None
|
|
|
|
|
|
async def test_replay_streamed_response_empty_text_part(
|
|
replay_mrp: models.ModelRequestParameters,
|
|
) -> None:
|
|
"""An empty `TextPart` emits a `PartStartEvent` but no `PartDeltaEvent`."""
|
|
response = ModelResponse(parts=[TextPart(content='')])
|
|
stream = CompletedStreamedResponse(response, model_request_parameters=replay_mrp, replay_events=True)
|
|
events = [event async for event in stream]
|
|
|
|
# Empty text: PartStartEvent, FinalResultEvent (from allow_text_output), PartEndEvent
|
|
assert isinstance(events[0], PartStartEvent)
|
|
assert isinstance(events[1], FinalResultEvent)
|
|
assert isinstance(events[2], PartEndEvent)
|
|
|
|
|
|
async def test_replay_streamed_response_empty_thinking_part(
|
|
replay_mrp: models.ModelRequestParameters,
|
|
) -> None:
|
|
"""An empty `ThinkingPart` emits a `PartStartEvent` but no `PartDeltaEvent`."""
|
|
response = ModelResponse(parts=[ThinkingPart(content='')])
|
|
stream = CompletedStreamedResponse(response, model_request_parameters=replay_mrp, replay_events=True)
|
|
events = [event async for event in stream]
|
|
|
|
assert len(events) == 2
|
|
assert isinstance(events[0], PartStartEvent)
|
|
assert isinstance(events[1], PartEndEvent)
|
|
|
|
|
|
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
|