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

13129 lines
490 KiB
Python

import asyncio
import json
import re
import sys
from collections import defaultdict
from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Callable
from contextlib import asynccontextmanager, nullcontext
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union
import pytest
from dirty_equals import IsJson
from pydantic import BaseModel, TypeAdapter, field_validator
from pydantic_core import ErrorDetails, to_json
from typing_extensions import Self
from pydantic_ai import (
AbstractToolset,
Agent,
AudioUrl,
BinaryContent,
BinaryImage,
CallDeferred,
CombinedToolset,
DocumentUrl,
ExternalToolset,
FilePart,
FunctionToolset,
ImageUrl,
IncompleteToolCall,
ModelMessage,
ModelMessagesTypeAdapter,
ModelProfile,
ModelRequest,
ModelResponse,
ModelResponsePart,
ModelRetry,
PrefixedToolset,
RequestUsage,
RetryPromptPart,
RunContext,
SystemPromptPart,
TextPart,
ThinkingPart,
ToolCallPart,
ToolReturn,
ToolReturnPart,
UnexpectedModelBehavior,
UserError,
UserPromptPart,
VideoUrl,
capture_run_messages,
)
from pydantic_ai._output import (
NativeOutput,
NativeOutputSchema,
OutputSpec,
PromptedOutput,
TextOutput,
)
from pydantic_ai.agent import AgentRunResult, WrapperAgent
from pydantic_ai.capabilities import AbstractCapability, NativeTool, PrepareOutputTools, PrepareTools, WrapRunHandler
from pydantic_ai.exceptions import ContentFilterError
from pydantic_ai.messages import AgentStreamEvent, FunctionToolResultEvent, ModelResponseStreamEvent
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.models.wrapper import WrapperModel
from pydantic_ai.native_tools import (
CodeExecutionTool,
MCPServerTool,
WebSearchTool,
WebSearchUserLocation,
)
from pydantic_ai.output import OutputObjectDefinition, StructuredDict, ToolOutput
from pydantic_ai.providers import Provider
from pydantic_ai.result import RunUsage
from pydantic_ai.settings import ModelSettings
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolDefinition, ToolDenied
if TYPE_CHECKING:
from pydantic_ai.providers.alibaba import AlibabaProvider
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.azure import AzureProvider
from pydantic_ai.providers.cerebras import CerebrasProvider
from pydantic_ai.providers.cohere import CohereProvider
from pydantic_ai.providers.deepseek import DeepSeekProvider
from pydantic_ai.providers.fireworks import FireworksProvider
from pydantic_ai.providers.github import GitHubProvider
from pydantic_ai.providers.google import GoogleProvider
from pydantic_ai.providers.groq import GroqProvider
from pydantic_ai.providers.heroku import HerokuProvider
from pydantic_ai.providers.litellm import LiteLLMProvider
from pydantic_ai.providers.mistral import MistralProvider
from pydantic_ai.providers.moonshotai import MoonshotAIProvider
from pydantic_ai.providers.nebius import NebiusProvider
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.providers.openrouter import OpenRouterProvider
from pydantic_ai.providers.ovhcloud import OVHcloudProvider
from pydantic_ai.providers.sambanova import SambaNovaProvider
from pydantic_ai.providers.together import TogetherProvider
from pydantic_ai.providers.vercel import VercelProvider
else:
try:
from pydantic_ai.providers.alibaba import AlibabaProvider
from pydantic_ai.providers.azure import AzureProvider
from pydantic_ai.providers.cerebras import CerebrasProvider
from pydantic_ai.providers.deepseek import DeepSeekProvider
from pydantic_ai.providers.fireworks import FireworksProvider
from pydantic_ai.providers.github import GitHubProvider
from pydantic_ai.providers.heroku import HerokuProvider
from pydantic_ai.providers.moonshotai import MoonshotAIProvider
from pydantic_ai.providers.nebius import NebiusProvider
from pydantic_ai.providers.ollama import OllamaProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.providers.openrouter import OpenRouterProvider
from pydantic_ai.providers.ovhcloud import OVHcloudProvider
from pydantic_ai.providers.sambanova import SambaNovaProvider
from pydantic_ai.providers.together import TogetherProvider
from pydantic_ai.providers.vercel import VercelProvider
except ImportError: # pragma: lax no cover
AlibabaProvider = AzureProvider = CerebrasProvider = DeepSeekProvider = None # type: ignore
FireworksProvider = GitHubProvider = HerokuProvider = None # type: ignore
MoonshotAIProvider = NebiusProvider = OllamaProvider = OpenAIProvider = None # type: ignore
OpenRouterProvider = OVHcloudProvider = SambaNovaProvider = None # type: ignore
TogetherProvider = VercelProvider = None # type: ignore
try:
from pydantic_ai.providers.anthropic import AnthropicProvider
except ImportError: # pragma: lax no cover
AnthropicProvider = None
try:
from pydantic_ai.providers.cohere import CohereProvider
except ImportError: # pragma: lax no cover
CohereProvider = None
try:
from pydantic_ai.providers.google import GoogleProvider
except ImportError: # pragma: lax no cover
GoogleProvider = None
try:
from pydantic_ai.providers.groq import GroqProvider
except ImportError: # pragma: lax no cover
GroqProvider = None
try:
from pydantic_ai.providers.litellm import LiteLLMProvider
except ImportError: # pragma: lax no cover
LiteLLMProvider = None
try:
from pydantic_ai.providers.mistral import MistralProvider
except ImportError: # pragma: lax no cover
MistralProvider = None
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsInstance, IsNow, IsStr, TestEnv, message, message_part
pytestmark = pytest.mark.anyio
requires_openai = pytest.mark.skipif(OpenAIProvider is None, reason='openai not installed') # pyright: ignore[reportUnnecessaryComparison]
requires_anthropic = pytest.mark.skipif(AnthropicProvider is None, reason='anthropic not installed') # pyright: ignore[reportUnnecessaryComparison]
requires_cohere = pytest.mark.skipif(CohereProvider is None, reason='cohere not installed') # pyright: ignore[reportUnnecessaryComparison]
requires_google = pytest.mark.skipif(GoogleProvider is None, reason='google-genai not installed') # pyright: ignore[reportUnnecessaryComparison]
requires_groq = pytest.mark.skipif(GroqProvider is None, reason='groq not installed') # pyright: ignore[reportUnnecessaryComparison]
requires_litellm = pytest.mark.skipif(LiteLLMProvider is None, reason='litellm not installed') # pyright: ignore[reportUnnecessaryComparison]
requires_mistral = pytest.mark.skipif(MistralProvider is None, reason='mistral not installed') # pyright: ignore[reportUnnecessaryComparison]
# Wall-clock guard for the readiness `Event.wait()`s in the cancellation tests below. The events are set
# near-instantly; the timeout only exists to fail fast on a genuine hang, since no global pytest timeout is
# configured. `timeout=1` was too tight under heavy xdist load and flaked (#5399), so allow generous headroom.
READINESS_WAIT_TIMEOUT = 10
def test_result_tuple():
def return_tuple(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"response": ["foo", "bar"]}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_tuple), output_type=tuple[str, str])
result = agent.run_sync('Hello')
assert isinstance(result.run_id, str)
assert result.output == ('foo', 'bar')
assert result.response == snapshot(
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"response": ["foo", "bar"]}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='function:return_tuple:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
)
)
class Person(BaseModel):
name: str
# Generic classes for testing tool name sanitization with generic types
T = TypeVar('T')
class ResultGeneric(BaseModel, Generic[T]):
"""A generic result class."""
value: T
success: bool
class StringData(BaseModel):
text: str
def test_result_list_of_models_with_stringified_response():
def return_list(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
# Simulate providers that return the nested payload as a JSON string under "response"
args_json = json.dumps(
{
'response': json.dumps(
[
{'name': 'John Doe'},
{'name': 'Jane Smith'},
]
)
}
)
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_list), output_type=list[Person])
result = agent.run_sync('Hello')
assert result.output == snapshot(
[
Person(name='John Doe'),
Person(name='Jane Smith'),
]
)
class Foo(BaseModel):
a: int
b: str
def test_result_pydantic_model():
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"a": 1, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=Foo)
result = agent.run_sync('Hello')
assert isinstance(result.output, Foo)
assert result.output.model_dump() == {'a': 1, 'b': 'foo'}
def test_result_pydantic_model_retry():
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if len(messages) == 1:
args_json = '{"a": "wrong", "b": "foo"}'
else:
args_json = '{"a": 42, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=Foo)
assert agent.name is None
result = agent.run_sync('Hello')
assert agent.name == 'agent'
assert isinstance(result.output, Foo)
assert result.output.model_dump() == {'a': 42, 'b': 'foo'}
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"a": "wrong", "b": "foo"}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='final_result',
content=[
ErrorDetails(
type='int_parsing',
loc=('a',),
msg='Input should be a valid integer, unable to parse string as an integer',
input='wrong',
)
],
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"a": 42, "b": "foo"}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=89, output_tokens=14),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.all_messages_json().startswith(b'[{"parts":[{"content":"Hello",')
def test_result_pydantic_model_validation_error():
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if len(messages) == 1:
args_json = '{"a": 1, "b": "foo"}'
else:
args_json = '{"a": 1, "b": "bar"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
class Bar(BaseModel):
a: int
b: str
@field_validator('b')
def check_b(cls, v: str) -> str:
if v == 'foo':
raise ValueError('must not be foo')
return v
agent = Agent(FunctionModel(return_model), output_type=Bar)
result = agent.run_sync('Hello')
assert isinstance(result.output, Bar)
assert result.output.model_dump() == snapshot({'a': 1, 'b': 'bar'})
messages_part_kinds = [(m.kind, [p.part_kind for p in m.parts]) for m in result.all_messages()]
assert messages_part_kinds == snapshot(
[
('request', ['user-prompt']),
('response', ['tool-call']),
('request', ['retry-prompt']),
('response', ['tool-call']),
('request', ['tool-return']),
]
)
retry_prompt = message_part(result.all_messages(), RetryPromptPart, message_index=2)
assert retry_prompt.model_response() == snapshot("""\
1 validation error:
```json
[
{
"type": "value_error",
"loc": [
"b"
],
"msg": "Value error, must not be foo",
"input": "foo"
}
]
```
Fix the errors and try again.\
""")
def test_output_validator():
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if len(messages) == 1:
args_json = '{"a": 41, "b": "foo"}'
else:
args_json = '{"a": 42, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=Foo)
@agent.output_validator
def validate_output(ctx: RunContext, o: Foo) -> Foo:
assert ctx.tool_name == 'final_result'
if o.a == 42:
return o
else:
raise ModelRetry('"a" should be 42')
result = agent.run_sync('Hello')
assert isinstance(result.output, Foo)
assert result.output.model_dump() == {'a': 42, 'b': 'foo'}
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"a": 41, "b": "foo"}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=7),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='"a" should be 42',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"a": 42, "b": "foo"}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=63, output_tokens=14),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_output_validator_retries():
"""Test that ctx.retry and ctx.max_retries are correctly tracked in RunContext for output validators."""
retries_log: list[int] = []
max_retries_log: list[int] = []
target_retries = 3
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
# Always return the same value, let the validator control retries
args_json = '{"a": 1, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=Foo, retries={'output': target_retries})
@agent.output_validator
def validate_output(ctx: RunContext, o: Foo) -> Foo:
retries_log.append(ctx.retry)
max_retries_log.append(ctx.max_retries)
# Succeed on the last retry
if ctx.retry == target_retries:
return o
else:
raise ModelRetry(f'Retry {ctx.retry}')
result = agent.run_sync('Hello')
assert isinstance(result.output, Foo)
# Should have been called target_retries + 1 times (0, 1, 2, 3)
assert retries_log == [0, 1, 2, 3]
assert max_retries_log == [target_retries] * (target_retries + 1)
def test_output_function_retries():
"""Test that ctx.retry and ctx.max_retries are correctly tracked in RunContext for output functions."""
retries_log: list[int] = []
max_retries_log: list[int] = []
target_retries = 3
def get_weather(ctx: RunContext, text: str) -> str:
retries_log.append(ctx.retry)
max_retries_log.append(ctx.max_retries)
if ctx.retry == target_retries:
return f'Weather: {text}'
else:
raise ModelRetry(f'Retry {ctx.retry}')
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='sunny')])
agent = Agent(FunctionModel(return_model), output_type=TextOutput(get_weather), retries={'output': target_retries})
result = agent.run_sync('Hello')
assert result.output == 'Weather: sunny'
# Should have been called target_retries + 1 times (0, 1, 2, 3)
assert retries_log == [0, 1, 2, 3]
assert max_retries_log == [target_retries] * (target_retries + 1)
def test_tool_output_function_retries():
"""Test that ctx.retry and ctx.max_retries are correctly tracked in RunContext for tool output functions."""
retries_log: list[int] = []
max_retries_log: list[int] = []
target_retries = 3
def get_weather(ctx: RunContext, city: str) -> str:
retries_log.append(ctx.retry)
max_retries_log.append(ctx.max_retries)
if ctx.retry == target_retries:
return f'Weather in {city}'
else:
raise ModelRetry(f'Retry {ctx.retry}')
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=get_weather, retries={'output': target_retries})
result = agent.run_sync('Hello')
assert result.output == 'Weather in Mexico City'
# Should have been called target_retries + 1 times (0, 1, 2, 3)
assert retries_log == [0, 1, 2, 3]
assert max_retries_log == [target_retries] * (target_retries + 1)
def test_tool_output_max_retries_overrides_agent_retries():
"""ToolOutput.max_retries takes priority over Agent retries. Regression test for #4678."""
retries_log: list[int] = []
max_retries_log: list[int] = []
target_retries = 5
def get_weather(ctx: RunContext, city: str) -> str:
retries_log.append(ctx.retry)
max_retries_log.append(ctx.max_retries)
if ctx.retry < target_retries:
raise ModelRetry(f'Retry {ctx.retry}')
return f'Weather in {city}'
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
# Agent retries=2 (lower than ToolOutput), ToolOutput max_retries=5
# The ToolOutput value should take priority, allowing 5 retries
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(get_weather, max_retries=target_retries),
retries={'tools': 2, 'output': 2},
)
result = agent.run_sync('Hello')
assert result.output == 'Weather in Mexico City'
assert retries_log == [0, 1, 2, 3, 4, 5]
assert max_retries_log == [target_retries] * (target_retries + 1)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=6),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry 0',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=60, output_tokens=12),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry 1',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=69, output_tokens=18),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry 2',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=78, output_tokens=24),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry 3',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=87, output_tokens=30),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry 4',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=96, output_tokens=36),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_tool_output_max_retries_per_tool():
"""Multiple ToolOutputs with different max_retries are tracked independently. Regression test for #4678."""
a_max_retries_seen: list[int] = []
b_max_retries_seen: list[int] = []
def output_a(ctx: RunContext, value: str) -> str:
a_max_retries_seen.append(ctx.max_retries)
if ctx.retry < 3:
raise ModelRetry(f'Retry A {ctx.retry}')
return f'A: {value}'
def output_b(ctx: RunContext, value: str) -> str:
b_max_retries_seen.append(ctx.max_retries)
raise ModelRetry(f'Retry B {ctx.retry}')
tool_names: dict[str, str] = {}
call_count = 0
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
tool_names.update({t.name: t.name for t in info.output_tools})
name_a = next(n for n in tool_names if 'output_a' in n)
name_b = next(n for n in tool_names if 'output_b' in n)
# First call output_b to verify it sees max_retries=1, then switch to output_a
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(name_b, '{"value": "x"}')])
return ModelResponse(parts=[ToolCallPart(name_a, '{"value": "hello"}')])
# output_a gets 3 retries, output_b gets 1 — agent default is 0
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(output_a, max_retries=3),
ToolOutput(output_b, max_retries=1),
],
retries={'tools': 0, 'output': 0},
)
result = agent.run_sync('Hello')
assert result.output == 'A: hello'
# output_b was called once and saw its own max_retries=1
assert b_max_retries_seen == [1]
# output_a was called 4 times (retries 0-3) and saw its own max_retries=3
assert a_max_retries_seen == [3, 3, 3, 3]
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result_output_b',
args='{"value": "x"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry B 0',
tool_name='final_result_output_b',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result_output_a',
args='{"value": "hello"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=61, output_tokens=10),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry A 0',
tool_name='final_result_output_a',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result_output_a',
args='{"value": "hello"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=71, output_tokens=15),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry A 1',
tool_name='final_result_output_a',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result_output_a',
args='{"value": "hello"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=81, output_tokens=20),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Retry A 2',
tool_name='final_result_output_a',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result_output_a',
args='{"value": "hello"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=91, output_tokens=25),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result_output_a',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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_streaming.py::TestPartialOutput` as well
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]] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('Hello world!')])
agent = Agent(FunctionModel(return_model))
@agent.output_validator
def validate_output(ctx: RunContext, output: str) -> str:
call_log.append((output, ctx.partial_output))
return output
result = agent.run_sync('test')
assert result.output == 'Hello world!'
assert call_log == snapshot([('Hello world!', False)])
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]] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool_name = info.output_tools[0].name
args_json = '{"a": 42, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(tool_name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=Foo)
@agent.output_validator
def validate_output(ctx: RunContext, output: Foo) -> Foo:
call_log.append((output, ctx.partial_output))
return output
result = agent.run_sync('test')
assert result.output == Foo(a=42, b='foo')
assert call_log == snapshot([(Foo(a=42, b='foo'), False)])
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()
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('Hello world!')])
agent = Agent(FunctionModel(return_model), output_type=TextOutput(process_output))
result = agent.run_sync('test')
assert result.output == 'HELLO WORLD!'
assert call_log == snapshot([('Hello world!', False)])
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())
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool_name = info.output_tools[0].name
args_json = '{"a": 21, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(tool_name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=process_foo)
result = agent.run_sync('test')
assert result.output == Foo(a=42, b='FOO')
assert call_log == snapshot([(Foo(a=21, b='foo'), False)])
def test_output_function_structured_get_output(self):
"""Test that output functions receive correct value for `partial_output` with sync run."""
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())
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool_name = info.output_tools[0].name
args_json = '{"a": 21, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(tool_name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=ToolOutput(process_foo, name='my_output'))
result = agent.run_sync('test')
assert result.output == Foo(a=42, b='FOO')
assert call_log == snapshot([(Foo(a=21, b='foo'), False)])
def test_output_function_structured_stream_output_only(self):
"""Test that output functions receive correct value for `partial_output` with sync run."""
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())
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool_name = info.output_tools[0].name
args_json = '{"a": 21, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(tool_name, args_json)])
agent = Agent(FunctionModel(return_model), output_type=ToolOutput(process_foo, name='my_output'))
result = agent.run_sync('test')
assert result.output == Foo(a=42, b='FOO')
assert call_log == snapshot([(Foo(a=21, b='foo'), False)])
# NOTE: When changing tests in this class:
# 1. Follow the existing order
# 2. Update tests in `tests/test_streaming.py::TestPartialOutput` as well
def test_plain_response_then_tuple():
call_index = 0
def return_tuple(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_index
assert info.output_tools is not None
call_index += 1
if call_index == 1:
return ModelResponse(parts=[TextPart('hello')])
else:
args_json = '{"response": ["foo", "bar"]}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(return_tuple), output_type=ToolOutput(tuple[str, str]))
result = agent.run_sync('Hello')
assert result.output == ('foo', 'bar')
assert call_index == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='hello')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='function:return_tuple:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please include your response in a tool call.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id=IsStr(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args='{"response": ["foo", "bar"]}', tool_call_id=IsStr())
],
usage=RequestUsage(input_tokens=68, output_tokens=8),
model_name='function:return_tuple:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result._output_tool_name == 'final_result' # pyright: ignore[reportPrivateUsage]
assert result.all_messages(output_tool_return_content='foobar')[-1] == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result', content='foobar', tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
)
)
assert result.all_messages()[-1] == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_output_tool_return_content_str_return():
agent = Agent('test')
result = agent.run_sync('Hello')
assert result.output == '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',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
msg = re.escape('Cannot set output tool return content when the return type is `str`.')
with pytest.raises(ValueError, match=msg):
result.all_messages(output_tool_return_content='foobar')
def test_output_tool_return_content_no_tool():
agent = Agent('test', output_type=int)
result = agent.run_sync('Hello')
assert result.output == 0
result._output_tool_name = 'wrong' # pyright: ignore[reportPrivateUsage]
with pytest.raises(LookupError, match=re.escape("No tool call found with tool name 'wrong'.")):
result.all_messages(output_tool_return_content='foobar')
def test_response_tuple():
m = TestModel()
agent = Agent(m, output_type=tuple[str, str])
result = agent.run_sync('Hello')
assert result.output == snapshot(('a', 'a'))
assert m.last_model_request_parameters is not None
assert m.last_model_request_parameters.output_mode == 'tool'
assert m.last_model_request_parameters.function_tools == snapshot([])
assert m.last_model_request_parameters.allow_text_output is False
assert m.last_model_request_parameters.output_tools is not None
assert len(m.last_model_request_parameters.output_tools) == 1
assert m.last_model_request_parameters.output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'properties': {
'response': {
'maxItems': 2,
'minItems': 2,
'prefixItems': [{'type': 'string'}, {'type': 'string'}],
'type': 'array',
}
},
'required': ['response'],
'type': 'object',
},
outer_typed_dict_key='response',
kind='output',
defer_loading=False,
)
]
)
def upcase(text: str) -> str:
return text.upper()
@pytest.mark.parametrize(
'input_union_callable',
[
lambda: Union[str, Foo], # noqa: UP007
lambda: Union[Foo, str], # noqa: UP007
lambda: str | Foo,
lambda: Foo | str,
lambda: [Foo, str],
lambda: [TextOutput(upcase), ToolOutput(Foo)],
],
ids=[
'Union[str, Foo]',
'Union[Foo, str]',
'str | Foo',
'Foo | str',
'[Foo, str]',
'[TextOutput(upcase), ToolOutput(Foo)]',
],
)
def test_response_union_allow_str(input_union_callable: Callable[[], Any]):
try:
union = input_union_callable()
except TypeError: # pragma: lax no cover
pytest.skip('Python version does not support `|` syntax for unions')
m = TestModel()
agent: Agent[object, str | Foo] = Agent(m, output_type=union)
got_tool_call_name = 'unset'
@agent.output_validator
def validate_output(ctx: RunContext, o: Any) -> Any:
nonlocal got_tool_call_name
got_tool_call_name = ctx.tool_name
return o
assert agent._output_schema.allows_text # pyright: ignore[reportPrivateUsage]
result = agent.run_sync('Hello')
assert isinstance(result.output, str)
assert result.output.lower() == snapshot('success (no tool calls)')
assert got_tool_call_name == snapshot(None)
assert m.last_model_request_parameters is not None
assert m.last_model_request_parameters.function_tools == snapshot([])
assert m.last_model_request_parameters.allow_text_output is True
assert m.last_model_request_parameters.output_tools is not None
assert len(m.last_model_request_parameters.output_tools) == 1
assert m.last_model_request_parameters.output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'properties': {
'a': {'type': 'integer'},
'b': {'type': 'string'},
},
'required': ['a', 'b'],
'title': 'Foo',
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
@pytest.mark.parametrize(
'union_code',
[
pytest.param('OutputType = Union[Foo, Bar]'),
pytest.param('OutputType = [Foo, Bar]'),
pytest.param('OutputType = [ToolOutput(Foo), ToolOutput(Bar)]'),
pytest.param('OutputType = Foo | Bar'),
pytest.param('OutputType: TypeAlias = Foo | Bar'),
pytest.param(
'type OutputType = Foo | Bar', marks=pytest.mark.skipif(sys.version_info < (3, 12), reason='3.12+')
),
],
)
def test_response_multiple_return_tools(create_module: Callable[[str], Any], union_code: str):
module_code = f'''
from pydantic import BaseModel
from typing import Union
from typing_extensions import TypeAlias
from pydantic_ai import ToolOutput
class Foo(BaseModel):
a: int
b: str
class Bar(BaseModel):
"""This is a bar model."""
b: str
{union_code}
'''
mod = create_module(module_code)
m = TestModel()
agent = Agent(m, output_type=mod.OutputType)
got_tool_call_name = 'unset'
@agent.output_validator
def validate_output(ctx: RunContext, o: Any) -> Any:
nonlocal got_tool_call_name
got_tool_call_name = ctx.tool_name
return o
result = agent.run_sync('Hello')
assert result.output == mod.Foo(a=0, b='a')
assert got_tool_call_name == snapshot('final_result_Foo')
assert m.last_model_request_parameters is not None
assert m.last_model_request_parameters.function_tools == snapshot([])
assert m.last_model_request_parameters.allow_text_output is False
assert m.last_model_request_parameters.output_tools is not None
assert len(m.last_model_request_parameters.output_tools) == 2
assert m.last_model_request_parameters.output_tools == snapshot(
[
ToolDefinition(
name='final_result_Foo',
description='Foo: The final response which ends this conversation',
parameters_json_schema={
'properties': {
'a': {'type': 'integer'},
'b': {'type': 'string'},
},
'required': ['a', 'b'],
'title': 'Foo',
'type': 'object',
},
kind='output',
defer_loading=False,
),
ToolDefinition(
name='final_result_Bar',
description='This is a bar model.',
parameters_json_schema={
'properties': {'b': {'type': 'string'}},
'required': ['b'],
'title': 'Bar',
'type': 'object',
},
kind='output',
defer_loading=False,
),
]
)
result = agent.run_sync('Hello', model=TestModel(seed=1))
assert result.output == mod.Bar(b='b')
assert got_tool_call_name == snapshot('final_result_Bar')
def test_output_type_generic_class_name_sanitization():
"""Test that generic class names with brackets are properly sanitized."""
# This will have a name like "ResultGeneric[StringData]" which needs sanitization
output_type = [ResultGeneric[StringData], ResultGeneric[int]]
m = TestModel()
agent = Agent(m, output_type=output_type)
agent.run_sync('Hello')
# The sanitizer should remove brackets from the generic type name
assert m.last_model_request_parameters is not None
assert m.last_model_request_parameters.output_tools is not None
assert len(m.last_model_request_parameters.output_tools) == 2
tool_names = [tool.name for tool in m.last_model_request_parameters.output_tools]
assert tool_names == snapshot(['final_result_ResultGenericStringData', 'final_result_ResultGenericint'])
def test_output_type_with_two_descriptions():
class MyOutput(BaseModel):
"""Description from docstring"""
valid: bool
m = TestModel()
agent = Agent(m, output_type=ToolOutput(MyOutput, description='Description from ToolOutput'))
result = agent.run_sync('Hello')
assert result.output == snapshot(MyOutput(valid=False))
assert m.last_model_request_parameters is not None
assert m.last_model_request_parameters.output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='Description from ToolOutput. Description from docstring',
parameters_json_schema={
'properties': {'valid': {'type': 'boolean'}},
'required': ['valid'],
'title': 'MyOutput',
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_tool_output_union():
class Foo(BaseModel):
a: int
b: str
class Bar(BaseModel):
c: bool
m = TestModel()
marker: ToolOutput[Foo | Bar] = ToolOutput(Foo | Bar, strict=False) # type: ignore
agent = Agent(m, output_type=marker)
result = agent.run_sync('Hello')
assert result.output == snapshot(Foo(a=0, b='a'))
assert m.last_model_request_parameters is not None
assert m.last_model_request_parameters.output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'$defs': {
'Bar': {
'properties': {'c': {'type': 'boolean'}},
'required': ['c'],
'title': 'Bar',
'type': 'object',
},
'Foo': {
'properties': {'a': {'type': 'integer'}, 'b': {'type': 'string'}},
'required': ['a', 'b'],
'title': 'Foo',
'type': 'object',
},
},
'properties': {'response': {'anyOf': [{'$ref': '#/$defs/Foo'}, {'$ref': '#/$defs/Bar'}]}},
'required': ['response'],
'type': 'object',
},
outer_typed_dict_key='response',
strict=False,
kind='output',
defer_loading=False,
)
]
)
def test_output_type_function():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
return Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=get_weather)
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_function_with_run_context():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(ctx: RunContext, city: str) -> Weather:
assert ctx is not None
return Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=get_weather)
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_bound_instance_method():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(self, city: str) -> Self:
return self
weather = Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=weather.get_weather)
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_bound_instance_method_with_run_context():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(self, ctx: RunContext, city: str) -> Self:
assert ctx is not None
return self
weather = Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=weather.get_weather)
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_function_with_retry():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
if city != 'Mexico City':
raise ModelRetry('City not found, I only know Mexico City')
return Weather(temperature=28.7, description='sunny')
def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if len(messages) == 1:
args_json = '{"city": "New York City"}'
else:
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=get_weather)
result = agent.run_sync('New York City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='New York City',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "New York City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=53, output_tokens=7),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='City not found, I only know Mexico City',
tool_name='final_result',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=68, output_tokens=13),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_output_type_text_output_function_with_retry():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(ctx: RunContext, city: str) -> Weather:
assert ctx is not None
if city != 'Mexico City':
raise ModelRetry('City not found, I only know Mexico City')
return Weather(temperature=28.7, description='sunny')
def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if len(messages) == 1:
city = 'New York City'
else:
city = 'Mexico City'
return ModelResponse(parts=[TextPart(content=city)])
agent = Agent(FunctionModel(call_tool), output_type=TextOutput(get_weather))
result = agent.run_sync('New York City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='New York City',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='New York City')],
usage=RequestUsage(input_tokens=53, output_tokens=3),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='City not found, I only know Mexico City',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Mexico City')],
usage=RequestUsage(input_tokens=70, output_tokens=5),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize(
'output_type',
[[str, str], [str, TextOutput(upcase)], [TextOutput(upcase), TextOutput(upcase)]],
)
def test_output_type_multiple_text_output(output_type: OutputSpec[str]):
with pytest.raises(UserError, match=re.escape('Only one `str` or `TextOutput` is allowed.')):
Agent('test', output_type=output_type)
def test_output_type_text_output_invalid():
def int_func(x: int) -> str:
return str(int) # pragma: no cover
with pytest.raises(UserError, match='TextOutput must take a function taking a single `str` argument'):
output_type: TextOutput[str] = TextOutput(int_func) # type: ignore
Agent('test', output_type=output_type)
def test_output_type_native_output_must_be_only():
"""Test that NativeOutput must be the only output type."""
with pytest.raises(UserError, match=re.escape('`NativeOutput` must be the only output type.')):
Agent('test', output_type=[NativeOutput(str), str])
def test_output_type_native_output_with_deferred_tool_requests():
"""Test that NativeOutput cannot contain DeferredToolRequests."""
with pytest.raises(UserError, match='`NativeOutput` cannot contain `DeferredToolRequests`'):
Agent('test', output_type=NativeOutput([DeferredToolRequests]))
def test_output_type_native_output_with_binary_image():
"""Test that NativeOutput cannot contain BinaryImage."""
with pytest.raises(UserError, match='`NativeOutput` cannot contain `BinaryImage`'):
Agent('test', output_type=NativeOutput([BinaryImage]))
def test_output_type_prompted_output_must_be_only():
"""Test that PromptedOutput must be the only output type."""
with pytest.raises(UserError, match=re.escape('`PromptedOutput` must be the only output type.')):
Agent('test', output_type=[PromptedOutput(str), str])
def test_output_type_prompted_output_with_deferred_tool_requests():
"""Test that PromptedOutput cannot contain DeferredToolRequests."""
with pytest.raises(UserError, match='`PromptedOutput` cannot contain `DeferredToolRequests`'):
Agent('test', output_type=PromptedOutput([DeferredToolRequests]))
def test_output_type_prompted_output_with_binary_image():
"""Test that PromptedOutput cannot contain BinaryImage."""
with pytest.raises(UserError, match='`PromptedOutput` cannot contain `BinaryImage`'):
Agent('test', output_type=PromptedOutput([BinaryImage]))
def test_output_type_async_function():
class Weather(BaseModel):
temperature: float
description: str
async def get_weather(city: str) -> Weather:
return Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=get_weather)
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result',
description='The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_function_with_custom_tool_name():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
return Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=ToolOutput(get_weather, name='get_weather'))
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='get_weather',
description='The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
)
]
)
def test_output_type_function_or_model():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
return Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=[get_weather, Weather])
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result_get_weather',
description='get_weather: The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
),
ToolDefinition(
name='final_result_Weather',
description='Weather: The final response which ends this conversation',
parameters_json_schema={
'properties': {'temperature': {'type': 'number'}, 'description': {'type': 'string'}},
'required': ['temperature', 'description'],
'title': 'Weather',
'type': 'object',
},
kind='output',
defer_loading=False,
),
]
)
def test_output_type_text_output_function():
def say_world(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='world')])
agent = Agent(FunctionModel(say_world), output_type=TextOutput(upcase))
result = agent.run_sync('hello')
assert result.output == snapshot('WORLD')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='world')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='function:say_world:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_output_type_handoff_to_agent():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
return Weather(temperature=28.7, description='sunny')
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(FunctionModel(call_tool), output_type=get_weather)
handoff_result = None
async def handoff(city: str) -> Weather:
result = await agent.run(f'Get me the weather in {city}')
nonlocal handoff_result
handoff_result = result
return result.output
def call_handoff_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
supervisor_agent = Agent(FunctionModel(call_handoff_tool), output_type=handoff)
result = supervisor_agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Mexico City',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=52, output_tokens=6),
model_name='function:call_handoff_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert handoff_result is not None
assert handoff_result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Get me the weather in Mexico City',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"city": "Mexico City"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=57, output_tokens=6),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_output_type_multiple_custom_tools():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
return Weather(temperature=28.7, description='sunny')
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(
FunctionModel(call_tool),
output_type=[
ToolOutput(get_weather, name='get_weather'),
ToolOutput(Weather, name='return_weather'),
],
)
result = agent.run_sync('Mexico City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert output_tools == snapshot(
[
ToolDefinition(
name='get_weather',
description='get_weather: The final response which ends this conversation',
parameters_json_schema={
'additionalProperties': False,
'properties': {'city': {'type': 'string'}},
'required': ['city'],
'type': 'object',
},
kind='output',
defer_loading=False,
),
ToolDefinition(
name='return_weather',
description='Weather: The final response which ends this conversation',
parameters_json_schema={
'properties': {'temperature': {'type': 'number'}, 'description': {'type': 'string'}},
'required': ['temperature', 'description'],
'title': 'Weather',
'type': 'object',
},
kind='output',
defer_loading=False,
),
]
)
def test_output_type_structured_dict():
PersonDict = StructuredDict(
{
'type': 'object',
'properties': {
'name': {'type': 'string'},
'age': {'type': 'integer'},
},
'required': ['name', 'age'],
},
name='Person',
description='A person',
)
AnimalDict = StructuredDict(
{
'type': 'object',
'properties': {
'name': {'type': 'string'},
'species': {'type': 'string'},
},
'required': ['name', 'species'],
},
name='Animal',
description='An animal',
)
output_tools = None
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
nonlocal output_tools
output_tools = info.output_tools
args_json = '{"name": "John Doe", "age": 30}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(
FunctionModel(call_tool),
output_type=[PersonDict, AnimalDict],
)
result = agent.run_sync('Generate a person')
assert result.output == snapshot({'name': 'John Doe', 'age': 30})
assert output_tools == snapshot(
[
ToolDefinition(
name='final_result_Person',
parameters_json_schema={
'properties': {'name': {'type': 'string'}, 'age': {'type': 'integer'}},
'required': ['name', 'age'],
'title': 'Person',
'type': 'object',
},
description='A person',
kind='output',
defer_loading=False,
),
ToolDefinition(
name='final_result_Animal',
parameters_json_schema={
'properties': {'name': {'type': 'string'}, 'species': {'type': 'string'}},
'required': ['name', 'species'],
'title': 'Animal',
'type': 'object',
},
description='An animal',
kind='output',
defer_loading=False,
),
]
)
class Apple(BaseModel):
color: str
class Banana(BaseModel):
length: float
def test_output_type_union_text_fallback():
"""A list `output_type` in tool mode can also be satisfied by the union JSON envelope
returned as a text part, parsed via the union processor.
"""
def return_apple_as_text(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Tool mode is active (output tools are offered), yet we return the union envelope as
# text rather than a tool call to exercise the auto-schema text fallback.
assert info.model_request_parameters.output_mode == 'tool'
assert info.output_tools
text = '{"result": {"kind": "Apple", "data": {"color": "red"}}}'
return ModelResponse(parts=[TextPart(content=text)])
agent = Agent(FunctionModel(return_apple_as_text), output_type=[Apple, Banana])
result = agent.run_sync('What fruit is it?')
assert result.output == snapshot(Apple(color='red'))
def test_output_type_union_text_fallback_invalid_data_retries():
"""When the union envelope text has the right `kind` but `data` that doesn't match that
member's schema, validation fails and the model is re-prompted with the validation error.
"""
calls = 0
def model_fn(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal calls
calls += 1
if calls == 1:
# Correct `kind`, but `data` is missing the required `color` field for `Apple`.
text = '{"result": {"kind": "Apple", "data": {"length": 12.0}}}'
else:
text = '{"result": {"kind": "Apple", "data": {"color": "green"}}}'
return ModelResponse(parts=[TextPart(content=text)])
agent = Agent(FunctionModel(model_fn), output_type=[Apple, Banana])
result = agent.run_sync('What fruit is it?')
assert result.output == snapshot(Apple(color='green'))
assert calls == 2
retry_parts = [p for m in result.all_messages() for p in m.parts if isinstance(p, RetryPromptPart)]
assert retry_parts == snapshot(
[
RetryPromptPart(
content=[{'type': 'missing', 'loc': ('color',), 'msg': 'Field required', 'input': {'length': 12.0}}],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
]
)
def test_output_type_union_text_fallback_invalid_kind_retries():
"""When the union envelope text carries a `kind` that doesn't match any allowed output type,
the discriminated union fails to validate and the model is re-prompted.
The same validation guards `PromptedOutput`, the realistic route to an invalid `kind`: there
the envelope schema is only advertised in the prompt (its `const` discriminator is never
enforced by the provider), so a weaker model can plausibly flub the `kind` value.
"""
calls = 0
def model_fn(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal calls
calls += 1
if calls == 1:
# `kind` is not one of the allowed discriminator values (`Apple` / `Banana`).
text = '{"result": {"kind": "Cherry", "data": {"color": "red"}}}'
else:
text = '{"result": {"kind": "Banana", "data": {"length": 6.0}}}'
return ModelResponse(parts=[TextPart(content=text)])
agent = Agent(FunctionModel(model_fn), output_type=[Apple, Banana])
result = agent.run_sync('What fruit is it?')
assert result.output == snapshot(Banana(length=6.0))
assert calls == 2
retry_parts = [p for m in result.all_messages() for p in m.parts if isinstance(p, RetryPromptPart)]
assert retry_parts == snapshot(
[
RetryPromptPart(
content=[
{
'type': 'literal_error',
'loc': ('result', 'kind'),
'msg': "Input should be 'Apple' or 'Banana'",
'input': 'Cherry',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
]
)
def test_prompted_output_union_invalid_kind_retries():
"""`PromptedOutput` is the realistic route to an unknown `kind`: the envelope schema is only
advertised in the prompt (no output tools, no provider-enforced `const` discriminator), so a
model can plausibly emit a `kind` outside the registered set. The shared `UnionOutputProcessor`
must still reject it as a regular `ValidationError` and re-prompt, exactly as the tool-mode text
fallback does in `test_output_type_union_text_fallback_invalid_kind_retries`.
"""
calls = 0
def model_fn(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal calls
calls += 1
# Prompted mode, not the tool-mode text fallback: no output tools are offered.
assert info.model_request_parameters.output_mode == 'prompted'
assert not info.output_tools
if calls == 1:
# `kind` is not one of the allowed discriminator values (`Apple` / `Banana`).
text = '{"result": {"kind": "Cherry", "data": {"color": "red"}}}'
else:
text = '{"result": {"kind": "Banana", "data": {"length": 6.0}}}'
return ModelResponse(parts=[TextPart(content=text)])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput([Apple, Banana]))
result = agent.run_sync('What fruit is it?')
assert result.output == snapshot(Banana(length=6.0))
assert calls == 2
retry_parts = [p for m in result.all_messages() for p in m.parts if isinstance(p, RetryPromptPart)]
assert retry_parts == snapshot(
[
RetryPromptPart(
content=[
{
'type': 'literal_error',
'loc': ('result', 'kind'),
'msg': "Input should be 'Apple' or 'Banana'",
'input': 'Cherry',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
]
)
def test_output_type_union_text_fallback_invalid_kind_exhausts_retries():
"""When the union envelope text keeps carrying an unknown `kind` past the retry budget, the
run raises `UnexpectedModelBehavior` — the invalid `kind` is fully absorbed by the retry
machinery and never surfaces as a raw `KeyError` from the `_processors` lookup.
"""
def return_invalid_kind(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
text = '{"result": {"kind": "Cherry", "data": {"color": "red"}}}'
return ModelResponse(parts=[TextPart(content=text)])
agent = Agent(FunctionModel(return_invalid_kind), output_type=[Apple, Banana], retries={'output': 1})
# `pytest.raises(UnexpectedModelBehavior)` would not catch a `KeyError`, so reaching this
# means the bad `kind` was routed through retry exhaustion, not the old crash path.
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(1\)'):
agent.run_sync('What fruit is it?')
def test_output_type_structured_dict_nested():
"""Test StructuredDict with nested JSON schemas using $ref - Issue #2466."""
# Schema with nested $ref that pydantic's generator can't resolve
CarDict = StructuredDict(
{
'$defs': {
'Tire': {
'type': 'object',
'properties': {'brand': {'type': 'string'}, 'size': {'type': 'integer'}},
'required': ['brand', 'size'],
}
},
'type': 'object',
'properties': {
'make': {'type': 'string'},
'model': {'type': 'string'},
'tires': {'type': 'array', 'items': {'$ref': '#/$defs/Tire'}},
},
'required': ['make', 'model', 'tires'],
},
name='Car',
description='A car with tires',
)
def call_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
# Verify the output tool schema has been properly transformed
# The $refs should be inlined by InlineDefsJsonSchemaTransformer
output_tool = info.output_tools[0]
schema = output_tool.parameters_json_schema
assert schema is not None
assert schema == snapshot(
{
'properties': {
'make': {'type': 'string'},
'model': {'type': 'string'},
'tires': {
'items': {
'properties': {'brand': {'type': 'string'}, 'size': {'type': 'integer'}},
'required': ['brand', 'size'],
'type': 'object',
},
'type': 'array',
},
},
'required': ['make', 'model', 'tires'],
'title': 'Car',
'type': 'object',
}
)
return ModelResponse(
parts=[
ToolCallPart(
output_tool.name, {'make': 'Toyota', 'model': 'Camry', 'tires': [{'brand': 'Michelin', 'size': 17}]}
)
]
)
agent = Agent(FunctionModel(call_tool), output_type=CarDict)
result = agent.run_sync('Generate a car')
assert result.output == snapshot({'make': 'Toyota', 'model': 'Camry', 'tires': [{'brand': 'Michelin', 'size': 17}]})
def test_structured_dict_recursive_refs():
class Node(BaseModel):
nodes: list['Node'] | dict[str, 'Node']
schema = Node.model_json_schema()
assert schema == snapshot(
{
'$defs': {
'Node': {
'properties': {
'nodes': {
'anyOf': [
{'items': {'$ref': '#/$defs/Node'}, 'type': 'array'},
{'additionalProperties': {'$ref': '#/$defs/Node'}, 'type': 'object'},
],
'title': 'Nodes',
}
},
'required': ['nodes'],
'title': 'Node',
'type': 'object',
}
},
'$ref': '#/$defs/Node',
}
)
with pytest.raises(
UserError,
match=re.escape(
'`StructuredDict` does not currently support recursive `$ref`s and `$defs`. See https://github.com/pydantic/pydantic/issues/12145 for more information.'
),
):
StructuredDict(schema)
def test_default_structured_output_mode():
class Foo(BaseModel):
bar: str
tool_model = TestModel(profile=ModelProfile(default_structured_output_mode='tool'))
native_model = TestModel(
profile=ModelProfile(supports_json_schema_output=True, default_structured_output_mode='native'),
custom_output_text=Foo(bar='baz').model_dump_json(),
)
prompted_model = TestModel(
profile=ModelProfile(default_structured_output_mode='prompted'),
custom_output_text=Foo(bar='baz').model_dump_json(),
)
tool_agent = Agent(tool_model, output_type=Foo)
tool_agent.run_sync('Hello')
assert tool_model.last_model_request_parameters is not None
assert tool_model.last_model_request_parameters.output_mode == 'tool'
assert tool_model.last_model_request_parameters.allow_text_output is False
assert tool_model.last_model_request_parameters.output_object is None
assert tool_model.last_model_request_parameters.output_tools == snapshot(
[
ToolDefinition(
name='final_result',
parameters_json_schema={
'properties': {'bar': {'type': 'string'}},
'required': ['bar'],
'title': 'Foo',
'type': 'object',
},
description='The final response which ends this conversation',
kind='output',
defer_loading=False,
)
]
)
native_agent = Agent(native_model, output_type=Foo)
native_agent.run_sync('Hello')
assert native_model.last_model_request_parameters is not None
assert native_model.last_model_request_parameters.output_mode == 'native'
assert native_model.last_model_request_parameters.allow_text_output is True
assert len(native_model.last_model_request_parameters.output_tools) == 0
assert native_model.last_model_request_parameters.output_object == snapshot(
OutputObjectDefinition(
json_schema={
'properties': {'bar': {'type': 'string'}},
'required': ['bar'],
'title': 'Foo',
'type': 'object',
},
name='Foo',
)
)
prompted_agent = Agent(prompted_model, output_type=Foo)
prompted_agent.run_sync('Hello')
assert prompted_model.last_model_request_parameters is not None
assert prompted_model.last_model_request_parameters.output_mode == 'prompted'
assert prompted_model.last_model_request_parameters.allow_text_output is True
assert len(prompted_model.last_model_request_parameters.output_tools) == 0
assert prompted_model.last_model_request_parameters.output_object == snapshot(
OutputObjectDefinition(
json_schema={
'properties': {'bar': {'type': 'string'}},
'required': ['bar'],
'title': 'Foo',
'type': 'object',
},
name='Foo',
)
)
def test_prompted_output():
def return_city_location(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
text = CityLocation(city='Mexico City', country='Mexico').model_dump_json()
return ModelResponse(parts=[TextPart(content=text)])
m = FunctionModel(return_city_location)
class CityLocation(BaseModel):
"""Description from docstring."""
city: str
country: str
agent = Agent(
m,
output_type=PromptedOutput(CityLocation, name='City & Country', description='Description from PromptedOutput'),
)
result = agent.run_sync('What is the capital of Mexico?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the capital of Mexico?',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city":"Mexico City","country":"Mexico"}')],
usage=RequestUsage(input_tokens=56, output_tokens=7),
model_name='function:return_city_location:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_prompted_output_with_template():
def return_foo(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
text = Foo(bar='baz').model_dump_json()
return ModelResponse(parts=[TextPart(content=text)])
m = FunctionModel(return_foo)
class Foo(BaseModel):
bar: str
agent = Agent(m, output_type=PromptedOutput(Foo, template='Gimme some JSON:'))
result = agent.run_sync('What is the capital of Mexico?')
assert result.output == snapshot(Foo(bar='baz'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the capital of Mexico?',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"bar":"baz"}')],
usage=RequestUsage(input_tokens=56, output_tokens=4),
model_name='function:return_foo:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_prompted_output_with_template_and_instructions():
def return_foo(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.instructions is not None
assert 'Be helpful' in info.instructions
assert 'Gimme some JSON:' in info.instructions
text = Foo(bar='baz').model_dump_json()
return ModelResponse(parts=[TextPart(content=text)])
m = FunctionModel(return_foo)
class Foo(BaseModel):
bar: str
agent = Agent(m, instructions='Be helpful', output_type=PromptedOutput(Foo, template='Gimme some JSON:'))
result = agent.run_sync('What is the capital of Mexico?')
assert result.output == snapshot(Foo(bar='baz'))
def test_prompted_output_with_template_false():
def return_foo(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.model_request_parameters.prompted_output_template is False
assert info.model_request_parameters.prompted_output_instructions is None
assert info.instructions is None
text = Foo(bar='baz').model_dump_json()
return ModelResponse(parts=[TextPart(content=text)])
m = FunctionModel(return_foo)
class Foo(BaseModel):
bar: str
agent = Agent(m, output_type=PromptedOutput(Foo, template=False))
result = agent.run_sync('What is the capital of Mexico?')
assert result.output == snapshot(Foo(bar='baz'))
def test_prompted_output_with_defs():
class Foo(BaseModel):
"""Foo description"""
foo: str
class Bar(BaseModel):
"""Bar description"""
bar: str
class Baz(BaseModel):
"""Baz description"""
baz: str
class FooBar(BaseModel):
"""FooBar description"""
foo: Foo
bar: Bar
class FooBaz(BaseModel):
"""FooBaz description"""
foo: Foo
baz: Baz
def return_foo_bar(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
text = '{"result": {"kind": "FooBar", "data": {"foo": {"foo": "foo"}, "bar": {"bar": "bar"}}}}'
return ModelResponse(parts=[TextPart(content=text)])
m = FunctionModel(return_foo_bar)
agent = Agent(
m,
output_type=PromptedOutput(
[FooBar, FooBaz], name='FooBar or FooBaz', description='FooBar or FooBaz description'
),
)
result = agent.run_sync('What is foo?')
assert result.output == snapshot(FooBar(foo=Foo(foo='foo'), bar=Bar(bar='bar')))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is foo?',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content='{"result": {"kind": "FooBar", "data": {"foo": {"foo": "foo"}, "bar": {"bar": "bar"}}}}'
)
],
usage=RequestUsage(input_tokens=53, output_tokens=17),
model_name='function:return_foo_bar:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_native_output():
def return_city_location(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
text = '{"city": "Mexico City"}'
else:
text = '{"city": "Mexico City", "country": "Mexico"}'
return ModelResponse(parts=[TextPart(content=text)])
m = FunctionModel(return_city_location)
class CityLocation(BaseModel):
city: str
country: str
agent = Agent(
m,
output_type=NativeOutput(CityLocation),
)
result = agent.run_sync('What is the capital of Mexico?')
assert result.output == snapshot(CityLocation(city='Mexico City', country='Mexico'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='What is the capital of Mexico?',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city": "Mexico City"}')],
usage=RequestUsage(input_tokens=56, output_tokens=5),
model_name='function:return_city_location:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
ErrorDetails(
type='missing',
loc=('country',),
msg='Field required',
input={'city': 'Mexico City'},
),
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city": "Mexico City", "country": "Mexico"}')],
usage=RequestUsage(input_tokens=81, output_tokens=12),
model_name='function:return_city_location:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_native_output_strict_mode():
class CityLocation(BaseModel):
city: str
country: str
agent = Agent(output_type=NativeOutput(CityLocation, strict=True))
output_schema = agent._output_schema # pyright: ignore[reportPrivateUsage]
assert isinstance(output_schema, NativeOutputSchema)
assert output_schema.object_def is not None
assert output_schema.object_def.strict
def test_prompted_output_function_with_retry():
class Weather(BaseModel):
temperature: float
description: str
def get_weather(city: str) -> Weather:
if city != 'Mexico City':
raise ModelRetry('City not found, I only know Mexico City')
return Weather(temperature=28.7, description='sunny')
def call_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if len(messages) == 1:
args_json = '{"city": "New York City"}'
else:
args_json = '{"city": "Mexico City"}'
return ModelResponse(parts=[TextPart(content=args_json)])
agent = Agent(FunctionModel(call_tool), output_type=PromptedOutput(get_weather))
result = agent.run_sync('New York City')
assert result.output == snapshot(Weather(temperature=28.7, description='sunny'))
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='New York City',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city": "New York City"}')],
usage=RequestUsage(input_tokens=53, output_tokens=6),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='City not found, I only know Mexico City',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"city": "Mexico City"}')],
usage=RequestUsage(input_tokens=70, output_tokens=11),
model_name='function:call_tool:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_run_with_history_new():
m = TestModel()
agent = Agent(m, system_prompt='Foobar')
@agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
result1 = agent.run_sync('Hello')
assert result1.new_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=53, output_tokens=9),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# if we pass new_messages, system prompt is inserted before the message_history messages
result2 = agent.run_sync('Hello again', message_history=result1.new_messages())
assert result2.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=53, output_tokens=9),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='Hello again', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=55, output_tokens=13),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result2.new_messages() == result2.all_messages()[-2:]
assert result2.output == snapshot('{"ret_a":"a-apple"}')
assert result2._output_tool_name == snapshot(None) # pyright: ignore[reportPrivateUsage]
assert result2.usage == snapshot(RunUsage(requests=1, input_tokens=55, output_tokens=13))
new_msg_part_kinds = [(m.kind, [p.part_kind for p in m.parts]) for m in result2.all_messages()]
assert new_msg_part_kinds == snapshot(
[
('request', ['system-prompt', 'user-prompt']),
('response', ['tool-call']),
('request', ['tool-return']),
('response', ['text']),
('request', ['user-prompt']),
('response', ['text']),
]
)
assert result2.new_messages_json().startswith(b'[{"parts":[{"content":"Hello again",')
# if we pass all_messages, system prompt is NOT inserted before the message_history messages,
# so only one system prompt
result3 = agent.run_sync('Hello again', message_history=result1.all_messages())
# same as result2 except for datetimes
assert result3.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=53, output_tokens=9),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='Hello again', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"ret_a":"a-apple"}')],
usage=RequestUsage(input_tokens=55, output_tokens=13),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result3.new_messages() == result3.all_messages()[-2:]
assert result3.output == snapshot('{"ret_a":"a-apple"}')
assert result3._output_tool_name == snapshot(None) # pyright: ignore[reportPrivateUsage]
assert result3.usage == snapshot(RunUsage(requests=1, input_tokens=55, output_tokens=13))
assert result3.timestamp == IsNow(tz=timezone.utc)
def test_run_with_history_new_structured():
m = TestModel()
class Response(BaseModel):
a: int
agent = Agent(m, system_prompt='Foobar', output_type=Response)
@agent.tool_plain
async def ret_a(x: str) -> str:
return f'{x}-apple'
result1 = agent.run_sync('Hello')
assert result1.new_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'a': 0},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=53, output_tokens=9),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
result2 = agent.run_sync('Hello again', message_history=result1.new_messages())
assert result2.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='ret_a', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ret_a', content='a-apple', tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args={'a': 0}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=53, output_tokens=9),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
# second call, notice no repeated system prompt
ModelRequest(
parts=[
UserPromptPart(content='Hello again', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args={'a': 0}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=59, output_tokens=13),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result2.output == snapshot(Response(a=0))
assert result2.new_messages() == result2.all_messages()[-3:]
assert result2._output_tool_name == snapshot('final_result') # pyright: ignore[reportPrivateUsage]
assert result2.usage == snapshot(RunUsage(requests=1, input_tokens=59, output_tokens=13))
new_msg_part_kinds = [(m.kind, [p.part_kind for p in m.parts]) for m in result2.all_messages()]
assert new_msg_part_kinds == snapshot(
[
('request', ['system-prompt', 'user-prompt']),
('response', ['tool-call']),
('request', ['tool-return']),
('response', ['tool-call']),
('request', ['tool-return']),
('request', ['user-prompt']),
('response', ['tool-call']),
('request', ['tool-return']),
]
)
assert result2.new_messages_json().startswith(b'[{"parts":[{"content":"Hello again",')
def test_run_with_history_ending_on_model_request_and_no_user_prompt():
m = TestModel()
agent = Agent(m)
@agent.system_prompt(dynamic=True)
async def system_prompt(ctx: RunContext) -> str:
return f'System prompt: user prompt length = {len(ctx.prompt or [])}'
messages: list[ModelMessage] = [
ModelRequest(
parts=[
SystemPromptPart(content='System prompt', dynamic_ref=system_prompt.__qualname__),
UserPromptPart(content=['Hello', ImageUrl('https://example.com/image.jpg')]),
UserPromptPart(content='How goes it?'),
],
instructions='Original instructions',
),
]
@agent.instructions
async def instructions(ctx: RunContext) -> str:
assert ctx.prompt == ['Hello', ImageUrl('https://example.com/image.jpg'), 'How goes it?']
return 'New instructions'
result = agent.run_sync(message_history=messages)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(
content='System prompt: user prompt length = 3',
timestamp=IsDatetime(),
dynamic_ref=IsStr(),
),
UserPromptPart(
content=['Hello', ImageUrl(url='https://example.com/image.jpg')],
timestamp=IsDatetime(),
),
UserPromptPart(
content='How goes it?',
timestamp=IsDatetime(),
),
],
timestamp=IsNow(tz=timezone.utc),
instructions='New instructions',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=61, output_tokens=4),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-1:]
def test_run_with_history_ending_on_model_response_with_tool_calls_and_no_user_prompt():
"""Test that an agent run with message_history ending on ModelResponse starts with CallToolsNode."""
def simple_response(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='Final response')])
agent = Agent(FunctionModel(simple_response))
@agent.tool_plain
def test_tool() -> str:
return 'Test response'
message_history = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[ToolCallPart(tool_name='test_tool', args='{}', tool_call_id='call_123')]),
]
result = agent.run_sync(message_history=message_history)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
]
),
ModelResponse(
parts=[ToolCallPart(tool_name='test_tool', args='{}', tool_call_id='call_123')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='test_tool',
content='Test response',
tool_call_id='call_123',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Final response')],
usage=RequestUsage(input_tokens=53, output_tokens=4),
model_name='function:simple_response:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
def test_run_with_history_ending_on_model_response_with_tool_calls_and_user_prompt():
"""Test that an agent run raises error when message_history ends on ModelResponse with tool calls and there's a new prompt."""
def simple_response(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='Final response')]) # pragma: no cover
agent = Agent(FunctionModel(simple_response))
message_history = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[ToolCallPart(tool_name='test_tool', args='{}', tool_call_id='call_123')]),
]
with pytest.raises(
UserError,
match=re.escape('Cannot provide a new user prompt when the message history contains unprocessed tool calls.'),
):
agent.run_sync(user_prompt='New question', message_history=message_history)
def test_run_with_history_ending_on_model_response_without_tool_calls_or_user_prompt():
def simple_response(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='Final response')]) # pragma: no cover
agent = Agent(FunctionModel(simple_response))
message_history = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[TextPart('world')]),
]
result = agent.run_sync(message_history=message_history)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
]
),
ModelResponse(
parts=[TextPart(content='world')],
timestamp=IsDatetime(),
),
]
)
assert result.new_messages() == snapshot([])
async def test_message_history_ending_on_model_response_with_instructions():
model = TestModel(custom_output_text='James likes cars in general, especially the Fiat 126p that his parents had.')
summarize_agent = Agent(
model,
instructions="""
Summarize this conversation to include all important facts about the user and
what their interactions were about.
""",
)
message_history = [
ModelRequest(parts=[UserPromptPart(content='Hi, my name is James')]),
ModelResponse(parts=[TextPart(content='Nice to meet you, James.')]),
ModelRequest(parts=[UserPromptPart(content='I like cars')]),
ModelResponse(parts=[TextPart(content='I like them too. Sport cars?')]),
ModelRequest(parts=[UserPromptPart(content='No, cars in general.')]),
ModelResponse(parts=[TextPart(content='Awesome. Which one do you like most?')]),
ModelRequest(parts=[UserPromptPart(content='Fiat 126p')]),
ModelResponse(parts=[TextPart(content="That's an old one, isn't it?")]),
ModelRequest(parts=[UserPromptPart(content='Yes, it is. My parents had one.')]),
ModelResponse(parts=[TextPart(content='Cool. Was it fast?')]),
]
result = await summarize_agent.run(message_history=message_history)
assert result.output == snapshot('James likes cars in general, especially the Fiat 126p that his parents had.')
assert result.new_messages() == snapshot(
[
ModelRequest(
parts=[],
timestamp=IsNow(tz=timezone.utc),
instructions="""\
Summarize this conversation to include all important facts about the user and
what their interactions were about.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='James likes cars in general, especially the Fiat 126p that his parents had.')],
usage=RequestUsage(input_tokens=73, output_tokens=43),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_empty_response():
def llm(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[])
else:
return ModelResponse(parts=[TextPart('ok here is text')])
agent = Agent(FunctionModel(llm))
result = agent.run_sync('Hello')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=51),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='ok here is text')],
usage=RequestUsage(input_tokens=63, output_tokens=4),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_empty_response_exceeds_max_retries():
def llm(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[])
agent = Agent(FunctionModel(llm), output_type=tuple[str, int])
with capture_run_messages() as messages:
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(1\)'):
agent.run_sync('Hello')
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=51),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text or include your response in a tool call.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=71),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_agent_message_history_includes_run_id() -> None:
agent = Agent(TestModel(custom_output_text='testing run_id'))
result = agent.run_sync('Hello')
history = result.all_messages()
run_ids = [message.run_id for message in history]
assert run_ids == snapshot([IsStr(), IsStr()])
assert len({*run_ids}) == snapshot(1)
def test_agent_message_history_includes_conversation_id() -> None:
agent = Agent(TestModel(custom_output_text='testing conv_id'))
result = agent.run_sync('Hello')
assert result.conversation_id != result.run_id
conversation_ids = [message.conversation_id for message in result.all_messages()]
assert conversation_ids == [result.conversation_id, result.conversation_id]
def test_agent_conversation_id_resolves_from_message_history() -> None:
agent = Agent(TestModel(custom_output_text='continuation'))
first = agent.run_sync('first turn')
second = agent.run_sync('second turn', message_history=first.all_messages())
assert second.conversation_id == first.conversation_id
new_conv_ids = [m.conversation_id for m in second.new_messages()]
assert all(cid == first.conversation_id for cid in new_conv_ids)
def test_agent_conversation_id_explicit_override() -> None:
agent = Agent(TestModel(custom_output_text='explicit'))
result = agent.run_sync('hi', conversation_id='conv-app-42')
assert result.conversation_id == 'conv-app-42'
assert all(m.conversation_id == 'conv-app-42' for m in result.all_messages())
def test_agent_conversation_id_new_sentinel_forks() -> None:
agent = Agent(TestModel(custom_output_text='fork'))
first = agent.run_sync('first')
forked = agent.run_sync('branch', message_history=first.all_messages(), conversation_id='new')
assert forked.conversation_id != first.conversation_id
new_conv_ids = [m.conversation_id for m in forked.new_messages()]
assert all(cid == forked.conversation_id for cid in new_conv_ids)
def test_agent_conversation_id_available_in_run_context() -> None:
captured: list[str | None] = []
def capture_metadata(ctx: RunContext) -> dict[str, Any]:
captured.append(ctx.conversation_id)
return {}
agent = Agent(TestModel(custom_output_text='ctx'), metadata=capture_metadata)
result = agent.run_sync('hi', conversation_id='conv-from-ctx')
assert result.conversation_id == 'conv-from-ctx'
assert captured
assert all(cid == 'conv-from-ctx' for cid in captured)
async def test_agent_conversation_id_surfaces_on_iter_and_async_stream() -> None:
"""`conversation_id` is reachable from `AgentRun` and `StreamedRunResult`."""
agent = Agent(TestModel(custom_output_text='surfaced'))
async with agent.iter('hi', conversation_id='conv-iter') as agent_run:
assert agent_run.conversation_id == 'conv-iter'
async for _ in agent_run:
pass
async with agent.run_stream('hi', conversation_id='conv-async-stream') as stream:
assert stream.conversation_id == 'conv-async-stream'
await stream.get_output()
def test_agent_conversation_id_surfaces_on_sync_stream() -> None:
"""`conversation_id` is reachable from `StreamedRunResultSync`."""
agent = Agent(TestModel(custom_output_text='surfaced'))
result = agent.run_stream_sync('hi', conversation_id='conv-sync-stream')
assert result.conversation_id == 'conv-sync-stream'
async def test_agent_run_result_conversation_id_property() -> None:
"""`AgentRunResult.conversation_id` returns the run's conversation ID."""
agent = Agent(TestModel(custom_output_text='ok'))
result = await agent.run('hi', conversation_id='conv-result')
assert result.conversation_id == 'conv-result'
async def test_agent_run_result_metadata_available() -> None:
agent = Agent(
TestModel(custom_output_text='metadata output'),
metadata=lambda ctx: {'prompt': ctx.prompt},
)
result = await agent.run('metadata prompt')
assert result.output == 'metadata output'
assert result.metadata == {'prompt': 'metadata prompt'}
async def test_agent_iter_metadata_surfaces_on_result() -> None:
agent = Agent(TestModel(custom_output_text='iter metadata output'), metadata={'env': 'tests'})
async with agent.iter('iter metadata prompt') as agent_run:
async for _ in agent_run:
pass
assert agent_run.metadata == {'env': 'tests'}
assert agent_run.result is not None
assert agent_run.result.metadata == {'env': 'tests'}
async def test_agent_metadata_persisted_when_run_fails() -> None:
agent = Agent(
TestModel(),
metadata=lambda ctx: {'prompt': ctx.prompt},
)
@agent.tool
def explode(_: RunContext) -> str:
raise RuntimeError('explode')
failing_prompt = 'metadata failure prompt'
captured_run = None
with pytest.raises(RuntimeError, match='explode'):
async with agent.iter(failing_prompt) as agent_run:
captured_run = agent_run
async for _ in agent_run:
pass
assert captured_run is not None
assert captured_run.metadata == {'prompt': failing_prompt}
assert captured_run.result is None
async def test_agent_metadata_recomputed_on_successful_run() -> None:
agent = Agent(
TestModel(custom_output_text='recomputed metadata'),
metadata=lambda ctx: {'requests': ctx.usage.requests},
)
async with agent.iter('recompute metadata prompt') as agent_run:
initial_metadata = agent_run.metadata
async for _ in agent_run:
pass
assert initial_metadata == {'requests': 0}
assert agent_run.metadata == {'requests': 1}
assert agent_run.result is not None
assert agent_run.result.metadata == {'requests': 1}
async def test_agent_metadata_override_with_dict() -> None:
agent = Agent(TestModel(custom_output_text='override dict base'), metadata={'env': 'base'})
with agent.override(metadata={'env': 'override'}):
result = await agent.run('override dict prompt')
assert result.metadata == {'env': 'override'}
async def test_agent_metadata_override_with_callable() -> None:
agent = Agent(TestModel(custom_output_text='override callable base'), metadata={'env': 'base'})
with agent.override(metadata=lambda ctx: {'computed': ctx.prompt}):
result = await agent.run('callable override prompt')
assert result.metadata == {'computed': 'callable override prompt'}
async def test_agent_run_metadata_kwarg_dict() -> None:
agent = Agent(TestModel(custom_output_text='kwarg dict output'))
result = await agent.run('kwarg dict prompt', metadata={'env': 'run'})
assert result.metadata == {'env': 'run'}
async def test_agent_run_metadata_kwarg_callable() -> None:
agent = Agent(TestModel(custom_output_text='kwarg callable output'))
def run_meta(ctx: RunContext) -> dict[str, Any]:
return {'prompt': ctx.prompt}
result = await agent.run('kwarg callable prompt', metadata=run_meta)
assert result.metadata == {'prompt': 'kwarg callable prompt'}
async def test_agent_run_metadata_kwarg_merges_agent_metadata() -> None:
agent = Agent(TestModel(custom_output_text='kwarg merge output'), metadata={'env': 'base', 'shared': 'agent'})
result = await agent.run('kwarg merge prompt', metadata={'run': 'value', 'shared': 'run'})
assert result.metadata == {'env': 'base', 'run': 'value', 'shared': 'run'}
async def test_agent_run_metadata_kwarg_ignored_with_override() -> None:
agent = Agent(TestModel(custom_output_text='kwarg override output'), metadata={'env': 'base'})
with agent.override(metadata={'env': 'override', 'override_only': True}):
result = await agent.run('kwarg override prompt', metadata={'run_only': True})
assert result.metadata == {'env': 'override', 'override_only': True}
def test_unknown_tool():
def empty(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('foobar', '{}')])
agent = Agent(FunctionModel(empty))
with capture_run_messages() as messages:
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 1"):
agent.run_sync('Hello')
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='foobar',
content="Unknown tool name: 'foobar'. No tools available.",
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=65, output_tokens=4),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_unknown_tool_fix():
def empty(m: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(m) > 1:
return ModelResponse(parts=[TextPart('success')])
else:
return ModelResponse(parts=[ToolCallPart('foobar', '{}')])
agent = Agent(FunctionModel(empty))
result = agent.run_sync('Hello')
assert result.output == 'success'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='foobar',
content="Unknown tool name: 'foobar'. No tools available.",
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success')],
usage=RequestUsage(input_tokens=65, output_tokens=3),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_unknown_tool_multiple_retries():
num_retries = 2
def empty(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('foobar', '{}')])
agent = Agent(FunctionModel(empty), retries={'tools': num_retries, 'output': num_retries})
with capture_run_messages() as messages:
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 2"):
agent.run_sync('Hello')
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='foobar',
content="Unknown tool name: 'foobar'. No tools available.",
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=65, output_tokens=4),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
tool_name='foobar',
content="Unknown tool name: 'foobar'. No tools available.",
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=79, output_tokens=6),
model_name='function:empty:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_unknown_tool_per_tool_retries_exceeded():
"""When output_retries > retries, the per-tool retry limit fires before the agent-level one."""
def empty(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('foobar', '{}')])
agent = Agent(FunctionModel(empty), retries={'tools': 1, 'output': 5})
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'foobar' exceeded max retries count of 1"):
agent.run_sync('Hello')
def test_tool_exceeds_token_limit_error():
def return_incomplete_tool(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
resp = ModelResponse(parts=[ToolCallPart('dummy_tool', args='{"foo": "bar",')])
resp.finish_reason = 'length'
return resp
agent = Agent(FunctionModel(return_incomplete_tool), output_type=str)
with pytest.raises(
IncompleteToolCall,
match=r'Model token limit \(10\) exceeded while generating a tool call, resulting in incomplete arguments\.',
):
agent.run_sync('Hello', model_settings=ModelSettings(max_tokens=10))
with pytest.raises(
IncompleteToolCall,
match=r'Model token limit \(provider default\) exceeded while generating a tool call, resulting in incomplete arguments\.',
):
agent.run_sync('Hello')
def test_tool_exceeds_token_limit_but_complete_args():
def return_complete_tool_but_hit_limit(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
resp = ModelResponse(parts=[ToolCallPart('dummy_tool', args='{"foo": "bar"}')])
resp.finish_reason = 'length'
return resp
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(return_complete_tool_but_hit_limit), output_type=str)
@agent.tool_plain
def dummy_tool(foo: str) -> str:
return 'tool-ok'
result = agent.run_sync('Hello')
assert result.output == 'done'
def test_empty_response_with_finish_reason_length():
def return_empty_response(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
resp = ModelResponse(parts=[])
resp.finish_reason = 'length'
return resp
agent = Agent(FunctionModel(return_empty_response), output_type=str)
with pytest.raises(
UnexpectedModelBehavior,
match=r'Model token limit \(10\) exceeded before any response was generated.',
):
agent.run_sync('Hello', model_settings=ModelSettings(max_tokens=10))
with pytest.raises(
UnexpectedModelBehavior,
match=r'Model token limit \(provider default\) exceeded before any response was generated.',
):
agent.run_sync('Hello')
def test_thinking_only_response_with_finish_reason_length():
def return_thinking_only(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
resp = ModelResponse(parts=[ThinkingPart(content='thinking...')])
resp.finish_reason = 'length'
return resp
agent = Agent(FunctionModel(return_thinking_only), output_type=str)
with pytest.raises(
UnexpectedModelBehavior,
match=r'Model token limit \(10\) exceeded before any response was generated.',
):
agent.run_sync('Hello', model_settings=ModelSettings(max_tokens=10))
with pytest.raises(
UnexpectedModelBehavior,
match=r'Model token limit \(provider default\) exceeded before any response was generated.',
):
agent.run_sync('Hello')
def test_model_requests_blocked(env: TestEnv):
try:
env.set('GEMINI_API_KEY', 'foobar')
agent = Agent('google:gemini-3-flash-preview', output_type=tuple[str, str], defer_model_check=True)
with pytest.raises(RuntimeError, match='Model requests are not allowed, since ALLOW_MODEL_REQUESTS is False'):
agent.run_sync('Hello')
except ImportError: # pragma: lax no cover
pytest.skip('google-genai not installed')
def test_override_model(env: TestEnv):
env.set('GEMINI_API_KEY', 'foobar')
agent = Agent('google:gemini-3-flash-preview', output_type=tuple[int, str], defer_model_check=True)
with agent.override(model='test'):
result = agent.run_sync('Hello')
assert result.output == snapshot((0, 'a'))
def test_set_model(env: TestEnv):
env.set('GEMINI_API_KEY', 'foobar')
agent = Agent(output_type=tuple[int, str])
agent.model = 'test'
result = agent.run_sync('Hello')
assert result.output == snapshot((0, 'a'))
def test_override_model_no_model():
agent = Agent(output_type=tuple[int, str])
with agent.override(model='test'):
result = agent.run_sync('Hello')
assert result.output == snapshot((0, 'a'))
def test_run_without_model():
agent = Agent()
with pytest.raises(UserError, match=r'`model` must either be set on the agent or included when calling it\.'):
agent.run_sync('Hello')
async def test_agent_name():
my_agent = Agent('test')
assert my_agent.name is None
await my_agent.run('Hello', infer_name=False)
assert my_agent.name is None
await my_agent.run('Hello')
assert my_agent.name == 'my_agent'
async def test_agent_name_already_set():
my_agent = Agent('test', name='fig_tree')
assert my_agent.name == 'fig_tree'
await my_agent.run('Hello')
assert my_agent.name == 'fig_tree'
async def test_agent_name_changes():
my_agent = Agent('test')
await my_agent.run('Hello')
assert my_agent.name == 'my_agent'
new_agent = my_agent
del my_agent
await new_agent.run('Hello')
assert new_agent.name == 'my_agent'
def test_agent_name_override():
agent = Agent('test', name='custom_name')
with agent.override(name='overridden_name'):
agent.run_sync('Hello')
assert agent.name == 'overridden_name'
def test_name_from_global(create_module: Callable[[str], Any]):
module_code = """
from pydantic_ai import Agent
my_agent = Agent('test')
def foo():
result = my_agent.run_sync('Hello')
return result.output
"""
mod = create_module(module_code)
assert mod.my_agent.name is None
assert mod.foo() == snapshot('success (no tool calls)')
assert mod.my_agent.name == 'my_agent'
class OutputType(BaseModel):
"""Result type used by multiple tests."""
value: str
class OutputTypeWithCount(BaseModel):
"""Result type with an additional int field, used to test schema validation failures."""
value: str
count: int
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_streaming.py::TestMultipleToolCalls` as well
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] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('final_result', {'value': 'final'}),
ToolCallPart('regular_tool', {'x': 1}),
ToolCallPart('another_tool', {'y': 2}),
ToolCallPart('deferred_tool', {'x': 3}),
],
)
agent = Agent(FunctionModel(return_model), 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
result = agent.run_sync('test early strategy')
messages = result.all_messages()
# Verify no tools were called after final result
assert tool_called == []
# Verify we got tool returns for all calls
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test early strategy', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args={'value': 'final'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='regular_tool', args={'x': 1}, tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args={'y': 2}, tool_call_id=IsStr()),
ToolCallPart(tool_name='deferred_tool', args={'x': 3}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=53, output_tokens=17),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='regular_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='another_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize('output_mode', ['native', 'prompted', 'auto'])
def test_early_strategy_prefers_structured_text_output_over_tool_calls(self, output_mode: str):
"""Under 'early', valid structured output text in the same response as function tool calls is the final
result, so the function tools are skipped and the run ends. This covers `NativeOutput`, `PromptedOutput`,
and a bare structured type (auto mode) — all of which validate the text against a schema, so a valid
output is a deliberate final result rather than the incidental preamble a plain `str` output would be.
A `FunctionModel` is used because it's the only way to reliably construct the response shape
(structured output text co-emitted with a function tool call) that motivated this behavior; real
models produce it only occasionally, e.g. Claude on Bedrock (see #6277).
"""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
TextPart(content='{"value": "final"}'),
ToolCallPart('regular_tool', {'x': 1}),
],
)
return ModelResponse(parts=[TextPart(content='{"value": "after tool"}')]) # pragma: no cover
if output_mode == 'native':
output_type = NativeOutput(OutputType)
elif output_mode == 'prompted':
output_type = PromptedOutput(OutputType)
else:
output_type = OutputType
agent = Agent(FunctionModel(return_model), 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
result = agent.run_sync('test early structured output')
assert result.output == OutputType(value='final')
assert tool_called == []
# The skipped tool call is recorded so the message history has no dangling tool call.
messages = result.all_messages()
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.'
def test_early_strategy_falls_back_to_tools_when_output_text_is_invalid(self):
"""Under 'early', text that doesn't validate as the output type isn't a final result, so the
co-emitted function tool still runs (prompted output is the realistic route to invalid text)."""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
TextPart(content='let me look that up'),
ToolCallPart('regular_tool', {'x': 1}),
],
)
return ModelResponse(parts=[TextPart(content='{"value": "after tool"}')])
agent = Agent(FunctionModel(return_model), output_type=PromptedOutput(OutputType), end_strategy='early')
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
result = agent.run_sync('test early fallback')
assert result.output == OutputType(value='after tool')
assert tool_called == ['regular_tool']
def test_early_strategy_prefers_image_output_over_tool_calls(self):
"""Under 'early', an image output in the same response as function tool calls is the final result,
so the function tools are skipped and the run ends."""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
FilePart(content=BinaryImage(data=b'image', media_type='image/png')),
ToolCallPart('regular_tool', {'x': 1}),
],
)
return ModelResponse( # pragma: no cover
parts=[FilePart(content=BinaryImage(data=b'after', media_type='image/png'))]
)
agent = Agent(
FunctionModel(return_model, profile=ModelProfile(supports_image_output=True)),
output_type=BinaryImage,
end_strategy='early',
)
@agent.tool_plain
def regular_tool(x: int) -> int: # pragma: no cover
tool_called.append('regular_tool')
return x
result = agent.run_sync('test early image output')
assert isinstance(result.output, BinaryImage)
assert result.output.data == b'image'
assert tool_called == []
def test_early_strategy_runs_tools_when_image_output_absent(self):
"""Under 'early' with image output, a response with only a function tool call (no image) has no
final result yet, so the tool runs and the run continues until an image is returned."""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('regular_tool', {'x': 1})])
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'image', media_type='image/png'))])
agent = Agent(
FunctionModel(return_model, profile=ModelProfile(supports_image_output=True)),
output_type=BinaryImage,
end_strategy='early',
)
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
result = agent.run_sync('test early image absent')
assert isinstance(result.output, BinaryImage)
assert result.output.data == b'image'
assert tool_called == ['regular_tool']
def test_non_early_strategy_runs_tools_alongside_structured_text_output(self):
"""The early-output behavior is 'early'-only: under the default 'graceful' strategy the co-emitted
function tools still run and the run continues, so their results can inform the eventual output."""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
TextPart(content='{"value": "final"}'),
ToolCallPart('regular_tool', {'x': 1}),
],
)
return ModelResponse(parts=[TextPart(content='{"value": "after tool"}')])
agent = Agent(FunctionModel(return_model), output_type=NativeOutput(OutputType), end_strategy='graceful')
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
result = agent.run_sync('test graceful structured output')
assert result.output == OutputType(value='after tool')
assert tool_called == ['regular_tool']
@pytest.mark.parametrize('output_type', ['str', 'text_fallback'])
def test_early_strategy_does_not_preempt_tools_for_plain_text_output(self, output_type: str):
"""Under 'early', plain/unstructured text output (`output_type=str`, or a `str` fallback in a larger
schema) must NOT preempt a co-emitted function tool: unlike native/prompted output, the model isn't
told its text is the final answer, so its preamble shouldn't silently skip the tool. The tool runs."""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
# Preamble text (a valid `str`) emitted alongside a function tool call.
return ModelResponse(
parts=[TextPart(content='Let me look that up.'), ToolCallPart('regular_tool', {'x': 1})]
)
return ModelResponse(parts=[TextPart(content='the answer')])
configured_output = str if output_type == 'str' else [OutputType, str]
agent = Agent(FunctionModel(return_model), output_type=configured_output, end_strategy='early')
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
result = agent.run_sync('test early plain text')
assert result.output == 'the answer'
assert tool_called == ['regular_tool']
def test_early_strategy_does_not_preempt_output_tool_calls(self):
"""Under 'early', structured non-tool output (here, an image) must not preempt a co-emitted output tool
call: the output tool produces the final result. This exercises the tool-kind gate — with only a plain
function tool the image would win, but an output tool call must be executed normally."""
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
FilePart(content=BinaryImage(data=b'image', media_type='image/png')),
ToolCallPart('final_output', {'value': 'from tool'}),
],
)
return ModelResponse(parts=[TextPart(content='done')]) # pragma: no cover
agent = Agent(
FunctionModel(return_model, profile=ModelProfile(supports_image_output=True)),
output_type=[ToolOutput(OutputType, name='final_output'), BinaryImage],
end_strategy='early',
)
result = agent.run_sync('test early does not preempt output tool')
assert result.output == OutputType(value='from tool')
def test_early_strategy_does_not_preempt_deferred_tool_calls(self):
"""Under 'early', valid structured output must not preempt a co-emitted deferred (external) tool call:
the deferred call is still surfaced as `DeferredToolRequests` rather than being silently dropped. This
exercises the tool-kind gate for a structured text output that would otherwise win."""
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
TextPart(content='{"value": "final"}'),
ToolCallPart('external_tool', {}),
],
)
return ModelResponse(parts=[TextPart(content='{"value": "after"}')]) # pragma: no cover
agent = Agent(
FunctionModel(return_model),
output_type=[NativeOutput(OutputType), DeferredToolRequests],
toolsets=[ExternalToolset(tool_defs=[ToolDefinition(name='external_tool', kind='external')])],
end_strategy='early',
)
result = agent.run_sync('test early does not preempt deferred tool')
assert isinstance(result.output, DeferredToolRequests)
assert [call.tool_name for call in result.output.calls] == ['external_tool']
def test_early_strategy_skips_unknown_tool_call_when_structured_output_wins(self):
"""Under 'early', valid structured output ends the run even when co-emitted with an unknown
(hallucinated) tool call: the unknown call has no definition, so it's treated like a function tool and
recorded as skipped — matching how `'early'` already handles unknown calls once an output tool wins.
This is the only coverage for the missing-`tool_def` arm of the kind gate."""
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
TextPart(content='{"value": "final"}'),
ToolCallPart('does_not_exist', {}),
],
)
return ModelResponse(parts=[TextPart(content='{"value": "after"}')]) # pragma: no cover
agent = Agent(FunctionModel(return_model), output_type=NativeOutput(OutputType), end_strategy='early')
result = agent.run_sync('test early unknown tool')
assert result.output == OutputType(value='final')
messages = result.all_messages()
assert isinstance(messages[-1], ModelRequest)
skipped = messages[-1].parts[0]
assert isinstance(skipped, ToolReturnPart)
assert skipped.tool_name == 'does_not_exist'
assert skipped.content == 'Tool not executed - a final result was already processed.'
def test_early_strategy_falls_back_to_tools_when_image_output_is_invalid(self):
"""Under 'early', an image that fails output validation isn't a final result, so the co-emitted
function tool runs — mirroring the invalid-text fallback rather than consuming an output retry."""
tool_called: list[str] = []
def return_model(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
FilePart(content=BinaryImage(data=b'bad', media_type='image/png')),
ToolCallPart('regular_tool', {'x': 1}),
],
)
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'good', media_type='image/png'))])
agent = Agent(
FunctionModel(return_model, profile=ModelProfile(supports_image_output=True)),
output_type=BinaryImage,
end_strategy='early',
)
@agent.output_validator
def reject_bad_image(image: BinaryImage) -> BinaryImage:
if image.data == b'bad':
raise ModelRetry('image rejected')
return image
@agent.tool_plain
def regular_tool(x: int) -> int:
tool_called.append('regular_tool')
return x
result = agent.run_sync('test early invalid image')
assert isinstance(result.output, BinaryImage)
assert result.output.data == b'good'
assert tool_called == ['regular_tool']
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
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'first'}),
ToolCallPart('second_output', {'value': 'second'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='early',
)
result = agent.run_sync('test early output tools')
# Verify the result came from the first output tool
assert isinstance(result.output, OutputType)
assert result.output.value == 'first'
# Verify only the first output tool was called
assert output_tools_called == ['first']
# Verify we got tool returns in the correct order
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test early output tools', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args={'value': 'second'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=54, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_early_strategy_uses_first_final_result(self):
"""Test that 'early' strategy uses the first final result and ignores subsequent ones."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('final_result', {'value': 'first'}),
ToolCallPart('final_result', {'value': 'second'}),
],
)
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='early')
result = agent.run_sync('test multiple final results')
messages = result.all_messages()
# Verify the result came from the first final tool
assert result.output.value == 'first'
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test multiple final results', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args={'value': 'second'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=54, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('regular_tool', {'x': 1}),
ToolCallPart('final_result', {'value': 'final'}),
ToolCallPart('another_tool', {'y': 2}),
ToolCallPart('unknown_tool', {'value': '???'}),
ToolCallPart('deferred_tool', {'x': 5}),
],
)
agent = Agent(FunctionModel(return_model), 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
result = agent.run_sync('test early strategy with final result in middle')
# Verify no tools were called
assert tool_called == []
# Verify we got appropriate tool returns
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test early strategy with final result in middle', timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='regular_tool', args={'x': 1}, tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args={'value': 'final'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args={'y': 2}, tool_call_id=IsStr()),
ToolCallPart(tool_name='unknown_tool', args={'value': '???'}, tool_call_id=IsStr()),
ToolCallPart(
tool_name='deferred_tool',
args={'x': 5},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=58, output_tokens=22),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='regular_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='another_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
content='Tool not executed - a final result was already processed.',
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('external_tool'),
ToolCallPart('final_result', {'value': 'final'}),
ToolCallPart('regular_tool', {'x': 1}),
],
)
agent = Agent(
FunctionModel(return_model),
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
result = agent.run_sync('test early strategy with external tool call')
assert result.output == snapshot(OutputType(value='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 external tool call', timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='external_tool', tool_call_id=IsStr()),
ToolCallPart(
tool_name='final_result',
args={'value': 'final'},
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='regular_tool',
args={'x': 1},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=57, output_tokens=11),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='regular_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='external_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_early_strategy_with_deferred_tool_call(self):
"""Test that early strategy handles deferred tool calls correctly."""
tool_called: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('deferred_tool'),
ToolCallPart('regular_tool', {'x': 1}),
],
)
agent = Agent(
FunctionModel(return_model),
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
result = agent.run_sync('test early strategy with deferred tool call')
assert result.output == 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 deferred tool call', timestamp=IsNow(tz=timezone.utc)
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='deferred_tool', tool_call_id=IsStr()),
ToolCallPart(
tool_name='regular_tool',
args={'x': 1},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=57, output_tokens=6),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=1,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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
result = agent.run_sync('test early strategy with regular tool calls')
# Verify the regular tool was executed
assert tool_called == ['regular_tool']
# Verify we got appropriate tool returns
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test early strategy with regular tool calls',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='regular_tool',
args={'x': 0},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=57, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=0,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'value': 'a'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=58, output_tokens=9),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('final_result', {'value': 'first'}),
ToolCallPart('regular_tool', {'x': 42}),
ToolCallPart('another_tool', {'y': 2}),
],
)
agent = Agent(FunctionModel(return_model), 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
result = agent.run_sync('test graceful strategy')
# Verify the result came from the output tool
assert result.output.value == 'first'
# 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 result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test graceful strategy', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='regular_tool', args={'x': 42}, tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args={'y': 2}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=53, output_tokens=13),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='regular_tool',
content=42,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='another_tool', content=2, tool_call_id=IsStr(), timestamp=IsNow(tz=timezone.utc)
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'first'}),
ToolCallPart('second_output', {'value': 'second'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='graceful',
)
result = agent.run_sync('test graceful output tools')
# Verify the result came from the first output tool
assert isinstance(result.output, OutputType)
assert result.output.value == 'first'
# Verify only the first output tool was called
assert output_tools_called == ['first']
# Verify we got tool returns in the correct order
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test graceful output tools', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args={'value': 'second'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=54, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_graceful_strategy_uses_first_final_result(self):
"""Test that 'graceful' strategy uses the first final result and ignores subsequent ones."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('final_result', {'value': 'first'}),
ToolCallPart('final_result', {'value': 'second'}),
],
)
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='graceful')
result = agent.run_sync('test multiple final results')
messages = result.all_messages()
# Verify the result came from the first final tool
assert result.output.value == 'first'
# Verify we got appropriate tool returns
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test multiple final results', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args={'value': 'second'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=54, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Output tool not used - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('regular_tool', {'x': 1}),
ToolCallPart('final_result', {'value': 'final'}),
ToolCallPart('another_tool', {'y': 2}),
ToolCallPart('unknown_tool', {'value': '???'}),
ToolCallPart('deferred_tool', {'x': 5}),
],
)
agent = Agent(FunctionModel(return_model), 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
result = agent.run_sync('test graceful strategy with final result in middle')
# Verify function tools were called but deferred tools were not
assert sorted(tool_called) == sorted(['regular_tool', 'another_tool'])
# Verify we got the correct final result
assert result.output.value == 'final'
# Verify we got appropriate tool returns
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='test graceful strategy with final result in middle',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='regular_tool', args={'x': 1}, tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args={'value': 'final'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args={'y': 2}, tool_call_id=IsStr()),
ToolCallPart(tool_name='unknown_tool', args={'value': '???'}, tool_call_id=IsStr()),
ToolCallPart(
tool_name='deferred_tool',
args={'x': 5},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=58, output_tokens=22),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=1,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='another_tool',
content=2,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
RetryPromptPart(
content="Unknown tool name: 'unknown_tool'. Available tools: 'another_tool', 'deferred_tool', 'final_result', 'regular_tool'",
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_exhaustive_strategy_executes_all_tools(self):
"""Test that 'exhaustive' strategy executes all tools while using first final result."""
tool_called: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('regular_tool', {'x': 42}),
ToolCallPart('final_result', {'value': 'first'}),
ToolCallPart('another_tool', {'y': 2}),
ToolCallPart('final_result', {'value': 'second'}),
ToolCallPart('unknown_tool', {'value': '???'}),
ToolCallPart('deferred_tool', {'x': 4}),
],
)
agent = Agent(FunctionModel(return_model), 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
result = agent.run_sync('test exhaustive strategy')
# Verify the result came from the first final tool
assert result.output.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 result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test exhaustive strategy', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='regular_tool', args={'x': 42}, tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='another_tool', args={'y': 2}, tool_call_id=IsStr()),
ToolCallPart(tool_name='final_result', args={'value': 'second'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='unknown_tool', args={'value': '???'}, tool_call_id=IsStr()),
ToolCallPart(
tool_name='deferred_tool',
args={'x': 4},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=53, output_tokens=27),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='regular_tool',
content=42,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='another_tool',
content=2,
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='final_result',
content='Output tool processed, but its value will not be the final result of the agent run.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
RetryPromptPart(
content="Unknown tool name: 'unknown_tool'. Available tools: 'another_tool', 'deferred_tool', 'final_result', 'regular_tool'",
tool_name='unknown_tool',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='deferred_tool',
content='Tool not executed - a final result was already processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'first'}),
ToolCallPart('second_output', {'value': 'second'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
)
result = agent.run_sync('test exhaustive output tools')
# Verify the result came from the first output tool
assert isinstance(result.output, OutputType)
assert result.output.value == 'first'
# Verify both output tools were called
assert sorted(output_tools_called) == ['first', 'second']
# Verify we got tool returns in the correct order
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test exhaustive output tools', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args={'value': 'first'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args={'value': 'second'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=54, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool processed, but its value will not be the final result of the agent run.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'invalid'}),
ToolCallPart('second_output', {'value': 'valid'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
)
result = agent.run_sync('test invalid first valid second')
# Verify the result came from the second output tool (first was invalid)
assert isinstance(result.output, OutputType)
assert result.output.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 invalid first valid second', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args={'value': 'invalid'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args={'value': 'valid'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=55, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='First output validation failed',
tool_name='first_output',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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')
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', {'value': 'invalid'}),
],
)
agent = Agent(
FunctionModel(return_model),
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
)
result = agent.run_sync('test valid first invalid second')
# Verify the result came from the first output tool (second was invalid, but we ignore it)
assert isinstance(result.output, OutputType)
assert result.output.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 valid first invalid second', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args={'value': 'valid'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args={'value': 'invalid'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=55, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
ToolReturnPart(
tool_name='second_output',
content='Output tool not used - output function execution failed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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')
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', {'value': 'invalid'}),
],
)
agent = Agent(
FunctionModel(return_model),
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
)
result = agent.run_sync('test exhaustive with tool retry')
# Verify the result came from the first output tool
assert isinstance(result.output, OutputType)
assert result.output.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=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='first_output', args={'value': 'valid'}, tool_call_id=IsStr()),
ToolCallPart(tool_name='second_output', args={'value': 'invalid'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=55, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='first_output',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
RetryPromptPart(
content='Second output validation failed',
tool_name='second_output',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
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
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
# Missing 'value' field will cause validation error
ToolCallPart('output_tool', {'invalid_field': 'invalid'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_output, name='output_tool'),
],
end_strategy='exhaustive',
)
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(1\)'):
agent.run_sync('test')
def test_exhaustive_skips_output_tool_exceeding_retries_on_validation(self):
"""Exhaustive strategy skips an output tool that exceeds its per-tool retry limit during validation,
when another output tool already produced a valid result."""
def process_first(output: OutputType) -> OutputType:
return output
def process_second(output: OutputType) -> OutputType: # pragma: no cover
assert False
call_count = 0
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
return ModelResponse(
parts=[
# First tool gets valid args, second gets invalid args
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', {'invalid': 'bad'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output', max_retries=0),
],
end_strategy='exhaustive',
)
# First tool succeeds, second fails validation and exceeds max_retries=0
# The run should succeed with the first tool's result
result = agent.run_sync('test')
assert isinstance(result.output, OutputType)
assert result.output.value == 'valid'
def test_exhaustive_skips_output_tool_exceeding_retries_on_execution(self):
"""Exhaustive strategy skips an output tool that exceeds its per-tool retry limit during execution,
when another output tool already produced a valid result."""
def process_first(output: OutputType) -> OutputType:
return output
def process_second(ctx: RunContext, value: str) -> str:
raise ModelRetry('always fails')
call_count = 0
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', '{"value": "hello"}'),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output', max_retries=0),
],
end_strategy='exhaustive',
)
# First tool succeeds, second passes validation but fails execution and exceeds max_retries=0
# The run should succeed with the first tool's result
result = agent.run_sync('test')
assert isinstance(result.output, OutputType)
assert result.output.value == 'valid'
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."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('final_result', {'bad_value': 'first'}, tool_call_id='first'),
ToolCallPart('final_result', {'value': 'second'}, tool_call_id='second'),
],
)
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='early')
result = agent.run_sync('test multiple final results')
# Verify the result came from the second final tool
assert result.output.value == 'second'
# Verify we got appropriate tool returns
assert result.new_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test multiple final results', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='final_result', args={'bad_value': 'first'}, tool_call_id='first'),
ToolCallPart(tool_name='final_result', args={'value': 'second'}, tool_call_id='second'),
],
usage=RequestUsage(input_tokens=54, output_tokens=10),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
ErrorDetails(
type='missing',
loc=('value',),
msg='Field required',
input={'bad_value': 'first'},
),
],
tool_name='final_result',
tool_call_id='first',
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
timestamp=IsNow(tz=timezone.utc),
tool_call_id='second',
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_exhaustive_strategy_second_output_schema_validation_fails(self):
"""Test exhaustive strategy when first output succeeds and second fails schema validation."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
output_tools_called.append('first')
return output
def process_second(output: OutputTypeWithCount) -> OutputTypeWithCount: # pragma: no cover
output_tools_called.append('second')
return output
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', {'value': 'invalid', 'count': 'not_an_int'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
)
result = agent.run_sync('test exhaustive with schema validation failure')
assert isinstance(result.output, OutputType)
assert result.output.value == 'valid'
assert output_tools_called == ['first']
def test_exhaustive_strategy_second_output_max_retries_exceeded(self):
"""Test exhaustive strategy when first output succeeds and second exceeds max retries."""
output_tools_called: list[str] = []
def process_first(output: OutputType) -> OutputType:
output_tools_called.append('first')
return output
def process_second(output: OutputTypeWithCount) -> OutputTypeWithCount: # pragma: no cover
output_tools_called.append('second')
return output
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', {'value': 'invalid', 'count': 'not_an_int'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='exhaustive',
retries={'output': 0},
)
result = agent.run_sync('test exhaustive with max retries exceeded')
assert isinstance(result.output, OutputType)
assert result.output.value == 'valid'
assert output_tools_called == ['first']
def test_early_strategy_second_output_max_retries_exceeded(self):
"""Test early strategy when first output succeeds and second exceeds max retries."""
def process_first(output: OutputType) -> OutputType:
return output
def process_second(output: OutputTypeWithCount) -> OutputTypeWithCount: # pragma: no cover
return output
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'valid'}),
ToolCallPart('second_output', {'value': 'invalid', 'count': 'not_an_int'}),
],
)
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(process_first, name='first_output'),
ToolOutput(process_second, name='second_output'),
],
end_strategy='early',
retries={'output': 0},
)
result = agent.run_sync('test early with max retries exceeded')
assert isinstance(result.output, OutputType)
assert result.output.value == 'valid'
def test_graceful_function_tool_retry_suppresses_output(self):
"""Retry-wins: a function-tool `ModelRetry` suppresses the output result under `graceful`."""
rounds: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if not rounds:
rounds.append('first')
return ModelResponse(
parts=[
ToolCallPart('flaky_tool', {'x': 1}),
ToolCallPart('final_result', {'value': 'premature'}),
]
)
rounds.append('second')
return ModelResponse(parts=[ToolCallPart('final_result', {'value': 'corrected'})])
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='graceful')
@agent.tool_plain
def flaky_tool(x: int) -> int:
if len(rounds) == 1:
raise ModelRetry('not yet')
return x # pragma: no cover
result = agent.run_sync('test')
# The first round's output was suppressed so the model could address the retry.
assert result.output.value == 'corrected'
assert rounds == ['first', 'second']
# The function-tool retry appears before the suppressed output's rewritten return part.
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='flaky_tool',
args={'x': 1},
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='final_result',
args={'value': 'premature'},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=51, output_tokens=9),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='not yet',
tool_name='flaky_tool',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='final_result',
content='Output not used as the final result - addressing tool retries from this round first.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'value': 'corrected'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=76, output_tokens=14),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_exhaustive_function_tool_retry_suppresses_output(self):
"""Retry-wins also applies under `exhaustive`."""
rounds: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
if not rounds:
rounds.append('first')
return ModelResponse(
parts=[
ToolCallPart('flaky_tool', {'x': 1}),
ToolCallPart('final_result', {'value': 'premature'}),
]
)
rounds.append('second')
return ModelResponse(parts=[ToolCallPart('final_result', {'value': 'corrected'})])
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='exhaustive')
@agent.tool_plain
def flaky_tool(x: int) -> int:
if len(rounds) == 1:
raise ModelRetry('not yet')
return x # pragma: no cover
result = agent.run_sync('test')
assert result.output.value == 'corrected'
assert rounds == ['first', 'second']
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='test', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='flaky_tool',
args={'x': 1},
tool_call_id=IsStr(),
),
ToolCallPart(
tool_name='final_result',
args={'value': 'premature'},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=51, output_tokens=9),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='not yet',
tool_name='flaky_tool',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='final_result',
content='Output not used as the final result - addressing tool retries from this round first.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'value': 'corrected'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=76, output_tokens=14),
model_name='function:return_model:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_early_function_tool_retry_does_not_suppress_output(self):
"""Under `early`, a successful output ends the run and function tools never run, so there's
no retry to suppress it."""
called: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('flaky_tool', {'x': 1}),
ToolCallPart('final_result', {'value': 'kept'}),
]
)
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='early')
@agent.tool_plain
def flaky_tool(x: int) -> int: # pragma: no cover
called.append('flaky_tool')
raise ModelRetry('not yet')
result = agent.run_sync('test')
assert result.output.value == 'kept'
assert called == []
def test_unknown_tool_retry_does_not_suppress_output(self):
"""An unknown/hallucinated tool's retry doesn't trigger retry-wins; the output still wins."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('hallucinated_tool', {'x': 1}),
ToolCallPart('final_result', {'value': 'kept'}),
]
)
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='graceful')
result = agent.run_sync('test')
assert result.output.value == 'kept'
def test_graceful_runs_function_tools_before_output(self):
"""Function tools the model emitted before an output tool complete before it validates."""
events: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('tool_a', {}),
ToolCallPart('tool_b', {}),
ToolCallPart('final_result', {'value': 'done'}),
]
)
agent = Agent(FunctionModel(return_model), output_type=OutputType, end_strategy='graceful')
@agent.tool_plain
def tool_a() -> str:
events.append('tool_a')
return 'a'
@agent.tool_plain
def tool_b() -> str:
events.append('tool_b')
return 'b'
@agent.output_validator
def check(data: OutputType) -> OutputType:
# Both function tools have run by the time the output is validated.
assert sorted(events) == ['tool_a', 'tool_b']
return data
result = agent.run_sync('call tool A and B before the final result')
assert result.output.value == 'done'
def test_sequential_tool_is_a_per_tool_barrier(self):
"""A `sequential=True` tool runs alone; other tools parallelize around it."""
active = 0
barrier_ran_alone = True
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart('parallel_a', {}),
ToolCallPart('parallel_b', {}),
ToolCallPart('barrier', {}),
ToolCallPart('parallel_c', {}),
]
)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(return_model))
async def track(name: str) -> str:
nonlocal active
active += 1
await asyncio.sleep(0.02)
active -= 1
return name
@agent.tool_plain
async def parallel_a() -> str:
return await track('a')
@agent.tool_plain
async def parallel_b() -> str:
return await track('b')
@agent.tool_plain(sequential=True)
async def barrier() -> str:
nonlocal barrier_ran_alone
# No other tool should be in-flight while the barrier runs.
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('c')
agent.run_sync('test')
assert barrier_ran_alone
def test_exhaustive_tool_output_sequential_barrier(self):
"""`ToolOutput(sequential=True)` makes the output a barrier: function tools emitted before
it complete before it runs, even under `exhaustive`'s parallel-by-default execution."""
events: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('tool_a', {}),
ToolCallPart('do_output', {'value': 'done'}),
]
)
def do_output(output: OutputType) -> OutputType:
events.append('output')
return output
agent = Agent(
FunctionModel(return_model),
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'
result = agent.run_sync('test')
assert result.output.value == 'done'
# The barrier output ran only after the preceding function tool completed.
assert events == ['tool_a', 'output']
def test_early_runs_function_tools_when_all_outputs_fail(self):
"""Under `early`, if every output tool fails, function tools run so the model can correct."""
called: list[str] = []
rounds = 0
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal rounds
assert info.output_tools is not None
rounds += 1
if rounds == 1:
return ModelResponse(
parts=[
ToolCallPart('regular_tool', {'x': 1}),
ToolCallPart('bad_output', {'value': 'x'}),
]
)
return ModelResponse(parts=[ToolCallPart('bad_output', {'value': 'ok'})])
def bad_output(output: OutputType) -> OutputType:
if output.value == 'x':
raise ModelRetry('bad')
return output
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(bad_output, name='bad_output'),
end_strategy='early',
)
@agent.tool_plain
def regular_tool(x: int) -> int:
called.append('regular_tool')
return x
result = agent.run_sync('test')
assert result.output.value == 'ok'
# The first-round output failed, so the function tool ran rather than being skipped.
assert called == ['regular_tool']
def test_exhaustive_tool_exception_captures_partial_request(self):
"""Under `exhaustive`, a tool raising mid-batch still surfaces completed tool returns in the
partial `state='interrupted'` request."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart('good_tool', {'x': 1}, tool_call_id='call_good'),
ToolCallPart('bad_tool', {'x': 2}, tool_call_id='call_bad'),
]
)
agent = Agent(FunctionModel(return_model), end_strategy='exhaustive')
@agent.tool_plain(sequential=True)
def good_tool(x: int) -> ToolReturn:
return ToolReturn(return_value=x * 10, content='extra context')
@agent.tool_plain(sequential=True)
def bad_tool(x: int) -> int:
raise RuntimeError('tool-failure')
with capture_run_messages() as messages:
with pytest.raises(RuntimeError, match='tool-failure'):
agent.run_sync('test')
interrupted = message(messages, ModelRequest, index=-1)
assert interrupted.state == 'interrupted'
# The completed tool's return part and its user-content part both surface.
assert interrupted.parts == snapshot(
[
ToolReturnPart(
tool_name='good_tool',
content=10,
tool_call_id='call_good',
timestamp=IsDatetime(),
),
UserPromptPart(content='extra context', timestamp=IsDatetime()),
]
)
def test_exhaustive_function_tool_user_content(self):
"""Under `exhaustive`, a function tool's `ToolReturn` content surfaces as a `UserPromptPart`."""
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('ctx_tool', {})])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(return_model), end_strategy='exhaustive')
@agent.tool_plain
def ctx_tool() -> ToolReturn:
return ToolReturn(return_value='ok', content='extra context for the model')
result = agent.run_sync('test')
assert result.output == 'done'
assert any(
isinstance(part, UserPromptPart) and part.content == 'extra context for the model'
for message in result.all_messages()
if isinstance(message, ModelRequest)
for part in message.parts
)
def test_exhaustive_approval_required_then_resume(self):
"""Under `exhaustive`, a tool raising `ApprovalRequired` defers; resuming with an approval
runs it through the normal pipeline."""
from pydantic_ai.exceptions import ApprovalRequired
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts):
return ModelResponse(parts=[TextPart('done')])
return ModelResponse(parts=[ToolCallPart('needs_approval', {}, tool_call_id='appr')])
agent = Agent(FunctionModel(return_model), end_strategy='exhaustive', output_type=[str, DeferredToolRequests])
approved = False
@agent.tool_plain
def needs_approval() -> str:
if not approved:
raise ApprovalRequired()
return 'approved-result'
result = agent.run_sync('test')
assert isinstance(result.output, DeferredToolRequests)
assert result.output.approvals == snapshot(
[ToolCallPart(tool_name='needs_approval', args={}, tool_call_id='appr')]
)
approved = True
results = DeferredToolResults()
results.approvals['appr'] = True
result2 = agent.run_sync(message_history=result.all_messages(), deferred_tool_results=results)
assert result2.output == 'done'
def test_early_no_function_tools_all_outputs_fail(self):
"""Under `early` with no function tools, a failing output simply retries on the next round."""
rounds = 0
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal rounds
assert info.output_tools is not None
rounds += 1
value = 'bad' if rounds == 1 else 'ok'
return ModelResponse(parts=[ToolCallPart('only_output', {'value': value})])
def only_output(output: OutputType) -> OutputType:
if output.value == 'bad':
raise ModelRetry('try again')
return output
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(only_output, name='only_output'),
end_strategy='early',
)
result = agent.run_sync('test')
assert result.output.value == 'ok'
assert rounds == 2
def test_early_multiple_outputs_and_function_tools(self):
"""Early with several output tools (first wins, rest skipped) and function tools (stubbed)."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('first_output', {'value': 'a'}),
ToolCallPart('second_output', {'value': 'b'}),
ToolCallPart('regular_tool', {'x': 1}),
]
)
agent = Agent(
FunctionModel(return_model),
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
return x
result = agent.run_sync('test')
assert result.output.value == 'a'
def test_graceful_interleaved_outputs_and_function_tools(self):
"""Graceful walk with outputs and function tools interleaved across emission order."""
called: list[str] = []
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(
parts=[
ToolCallPart('tool_a', {}),
ToolCallPart('first_output', {'value': 'a'}),
ToolCallPart('tool_b', {}),
ToolCallPart('second_output', {'value': 'b'}),
]
)
agent = Agent(
FunctionModel(return_model),
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'
result = agent.run_sync('test')
assert result.output.value == 'a'
assert sorted(called) == ['tool_a', 'tool_b']
def test_exhaustive_call_deferred(self):
"""Under `exhaustive`, a tool raising `CallDeferred` is collected as an external deferred call."""
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('slow_tool', {}, tool_call_id='slow')])
agent = Agent(FunctionModel(return_model), end_strategy='exhaustive', output_type=[str, DeferredToolRequests])
@agent.tool_plain
def slow_tool() -> str:
raise CallDeferred
result = agent.run_sync('test')
assert isinstance(result.output, DeferredToolRequests)
assert result.output.calls == snapshot([ToolCallPart(tool_name='slow_tool', args={}, tool_call_id='slow')])
async def test_exhaustive_outer_cancellation_cancels_pending_tools(self):
"""Outer cancellation during `exhaustive` parallel execution cancels still-pending tool tasks."""
first_done = asyncio.Event()
pending_started = asyncio.Event()
pending_cancelled = asyncio.Event()
def return_model(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='fast_tool'),
ToolCallPart(tool_name='slow_tool'),
]
)
agent = Agent(FunctionModel(return_model), end_strategy='exhaustive')
@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
task = asyncio.create_task(agent.run('test'))
await asyncio.wait_for(first_done.wait(), timeout=READINESS_WAIT_TIMEOUT)
await asyncio.wait_for(pending_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert pending_cancelled.is_set()
async def test_exhaustive_parallel_ordered_events(self):
"""Under `parallel_ordered_events`, exhaustive emits function-tool result events in emission
order even though tasks complete out of order (needed for `DBOSAgent` replay determinism)."""
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='slow_first'), ToolCallPart(tool_name='fast_second')]
)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(return_model), end_strategy='exhaustive')
@agent.tool_plain
async def slow_first() -> str:
await asyncio.sleep(0.03)
return 'slow'
@agent.tool_plain
async def fast_second() -> str:
return 'fast'
result_order: list[str | None] = []
with agent.parallel_tool_call_execution_mode('parallel_ordered_events'):
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 event_stream:
async for event in event_stream:
if isinstance(event, FunctionToolResultEvent):
result_order.append(event.part.tool_name)
# `fast_second` completes first, but events are emitted in emission order.
assert result_order == ['slow_first', 'fast_second']
# NOTE: When changing tests in this class:
# 1. Follow the existing order
# 2. Update tests in `tests/test_streaming.py::TestMultipleToolCalls` as well
async def test_model_settings_override() -> None:
def return_settings(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(to_json(info.model_settings).decode())])
my_agent = Agent(FunctionModel(return_settings))
assert (await my_agent.run('Hello')).output == IsJson(None)
assert (await my_agent.run('Hello', model_settings={'temperature': 0.5})).output == IsJson({'temperature': 0.5})
my_agent = Agent(FunctionModel(return_settings), model_settings={'temperature': 0.1})
assert (await my_agent.run('Hello')).output == IsJson({'temperature': 0.1})
assert (await my_agent.run('Hello', model_settings={'temperature': 0.5})).output == IsJson({'temperature': 0.5})
async def test_empty_text_part():
def return_empty_text(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"response": ["foo", "bar"]}'
return ModelResponse(
parts=[
TextPart(''),
ToolCallPart(info.output_tools[0].name, args_json),
],
)
agent = Agent(FunctionModel(return_empty_text), output_type=tuple[str, str])
result = await agent.run('Hello')
assert result.output == ('foo', 'bar')
def test_heterogeneous_responses_non_streaming() -> None:
"""Indicates that tool calls are prioritized over text in heterogeneous responses."""
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
parts: list[ModelResponsePart] = []
if len(messages) == 1:
parts = [TextPart(content='foo'), ToolCallPart('get_location', {'loc_name': 'London'})]
else:
parts = [TextPart(content='final response')]
return ModelResponse(parts=parts)
agent = Agent(FunctionModel(return_model))
@agent.tool_plain
async def get_location(loc_name: str) -> str:
if loc_name == 'London':
return json.dumps({'lat': 51, 'lng': 0})
else:
raise ModelRetry('Wrong location, please try again') # pragma: no cover
result = agent.run_sync('Hello')
assert result.output == 'final response'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(content='foo'),
ToolCallPart(tool_name='get_location', args={'loc_name': 'London'}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=51, output_tokens=6),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_location',
content='{"lat": 51, "lng": 0}',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='final response')],
usage=RequestUsage(input_tokens=56, output_tokens=8),
model_name='function:return_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_nested_capture_run_messages() -> None:
"""Each `capture_run_messages` context captures the runs for which it is the innermost active context.
Regression test for https://github.com/pydantic/pydantic-ai/issues/1568.
"""
agent1 = Agent('test')
agent2 = Agent(TestModel(custom_output_text='inner result'))
with capture_run_messages() as messages1:
assert messages1 == []
res1 = agent1.run_sync('Hi Bro')
assert res1.output == 'success (no tool calls)'
with capture_run_messages() as messages2:
# A nested context starts empty and captures only its own run.
assert messages2 == []
assert messages1 is not messages2
res2 = agent2.run_sync('Hello')
assert res2.output == 'inner result'
# The outer context still holds only the run(s) it captured before nesting.
assert messages1 == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hi Bro', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=52, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# The inner context holds only the nested run.
assert messages2 == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='inner result')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert messages1 != messages2
def test_nested_capture_run_messages_in_tool() -> None:
"""A nested agent run wrapped in its own `capture_run_messages` (e.g. inside a tool) is captured
independently of the outer run, even when the inner run raises.
Regression test for https://github.com/pydantic/pydantic-ai/issues/1568.
"""
def raise_unexpected(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise UnexpectedModelBehavior('boom')
agent_outer = Agent('test')
agent_inner = Agent(FunctionModel(raise_unexpected))
captured_inner: list[ModelMessage] | None = None
@agent_outer.tool_plain
async def call_inner(x: str) -> str:
nonlocal captured_inner
with capture_run_messages() as inner_messages:
captured_inner = inner_messages
result = await agent_inner.run(x)
return result.output # pragma: no cover
with capture_run_messages() as outer_messages:
with pytest.raises(UnexpectedModelBehavior):
agent_outer.run_sync('foobar')
# The inner context captured the inner run's request even though it raised...
assert captured_inner is not None
assert any(isinstance(m, ModelRequest) for m in captured_inner)
# ...and it is a distinct list from the outer capture.
assert captured_inner is not outer_messages
# The outer context captured the outer run up to the tool call that triggered the failing inner run.
assert any(
isinstance(m, ModelResponse) and any(isinstance(p, ToolCallPart) for p in m.parts) for m in outer_messages
)
def test_sequential_capture_run_messages() -> None:
"""Sequential (non-nested) `capture_run_messages` contexts each capture their own run."""
agent = Agent('test')
with capture_run_messages() as messages1:
agent.run_sync('First')
with capture_run_messages() as messages2:
agent.run_sync('Second')
assert messages1 is not messages2
first_prompt = messages1[0]
second_prompt = messages2[0]
assert isinstance(first_prompt, ModelRequest)
assert isinstance(second_prompt, ModelRequest)
assert first_prompt.parts[0] == snapshot(UserPromptPart(content='First', timestamp=IsNow(tz=timezone.utc)))
assert second_prompt.parts[0] == snapshot(UserPromptPart(content='Second', timestamp=IsNow(tz=timezone.utc)))
def test_double_capture_run_messages() -> None:
agent = Agent('test')
with capture_run_messages() as messages:
assert messages == []
result = agent.run_sync('Hello')
assert result.output == 'success (no tool calls)'
result2 = agent.run_sync('Hello 2')
assert result2.output == 'success (no tool calls)'
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_capture_run_messages_with_user_exception_does_not_contain_internal_errors() -> None:
"""Test that user exceptions within capture_run_messages context have clean stack traces."""
agent = Agent('test')
try:
with capture_run_messages():
agent.run_sync('Hello')
raise ZeroDivisionError('division by zero')
except Exception as e:
assert e.__context__ is None
async def test_tool_exception_captures_partial_request() -> None:
"""When one tool raises mid-loop, completed tool returns are captured in a
`ModelRequest` with `state='interrupted'` so `capture_run_messages` reflects
the partial state.
"""
def llm(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='good_tool', args='{"x": 1}', tool_call_id='call_good'),
ToolCallPart(tool_name='bad_tool', args='{"x": 2}', tool_call_id='call_bad'),
]
)
agent = Agent(FunctionModel(function=llm))
# `sequential=True` makes tool execution deterministic: good_tool runs and
# completes before bad_tool raises, so the captured partial reliably includes
# good_tool's return.
@agent.tool_plain(sequential=True)
def good_tool(x: int) -> int:
return x * 10
@agent.tool_plain(sequential=True)
def bad_tool(x: int) -> int:
raise RuntimeError('tool-failure')
with capture_run_messages() as messages:
with pytest.raises(RuntimeError, match='tool-failure'):
await agent.run('Hello')
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='good_tool', args='{"x": 1}', tool_call_id='call_good'),
ToolCallPart(tool_name='bad_tool', args='{"x": 2}', tool_call_id='call_bad'),
],
usage=RequestUsage(input_tokens=51, output_tokens=8),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='good_tool',
content=10,
tool_call_id='call_good',
timestamp=IsDatetime(),
),
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
async def test_tool_execution_cancellation_captures_partial_request() -> None:
"""Cancellation mid-tool-execution captures the completed tool returns as a partial
`ModelRequest` with `state='interrupted'`.
"""
first_done = asyncio.Event()
never = asyncio.Event()
def llm(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='fast_tool', args='{"x": 1}', tool_call_id='call_fast'),
ToolCallPart(tool_name='slow_tool', args='{"x": 2}', tool_call_id='call_slow'),
]
)
agent = Agent(FunctionModel(function=llm))
@agent.tool_plain
async def fast_tool(x: int) -> int:
first_done.set()
return x * 10
@agent.tool_plain
async def slow_tool(x: int) -> int:
await never.wait()
return x # pragma: no cover
captured: list[ModelMessage] = []
async def consume() -> None:
nonlocal captured
with capture_run_messages() as messages:
try:
await agent.run('Hello')
finally:
captured = list(messages)
task = asyncio.create_task(consume())
await asyncio.wait_for(first_done.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
partial_requests = [m for m in captured if isinstance(m, ModelRequest) and m.state == 'interrupted']
assert len(partial_requests) == 1
parts = partial_requests[0].parts
assert any(isinstance(p, ToolReturnPart) and p.tool_name == 'fast_tool' and p.content == 10 for p in parts)
def test_dynamic_false_no_reevaluate():
"""When dynamic is false (default), the system prompt is not reevaluated
i.e: SystemPromptPart(
content="A", <--- Remains the same when `message_history` is passed.
)
"""
agent = Agent('test', system_prompt='Foobar')
dynamic_value = 'A'
@agent.system_prompt
async def func() -> str:
return dynamic_value
res = agent.run_sync('Hello')
assert res.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
SystemPromptPart(content=dynamic_value, timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
kind='request',
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=53, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
kind='response',
conversation_id=IsStr(),
),
]
)
dynamic_value = 'B'
res_two = agent.run_sync('World', message_history=res.all_messages())
assert res_two.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
SystemPromptPart(
content='A', # Remains the same
timestamp=IsNow(tz=timezone.utc),
),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
kind='request',
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=53, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
kind='response',
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='World', timestamp=IsNow(tz=timezone.utc), part_kind='user-prompt')],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
kind='request',
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=54, output_tokens=8),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
kind='response',
conversation_id=IsStr(),
),
]
)
assert res_two.new_messages() == res_two.all_messages()[-2:]
def test_dynamic_true_reevaluate_system_prompt():
"""When dynamic is true, the system prompt is reevaluated
i.e: SystemPromptPart(
content="B", <--- Updated value
)
"""
agent = Agent('test', system_prompt='Foobar')
dynamic_value = 'A'
@agent.system_prompt(dynamic=True)
async def func():
return dynamic_value
res = agent.run_sync('Hello')
assert res.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
SystemPromptPart(
content=dynamic_value,
dynamic_ref=func.__qualname__,
timestamp=IsNow(tz=timezone.utc),
),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
kind='request',
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=53, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
kind='response',
conversation_id=IsStr(),
),
]
)
dynamic_value = 'B'
res_two = agent.run_sync('World', message_history=res.all_messages())
assert res_two.all_messages() == snapshot(
[
ModelRequest(
parts=[
SystemPromptPart(content='Foobar', timestamp=IsNow(tz=timezone.utc)),
SystemPromptPart(
content='B',
dynamic_ref=func.__qualname__,
timestamp=IsNow(tz=timezone.utc),
),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
kind='request',
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=53, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
kind='response',
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='World', timestamp=IsNow(tz=timezone.utc), part_kind='user-prompt')],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
kind='request',
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=54, output_tokens=8),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
kind='response',
conversation_id=IsStr(),
),
]
)
assert res_two.new_messages() == res_two.all_messages()[-2:]
def test_dynamic_system_prompt_no_changes():
"""Test coverage for _reevaluate_dynamic_prompts branch where no parts are changed
and the messages loop continues after replacement of parts.
"""
agent = Agent('test')
@agent.system_prompt(dynamic=True)
async def dynamic_func() -> str:
return 'Dynamic'
result1 = agent.run_sync('Hello')
# Create ModelRequest with non-dynamic SystemPromptPart (no dynamic_ref)
manual_request = ModelRequest(parts=[SystemPromptPart(content='Static'), UserPromptPart(content='Manual')])
# Mix dynamic and non-dynamic messages to trigger branch coverage
result2 = agent.run_sync('Second call', message_history=result1.all_messages() + [manual_request])
assert result2.output == 'success (no tool calls)'
def test_dynamic_system_prompt_none_return():
"""Test dynamic system prompts with None return values."""
agent = Agent('test')
dynamic_values = [None, 'DYNAMIC']
@agent.system_prompt(dynamic=True)
def dynamic_sys() -> str | None:
return dynamic_values.pop(0)
with capture_run_messages() as base_messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='baseline'))
base_req = message(base_messages, ModelRequest)
sys_texts = [p.content for p in base_req.parts if isinstance(p, SystemPromptPart)]
# The None value should have a '' placeholder due to keeping a reference to the dynamic prompt
assert '' in sys_texts
assert 'DYNAMIC' not in sys_texts
# Run a second time to capture the updated system prompt
with capture_run_messages() as messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='baseline'))
req = message(messages, ModelRequest)
sys_texts = [p.content for p in req.parts if isinstance(p, SystemPromptPart)]
# The None value should have a '' placeholder due to keep a reference to the dynamic prompt
assert '' not in sys_texts
assert 'DYNAMIC' in sys_texts
def test_system_prompt_none_return_are_omitted():
"""Test dynamic system prompts with None return values."""
agent = Agent('test', system_prompt='STATIC')
@agent.system_prompt
def dynamic_sys() -> str | None:
return None
with capture_run_messages() as base_messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='baseline'))
base_req = message(base_messages, ModelRequest)
sys_texts = [p.content for p in base_req.parts if isinstance(p, SystemPromptPart)]
# The None value should be omitted
assert 'STATIC' in sys_texts
assert '' not in sys_texts
def test_capture_run_messages_tool_agent() -> None:
agent_outer = Agent('test')
agent_inner = Agent(TestModel(custom_output_text='inner agent result'))
@agent_outer.tool_plain
async def foobar(x: str) -> str:
result_ = await agent_inner.run(x)
return result_.output
with capture_run_messages() as messages:
result = agent_outer.run_sync('foobar')
assert result.output == snapshot('{"foobar":"inner agent result"}')
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='foobar', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foobar', args={'x': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='foobar',
content='inner agent result',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"foobar":"inner agent result"}')],
usage=RequestUsage(input_tokens=54, output_tokens=11),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class Bar(BaseModel):
c: int
d: str
def test_custom_output_type_sync() -> None:
agent = Agent('test', output_type=Foo)
assert agent.run_sync('Hello').output == snapshot(Foo(a=0, b='a'))
assert agent.run_sync('Hello', output_type=Bar).output == snapshot(Bar(c=0, d='a'))
assert agent.run_sync('Hello', output_type=str).output == snapshot('success (no tool calls)')
assert agent.run_sync('Hello', output_type=int).output == snapshot(0)
async def test_custom_output_type_async() -> None:
agent = Agent('test')
result = await agent.run('Hello')
assert result.output == snapshot('success (no tool calls)')
result = await agent.run('Hello', output_type=Foo)
assert result.output == snapshot(Foo(a=0, b='a'))
result = await agent.run('Hello', output_type=int)
assert result.output == snapshot(0)
def test_custom_output_type_invalid() -> None:
agent = Agent('test')
@agent.output_validator
def validate_output(ctx: RunContext, o: Any) -> Any: # pragma: no cover
return o
with pytest.raises(UserError, match='Cannot set a custom run `output_type` when the agent has output validators'):
agent.run_sync('Hello', output_type=int)
def test_binary_content_serializable():
agent = Agent('test')
content = BinaryContent(data=b'Hello', media_type='text/plain')
result = agent.run_sync(['Hello', content])
serialized = result.all_messages_json()
assert json.loads(serialized) == snapshot(
[
{
'parts': [
{
'content': [
'Hello',
{
'data': 'SGVsbG8=',
'media_type': 'text/plain',
'vendor_metadata': None,
'kind': 'binary',
'identifier': 'f7ff9e',
},
],
'timestamp': IsStr(),
'part_kind': 'user-prompt',
}
],
'timestamp': IsStr(),
'instructions': None,
'kind': 'request',
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
},
{
'parts': [
{
'content': 'success (no tool calls)',
'id': None,
'provider_name': None,
'part_kind': 'text',
'provider_details': None,
}
],
'usage': {
'input_tokens': 56,
'cache_write_tokens': 0,
'cache_read_tokens': 0,
'output_tokens': 4,
'input_audio_tokens': 0,
'cache_audio_read_tokens': 0,
'output_audio_tokens': 0,
'details': {},
},
'model_name': 'test',
'provider_name': 'test',
'provider_details': None,
'provider_response_id': None,
'provider_url': None,
'timestamp': IsStr(),
'kind': 'response',
'finish_reason': None,
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
},
]
)
# We also need to be able to round trip the serialized messages.
messages = ModelMessagesTypeAdapter.validate_json(serialized)
assert messages == result.all_messages()
def test_image_url_serializable_missing_media_type():
agent = Agent('test')
content = ImageUrl('https://example.com/chart.jpeg')
result = agent.run_sync(['Hello', content])
serialized = result.all_messages_json()
assert json.loads(serialized) == snapshot(
[
{
'parts': [
{
'content': [
'Hello',
{
'url': 'https://example.com/chart.jpeg',
'force_download': False,
'vendor_metadata': None,
'kind': 'image-url',
'media_type': 'image/jpeg',
'identifier': 'a72e39',
},
],
'timestamp': IsStr(),
'part_kind': 'user-prompt',
}
],
'timestamp': IsStr(),
'instructions': None,
'kind': 'request',
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
},
{
'parts': [
{
'content': 'success (no tool calls)',
'id': None,
'provider_name': None,
'part_kind': 'text',
'provider_details': None,
}
],
'usage': {
'input_tokens': 51,
'cache_write_tokens': 0,
'cache_read_tokens': 0,
'output_tokens': 4,
'input_audio_tokens': 0,
'cache_audio_read_tokens': 0,
'output_audio_tokens': 0,
'details': {},
},
'model_name': 'test',
'timestamp': IsStr(),
'provider_name': 'test',
'provider_details': None,
'provider_url': None,
'provider_response_id': None,
'kind': 'response',
'finish_reason': None,
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
},
]
)
# We also need to be able to round trip the serialized messages.
messages = ModelMessagesTypeAdapter.validate_json(serialized)
part = message_part(messages, UserPromptPart)
content = part.content[1]
assert isinstance(content, ImageUrl)
assert content.media_type == 'image/jpeg'
assert messages == result.all_messages()
def test_image_url_serializable():
agent = Agent('test')
content = ImageUrl('https://example.com/chart', media_type='image/jpeg')
result = agent.run_sync(['Hello', content])
serialized = result.all_messages_json()
assert json.loads(serialized) == snapshot(
[
{
'parts': [
{
'content': [
'Hello',
{
'url': 'https://example.com/chart',
'force_download': False,
'vendor_metadata': None,
'kind': 'image-url',
'media_type': 'image/jpeg',
'identifier': 'bdd86d',
},
],
'timestamp': IsStr(),
'part_kind': 'user-prompt',
}
],
'timestamp': IsStr(),
'instructions': None,
'kind': 'request',
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
},
{
'parts': [
{
'content': 'success (no tool calls)',
'id': None,
'provider_name': None,
'part_kind': 'text',
'provider_details': None,
}
],
'usage': {
'input_tokens': 51,
'cache_write_tokens': 0,
'cache_read_tokens': 0,
'output_tokens': 4,
'input_audio_tokens': 0,
'cache_audio_read_tokens': 0,
'output_audio_tokens': 0,
'details': {},
},
'model_name': 'test',
'timestamp': IsStr(),
'provider_name': 'test',
'provider_details': None,
'provider_url': None,
'provider_response_id': None,
'kind': 'response',
'finish_reason': None,
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
},
]
)
# We also need to be able to round trip the serialized messages.
messages = ModelMessagesTypeAdapter.validate_json(serialized)
part = message_part(messages, UserPromptPart)
content = part.content[1]
assert isinstance(content, ImageUrl)
assert content.media_type == 'image/jpeg'
assert messages == result.all_messages()
def test_tool_returning_binary_content_directly():
"""Test that a tool returning BinaryContent directly works correctly."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('get_image', {})])
else:
return ModelResponse(parts=[TextPart('Image received')])
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def get_image() -> BinaryContent:
"""Return a simple image."""
png_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178\x00\x00\x00\x00IEND\xaeB`\x82'
return BinaryContent(png_data, media_type='image/png')
# This should work without the serialization error
result = agent.run_sync('Get an image')
assert result.output == 'Image received'
def test_tool_returning_binary_content_with_identifier():
"""Test that a tool returning BinaryContent directly works correctly."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('get_image', {})])
else:
return ModelResponse(parts=[TextPart('Image received')])
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def get_image() -> BinaryContent:
"""Return a simple image."""
png_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178\x00\x00\x00\x00IEND\xaeB`\x82'
return BinaryContent(png_data, media_type='image/png', identifier='image_id_1')
# This should work without the serialization error
result = agent.run_sync('Get an image')
assert result.all_messages()[2] == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_image',
content=IsInstance(BinaryContent),
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_tool_returning_file_url_with_identifier():
"""Test that a tool returning FileUrl subclasses with identifiers works correctly."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('get_files', {})])
else:
return ModelResponse(parts=[TextPart('Files received')])
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def get_files():
"""Return various file URLs with custom identifiers."""
return [
ImageUrl(url='https://example.com/image.jpg', identifier='img_001'),
VideoUrl(url='https://example.com/video.mp4', identifier='vid_002'),
AudioUrl(url='https://example.com/audio.mp3', identifier='aud_003'),
DocumentUrl(url='https://example.com/document.pdf', identifier='doc_004'),
]
result = agent.run_sync('Get some files')
assert result.all_messages()[2] == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='get_files',
content=[
ImageUrl(url='https://example.com/image.jpg', _identifier='img_001'),
VideoUrl(url='https://example.com/video.mp4', _identifier='vid_002'),
AudioUrl(url='https://example.com/audio.mp3', _identifier='aud_003'),
DocumentUrl(url='https://example.com/document.pdf', _identifier='doc_004'),
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_raise_error_when_system_prompt_is_set():
agent = Agent('test', instructions='An instructions!')
@agent.system_prompt
def system_prompt() -> str:
return 'A system prompt!'
result = agent.run_sync('Hello')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[
SystemPromptPart(content='A system prompt!', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
instructions='An instructions!',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_raise_error_when_instructions_is_set():
agent = Agent('test', system_prompt='A system prompt!')
@agent.instructions
def instructions() -> str:
return 'An instructions!'
@agent.instructions
def empty_instructions() -> str:
return ''
result = agent.run_sync('Hello')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[
SystemPromptPart(content='A system prompt!', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
instructions='An instructions!',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_both_instructions_and_system_prompt_are_set():
agent = Agent('test', instructions='An instructions!', system_prompt='A system prompt!')
result = agent.run_sync('Hello')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[
SystemPromptPart(content='A system prompt!', timestamp=IsNow(tz=timezone.utc)),
UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc)),
],
timestamp=IsNow(tz=timezone.utc),
instructions='An instructions!',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_decorator_without_parenthesis():
agent = Agent('test')
@agent.instructions
def instructions() -> str:
return 'You are a helpful assistant.'
result = agent.run_sync('Hello')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_decorator_with_parenthesis():
agent = Agent('test')
@agent.instructions()
def instructions_2() -> str:
return 'You are a helpful assistant.'
result = agent.run_sync('Hello')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_with_message_history():
agent = Agent('test', instructions='You are a helpful assistant.')
result = agent.run_sync(
'Hello',
message_history=[ModelRequest(parts=[SystemPromptPart(content='You are a helpful assistant')])],
)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[SystemPromptPart(content='You are a helpful assistant', timestamp=IsNow(tz=timezone.utc))]
),
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (no tool calls)')],
usage=RequestUsage(input_tokens=56, output_tokens=4),
model_name='test',
timestamp=IsNow(tz=timezone.utc),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == result.all_messages()[-2:]
def test_instructions_parameter_with_sequence():
def instructions() -> str:
return 'You are a potato.'
def empty_instructions() -> str:
return ''
agent = Agent('test', instructions=('You are a helpful assistant.', empty_instructions, instructions))
result = agent.run_sync('Hello')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsNow(tz=timezone.utc),
instructions="""\
You are a helpful assistant.
You are a potato.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_instructions_during_run():
agent = Agent('test', instructions='You are a helpful assistant.')
result = agent.run_sync('Hello', instructions='Your task is to greet people.')
assert result.all_messages()[0] == snapshot(
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsNow(tz=timezone.utc),
instructions="""\
You are a helpful assistant.
Your task is to greet people.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
)
)
result2 = agent.run_sync('Hello again!')
assert result2.all_messages()[0] == snapshot(
ModelRequest(
parts=[UserPromptPart(content='Hello again!', timestamp=IsDatetime())],
timestamp=IsNow(tz=timezone.utc),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
)
)
def test_multi_agent_instructions_with_structured_output():
"""Test that Agent2 uses its own instructions when called with Agent1's history.
Reproduces issue #3207: when running agents sequentially with no user_prompt
and structured output, Agent2's instructions were ignored.
"""
class Output(BaseModel):
text: str
agent1 = Agent('test', instructions='Agent 1 instructions')
agent2 = Agent('test', instructions='Agent 2 instructions', output_type=Output)
result1 = agent1.run_sync('Hello')
result2 = agent2.run_sync(message_history=result1.new_messages())
messages = result2.new_messages()
assert messages == snapshot(
[
ModelRequest(
parts=[],
timestamp=IsNow(tz=timezone.utc),
instructions='Agent 2 instructions',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args={'text': 'a'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=9),
model_name='test',
timestamp=IsDatetime(),
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=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# Verify Agent2's retry requests used Agent2's instructions (not Agent1's)
requests = [m for m in messages if isinstance(m, ModelRequest)]
assert any(r.instructions == 'Agent 2 instructions' for r in requests)
def test_empty_final_response():
"""An empty final response asks the model for text rather than reusing text sent alongside a tool call."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[TextPart('foo'), ToolCallPart('my_tool', {'x': 1})])
elif len(messages) == 3:
return ModelResponse(parts=[TextPart('bar'), ToolCallPart('my_tool', {'x': 2})])
elif len(messages) == 5:
return ModelResponse(parts=[])
else:
return ModelResponse(parts=[TextPart('baz')])
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def my_tool(x: int) -> int:
return x * 2
result = agent.run_sync('Hello')
assert result.output == 'baz'
assert result.new_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(content='foo'),
ToolCallPart(tool_name='my_tool', args={'x': 1}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content=2,
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(content='bar'),
ToolCallPart(tool_name='my_tool', args={'x': 2}, tool_call_id=IsStr()),
],
usage=RequestUsage(input_tokens=52, output_tokens=10),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content=4,
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=53, output_tokens=10),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text or call a tool.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='baz')],
usage=RequestUsage(input_tokens=69, output_tokens=11),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_agent_run_result_serialization() -> None:
agent = Agent('test', output_type=Foo)
result = agent.run_sync('Hello')
# Check that dump_json doesn't raise an error
adapter = TypeAdapter(AgentRunResult[Foo])
serialized_data = adapter.dump_json(result)
# Check that we can load the data back
deserialized_result = adapter.validate_json(serialized_data)
assert deserialized_result == result
def test_agent_repr() -> None:
agent = Agent()
assert repr(agent) == snapshot(
"Agent(model=None, name=None, end_strategy='graceful', model_settings=None, output_type=<class 'str'>)"
)
async def test_agent_context_manager_no_model():
agent = Agent()
async with agent:
pass
@requires_openai
async def test_provider_lifecycle_closes_client():
"""Provider lifecycle closes owned HTTP client on exit.
Regression test for PR #4421 (provider lifecycle management).
https://github.com/pydantic/pydantic-ai/pull/4421
"""
provider = OpenAIProvider(api_key='test-key')
async with provider:
http_client = provider.client._client # pyright: ignore[reportPrivateUsage]
assert not http_client.is_closed
assert http_client.is_closed
@requires_openai
async def test_provider_reentrant_lifecycle():
"""Reentrant provider lifecycle keeps client open until outermost exit.
Regression test for PR #4421 (provider lifecycle management).
https://github.com/pydantic/pydantic-ai/pull/4421
"""
provider = OpenAIProvider(api_key='test-key')
async with provider:
http_client = provider.client._client # pyright: ignore[reportPrivateUsage]
async with provider:
assert not http_client.is_closed
assert not http_client.is_closed
assert http_client.is_closed
@requires_openai
async def test_provider_aexit_without_aenter():
"""Calling __aexit__ without __aenter__ is a no-op (no crash).
Regression test for PR #4421 (provider lifecycle management).
https://github.com/pydantic/pydantic-ai/pull/4421
"""
provider = OpenAIProvider(api_key='test-key')
await provider.__aexit__(None, None, None)
# Clean up the owned http client to avoid ResourceWarning from __del__
assert provider._own_http_client is not None # pyright: ignore[reportPrivateUsage]
await provider._own_http_client.aclose() # pyright: ignore[reportPrivateUsage]
@requires_openai
async def test_provider_aexit_without_aenter_then_async_with():
"""Bare __aexit__ before any __aenter__ must not corrupt later lifecycle state."""
provider = OpenAIProvider(api_key='test-key')
await provider.__aexit__(None, None, None)
async with provider:
assert provider._own_http_client is not None # pyright: ignore[reportPrivateUsage]
assert not provider._own_http_client.is_closed # pyright: ignore[reportPrivateUsage]
assert provider._own_http_client.is_closed # pyright: ignore[reportPrivateUsage]
@requires_openai
async def test_provider_reentry_after_close():
"""Provider can be re-entered after exit by recreating the HTTP client."""
provider = OpenAIProvider(api_key='test-key')
async with provider:
first_client = provider.client._client # pyright: ignore[reportPrivateUsage]
assert not first_client.is_closed
assert first_client.is_closed
async with provider:
second_client = provider.client._client # pyright: ignore[reportPrivateUsage]
assert not second_client.is_closed
assert second_client is not first_client
assert second_client.is_closed
@requires_openai
async def test_gateway_provider_reentry_after_close():
"""Gateway provider restores event_hooks on re-entry."""
from pydantic_ai.providers.gateway import gateway_provider
provider = gateway_provider('openai', api_key='test-key', base_url='https://gateway.example.com/proxy')
async with provider:
first_client = provider._own_http_client # pyright: ignore[reportPrivateUsage]
assert first_client is not None
assert not first_client.is_closed
assert len(first_client.event_hooks.get('request', [])) == 1
assert first_client.is_closed
async with provider:
second_client = provider._own_http_client # pyright: ignore[reportPrivateUsage]
assert second_client is not None
assert not second_client.is_closed
assert second_client is not first_client
assert len(second_client.event_hooks.get('request', [])) == 1
assert second_client.is_closed
@requires_openai
async def test_azure_provider_lifecycle_closes_client():
"""Azure provider lifecycle closes owned HTTP client on exit.
Regression test for PR #4421 (provider lifecycle management).
https://github.com/pydantic/pydantic-ai/pull/4421
"""
provider = AzureProvider(
azure_endpoint='https://test.openai.azure.com',
api_key='test-key',
api_version='2024-02-01',
)
async with provider:
http_client = provider.client._client # pyright: ignore[reportPrivateUsage]
assert not http_client.is_closed
assert http_client.is_closed
@pytest.mark.parametrize(
'provider_factory',
[
pytest.param(lambda: OpenAIProvider(api_key='t'), marks=[requires_openai], id='openai'),
pytest.param(lambda: AnthropicProvider(api_key='t'), marks=[requires_anthropic], id='anthropic'),
pytest.param(lambda: GroqProvider(api_key='t'), marks=[requires_groq], id='groq'),
pytest.param(lambda: MistralProvider(api_key='t'), marks=[requires_mistral], id='mistral'),
pytest.param(lambda: CohereProvider(api_key='t'), marks=[requires_cohere], id='cohere'),
pytest.param(lambda: GoogleProvider(api_key='t'), marks=[requires_google], id='google'),
pytest.param(
lambda: AzureProvider(azure_endpoint='https://t.openai.azure.com', api_key='t', api_version='2024-02-01'),
marks=[requires_openai],
id='azure',
),
pytest.param(lambda: CerebrasProvider(api_key='t'), marks=[requires_openai], id='cerebras'),
pytest.param(lambda: DeepSeekProvider(api_key='t'), marks=[requires_openai], id='deepseek'),
pytest.param(lambda: FireworksProvider(api_key='t'), marks=[requires_openai], id='fireworks'),
pytest.param(lambda: GitHubProvider(api_key='t'), marks=[requires_openai], id='github'),
pytest.param(lambda: HerokuProvider(api_key='t'), marks=[requires_openai], id='heroku'),
pytest.param(lambda: LiteLLMProvider(api_key='t'), marks=[requires_litellm], id='litellm'),
pytest.param(lambda: MoonshotAIProvider(api_key='t'), marks=[requires_openai], id='moonshotai'),
pytest.param(lambda: NebiusProvider(api_key='t'), marks=[requires_openai], id='nebius'),
pytest.param(
lambda: OllamaProvider(base_url='http://localhost:11434/v1'), marks=[requires_openai], id='ollama'
),
pytest.param(lambda: OpenRouterProvider(api_key='t'), marks=[requires_openai], id='openrouter'),
pytest.param(lambda: OVHcloudProvider(api_key='t'), marks=[requires_openai], id='ovhcloud'),
pytest.param(lambda: SambaNovaProvider(api_key='t'), marks=[requires_openai], id='sambanova'),
pytest.param(lambda: TogetherProvider(api_key='t'), marks=[requires_openai], id='together'),
pytest.param(lambda: VercelProvider(api_key='t'), marks=[requires_openai], id='vercel'),
pytest.param(lambda: AlibabaProvider(api_key='t'), marks=[requires_openai], id='alibaba'),
],
)
async def test_provider_reentry_recreates_http_client(provider_factory: Callable[[], Provider[Any]]):
"""All providers with _own_http_client properly close on exit and recreate on re-entry."""
provider = provider_factory()
assert provider._own_http_client is not None # pyright: ignore[reportPrivateUsage]
async with provider:
first_client = provider._own_http_client # pyright: ignore[reportPrivateUsage]
assert first_client is not None
assert not first_client.is_closed
assert first_client.is_closed
async with provider:
second_client = provider._own_http_client # pyright: ignore[reportPrivateUsage]
assert second_client is not None
assert not second_client.is_closed
assert second_client is not first_client
assert second_client.is_closed
def test_tool_call_with_validation_value_error_serializable():
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('foo_tool', {'bar': 0})])
elif len(messages) == 3:
return ModelResponse(parts=[ToolCallPart('foo_tool', {'bar': 1})])
else:
return ModelResponse(parts=[TextPart('Tool returned 1')])
agent = Agent(FunctionModel(llm))
class Foo(BaseModel):
bar: int
@field_validator('bar')
def validate_bar(cls, v: int) -> int:
if v == 0:
raise ValueError('bar cannot be 0')
return v
@agent.tool_plain
def foo_tool(foo: Foo) -> int:
return foo.bar
result = agent.run_sync('Hello')
assert json.loads(result.all_messages_json())[2] == snapshot(
{
'parts': [
{
'content': [
{'type': 'value_error', 'loc': ['bar'], 'msg': 'Value error, bar cannot be 0', 'input': 0}
],
'tool_name': 'foo_tool',
'tool_call_id': IsStr(),
'timestamp': IsStr(),
'part_kind': 'retry-prompt',
}
],
'timestamp': IsStr(),
'instructions': None,
'kind': 'request',
'run_id': IsStr(),
'conversation_id': IsStr(),
'metadata': None,
'state': 'complete',
}
)
def test_unsupported_output_mode():
class Foo(BaseModel):
bar: str
def hello(_: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('hello')]) # pragma: no cover
model = FunctionModel(hello, profile=ModelProfile(supports_tools=False, supports_json_schema_output=False))
agent = Agent(model, output_type=NativeOutput(Foo))
with pytest.raises(UserError, match=re.escape('Native structured output is not supported by this model.')):
agent.run_sync('Hello')
agent = Agent(model, output_type=ToolOutput(Foo))
with pytest.raises(UserError, match=re.escape('Tool output is not supported by this model.')):
agent.run_sync('Hello')
agent = Agent(model, output_type=BinaryImage)
with pytest.raises(UserError, match=re.escape('Image output is not supported by this model.')):
agent.run_sync('Hello')
def test_multimodal_tool_response():
"""Test ToolReturn with custom content and tool return."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[TextPart('Starting analysis'), ToolCallPart('analyze_data', {})])
else:
return ModelResponse(
parts=[
TextPart('Analysis completed'),
]
)
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def analyze_data() -> ToolReturn:
return ToolReturn(
return_value='Data analysis completed successfully',
content=[
'Here are the analysis results:',
ImageUrl('https://example.com/chart.jpg'),
'The chart shows positive trends.',
],
metadata={'foo': 'bar'},
)
result = agent.run_sync('Please analyze the data')
# Verify final output
assert result.output == 'Analysis completed'
# Verify message history contains the expected parts
# Verify the complete message structure using snapshot
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Please analyze the data', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(content='Starting analysis'),
ToolCallPart(
tool_name='analyze_data',
args={},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:llm:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='analyze_data',
content='Data analysis completed successfully',
tool_call_id=IsStr(),
metadata={'foo': 'bar'},
timestamp=IsNow(tz=timezone.utc),
),
UserPromptPart(
content=[
'Here are the analysis results:',
ImageUrl(url='https://example.com/chart.jpg', identifier='672a5c'),
'The chart shows positive trends.',
],
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Analysis completed')],
usage=RequestUsage(input_tokens=70, output_tokens=6),
model_name='function:llm:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_plain_tool_response():
"""Test ToolReturn with custom content and tool return."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[TextPart('Starting analysis'), ToolCallPart('analyze_data', {})])
else:
return ModelResponse(
parts=[
TextPart('Analysis completed'),
]
)
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def analyze_data() -> ToolReturn:
return ToolReturn(
return_value='Data analysis completed successfully',
metadata={'foo': 'bar'},
)
result = agent.run_sync('Please analyze the data')
# Verify final output
assert result.output == 'Analysis completed'
# Verify message history contains the expected parts
# Verify the complete message structure using snapshot
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Please analyze the data', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(content='Starting analysis'),
ToolCallPart(
tool_name='analyze_data',
args={},
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=54, output_tokens=4),
model_name='function:llm:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='analyze_data',
content='Data analysis completed successfully',
tool_call_id=IsStr(),
metadata={'foo': 'bar'},
timestamp=IsNow(tz=timezone.utc),
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Analysis completed')],
usage=RequestUsage(input_tokens=58, output_tokens=6),
model_name='function:llm:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_many_multimodal_tool_response():
"""Test ToolReturn with custom content and tool return."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[TextPart('Starting analysis'), ToolCallPart('analyze_data', {})])
else:
return ModelResponse( # pragma: no cover
parts=[
TextPart('Analysis completed'),
]
)
agent = Agent(FunctionModel(llm))
@agent.tool_plain
def analyze_data() -> list[Any]:
return [
ToolReturn(
return_value='Data analysis completed successfully',
content=[
'Here are the analysis results:',
ImageUrl('https://example.com/chart.jpg'),
'The chart shows positive trends.',
],
metadata={'foo': 'bar'},
),
'Something else',
]
with pytest.raises(
UserError,
match=re.escape(
"The return value of tool 'analyze_data' contains invalid nested `ToolReturn` objects. `ToolReturn` should be used directly."
),
):
agent.run_sync('Please analyze the data')
def test_override_toolsets():
foo_toolset = FunctionToolset()
@foo_toolset.tool_plain
def foo() -> str:
return 'Hello from foo'
available_tools: list[list[str]] = []
available_tools_property: list[set[str]] = []
async def prepare_tools(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
nonlocal available_tools
available_tools.append([tool_def.name for tool_def in tool_defs])
available_tools_property.append(ctx.available_tool_names)
return tool_defs
agent = Agent('test', toolsets=[foo_toolset], capabilities=[PrepareTools(prepare_tools)])
@agent.tool_plain
def baz() -> str:
return 'Hello from baz'
result = agent.run_sync('Hello')
assert available_tools[-1] == snapshot(['baz', 'foo'])
assert available_tools_property[-1] == {'baz', 'foo'}
assert result.output == snapshot('{"baz":"Hello from baz","foo":"Hello from foo"}')
bar_toolset = FunctionToolset()
@bar_toolset.tool_plain
def bar() -> str:
return 'Hello from bar'
with agent.override(toolsets=[bar_toolset]):
result = agent.run_sync('Hello')
assert available_tools[-1] == snapshot(['baz', 'bar'])
assert result.output == snapshot('{"baz":"Hello from baz","bar":"Hello from bar"}')
with agent.override(toolsets=[]):
result = agent.run_sync('Hello')
assert available_tools[-1] == snapshot(['baz'])
assert result.output == snapshot('{"baz":"Hello from baz"}')
result = agent.run_sync('Hello', toolsets=[bar_toolset])
assert available_tools[-1] == snapshot(['baz', 'foo', 'bar'])
assert result.output == snapshot('{"baz":"Hello from baz","foo":"Hello from foo","bar":"Hello from bar"}')
with agent.override(toolsets=[]):
result = agent.run_sync('Hello', toolsets=[bar_toolset])
assert available_tools[-1] == snapshot(['baz'])
assert result.output == snapshot('{"baz":"Hello from baz"}')
def test_override_tools():
def foo() -> str:
return 'Hello from foo'
def bar() -> str:
return 'Hello from bar'
available_tools: list[list[str]] = []
async def prepare_tools(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
nonlocal available_tools
available_tools.append([tool_def.name for tool_def in tool_defs])
return tool_defs
agent = Agent('test', tools=[foo], capabilities=[PrepareTools(prepare_tools)])
result = agent.run_sync('Hello')
assert available_tools[-1] == snapshot(['foo'])
assert result.output == snapshot('{"foo":"Hello from foo"}')
with agent.override(tools=[bar]):
result = agent.run_sync('Hello')
assert available_tools[-1] == snapshot(['bar'])
assert result.output == snapshot('{"bar":"Hello from bar"}')
with agent.override(tools=[]):
result = agent.run_sync('Hello')
assert available_tools[-1] == snapshot([])
assert result.output == snapshot('success (no tool calls)')
def test_toolset_factory():
toolset = FunctionToolset()
@toolset.tool_plain
def foo() -> str:
return 'Hello from foo'
available_tools: list[str] = []
async def prepare_tools(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
nonlocal available_tools
available_tools = [tool_def.name for tool_def in tool_defs]
return tool_defs
toolset_creation_counts: dict[str, int] = defaultdict(int)
def via_toolsets_arg(ctx: RunContext) -> AbstractToolset:
nonlocal toolset_creation_counts
toolset_creation_counts['via_toolsets_arg'] += 1
return toolset.prefixed('via_toolsets_arg')
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('via_toolsets_arg_foo')])
elif len(messages) == 3:
return ModelResponse(parts=[ToolCallPart('via_toolset_decorator_foo')])
else:
return ModelResponse(parts=[TextPart('Done')])
agent = Agent(FunctionModel(respond), toolsets=[via_toolsets_arg], capabilities=[PrepareTools(prepare_tools)])
@agent.toolset
def via_toolset_decorator(ctx: RunContext) -> AbstractToolset:
nonlocal toolset_creation_counts
toolset_creation_counts['via_toolset_decorator'] += 1
return toolset.prefixed('via_toolset_decorator')
@agent.toolset(per_run_step=False)
async def via_toolset_decorator_for_entire_run(ctx: RunContext) -> AbstractToolset:
nonlocal toolset_creation_counts
toolset_creation_counts['via_toolset_decorator_for_entire_run'] += 1
return toolset.prefixed('via_toolset_decorator_for_entire_run')
run_result = agent.run_sync('Hello')
assert run_result._state.run_step == 3 # pyright: ignore[reportPrivateUsage]
assert len(available_tools) == 3
assert toolset_creation_counts == snapshot(
defaultdict(int, {'via_toolsets_arg': 3, 'via_toolset_decorator': 3, 'via_toolset_decorator_for_entire_run': 1})
)
def test_adding_tools_during_run():
toolset = FunctionToolset()
def foo() -> str:
return 'Hello from foo'
@toolset.tool_plain
def add_foo_tool() -> str:
toolset.add_function(foo)
return 'foo tool added'
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('add_foo_tool')])
elif len(messages) == 3:
return ModelResponse(parts=[ToolCallPart('foo')])
else:
return ModelResponse(parts=[TextPart('Done')])
agent = Agent(FunctionModel(respond), toolsets=[toolset])
result = agent.run_sync('Add the foo tool and run it')
assert result.output == snapshot('Done')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Add the foo tool and run it',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='add_foo_tool', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=57, output_tokens=2),
model_name='function:respond:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='add_foo_tool',
content='foo tool added',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='foo', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=60, output_tokens=4),
model_name='function:respond:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='foo',
content='Hello from foo',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Done')],
usage=RequestUsage(input_tokens=63, output_tokens=5),
model_name='function:respond:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_prepare_output_tools():
@dataclass
class AgentDeps:
plan_presented: bool = False
async def present_plan(ctx: RunContext[AgentDeps], plan: str) -> str:
"""
Present the plan to the user.
"""
ctx.deps.plan_presented = True
return plan
async def run_sql(ctx: RunContext[AgentDeps], purpose: str, query: str) -> str:
"""
Run an SQL query.
"""
return 'SQL query executed successfully'
async def only_if_plan_presented(
ctx: RunContext[AgentDeps], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
return tool_defs if ctx.deps.plan_presented else []
agent = Agent(
model='test',
deps_type=AgentDeps,
tools=[present_plan],
output_type=[ToolOutput(run_sql, name='run_sql')],
capabilities=[PrepareOutputTools(only_if_plan_presented)],
)
result = agent.run_sync('Hello', deps=AgentDeps())
assert result.output == snapshot('SQL query executed successfully')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='present_plan',
args={'plan': 'a'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='present_plan',
content='a',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='run_sql',
args={'purpose': 'a', 'query': 'a'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=52, output_tokens=12),
model_name='test',
timestamp=IsDatetime(),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='run_sql',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_prepare_output_tools_receives_output_max_retries():
"""Regression for the bug surfaced in #4745 / #4859 design discussion: the
`prepare_output_tools` callable must see `ctx.max_retries == max_output_retries`,
not the function-tool retry budget. Also verifies `ctx.retry` advances per output retry
and that per-tool retry counts in `ctx.retries` propagate (matching `prepare_tools`).
"""
seen_retries: list[int] = []
seen_max_retries: list[int] = []
seen_per_tool_retries: list[int] = []
target_retries = 3
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools
# Always return the same output; the validator drives retries.
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"a": 1, "b": "foo"}')])
async def prep(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
seen_retries.append(ctx.retry)
seen_max_retries.append(ctx.max_retries)
seen_per_tool_retries.append(ctx.retries.get(tool_defs[0].name, 0))
return tool_defs
agent = Agent(
FunctionModel(return_model),
output_type=Foo,
# tool retry budget — different from output, must NOT leak into prep ctx
retries={'output': target_retries, 'tools': 1},
capabilities=[PrepareOutputTools(prep)],
)
@agent.output_validator
def validate_output(ctx: RunContext, o: Foo) -> Foo:
if ctx.retry == target_retries:
return o
raise ModelRetry(f'Retry {ctx.retry}')
result = agent.run_sync('Hello')
assert isinstance(result.output, Foo)
# `prep` runs once per step (= target_retries + 1 calls). Each call should see the
# OUTPUT retry budget, never the tool retry budget (`retries=1`).
assert seen_max_retries == [target_retries] * (target_retries + 1)
assert seen_retries == [0, 1, 2, 3]
# Per-tool retry counts populated by `for_run_step` propagate too — matching
# what `prepare_tools` sees on the function-tool side.
assert seen_per_tool_retries == [0, 1, 2, 3]
def test_prepare_output_tools_sees_run_level_output_retries_override():
"""`prepare_output_tools` sees the run-level output retry override on `ctx.max_retries`,
not the agent-level default — so capability hooks observe the same budget the run will enforce.
Regression for https://github.com/pydantic/pydantic-ai/pull/5075#discussion_r3170685538.
"""
seen_max_retries: list[int] = []
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"a": 1, "b": "foo"}')])
async def prep(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
seen_max_retries.append(ctx.max_retries)
return tool_defs
agent = Agent(
FunctionModel(return_model),
output_type=Foo,
retries={'output': 5},
capabilities=[PrepareOutputTools(prep)],
)
result = agent.run_sync('Hello', retries={'output': 2})
assert isinstance(result.output, Foo)
# Hook sees run-level override (2), not the agent-level default (5).
assert seen_max_retries == [2]
def test_set_mcp_sampling_model():
try:
from fastmcp.client.transports import StdioTransport
from pydantic_ai.mcp import MCPToolset
except ImportError: # pragma: lax no cover
pytest.skip('mcp is not installed')
test_model = TestModel()
transport = StdioTransport(command='python', args=['-m', 'tests.mcp_server'])
server1 = MCPToolset(transport)
server2 = MCPToolset(transport, sampling_model=test_model)
toolset = CombinedToolset([server1, PrefixedToolset(server2, 'prefix')])
agent = Agent(None, toolsets=[toolset])
with pytest.raises(UserError, match=re.escape('No sampling model provided and no model set on the agent.')):
agent.set_mcp_sampling_model()
assert server1.sampling_model is None
assert server2.sampling_model is test_model
def _callback(toolset: MCPToolset) -> Any:
# Probe fastmcp's private session-kwargs to confirm the client's sampling callback was
# rewired alongside the public `sampling_model` attribute.
return toolset.client._session_kwargs.get('sampling_callback') # pyright: ignore[reportPrivateUsage]
agent.model = test_model
agent.set_mcp_sampling_model()
assert server1.sampling_model is test_model
assert server2.sampling_model is test_model
assert _callback(server1) is not None
assert _callback(server2) is not None
function_model = FunctionModel(lambda messages, info: ModelResponse(parts=[TextPart('Hello')]))
with agent.override(model=function_model):
agent.set_mcp_sampling_model()
assert server1.sampling_model is function_model
assert server2.sampling_model is function_model
function_model2 = FunctionModel(lambda messages, info: ModelResponse(parts=[TextPart('Goodbye')]))
agent.set_mcp_sampling_model(function_model2)
assert server1.sampling_model is function_model2
assert server2.sampling_model is function_model2
async def test_explicit_context_manager():
try:
from fastmcp.client.transports import StdioTransport
from pydantic_ai.mcp import MCPToolset
except ImportError: # pragma: lax no cover
pytest.skip('mcp is not installed')
transport = StdioTransport(command='python', args=['-m', 'tests.mcp_server'])
server1 = MCPToolset(transport)
server2 = MCPToolset(transport)
toolset = CombinedToolset([server1, PrefixedToolset(server2, 'prefix')])
agent = Agent('test', toolsets=[toolset])
async with agent:
assert server1.is_running
assert server2.is_running
async with agent:
assert server1.is_running
assert server2.is_running
async def test_implicit_context_manager():
try:
from fastmcp.client.transports import StdioTransport
from pydantic_ai.mcp import MCPToolset
except ImportError: # pragma: lax no cover
pytest.skip('mcp is not installed')
transport = StdioTransport(command='python', args=['-m', 'tests.mcp_server'])
server1 = MCPToolset(transport)
server2 = MCPToolset(transport)
toolset = CombinedToolset([server1, PrefixedToolset(server2, 'prefix')])
agent = Agent('test', toolsets=[toolset])
async with agent.iter(user_prompt='Hello'):
assert server1.is_running
assert server2.is_running
def test_parallel_mcp_calls():
try:
from fastmcp.client.transports import StdioTransport
from pydantic_ai.mcp import MCPToolset
except ImportError: # pragma: lax no cover
pytest.skip('mcp is not installed')
async def call_tools_parallel(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart(tool_name='get_none'),
ToolCallPart(tool_name='get_multiple_items'),
]
)
else:
return ModelResponse(parts=[TextPart('finished')])
server = MCPToolset(StdioTransport(command='python', args=['-m', 'tests.mcp_server']))
agent = Agent(FunctionModel(call_tools_parallel), toolsets=[server])
result = agent.run_sync('call tools in parallel')
assert result.output == snapshot('finished')
async def test_parallel_tool_exception_cancels_sibling_tasks():
"""Non-CancelledError exceptions during parallel tool execution must cancel sibling tasks.
Regression test for https://github.com/pydantic/pydantic-ai/issues/4423.
Previously only asyncio.CancelledError triggered cleanup; any other exception
left the remaining tasks running as orphaned asyncio tasks.
"""
slow_tool_started = asyncio.Event()
slow_tool_cancelled = asyncio.Event()
async def call_two_tools(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='fast_failing_tool'),
ToolCallPart(tool_name='slow_tool'),
]
)
agent = Agent(FunctionModel(call_two_tools))
@agent.tool_plain
async def fast_failing_tool() -> str:
# Yield control so slow_tool can start, then raise.
await asyncio.sleep(0)
raise RuntimeError('boom')
@agent.tool_plain
async def slow_tool() -> str:
slow_tool_started.set()
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
slow_tool_cancelled.set()
raise
return 'done' # pragma: no cover
tasks_before = asyncio.all_tasks()
with pytest.raises(RuntimeError, match='boom'):
await agent.run('call tools')
# Give the event loop a moment to process cancellations.
await asyncio.sleep(0)
# The slow tool must have started (confirming both tasks ran in parallel).
assert slow_tool_started.is_set(), 'slow_tool never started — not running in parallel'
# The slow tool must have been cancelled when fast_failing_tool raised.
assert slow_tool_cancelled.is_set(), 'slow_tool was not cancelled after RuntimeError'
# No new asyncio tasks should be left over from this run.
leaked = asyncio.all_tasks() - tasks_before
assert not leaked, f'Orphaned tasks remain: {leaked}'
async def test_parallel_tool_outer_cancellation_only_cancels_pending_tool_tasks():
"""Outer cancellation can arrive after one parallel tool task has already finished.
The cleanup path should skip finished tasks and cancel only sibling tasks that are
still pending.
"""
completed_tool_finished = asyncio.Event()
pending_tool_started = asyncio.Event()
pending_tool_cancelled = asyncio.Event()
async def call_two_tools(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='completed_tool'),
ToolCallPart(tool_name='pending_tool'),
]
)
agent = Agent(FunctionModel(call_two_tools))
@agent.tool_plain
async def completed_tool() -> str:
completed_tool_finished.set()
return 'done'
@agent.tool_plain
async def pending_tool() -> str:
pending_tool_started.set()
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
pending_tool_cancelled.set()
raise
return 'done' # pragma: no cover
task = asyncio.create_task(agent.run('call tools'))
await asyncio.wait_for(completed_tool_finished.wait(), timeout=READINESS_WAIT_TIMEOUT)
await asyncio.wait_for(pending_tool_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert pending_tool_cancelled.is_set()
async def test_wrap_run_readiness_wait_cancels_wrapper_task_on_outer_cancellation():
"""Outer cancellation while waiting for `wrap_run` readiness should clean up the wrapper task.
Target boundary: `Agent.iter()` creates `_wrap_task` and `_ready_waiter`, then waits for
`asyncio.wait({_ready_waiter, _wrap_task}, return_when=asyncio.FIRST_COMPLETED)`.
The test should cancel the parent task while that wait is pending, then assert the
capability's `wrap_run` cleanup has completed before cancellation returns.
"""
cleanup_finished = asyncio.Event()
started = asyncio.Event()
never_finishes = asyncio.Future[AgentRunResult[Any]]()
class WrapRunCapability(AbstractCapability):
async def wrap_run(self, ctx: RunContext, *, handler: WrapRunHandler) -> AgentRunResult[Any]:
try:
started.set()
return await never_finishes
finally:
cleanup_finished.set()
agent = Agent(TestModel(), capabilities=[WrapRunCapability()])
task = asyncio.create_task(agent.run('test'))
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert cleanup_finished.is_set()
async def test_run_handoff_survives_absorbed_cancellation():
"""Outer cancellation must not deadlock the run when the wrapped run absorbs it (#6422).
Under Temporal's cooperative activity cancellation (`WAIT_CANCELLATION_COMPLETED`), a
durable step inside `before_run` can swallow the injected `CancelledError` and return
normally. `_do_run` then proceeds to `await _run_done`, which the cancelled caller never
sets on the drain path — so unless the handoff cleanup unblocks it before draining
`_wrap_task`, the run task deadlocks forever. Here a `before_run` that absorbs one
cancellation simulates that survivor condition without a Temporal dependency.
"""
in_flight = asyncio.Event()
class SwallowCancelBeforeRun(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
in_flight.set()
try:
await asyncio.Event().wait() # the in-flight "durable step"
except asyncio.CancelledError:
pass # step completed successfully; cancellation consumed
agent = Agent(TestModel(), capabilities=[SwallowCancelBeforeRun()])
task = asyncio.create_task(agent.run('test'))
await asyncio.wait_for(in_flight.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
try:
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
except asyncio.CancelledError:
pass # expected: the run ended cancelled
except (TimeoutError, asyncio.TimeoutError): # pragma: no cover - fails only on regression
pytest.fail('deadlock: run task still pending after cancellation (#6422)')
async def test_streaming_handoff_survives_absorbed_cancellation():
"""Streaming counterpart of #6422: model request survives cancellation without deadlock.
When the model request is in flight and absorbs the injected `CancelledError` before
completing — the documented behavior of Temporal's cooperative activity cancellation, as
routed through `model.request_stream` — `_streaming_handler` is left parked on
`stream_done.wait()`. Unless the handoff cleanup sets `stream_done` before draining
`wrap_task`, the run task deadlocks. `SwallowOneCancelModel` reproduces that at the
`request_stream` await boundary.
"""
in_flight = asyncio.Event()
class SwallowOneCancelModel(WrapperModel):
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
run_context: RunContext[Any] | None = None,
) -> AsyncGenerator[StreamedResponse]:
in_flight.set()
try:
await asyncio.Event().wait() # the in-flight model request ("activity")
except asyncio.CancelledError:
pass # activity completed successfully; cancellation consumed
async with super().request_stream(
messages, model_settings, model_request_parameters, run_context
) as streamed_response:
yield streamed_response
async def event_stream_handler(ctx: RunContext, events: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in events: # pragma: no cover - handler never runs on the cancelled path
pass
agent = Agent(SwallowOneCancelModel(TestModel()))
task = asyncio.create_task(agent.run('hello', event_stream_handler=event_stream_handler))
await asyncio.wait_for(in_flight.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
try:
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
except asyncio.CancelledError:
pass # expected: the run ended cancelled
except (TimeoutError, asyncio.TimeoutError): # pragma: no cover - fails only on regression
pytest.fail('deadlock: run task still pending after cancellation (#6422)')
async def test_run_stream_events_aclose_survives_absorbed_cancellation():
"""`run_stream_events` teardown must not deadlock when the run survives cancellation (#6422).
`_RunStreamEventsIterator.aclose()` cancels the background run and drains it, relying on the
cancellation to unblock a run parked pushing an event into the zero-buffer stream. If an inline
tool absorbs the injected `CancelledError` and completes (Temporal's cooperative cancellation),
the run resumes and the internal handler blocks forever on the next `send` — unless the receive
end is closed before the drain. Unlike the streaming/run-level handoffs, the survivor here is
directly on the run task rather than a child wrapper task.
"""
tool_in_flight = asyncio.Event()
agent = Agent(TestModel(call_tools=['swallow_tool']))
@agent.tool_plain
async def swallow_tool() -> str:
tool_in_flight.set()
try:
await asyncio.Event().wait() # the in-flight "durable step"
except asyncio.CancelledError:
pass # step completed successfully; cancellation consumed
return 'done'
async def consume() -> None:
async with agent.run_stream_events('hello') as events:
async for _ in events:
pass # naturally blocks once the tool parks (no further events until it returns)
task = asyncio.create_task(consume())
await asyncio.wait_for(tool_in_flight.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
try:
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
except asyncio.CancelledError:
pass # expected: teardown completed and the run ended cancelled
except (TimeoutError, asyncio.TimeoutError): # pragma: no cover - fails only on regression
pytest.fail('deadlock: run_stream_events teardown still pending after cancellation (#6422)')
async def test_parallel_tool_outer_cancellation_waits_for_tool_cleanup():
"""Outer cancellation during parallel tool execution should await cancelled tool cleanup.
Target boundary: parallel tool execution creates one task per tool call and waits with
`asyncio.wait(...)`. The cancel handler must drain those tasks so each tool's `finally`
runs before the parent function exits; otherwise cleanup races against the parent
unwinding.
"""
started = asyncio.Event()
cleanup_started = asyncio.Event()
cleanup_can_finish = asyncio.Event()
cleanup_finished = asyncio.Event()
async def call_two_tools(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='slow_tool_a'),
ToolCallPart(tool_name='slow_tool_b'),
]
)
agent = Agent(FunctionModel(call_two_tools))
@agent.tool_plain
async def slow_tool_a() -> str:
started.set()
try:
await asyncio.sleep(10)
finally:
cleanup_started.set()
await cleanup_can_finish.wait()
cleanup_finished.set()
return 'done' # pragma: no cover
@agent.tool_plain
async def slow_tool_b() -> str:
await asyncio.sleep(10)
return 'done' # pragma: no cover
task = asyncio.create_task(agent.run('call tools'))
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
await asyncio.wait_for(cleanup_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
assert not task.done()
cleanup_can_finish.set()
with pytest.raises(asyncio.CancelledError):
await task
assert cleanup_finished.is_set()
@pytest.mark.parametrize('mode', ['argument', 'contextmanager'])
def test_sequential_calls(mode: Literal['argument', 'contextmanager']):
"""Test that tool calls are executed correctly when a `sequential` tool is present in the call."""
async def call_tools_sequential(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='call_first'),
ToolCallPart(tool_name='call_first'),
ToolCallPart(tool_name='call_first'),
ToolCallPart(tool_name='call_first'),
ToolCallPart(tool_name='call_first'),
ToolCallPart(tool_name='call_first'),
ToolCallPart(tool_name='increment_integer_holder'),
ToolCallPart(tool_name='requires_approval'),
ToolCallPart(tool_name='call_second'),
ToolCallPart(tool_name='call_second'),
ToolCallPart(tool_name='call_second'),
ToolCallPart(tool_name='call_second'),
ToolCallPart(tool_name='call_second'),
ToolCallPart(tool_name='call_second'),
ToolCallPart(tool_name='call_second'),
]
)
sequential_toolset = FunctionToolset()
integer_holder: int = 1
@sequential_toolset.tool_plain
def call_first():
nonlocal integer_holder
assert integer_holder == 1
@sequential_toolset.tool_plain(sequential=mode == 'argument')
def increment_integer_holder():
nonlocal integer_holder
integer_holder = 2
@sequential_toolset.tool_plain
def requires_approval():
from pydantic_ai.exceptions import ApprovalRequired
raise ApprovalRequired()
@sequential_toolset.tool_plain
def call_second():
nonlocal integer_holder
assert integer_holder == 2
agent = Agent(
FunctionModel(call_tools_sequential), toolsets=[sequential_toolset], output_type=[str, DeferredToolRequests]
)
user_prompt = 'call a lot of tools'
if mode == 'contextmanager':
with agent.parallel_tool_call_execution_mode('sequential'):
result = agent.run_sync(user_prompt)
else:
result = agent.run_sync(user_prompt)
assert result.output == snapshot(
DeferredToolRequests(approvals=[ToolCallPart(tool_name='requires_approval', tool_call_id=IsStr())])
)
assert integer_holder == 2
def test_toolsets():
toolset = FunctionToolset()
@toolset.tool_plain
def foo() -> str:
return 'Hello from foo' # pragma: no cover
agent = Agent('test', toolsets=[toolset])
assert toolset in agent.toolsets
other_toolset = FunctionToolset()
with agent.override(toolsets=[other_toolset]):
assert other_toolset in agent.toolsets
assert toolset not in agent.toolsets
async def test_wrapper_agent():
foo_toolset = FunctionToolset()
@foo_toolset.tool_plain
def foo() -> str:
return 'Hello from foo' # pragma: no cover
test_model = TestModel()
agent = Agent(
test_model,
system_prompt='You are a wrapped agent',
toolsets=[foo_toolset],
output_type=Foo,
)
wrapper_agent = WrapperAgent(agent)
assert [p.content for p in await wrapper_agent.system_prompt_parts()] == ['You are a wrapped agent']
assert wrapper_agent.toolsets == agent.toolsets
assert wrapper_agent.model == agent.model
assert wrapper_agent.name == agent.name
wrapper_agent.name = 'wrapped'
assert wrapper_agent.name == 'wrapped'
assert wrapper_agent.description == agent.description
wrapper_agent.description = 'wrapped description'
assert wrapper_agent.description == 'wrapped description'
# `render_description` is `Agent`-only; setting via `wrapper_agent.description` mutates the wrapped agent.
assert agent.render_description() == 'wrapped description'
assert wrapper_agent.output_type == agent.output_type
assert wrapper_agent.event_stream_handler == agent.event_stream_handler
assert wrapper_agent.root_capability is agent.root_capability
assert wrapper_agent.output_json_schema() == snapshot(
{
'type': 'object',
'properties': {'a': {'title': 'A', 'type': 'integer'}, 'b': {'title': 'B', 'type': 'string'}},
'title': 'Foo',
'required': ['a', 'b'],
}
)
assert wrapper_agent.output_json_schema(output_type=str) == snapshot({'type': 'string'})
bar_toolset = FunctionToolset()
@bar_toolset.tool_plain
def bar() -> str:
return 'Hello from bar'
with wrapper_agent.override(toolsets=[bar_toolset]):
async with wrapper_agent:
async with wrapper_agent.iter(user_prompt='Hello') as run:
async for _ in run:
pass
assert run.result is not None
assert run.result.output == snapshot(Foo(a=0, b='a'))
assert test_model.last_model_request_parameters is not None
assert [t.name for t in test_model.last_model_request_parameters.function_tools] == snapshot(['bar'])
async def test_thinking_only_response_retry():
"""Test that thinking-only responses trigger a retry mechanism."""
call_count = 0
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: return thinking-only response
return ModelResponse(
parts=[ThinkingPart(content='Let me think about this...')],
model_name='thinking-test-model',
)
else:
# Second call: return proper response
return ModelResponse(
parts=[TextPart(content='Final answer')],
model_name='thinking-test-model',
)
model = FunctionModel(model_function)
agent = Agent(model, instructions='You are a helpful assistant.')
result = await agent.run('Hello')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
),
],
timestamp=IsNow(tz=timezone.utc),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ThinkingPart(content='Let me think about this...')],
usage=RequestUsage(input_tokens=51, output_tokens=6),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
instructions='You are a helpful assistant.',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Final answer')],
usage=RequestUsage(input_tokens=63, output_tokens=8),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_retry_message_no_tools():
"""Test that thinking-only retry message does not suggest 'call a tool' when no function tools are registered."""
call_count = 0
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ThinkingPart(content='thinking...')])
else:
return ModelResponse(parts=[TextPart(content='result')])
agent = Agent(FunctionModel(model_function))
result = await agent.run('Hello')
assert result.output == 'result'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ThinkingPart(content='thinking...')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='result')],
usage=RequestUsage(input_tokens=63, output_tokens=3),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_thinking_only_response_retry_with_tool_output():
"""Test that thinking-only responses retry with tool-output guidance when text output is not allowed."""
call_count = 0
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
if call_count == 1:
return ModelResponse(parts=[ThinkingPart(content='thinking...')])
else:
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"a": 1, "b": "ok"}')])
result = await Agent(FunctionModel(model_function), output_type=ToolOutput(Foo)).run('Hello')
assert result.output == Foo(a=1, b='ok')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ThinkingPart(content='thinking...')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please include your response in a tool call.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"a": 1, "b": "ok"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=68, output_tokens=9),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_thinking_only_response_after_tool_call_retries():
"""Text returned alongside a tool call is not reused as output when a later response is thinking-only.
Such text typically announces the tool call rather than concluding the run, so the model is
asked for a fresh text response instead.
"""
call_count = 0
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: return text alongside a tool call
return ModelResponse(
parts=[
TextPart(content="I'll save your progress."),
ToolCallPart(tool_name='save_progress', args='{"data": "test"}'),
],
)
elif call_count == 2:
# Second call (after tool return): return thinking-only
return ModelResponse(
parts=[ThinkingPart(content='Nothing more to say.')],
)
else:
# Third call (after the retry prompt): return the actual output
return ModelResponse(parts=[TextPart(content='Your progress is saved.')])
agent = Agent(FunctionModel(model_function))
@agent.tool_plain
def save_progress(data: str) -> str:
return 'saved'
result = await agent.run('Hello')
assert result.output == 'Your progress is saved.'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(content="I'll save your progress."),
ToolCallPart(
tool_name='save_progress',
args='{"data": "test"}',
tool_call_id=IsStr(),
),
],
usage=RequestUsage(input_tokens=51, output_tokens=10),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='save_progress',
content='saved',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ThinkingPart(content='Nothing more to say.')],
usage=RequestUsage(input_tokens=52, output_tokens=15),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return text or call a tool.',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Your progress is saved.')],
usage=RequestUsage(input_tokens=68, output_tokens=20),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_hitl_tool_approval():
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart(
tool_name='create_file',
args={'path': 'new_file.py', 'content': 'print("Hello, world!")'},
tool_call_id='create_file',
),
ToolCallPart(
tool_name='delete_file', args={'path': 'ok_to_delete.py'}, tool_call_id='ok_to_delete'
),
ToolCallPart(
tool_name='delete_file', args={'path': 'never_delete.py'}, tool_call_id='never_delete'
),
]
)
else:
return ModelResponse(parts=[TextPart('Done!')])
model = FunctionModel(model_function)
agent = Agent(model, output_type=[str, DeferredToolRequests])
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> str:
return f'File {path!r} deleted'
@agent.tool_plain
def create_file(path: str, content: str) -> str:
return f'File {path!r} created with content: {content}'
result = await agent.run('Create new_file.py and delete ok_to_delete.py and never_delete.py')
messages = result.all_messages()
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Create new_file.py and delete ok_to_delete.py and never_delete.py',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='create_file',
args={'path': 'new_file.py', 'content': 'print("Hello, world!")'},
tool_call_id='create_file',
),
ToolCallPart(
tool_name='delete_file', args={'path': 'ok_to_delete.py'}, tool_call_id='ok_to_delete'
),
ToolCallPart(
tool_name='delete_file', args={'path': 'never_delete.py'}, tool_call_id='never_delete'
),
],
usage=RequestUsage(input_tokens=60, output_tokens=23),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='create_file',
content='File \'new_file.py\' created with content: print("Hello, world!")',
tool_call_id='create_file',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.output == snapshot(
DeferredToolRequests(
approvals=[
ToolCallPart(tool_name='delete_file', args={'path': 'ok_to_delete.py'}, tool_call_id='ok_to_delete'),
ToolCallPart(tool_name='delete_file', args={'path': 'never_delete.py'}, tool_call_id='never_delete'),
]
)
)
result = await agent.run(
message_history=messages,
deferred_tool_results=DeferredToolResults(
approvals={'ok_to_delete': True, 'never_delete': ToolDenied('File cannot be deleted')},
),
)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Create new_file.py and delete ok_to_delete.py and never_delete.py',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='create_file',
args={'path': 'new_file.py', 'content': 'print("Hello, world!")'},
tool_call_id='create_file',
),
ToolCallPart(
tool_name='delete_file', args={'path': 'ok_to_delete.py'}, tool_call_id='ok_to_delete'
),
ToolCallPart(
tool_name='delete_file', args={'path': 'never_delete.py'}, tool_call_id='never_delete'
),
],
usage=RequestUsage(input_tokens=60, output_tokens=23),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='create_file',
content='File \'new_file.py\' created with content: print("Hello, world!")',
tool_call_id='create_file',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='delete_file',
content="File 'ok_to_delete.py' deleted",
tool_call_id='ok_to_delete',
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='delete_file',
content='File cannot be deleted',
tool_call_id='never_delete',
timestamp=IsDatetime(),
outcome='denied',
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Done!')],
usage=RequestUsage(input_tokens=78, output_tokens=24),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.output == snapshot('Done!')
assert result.new_messages() == snapshot(
[
ModelRequest(
parts=[
ToolReturnPart(
tool_name='delete_file',
content="File 'ok_to_delete.py' deleted",
tool_call_id='ok_to_delete',
timestamp=IsDatetime(),
),
ToolReturnPart(
tool_name='delete_file',
content='File cannot be deleted',
tool_call_id='never_delete',
timestamp=IsDatetime(),
outcome='denied',
),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Done!')],
usage=RequestUsage(input_tokens=78, output_tokens=24),
model_name='function:model_function:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_run_with_deferred_tool_results_errors():
agent = Agent('test')
message_history: list[ModelMessage] = [ModelRequest(parts=[UserPromptPart(content=['Hello', 'world'])])]
with pytest.raises(
UserError,
match=re.escape('Tool call results were provided, but the message history does not contain a `ModelResponse`.'),
):
await agent.run(
'Hello again',
message_history=message_history,
deferred_tool_results=DeferredToolResults(approvals={'create_file': True}),
)
message_history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[TextPart(content='Hello to you too!')]),
]
with pytest.raises(
UserError,
match=re.escape(
'Tool call results were provided, but the message history does not contain any unprocessed tool calls.'
),
):
await agent.run(
'Hello again',
message_history=message_history,
deferred_tool_results=DeferredToolResults(approvals={'create_file': True}),
)
message_history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[ToolCallPart(tool_name='say_hello')]),
]
with pytest.raises(
UserError,
match=re.escape('Cannot provide a new user prompt when the message history contains unprocessed tool calls.'),
):
await agent.run('Hello', message_history=message_history)
with pytest.raises(
UserError, match=re.escape('Tool call results need to be provided for all deferred tool calls.')
):
await agent.run(
message_history=message_history,
deferred_tool_results=DeferredToolResults(),
)
with pytest.raises(
UserError, match=re.escape('Tool call results were provided, but the message history is empty.')
):
await agent.run(
'Hello again',
deferred_tool_results=DeferredToolResults(approvals={'create_file': True}),
)
message_history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(
parts=[
ToolCallPart(tool_name='run_me', tool_call_id='run_me'),
ToolCallPart(tool_name='run_me_too', tool_call_id='run_me_too'),
ToolCallPart(tool_name='defer_me', tool_call_id='defer_me'),
]
),
ModelRequest(
parts=[
ToolReturnPart(tool_name='run_me', tool_call_id='run_me', content='Success'),
RetryPromptPart(tool_name='run_me_too', tool_call_id='run_me_too', content='Failure'),
]
),
]
with pytest.raises(
UserError, match=re.escape("Tool call 'run_me' was already executed and its result cannot be overridden.")
):
await agent.run(
message_history=message_history,
deferred_tool_results=DeferredToolResults(
calls={'run_me': 'Failure', 'defer_me': 'Failure'},
),
)
with pytest.raises(
UserError, match=re.escape("Tool call 'run_me_too' was already executed and its result cannot be overridden.")
):
await agent.run(
message_history=message_history,
deferred_tool_results=DeferredToolResults(
calls={'run_me_too': 'Success', 'defer_me': 'Failure'},
),
)
async def test_deferred_tool_requests_reject_duplicate_tool_call_ids():
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart(tool_name='safe_tool', args={'item': 'visible-safe'}, tool_call_id='duplicate-id'),
ToolCallPart(tool_name='danger_tool', args={'item': 'hidden-danger'}, tool_call_id='duplicate-id'),
]
)
agent = Agent(FunctionModel(model_function), output_type=[str, DeferredToolRequests])
def safe_tool(item: str) -> str:
return f'safe:{item}' # pragma: no cover
def danger_tool(item: str) -> str:
return f'danger:{item}' # pragma: no cover
agent.tool_plain(requires_approval=True)(safe_tool)
agent.tool_plain(requires_approval=True)(danger_tool)
with pytest.raises(UnexpectedModelBehavior, match='duplicate ids'):
await agent.run('approve the safe operation')
async def test_deferred_tool_results_reject_duplicate_tool_call_ids_in_history():
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('done')]) # pragma: no cover
agent = Agent(FunctionModel(model_function), output_type=[str, DeferredToolRequests])
def safe_tool(item: str) -> str:
return f'safe:{item}' # pragma: no cover
def danger_tool(item: str) -> str:
return f'danger:{item}' # pragma: no cover
agent.tool_plain(requires_approval=True)(safe_tool)
agent.tool_plain(requires_approval=True)(danger_tool)
message_history = [
ModelRequest(parts=[UserPromptPart(content='approve the safe operation')]),
ModelResponse(
parts=[
ToolCallPart(tool_name='safe_tool', args={'item': 'visible-safe'}, tool_call_id='duplicate-id'),
ToolCallPart(tool_name='danger_tool', args={'item': 'hidden-danger'}, tool_call_id='duplicate-id'),
]
),
]
with pytest.raises(UserError, match='duplicate tool_call_id'):
await agent.run(
message_history=message_history,
deferred_tool_results=DeferredToolResults(approvals={'duplicate-id': True}),
)
async def test_user_prompt_with_deferred_tool_results():
"""Test that user_prompt can be provided alongside deferred_tool_results."""
from pydantic_ai.exceptions import ApprovalRequired
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# First call: model requests tool approval
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart(
tool_name='update_file', tool_call_id='update_file_1', args={'path': '.env', 'content': ''}
),
]
)
# Second call: model responds to tool results and user prompt
else:
# Verify we received both tool results and user prompt
last_request = message(messages, ModelRequest, index=-1)
has_tool_return = any(isinstance(p, ToolReturnPart) for p in last_request.parts)
has_user_prompt = any(isinstance(p, UserPromptPart) for p in last_request.parts)
assert has_tool_return, 'Expected tool return part in request'
assert has_user_prompt, 'Expected user prompt part in request'
# Get user prompt content
user_prompt_content = next(p.content for p in last_request.parts if isinstance(p, UserPromptPart))
return ModelResponse(parts=[TextPart(f'Approved and {user_prompt_content}')])
agent = Agent(FunctionModel(llm), output_type=[str, DeferredToolRequests])
@agent.tool
def update_file(ctx: RunContext, path: str, content: str) -> str:
if path == '.env' and not ctx.tool_call_approved:
raise ApprovalRequired
return f'File {path!r} updated'
# First run: get deferred tool requests
result = await agent.run('Update .env file')
assert isinstance(result.output, DeferredToolRequests)
assert len(result.output.approvals) == 1
messages = result.all_messages()
# Snapshot the message history after first run to show the state before deferred tool results
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Update .env file', timestamp=IsDatetime())],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='update_file', tool_call_id='update_file_1', args={'path': '.env', 'content': ''}
)
],
usage=RequestUsage(input_tokens=53, output_tokens=6),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# Second run: provide approvals AND user prompt
results = DeferredToolResults(approvals={result.output.approvals[0].tool_call_id: True})
result2 = await agent.run('continue with the operation', message_history=messages, deferred_tool_results=results)
assert isinstance(result2.output, str)
assert 'continue with the operation' in result2.output
# Snapshot the new messages to show how tool results and user prompt are combined
new_messages = result2.new_messages()
assert new_messages == snapshot(
[
ModelRequest(
parts=[
ToolReturnPart(
tool_name='update_file',
content="File '.env' updated",
tool_call_id='update_file_1',
timestamp=IsDatetime(),
),
UserPromptPart(content='continue with the operation', timestamp=IsDatetime()),
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Approved and continue with the operation')],
usage=RequestUsage(input_tokens=61, output_tokens=12),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_tool_requires_approval_no_output_type():
"""Adding a requires_approval tool without DeferredToolRequests in output type is allowed.
The error is raised at runtime if the tool is called and no handler resolves it.
"""
agent = Agent('test')
@agent.tool_plain(requires_approval=True)
def delete_file(path: str) -> None:
pass
async def test_consecutive_model_responses_in_history():
received_messages: list[ModelMessage] | None = None
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal received_messages
received_messages = messages
return ModelResponse(
parts=[
TextPart('All right then, goodbye!'),
]
)
history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Hello...')]),
ModelResponse(parts=[TextPart(content='...world!')]),
ModelResponse(parts=[TextPart(content='Anything else I can help with?')]),
]
m = FunctionModel(llm)
agent = Agent(m)
result = await agent.run('No thanks', message_history=history)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello...',
timestamp=IsDatetime(),
)
]
),
ModelResponse(
parts=[TextPart(content='...world!'), TextPart(content='Anything else I can help with?')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content='No thanks',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='All right then, goodbye!')],
usage=RequestUsage(input_tokens=54, output_tokens=12),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert result.new_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='No thanks',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='All right then, goodbye!')],
usage=RequestUsage(input_tokens=54, output_tokens=12),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert received_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello...',
timestamp=IsDatetime(),
)
]
),
ModelResponse(
parts=[TextPart(content='...world!'), TextPart(content='Anything else I can help with?')],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
UserPromptPart(
content='No thanks',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_override_instructions_basic():
"""Test that override can override instructions."""
agent = Agent('test')
@agent.instructions
def instr_fn() -> str:
return 'SHOULD_BE_IGNORED'
with capture_run_messages() as base_messages:
agent.run_sync('Hello', model=TestModel(custom_output_text='baseline'))
base_req = message(base_messages, ModelRequest)
assert base_req.instructions == 'SHOULD_BE_IGNORED'
with agent.override(instructions='OVERRIDE'):
with capture_run_messages() as messages:
agent.run_sync('Hello', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'OVERRIDE'
def test_override_reset_after_context():
"""Test that instructions are reset after exiting the override context."""
agent = Agent('test', instructions='ORIG')
with agent.override(instructions='NEW'):
with capture_run_messages() as messages_new:
agent.run_sync('Hi', model=TestModel(custom_output_text='ok'))
with capture_run_messages() as messages_orig:
agent.run_sync('Hi', model=TestModel(custom_output_text='ok'))
req_new = message(messages_new, ModelRequest)
req_orig = message(messages_orig, ModelRequest)
assert req_new.instructions == 'NEW'
assert req_orig.instructions == 'ORIG'
def test_override_none_clears_instructions():
"""Test that passing None for instructions clears all instructions."""
agent = Agent('test', instructions='BASE')
@agent.instructions
def instr_fn() -> str: # pragma: no cover - ignored under override
return 'ALSO_BASE'
with agent.override(instructions=None):
with capture_run_messages() as messages:
agent.run_sync('Hello', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions is None
def test_override_instructions_callable_replaces_functions():
"""Override with a callable should replace existing instruction functions."""
agent = Agent('test')
@agent.instructions
def base_fn() -> str:
return 'BASE_FN'
def override_fn() -> str:
return 'OVERRIDE_FN'
with capture_run_messages() as base_messages:
agent.run_sync('Hello', model=TestModel(custom_output_text='baseline'))
base_req = message(base_messages, ModelRequest)
assert base_req.instructions is not None
assert 'BASE_FN' in base_req.instructions
with agent.override(instructions=override_fn):
with capture_run_messages() as messages:
agent.run_sync('Hello', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'OVERRIDE_FN'
assert 'BASE_FN' not in req.instructions
async def test_override_instructions_async_callable():
"""Override with an async callable should be awaited."""
agent = Agent('test')
async def override_fn() -> str:
await asyncio.sleep(0)
return 'ASYNC_FN'
with agent.override(instructions=override_fn):
with capture_run_messages() as messages:
await agent.run('Hi', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'ASYNC_FN'
def test_override_instructions_sequence_mixed_types():
"""Override can mix literal strings and functions."""
agent = Agent('test', instructions='BASE')
def override_fn() -> str:
return 'FUNC_PART'
def override_fn_2() -> str:
return 'FUNC_PART_2'
with agent.override(instructions=['OVERRIDE1', override_fn, 'OVERRIDE2', override_fn_2]):
with capture_run_messages() as messages:
agent.run_sync('Hello', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'OVERRIDE1\nOVERRIDE2\n\nFUNC_PART\n\nFUNC_PART_2'
assert 'BASE' not in req.instructions
async def test_override_concurrent_isolation():
"""Test that concurrent overrides are isolated from each other."""
agent = Agent('test', instructions='ORIG')
async def run_with(instr: str) -> str | None:
with agent.override(instructions=instr):
with capture_run_messages() as messages:
await agent.run('Hi', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
return req.instructions
a, b = await asyncio.gather(
run_with('A'),
run_with('B'),
)
assert a == 'A'
assert b == 'B'
def test_override_replaces_instructions():
"""Test overriding instructions replaces the base instructions."""
agent = Agent('test', instructions='ORIG_INSTR')
with agent.override(instructions='NEW_INSTR'):
with capture_run_messages() as messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'NEW_INSTR'
def test_override_nested_contexts():
"""Test nested override contexts."""
agent = Agent('test', instructions='ORIG')
with agent.override(instructions='OUTER'):
with capture_run_messages() as outer_messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='ok'))
with agent.override(instructions='INNER'):
with capture_run_messages() as inner_messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='ok'))
outer_req = message(outer_messages, ModelRequest)
inner_req = message(inner_messages, ModelRequest)
assert outer_req.instructions == 'OUTER'
assert inner_req.instructions == 'INNER'
async def test_override_async_run():
"""Test override with async run method."""
agent = Agent('test', instructions='ORIG')
with agent.override(instructions='ASYNC_OVERRIDE'):
with capture_run_messages() as messages:
await agent.run('Hi', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'ASYNC_OVERRIDE'
def test_override_with_dynamic_prompts():
"""Test override interacting with dynamic prompts."""
agent = Agent('test')
dynamic_value = 'DYNAMIC'
@agent.system_prompt
def dynamic_sys() -> str:
return dynamic_value
@agent.instructions
def dynamic_instr() -> str:
return 'DYNAMIC_INSTR'
with capture_run_messages() as base_messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='baseline'))
base_req = message(base_messages, ModelRequest)
assert base_req.instructions == 'DYNAMIC_INSTR'
# Override should take precedence over dynamic instructions but leave system prompts intact
with agent.override(instructions='OVERRIDE_INSTR'):
with capture_run_messages() as messages:
agent.run_sync('Hi', model=TestModel(custom_output_text='ok'))
req = message(messages, ModelRequest)
assert req.instructions == 'OVERRIDE_INSTR'
sys_texts = [p.content for p in req.parts if isinstance(p, SystemPromptPart)]
# The dynamic system prompt should still be present since overrides target instructions only
assert dynamic_value in sys_texts
def test_continue_conversation_that_ended_in_output_tool_call(allow_model_requests: None):
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) and p.tool_name == 'roll_dice' for p in messages[-1].parts):
return ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'dice_roll': 4},
tool_call_id='pyd_ai_tool_call_id__final_result',
)
]
)
return ModelResponse(
parts=[ToolCallPart(tool_name='roll_dice', args={}, tool_call_id='pyd_ai_tool_call_id__roll_dice')]
)
class Result(BaseModel):
dice_roll: int
agent = Agent(FunctionModel(llm), output_type=Result)
@agent.tool_plain
def roll_dice() -> int:
return 4
result = agent.run_sync('Roll me a dice.')
messages = result.all_messages()
assert messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Roll me a dice.',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='roll_dice', args={}, tool_call_id='pyd_ai_tool_call_id__roll_dice')],
usage=RequestUsage(input_tokens=55, output_tokens=2),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='roll_dice',
content=4,
tool_call_id='pyd_ai_tool_call_id__roll_dice',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'dice_roll': 4},
tool_call_id='pyd_ai_tool_call_id__final_result',
)
],
usage=RequestUsage(input_tokens=56, output_tokens=6),
model_name='function:llm:',
timestamp=IsDatetime(),
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=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
result = agent.run_sync('Roll me a dice again.', message_history=messages)
new_messages = result.new_messages()
assert new_messages == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Roll me a dice again.',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='roll_dice', args={}, tool_call_id='pyd_ai_tool_call_id__roll_dice')],
usage=RequestUsage(input_tokens=66, output_tokens=8),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='roll_dice',
content=4,
tool_call_id='pyd_ai_tool_call_id__roll_dice',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args={'dice_roll': 4},
tool_call_id='pyd_ai_tool_call_id__final_result',
)
],
usage=RequestUsage(input_tokens=67, output_tokens=12),
model_name='function:llm:',
timestamp=IsDatetime(),
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=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert not any(isinstance(p, ToolReturnPart) and p.tool_name == 'final_result' for p in new_messages[0].parts)
def test_agent_native_tools_runtime_vs_agent_level():
"""Test that runtime `capabilities=[NativeTool(...)]` is merged with agent-level native tools."""
model = TestModel()
agent = Agent(
model=model,
capabilities=[
NativeTool(WebSearchTool()),
NativeTool(CodeExecutionTool()),
NativeTool(MCPServerTool(id='deepwiki', url='https://mcp.deepwiki.com/mcp')),
NativeTool(MCPServerTool(id='github', url='https://api.githubcopilot.com/mcp')),
],
)
# Runtime tool with same unique ID should override agent-level tool
with pytest.raises(Exception, match='TestModel does not support built-in tools'):
agent.run_sync(
'Hello',
capabilities=[
NativeTool(WebSearchTool(search_context_size='high')),
NativeTool(MCPServerTool(id='example', url='https://mcp.example.com/mcp')),
NativeTool(
MCPServerTool(id='github', url='https://mcp.githubcopilot.com/mcp', authorization_token='token')
),
],
)
assert model.last_model_request_parameters is not None
assert model.last_model_request_parameters.native_tools == snapshot(
[
WebSearchTool(search_context_size='high'),
CodeExecutionTool(),
MCPServerTool(id='deepwiki', url='https://mcp.deepwiki.com/mcp'),
MCPServerTool(id='github', url='https://mcp.githubcopilot.com/mcp', authorization_token='token'),
MCPServerTool(id='example', url='https://mcp.example.com/mcp'),
]
)
def test_agent_override_native_tools_empty_runs_with_test_model():
"""Test that agent-level native tools can be removed when overriding the model."""
model = TestModel()
agent = Agent(model=model, capabilities=[NativeTool(WebSearchTool())])
with agent.override(model=model, native_tools=[]):
result = agent.run_sync('Hello')
assert result.output == 'success (no tool calls)'
assert model.last_model_request_parameters is not None
assert model.last_model_request_parameters.native_tools == []
def test_agent_override_native_tools_replaces_agent_level_tools():
"""Test that override native_tools replace, rather than append to, agent-level native tools."""
model = TestModel()
agent = Agent(model=model, capabilities=[NativeTool(WebSearchTool())])
with (
agent.override(native_tools=[CodeExecutionTool()]),
pytest.raises(UserError, match='TestModel does not support built-in tools'),
):
agent.run_sync('Hello')
assert model.last_model_request_parameters is not None
assert model.last_model_request_parameters.native_tools == snapshot([CodeExecutionTool()])
def test_agent_override_native_tools_preserves_runtime_additive_tools():
"""Test that runtime `capabilities=[NativeTool(...)]` are still added to overridden native tools."""
model = TestModel()
agent = Agent(model=model, capabilities=[NativeTool(WebSearchTool())])
with (
agent.override(native_tools=[CodeExecutionTool()]),
pytest.raises(UserError, match='TestModel does not support built-in tools'),
):
agent.run_sync(
'Hello',
capabilities=[NativeTool(MCPServerTool(id='example', url='https://mcp.example.com/mcp'))],
)
assert model.last_model_request_parameters is not None
assert model.last_model_request_parameters.native_tools == snapshot(
[CodeExecutionTool(), MCPServerTool(id='example', url='https://mcp.example.com/mcp')]
)
def test_agent_rejects_conflicting_agent_level_native_tool_ids():
"""Two agent-level native tools sharing an id but with conflicting definitions fail at construction.
Unit test rather than VCR: the guard raises before any request a cassette could record.
"""
with pytest.raises(UserError, match="Native tool id 'mcp_server:api' maps to conflicting definitions"):
Agent(
model=TestModel(),
capabilities=[
NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-a/api')),
NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-b/api')),
],
)
def test_agent_allows_identical_agent_level_native_tools():
"""Identical duplicates within a layer are allowed and collapsed by the model-layer dedup.
Unit test rather than VCR: it pins the `native_tools` request parameters ahead of the
`TestModel` pre-request guard, which no cassette would reliably catch.
"""
model = TestModel()
agent = Agent(
model=model,
capabilities=[
NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/api')),
NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/api')),
],
)
with pytest.raises(UserError, match='TestModel does not support built-in tools'):
agent.run_sync('Hello')
assert model.last_model_request_parameters is not None
assert model.last_model_request_parameters.native_tools == snapshot(
[MCPServerTool(id='api', url='https://mcp.example.com/api')]
)
def test_agent_rejects_conflicting_run_level_native_tool_ids():
"""Two run-level native tools sharing an id but with conflicting definitions fail at run time.
Unit test rather than VCR: the guard raises before any request a cassette could record.
"""
agent = Agent(model=TestModel())
with pytest.raises(UserError, match="Native tool id 'mcp_server:api' maps to conflicting definitions"):
agent.run_sync(
'Hello',
capabilities=[
NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-a/api')),
NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-b/api')),
],
)
def test_agent_rejects_conflicting_override_native_tool_ids():
"""Two `override(native_tools=...)` tools sharing an id but with conflicting definitions fail.
Unit test rather than VCR: the guard raises before any request a cassette could record.
"""
agent = Agent(model=TestModel())
with (
agent.override(
native_tools=[
MCPServerTool(id='api', url='https://mcp.example.com/tenant-a/api'),
MCPServerTool(id='api', url='https://mcp.example.com/tenant-b/api'),
]
),
pytest.raises(UserError, match="Native tool id 'mcp_server:api' maps to conflicting definitions"),
):
agent.run_sync('Hello')
def test_agent_rejects_conflicting_dynamic_capability_native_tool_ids():
"""Native tools contributed by `for_run()` are validated within the run-capabilities layer.
Unit test rather than VCR: the guard raises before any request a cassette could record.
"""
agent = Agent(model=TestModel())
def cap_a(ctx: RunContext[Any]) -> AbstractCapability[Any]:
return NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-a/api'))
def cap_b(ctx: RunContext[Any]) -> AbstractCapability[Any]:
return NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-b/api'))
with pytest.raises(UserError, match="Native tool id 'mcp_server:api' maps to conflicting definitions"):
agent.run_sync('Hello', capabilities=[cap_a, cap_b])
def test_agent_rejects_conflicting_agent_level_dynamic_capability_native_tool_ids():
"""Conflicting native tools contributed by agent-level capability functions only materialize
in `for_run`, so they escape the construction-time check but are caught when the resolved
base layer is validated at run time.
Unit test rather than VCR: the guard raises before any request a cassette could record.
"""
def cap_a(ctx: RunContext[Any]) -> AbstractCapability[Any]:
return NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-a/api'))
def cap_b(ctx: RunContext[Any]) -> AbstractCapability[Any]:
return NativeTool(MCPServerTool(id='api', url='https://mcp.example.com/tenant-b/api'))
agent = Agent(model=TestModel(), capabilities=[cap_a, cap_b])
with pytest.raises(
UserError, match="Native tool id 'mcp_server:api' maps to conflicting definitions in agent capabilities"
):
agent.run_sync('Hello')
def test_agent_rejects_conflicting_override_spec_native_tool_ids():
"""Native tools from `override(spec=...)` capabilities are validated as the base layer.
Unit test rather than VCR: the guard raises before any request a cassette could record.
"""
agent = Agent(model=TestModel())
spec = {
'capabilities': [
{
'NativeTool': {
'tool': {'kind': 'mcp_server', 'id': 'api', 'url': 'https://mcp.example.com/tenant-a/api'}
}
},
{
'NativeTool': {
'tool': {'kind': 'mcp_server', 'id': 'api', 'url': 'https://mcp.example.com/tenant-b/api'}
}
},
]
}
with (
agent.override(spec=spec),
pytest.raises(UserError, match="Native tool id 'mcp_server:api' maps to conflicting definitions"),
):
agent.run_sync('Hello')
@pytest.mark.parametrize('instrument', [False, True])
def test_agent_override_native_tools_preserves_dynamic_capability_tools(instrument: bool):
"""Native tools that only materialize in `for_run` (here from a capability function) are
preserved under `override(native_tools=...)`, which replaces only the agent's baseline tools.
With `instrument=True`, the Instrumentation capability joins the resolved layers as part of
the baseline, and must not be attributed to the preserved per-run layer.
Unit test rather than VCR: it pins the `native_tools` request parameters ahead of the
`TestModel` pre-request guard, which no cassette would reliably catch.
"""
model = TestModel()
agent = Agent(model=model, capabilities=[NativeTool(WebSearchTool())])
agent.instrument = instrument
def dynamic_cap(ctx: RunContext) -> AbstractCapability:
return NativeTool(MCPServerTool(id='example', url='https://mcp.example.com/mcp'))
with (
agent.override(native_tools=[CodeExecutionTool()]),
pytest.raises(UserError, match='TestModel does not support built-in tools'),
):
agent.run_sync('Hello', capabilities=[dynamic_cap])
assert model.last_model_request_parameters is not None
assert model.last_model_request_parameters.native_tools == snapshot(
[CodeExecutionTool(), MCPServerTool(id='example', url='https://mcp.example.com/mcp')]
)
async def test_agent_capability_for_run_called_once_per_run():
"""`AbstractCapability.for_run` is documented as called once per run and may have per-run
side effects, so agent-level and run-level capabilities must each be resolved exactly once —
including when `for_run` returns a fresh instance for per-run state isolation.
Unit test rather than VCR: resolution counts are internal and wouldn't show up in any
request payload a cassette could pin.
"""
for_run_calls: dict[str, int] = {}
class CountingCapability(AbstractCapability):
def __init__(self, name: str):
self.name = name
async def for_run(self, ctx: RunContext) -> AbstractCapability:
for_run_calls[self.name] = for_run_calls.get(self.name, 0) + 1
return CountingCapability(self.name)
agent = Agent(TestModel(), capabilities=[CountingCapability('agent')])
await agent.run('Hello', capabilities=[CountingCapability('run')])
assert for_run_calls == {'agent': 1, 'run': 1}
async def test_run_with_unapproved_tool_call_in_history():
def should_not_call_model(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
raise ValueError('The agent should not call the model.') # pragma: no cover
agent = Agent(
model=FunctionModel(function=should_not_call_model),
output_type=[str, DeferredToolRequests],
)
@agent.tool_plain(requires_approval=True)
def delete_file() -> None:
print('File deleted.') # pragma: no cover
messages = [
ModelRequest(parts=[UserPromptPart(content='Hello')]),
ModelResponse(parts=[ToolCallPart(tool_name='delete_file')]),
]
result = await agent.run(message_history=messages)
assert result.all_messages() == messages
assert result.output == snapshot(
DeferredToolRequests(approvals=[ToolCallPart(tool_name='delete_file', tool_call_id=IsStr())])
)
async def test_message_history():
def llm(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('ok here is text')])
agent = Agent(FunctionModel(llm))
async with agent.iter(
message_history=[
ModelRequest(parts=[UserPromptPart(content='Hello')]),
],
) as run:
async for _ in run:
pass
assert run.new_messages() == snapshot(
[
ModelResponse(
parts=[TextPart(content='ok here is text')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert run.new_messages_json().startswith(b'[{"parts":[{"content":"ok here is text",')
assert run.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(
content='Hello',
timestamp=IsDatetime(),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='ok here is text')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
assert run.all_messages_json().startswith(b'[{"parts":[{"content":"Hello",')
@dataclass
class UserContext:
location: str | None
async def prepared_web_search(ctx: RunContext[UserContext]) -> WebSearchTool | None:
if not ctx.deps.location:
return None
return WebSearchTool(
search_context_size='medium',
user_location=WebSearchUserLocation(city=ctx.deps.location),
)
async def test_dynamic_native_tool_configured():
model = TestModel()
agent = Agent(model, capabilities=[NativeTool(prepared_web_search)], deps_type=UserContext)
user_context = UserContext(location='London')
with pytest.raises(UserError, match='TestModel does not support built-in tools'):
await agent.run('Hello', deps=user_context)
assert model.last_model_request_parameters is not None
tools = model.last_model_request_parameters.native_tools
assert len(tools) == 1
tool = tools[0]
assert isinstance(tool, WebSearchTool)
assert tool.user_location is not None
assert tool.user_location.get('city') == 'London'
assert tool.search_context_size == 'medium'
async def test_dynamic_native_tool_omitted():
model = TestModel()
agent = Agent(model, capabilities=[NativeTool(prepared_web_search)], deps_type=UserContext)
user_context = UserContext(location=None)
await agent.run('Hello', deps=user_context)
assert model.last_model_request_parameters is not None
tools = model.last_model_request_parameters.native_tools
assert len(tools) == 0
async def test_mixed_static_and_dynamic_native_tools():
model = TestModel()
static_tool = CodeExecutionTool()
agent = Agent(
model,
capabilities=[NativeTool(static_tool), NativeTool(prepared_web_search)],
deps_type=UserContext,
)
# Case 1: Dynamic tool returns None
with pytest.raises(UserError, match='TestModel does not support built-in tools'):
await agent.run('Hello', deps=UserContext(location=None))
assert model.last_model_request_parameters is not None
tools = model.last_model_request_parameters.native_tools
assert len(tools) == 1
assert tools[0] == static_tool
# Case 2: Dynamic tool returns a tool
with pytest.raises(UserError, match='TestModel does not support built-in tools'):
await agent.run('Hello', deps=UserContext(location='Paris'))
assert model.last_model_request_parameters is not None
tools = model.last_model_request_parameters.native_tools
assert len(tools) == 2
assert tools[0] == static_tool
dynamic_tool = tools[1]
assert isinstance(dynamic_tool, WebSearchTool)
assert dynamic_tool.user_location is not None
assert dynamic_tool.user_location.get('city') == 'Paris'
def sync_dynamic_tool(ctx: RunContext[UserContext]) -> WebSearchTool:
"""Verify that synchronous functions work."""
return WebSearchTool(search_context_size='low')
async def test_sync_dynamic_tool():
model = TestModel()
agent = Agent(model, capabilities=[NativeTool(sync_dynamic_tool)], deps_type=UserContext)
with pytest.raises(UserError, match='TestModel does not support built-in tools'):
await agent.run('Hello', deps=UserContext(location='London'))
assert model.last_model_request_parameters is not None
tools = model.last_model_request_parameters.native_tools
assert len(tools) == 1
assert isinstance(tools[0], WebSearchTool)
assert tools[0].search_context_size == 'low'
async def test_dynamic_tool_in_run_call():
"""Verify dynamic tools can be passed to agent.run() via `capabilities=[NativeTool(...)]`."""
model = TestModel()
agent = Agent(model, deps_type=UserContext)
with pytest.raises(UserError, match='TestModel does not support built-in tools'):
await agent.run('Hello', deps=UserContext(location='Berlin'), capabilities=[NativeTool(prepared_web_search)])
assert model.last_model_request_parameters is not None
tools = model.last_model_request_parameters.native_tools
assert len(tools) == 1
tool = tools[0]
assert isinstance(tool, WebSearchTool)
assert tool.user_location is not None
assert tool.user_location.get('city') == 'Berlin'
@pytest.mark.parametrize(
'tool_choice',
[
pytest.param('required', id='required'),
pytest.param(['get_weather'], id='list'),
],
)
async def test_tool_choice_required_or_list_rejected_in_agent_run(tool_choice: Any):
"""Verify that statically-set tool_choice='required' or list[str] raises UserError in agent.run().
These settings exclude output tools and would force a tool call on every step, preventing
the agent from producing a final response. Users should use ToolOrOutput, set tool_choice
dynamically via a capability that returns a callable from get_model_settings(), or use
pydantic_ai.direct.model_request for single-shot calls.
"""
model = TestModel()
agent = Agent(model)
settings: ModelSettings = {'tool_choice': tool_choice}
with pytest.raises(UserError, match='prevents the agent from producing a final response'):
await agent.run('Hello', model_settings=settings)
async def test_central_content_filter_handling():
"""
Test that the agent graph correctly raises ContentFilterError
when a model returns finish_reason='content_filter' AND empty content.
"""
async def filtered_response(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[],
model_name='test-model',
finish_reason='content_filter',
provider_details={'finish_reason': 'content_filter'},
)
model = FunctionModel(function=filtered_response, model_name='test-model')
agent = Agent(model)
with pytest.raises(
ContentFilterError, match=re.escape("Content filter triggered. Finish reason: 'content_filter'")
):
await agent.run('Trigger filter')
async def test_central_content_filter_with_partial_content():
"""
Test that the agent graph returns partial content (does not raise exception)
even if finish_reason='content_filter', provided parts are not empty.
"""
async def filtered_response(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[TextPart('Partially generated content...')], model_name='test-model', finish_reason='content_filter'
)
model = FunctionModel(function=filtered_response, model_name='test-model')
agent = Agent(model)
# Should NOT raise ContentFilterError
result = await agent.run('Trigger filter')
assert result.output == 'Partially generated content...'
async def test_agent_allows_none_output_empty_response():
"""Test that Agent(output_type=str | None) succeeds on empty response."""
async def empty_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[])
model = FunctionModel(function=empty_model)
agent = Agent(model, output_type=str | None)
result = await agent.run('hello')
assert result.output is None
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=51),
model_name='function:empty_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_agent_allows_none_output_after_tool():
"""Test that Agent(output_type=str | None) succeeds after tool call with no final text."""
call_count = 0
async def tool_then_empty_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='noop', args={}, tool_call_id='123')])
return ModelResponse(parts=[])
model = FunctionModel(function=tool_then_empty_model)
agent = Agent(model, output_type=str | None)
@agent.tool_plain
def noop() -> str:
return 'done'
result = await agent.run('hello')
assert result.output is None
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='noop', args={}, tool_call_id='123')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:tool_then_empty_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='noop',
content='done',
tool_call_id='123',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:tool_then_empty_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_agent_allows_none_output_thinking_only():
"""Test that Agent(output_type=str | None) succeeds on a thinking-only response after a tool call.
Some models emit only a ThinkingPart after completing the task via a tool call, with nothing to
add. Forcing a retry only produces unnecessary follow-up text, so this is accepted as a None result.
Uses FunctionModel because no real provider reliably emits a thinking-only final response on demand.
"""
call_count = 0
async def tool_then_thinking_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='noop', args={}, tool_call_id='123')])
return ModelResponse(parts=[ThinkingPart(content='Task complete, nothing more to add.')])
model = FunctionModel(function=tool_then_thinking_model)
agent = Agent(model, output_type=str | None)
@agent.tool_plain
def noop() -> str:
return 'done'
result = await agent.run('hello')
assert result.output is None
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='noop', args={}, tool_call_id='123')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:tool_then_thinking_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='noop',
content='done',
tool_call_id='123',
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ThinkingPart(content='Task complete, nothing more to add.')],
usage=RequestUsage(input_tokens=52, output_tokens=9),
model_name='function:tool_then_thinking_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_agent_allows_none_output_validator_called():
"""Test that output validators are called when returning None on empty response."""
async def empty_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[])
validator_called = False
model = FunctionModel(function=empty_model)
agent = Agent(model, output_type=str | None)
@agent.output_validator
async def validate_output(ctx: RunContext, output: str | None) -> str | None:
nonlocal validator_called
validator_called = True
assert output is None
return output
result = await agent.run('hello')
assert result.output is None
assert validator_called
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=51),
model_name='function:empty_model:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_agent_allows_none_output_validator_retry():
"""Test that output validator raising ModelRetry triggers a retry when output is None."""
async def model_then_text(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[])
return ModelResponse(parts=[TextPart(content='hello')])
model = FunctionModel(function=model_then_text)
agent = Agent(model, output_type=str | None)
@agent.output_validator
async def reject_none(ctx: RunContext, output: str | None) -> str | None:
if output is None:
raise ModelRetry('None not acceptable, please respond')
return output
result = await agent.run('hello')
assert result.output == 'hello'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[],
usage=RequestUsage(input_tokens=51),
model_name='function:model_then_text:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='None not acceptable, please respond',
tool_call_id=IsStr(),
timestamp=IsNow(tz=timezone.utc),
)
],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='hello')],
usage=RequestUsage(input_tokens=65, output_tokens=1),
model_name='function:model_then_text:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_agent_still_fails_if_none_not_allowed():
"""Test that Agent(output_type=str) still fails on empty response."""
async def empty_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[])
model = FunctionModel(function=empty_model)
agent = Agent(model, output_type=str)
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries'):
await agent.run('hello')
def test_agent_output_type_bare_none_error():
"""Test that Agent(output_type=None) raises a clear error."""
with pytest.raises(UserError, match='At least one output type must be provided other than `None`'):
Agent('test', output_type=None) # type: ignore[arg-type]
async def test_agent_allows_none_output_tool_mode_none_via_tool():
"""Test that `int | None` exposes a separate `final_result_NoneType` tool the model can call."""
seen_tool_names: list[str] = []
async def call_none_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
seen_tool_names[:] = [t.name for t in info.output_tools]
none_tool = next(t for t in info.output_tools if 'NoneType' in t.name)
return ModelResponse(
parts=[ToolCallPart(tool_name=none_tool.name, args={'response': None}, tool_call_id='pyd_ai_id')]
)
agent = Agent(FunctionModel(function=call_none_tool), output_type=int | None)
result = await agent.run('hello')
assert result.output is None
assert seen_tool_names == snapshot(['final_result_int', 'final_result_NoneType'])
async def test_agent_allows_none_output_tool_mode_int_via_tool():
"""Test that `int | None` still resolves the int branch via the int tool."""
async def call_int_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
int_tool = next(t for t in info.output_tools if t.name == 'final_result_int')
return ModelResponse(
parts=[ToolCallPart(tool_name=int_tool.name, args={'response': 42}, tool_call_id='pyd_ai_id')]
)
agent = Agent(FunctionModel(function=call_int_tool), output_type=int | None)
result = await agent.run('hello')
assert result.output == 42
async def test_agent_allows_none_output_tool_mode_empty_response():
"""Test that `int | None` still falls back to `None` on an empty response."""
async def empty(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[])
agent = Agent(FunctionModel(function=empty), output_type=int | None)
result = await agent.run('hello')
assert result.output is None
async def test_agent_allows_none_output_native_structured_none():
"""Test that `NativeOutput(int | None)` returns `None` when the model emits the NoneType branch."""
async def native_none(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[TextPart(content=json.dumps({'result': {'kind': 'NoneType', 'data': {'response': None}}}))]
)
agent = Agent(FunctionModel(function=native_none), output_type=NativeOutput([int, type(None)]))
result = await agent.run('hello')
assert result.output is None
async def test_agent_allows_none_output_prompted_structured_none():
"""Test that `PromptedOutput(int | None)` returns `None` when the model emits the NoneType branch."""
async def prompted_none(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[TextPart(content=json.dumps({'result': {'kind': 'NoneType', 'data': {'response': None}}}))]
)
agent = Agent(FunctionModel(function=prompted_none), output_type=PromptedOutput([int, type(None)]))
result = await agent.run('hello')
assert result.output is None
async def test_agent_allows_none_output_tool_output_union_null():
"""Test that `ToolOutput(int | None)` accepts a JSON `null` via the tool's `anyOf` schema."""
async def call_final_result(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args={'response': None}, tool_call_id='pyd_ai_id')]
)
agent = Agent(FunctionModel(function=call_final_result), output_type=ToolOutput(int | None)) # type: ignore[arg-type]
result = await agent.run('hello')
assert result.output is None
async def test_agent_allows_none_output_explicit_none_tool():
"""Test that `[ToolOutput(int), ToolOutput(type(None))]` surfaces one tool per type."""
async def call_none_tool(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[ToolCallPart(tool_name='final_result_NoneType', args={'response': None}, tool_call_id='pyd_ai_id')]
)
agent = Agent(
FunctionModel(function=call_none_tool),
output_type=[ToolOutput(int), ToolOutput(type(None))],
)
result = await agent.run('hello')
assert result.output is None
# region Dynamic model_settings
def _text_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('response')])
def _model_with_settings(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
max_tokens = info.model_settings.get('max_tokens') if info.model_settings else None
temperature = info.model_settings.get('temperature') if info.model_settings else None
return ModelResponse(parts=[TextPart(f'max_tokens={max_tokens} temperature={temperature}')])
class TestCallableAgentLevelSettings:
"""Test agent-level callable model_settings."""
def test_callable_agent_settings(self):
def dynamic_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(max_tokens=200)
agent = Agent(FunctionModel(_model_with_settings), model_settings=dynamic_settings)
result = agent.run_sync('Hello')
assert result.output == 'max_tokens=200 temperature=None'
def test_callable_receives_run_context(self):
contexts: list[RunContext[str]] = []
def dynamic_settings(ctx: RunContext[str]) -> ModelSettings:
contexts.append(ctx)
return ModelSettings(max_tokens=50)
agent = Agent(FunctionModel(_text_model), deps_type=str, model_settings=dynamic_settings)
agent.run_sync('Hello', deps='test-deps')
assert len(contexts) >= 1
assert contexts[0].deps == 'test-deps'
def test_callable_sees_model_settings_from_model(self):
"""The callable should see `ctx.model_settings` set to the model's base settings."""
seen_settings: list[ModelSettings | None] = []
def dynamic_settings(ctx: RunContext) -> ModelSettings:
seen_settings.append(ctx.model_settings)
return ModelSettings(max_tokens=100)
# FunctionModel has no settings (None), so ctx.model_settings should be None
agent = Agent(FunctionModel(_text_model), model_settings=dynamic_settings)
agent.run_sync('Hello')
assert len(seen_settings) >= 1
assert seen_settings[0] is None
class TestCallableRunLevelSettings:
"""Test run-level callable model_settings."""
def test_callable_run_settings(self):
def dynamic_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(temperature=0.9)
agent = Agent(FunctionModel(_model_with_settings))
result = agent.run_sync('Hello', model_settings=dynamic_settings)
assert result.output == 'max_tokens=None temperature=0.9'
def test_callable_run_sees_merged_agent_settings(self):
"""Run-level callable should see merged model+agent settings via ctx.model_settings."""
seen_settings: list[ModelSettings | None] = []
def run_settings(ctx: RunContext) -> ModelSettings:
seen_settings.append(ctx.model_settings)
return ModelSettings(temperature=0.5)
agent = Agent(FunctionModel(_text_model), model_settings=ModelSettings(max_tokens=100))
agent.run_sync('Hello', model_settings=run_settings)
assert len(seen_settings) >= 1
assert seen_settings[0] is not None
assert seen_settings[0].get('max_tokens') == 100
class TestMixedStaticAndCallableSettings:
"""Test mixing static and callable model_settings."""
def test_static_agent_callable_run(self):
def run_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(temperature=0.8)
agent = Agent(
FunctionModel(_model_with_settings),
model_settings=ModelSettings(max_tokens=100),
)
result = agent.run_sync('Hello', model_settings=run_settings)
assert result.output == 'max_tokens=100 temperature=0.8'
def test_callable_agent_static_run(self):
def agent_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(max_tokens=150)
agent = Agent(FunctionModel(_model_with_settings), model_settings=agent_settings)
result = agent.run_sync('Hello', model_settings=ModelSettings(temperature=0.6))
assert result.output == 'max_tokens=150 temperature=0.6'
def test_callable_agent_callable_run(self):
def agent_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(max_tokens=200)
def run_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(temperature=0.4)
agent = Agent(FunctionModel(_model_with_settings), model_settings=agent_settings)
result = agent.run_sync('Hello', model_settings=run_settings)
assert result.output == 'max_tokens=200 temperature=0.4'
class TestPerStepSettingsResolution:
"""Test that callable model_settings is called before each model request."""
def test_called_each_step(self):
call_count = 0
step_numbers: list[int] = []
def dynamic_settings(ctx: RunContext) -> ModelSettings:
nonlocal call_count
call_count += 1
step_numbers.append(ctx.run_step)
return ModelSettings(max_tokens=100)
def multi_step_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', args='{"x": 1}')])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(multi_step_model), model_settings=dynamic_settings)
@agent.tool_plain
def my_tool(x: int) -> str:
return f'result-{x}'
agent.run_sync('Hello')
assert call_count >= 2
assert step_numbers == sorted(step_numbers)
def test_step_dependent_settings(self):
"""Settings can vary based on run_step."""
def step_dependent_settings(ctx: RunContext) -> ModelSettings:
if ctx.run_step <= 1:
return ModelSettings(max_tokens=100)
return ModelSettings(max_tokens=500)
settings_per_step: list[tuple[int, int | None]] = []
def tracking_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
max_tokens = info.model_settings.get('max_tokens') if info.model_settings else None
step = len(messages)
settings_per_step.append((step, max_tokens))
if step == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', args='{"x": 1}')])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(tracking_model), model_settings=step_dependent_settings)
@agent.tool_plain
def my_tool(x: int) -> str:
return f'result-{x}'
agent.run_sync('Hello')
assert settings_per_step[0][1] == 100
assert settings_per_step[1][1] == 500
class TestDynamicSettingsPrecedence:
"""Test that run > agent > model precedence is maintained with callables."""
def test_callable_run_overrides_callable_agent(self):
def agent_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(temperature=0.2)
def run_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(temperature=0.8)
agent = Agent(FunctionModel(_model_with_settings), model_settings=agent_settings)
result = agent.run_sync('Hello', model_settings=run_settings)
assert result.output == 'max_tokens=None temperature=0.8'
class TestOverrideWithModelSettings:
"""Test the override() context manager with model_settings."""
def test_override_with_static(self):
agent = Agent(FunctionModel(_model_with_settings))
with agent.override(model_settings=ModelSettings(max_tokens=42)):
result = agent.run_sync('Hello')
assert result.output == 'max_tokens=42 temperature=None'
def test_override_with_callable(self):
def override_settings(ctx: RunContext) -> ModelSettings:
return ModelSettings(max_tokens=99)
agent = Agent(FunctionModel(_model_with_settings))
with agent.override(model_settings=override_settings):
result = agent.run_sync('Hello')
assert result.output == 'max_tokens=99 temperature=None'
def test_override_replaces_agent_settings(self):
"""Override model_settings should replace agent-level settings."""
agent = Agent(
FunctionModel(_model_with_settings),
model_settings=ModelSettings(max_tokens=100, temperature=0.5),
)
with agent.override(model_settings=ModelSettings(max_tokens=42)):
result = agent.run_sync('Hello')
assert result.output == 'max_tokens=42 temperature=None'
def test_override_ignores_run_settings(self):
"""When override is set, run-level model_settings should be ignored."""
agent = Agent(FunctionModel(_model_with_settings))
with agent.override(model_settings=ModelSettings(max_tokens=42)):
result = agent.run_sync('Hello', model_settings=ModelSettings(temperature=0.9))
assert result.output == 'max_tokens=42 temperature=None'
def test_override_resets_after_context(self):
"""After exiting override context, original settings should be restored."""
agent = Agent(
FunctionModel(_model_with_settings),
model_settings=ModelSettings(max_tokens=100),
)
with agent.override(model_settings=ModelSettings(max_tokens=42)):
result = agent.run_sync('Hello')
assert result.output == 'max_tokens=42 temperature=None'
result = agent.run_sync('Hello')
assert result.output == 'max_tokens=100 temperature=None'
def test_output_validator_retry_consistency_across_paths():
"""Output validators see global retry info, matching the text-output path.
Regression test for https://github.com/pydantic/pydantic-ai/issues/4385:
the text path sets ctx.retry/max_retries to the global output retry counter,
but the tool-output path was using the per-tool counter, causing inconsistent
ctx.retry and ctx.max_retries values in @agent.output_validator.
Using `ToolOutput(max_retries=5)` with `retries={'output': 2}` exposes the bug:
without the fix, the validator would see max_retries=5 (per-tool value)
instead of max_retries=2 (global output retry budget, matching the text path).
"""
retries_log: list[int] = []
max_retries_log: list[int] = []
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"a": 1, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(Foo, max_retries=5),
retries={'output': 2},
)
@agent.output_validator
def validate_output(ctx: RunContext, o: Foo) -> Foo:
retries_log.append(ctx.retry)
max_retries_log.append(ctx.max_retries)
if ctx.retry == 2:
return o
raise ModelRetry(f'Retry {ctx.retry}')
result = agent.run_sync('Hello')
assert isinstance(result.output, Foo)
assert retries_log == [0, 1, 2]
assert max_retries_log == [2, 2, 2]
def test_output_validator_exceeds_output_retries():
"""Output validator that never succeeds should respect output_retries limit.
When the output_validator always raises ModelRetry, the agent should stop
after output_retries attempts. The per-tool limit from ToolManager enforces
this via output_retries flowing into ToolsetTool.max_retries.
"""
retries_log: list[int] = []
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
args_json = '{"a": 1, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(Foo),
retries={'output': 2},
)
@agent.output_validator
def validate_output(ctx: RunContext, o: Foo) -> Foo:
retries_log.append(ctx.retry)
raise ModelRetry(f'Retry {ctx.retry}')
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries \\(2\\)'):
agent.run_sync('Hello')
assert retries_log == [0, 1, 2]
async def test_concurrent_runs_output_retry_isolation():
"""Concurrent runs on the same agent must not share output retry state.
OutputToolset.for_run_step returns a shallow copy so each run gets
isolated _output_retry_count values across await points.
"""
retries_by_run: dict[str, list[int]] = {'fast': [], 'slow': []}
call_count = 0
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
args_json = '{"a": 1, "b": "foo"}'
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, args_json)])
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(Foo),
retries={'output': 3},
)
@agent.output_validator
async def validate_output(ctx: RunContext, o: Foo) -> Foo:
# Use the prompt to identify which run this is
run_id = 'slow' if 'slow' in (ctx.prompt or '') else 'fast'
retries_by_run[run_id].append(ctx.retry)
if ctx.retry < 2:
if run_id == 'slow':
await asyncio.sleep(0.05)
raise ModelRetry(f'{run_id} retry {ctx.retry}')
return o
result_fast, result_slow = await asyncio.gather(
agent.run('fast'),
agent.run('slow'),
)
assert isinstance(result_fast.output, Foo)
assert isinstance(result_slow.output, Foo)
assert retries_by_run['fast'] == [0, 1, 2]
assert retries_by_run['slow'] == [0, 1, 2]
def test_output_validator_retry_counter_with_tool_switch():
"""Global retry counter tracks across output tool switches.
When the model switches from one output tool to another, the global
retry counter (visible to output validators via ctx.retry) keeps
incrementing. Each tool's per-tool counter is independent.
"""
validator_retries: list[int] = []
validator_max_retries: list[int] = []
def output_a(value: str) -> str:
return value
def output_b(value: str) -> str:
return value
call_count = 0
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
assert info.output_tools is not None
tool_names: dict[str, str] = {}
tool_names.update({t.name: t.name for t in info.output_tools})
name_a = next(n for n in tool_names if 'output_a' in n)
name_b = next(n for n in tool_names if 'output_b' in n)
call_count += 1
# First call: output_b (will fail validation), second+: output_a
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(name_b, '{"value": "x"}')])
return ModelResponse(parts=[ToolCallPart(name_a, '{"value": "hello"}')])
agent = Agent(
FunctionModel(return_model),
output_type=[
ToolOutput(output_a, max_retries=3),
ToolOutput(output_b, max_retries=1),
],
retries={'tools': 0, 'output': 0},
)
@agent.output_validator
def validate_output(ctx: RunContext, o: str) -> str:
validator_retries.append(ctx.retry)
validator_max_retries.append(ctx.max_retries)
if ctx.retry < 2:
raise ModelRetry(f'Retry {ctx.retry}')
return o
result = agent.run_sync('Hello')
assert result.output == 'hello'
# Global retry counter increments across tool switches
assert validator_retries == [0, 1, 2]
# max_retries reflects the agent-level default (0) since output_retries not set
assert validator_max_retries == [0, 0, 0]
def test_output_tool_validation_vs_execution_retry_counting():
"""Both validation failures and execution failures increment the global retry counter.
Validation failure (bad args from model) and execution failure (ModelRetry from
output function) both go through process_tool_calls and should both increment
ctx.state.output_retries_used for output validator context tracking.
"""
validator_retries: list[int] = []
call_count = 0
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
assert info.output_tools is not None
call_count += 1
tool_name = info.output_tools[0].name
# First call: send invalid args (validation failure path)
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name, '{"bad_field": 1}')])
# Subsequent calls: send valid args (execution path)
return ModelResponse(parts=[ToolCallPart(tool_name, '{"a": 1, "b": "foo"}')])
agent = Agent(
FunctionModel(return_model),
output_type=ToolOutput(Foo),
retries={'output': 5},
)
@agent.output_validator
def validate_output(ctx: RunContext, o: Foo) -> Foo:
validator_retries.append(ctx.retry)
if ctx.retry < 2:
raise ModelRetry(f'Retry {ctx.retry}')
return o
result = agent.run_sync('Hello')
assert isinstance(result.output, Foo)
# retry 0: never reached validator (validation failure from bad args)
# retry 1: reached validator, raised ModelRetry (execution failure)
# retry 2: reached validator, succeeded
assert validator_retries == [1, 2]
@dataclass(kw_only=True)
class OutputRetryBudgetCase:
"""One row in the output-retry budget precedence matrix.
`init`/`override`/`run` flow into `Agent(...)`, `agent.override(...)`, and
`agent.run_sync(...)` respectively. The test asserts the effective budget
by exhausting it with an always-retrying output_validator and confirming
the validator saw exactly `range(expected_budget + 1)` invocations.
All cases run on the tool-output path; text-path parity has its own test
(`test_text_path_honors_output_retry_budget`) below.
"""
id: str
init: dict[str, Any]
override: dict[str, Any] | None
run: dict[str, Any]
expected_budget: int
expects_deprecation_warning: bool
OUTPUT_RETRY_BUDGET_CASES = [
OutputRetryBudgetCase(
id='run-arg-caps-agent-default',
init={'retries': {'output': 5}},
override=None,
run={'retries': 2},
expected_budget=2,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='run-arg-beats-spec',
init={'retries': {'output': 5}},
override=None,
run={'retries': 2, 'spec': {'retries': {'output': 4}}},
expected_budget=2,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='spec-only-beats-agent-default',
init={'retries': {'output': 5}},
override=None,
run={'spec': {'retries': {'output': 2}}},
expected_budget=2,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='override-kwarg-caps-agent-default',
init={'retries': {'output': 5}},
override={'retries': 2},
run={},
expected_budget=2,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='override-spec-honored',
init={'retries': {'output': 5}},
override={'spec': {'retries': {'output': 2}}},
run={},
expected_budget=2,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='override-beats-run-arg',
init={'retries': {'output': 5}},
override={'retries': 1},
run={'retries': 10},
expected_budget=1,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='int-retries-sets-both',
init={'retries': 5},
override=None,
run={},
expected_budget=5,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='dict-tools-only-keeps-output-default',
init={'retries': {'tools': 5}},
override=None,
run={},
expected_budget=1,
expects_deprecation_warning=False,
),
OutputRetryBudgetCase(
id='dict-sets-both-independently',
init={'retries': {'tools': 3, 'output': 5}},
override=None,
run={},
expected_budget=5,
expects_deprecation_warning=False,
),
]
@pytest.mark.parametrize('case', OUTPUT_RETRY_BUDGET_CASES, ids=lambda c: c.id)
def test_output_retry_budget_resolution(case: OutputRetryBudgetCase):
"""Effective output-retry budget = override > run arg > spec > agent default.
Exhausts the resolved budget with an always-retrying output_validator and asserts the
validator saw `range(expected_budget + 1)` invocations and the exhaustion error reports
the expected budget.
"""
retries_log: list[int] = []
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"a": 1, "b": "foo"}')])
agent = Agent(FunctionModel(return_model), output_type=ToolOutput(Foo), **case.init)
@agent.output_validator
def always_retry(ctx: RunContext, o: Foo) -> Foo:
retries_log.append(ctx.retry)
raise ModelRetry(f'retry {ctx.retry}')
expected_msg = rf'Exceeded maximum output retries \({case.expected_budget}\)'
override_ctx = agent.override(**case.override) if case.override is not None else nullcontext()
with override_ctx:
with pytest.raises(UnexpectedModelBehavior, match=expected_msg):
agent.run_sync('Hello', **case.run)
assert retries_log == list(range(case.expected_budget + 1))
def test_text_path_honors_output_retry_budget():
"""Parity check: the text-output path enforces the run-level output retry override too.
The matrix above runs on the tool-output path; this test verifies the same precedence applies
when the model returns a `TextPart` and the agent's `output_type` is `str`.
"""
retries_log: list[int] = []
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('hello')])
agent = Agent(FunctionModel(return_model), output_type=str, retries={'output': 5})
@agent.output_validator
def always_retry(ctx: RunContext, o: str) -> str:
retries_log.append(ctx.retry)
raise ModelRetry(f'retry {ctx.retry}')
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(2\)'):
agent.run_sync('Hello', retries={'output': 2})
assert retries_log == [0, 1, 2]
def test_run_level_output_retry_override_without_validators():
"""Run-level output retry override takes effect and isolates from the shared agent-level toolset
even when no output_validator is registered.
Exercises the shared-toolset clone branch: with `output_schema == self._output_schema` and no
output_validators registered, `Agent.iter` must clone the shared `self._output_toolset` before
mutating `max_retries` so the agent-level toolset retains its original budget for subsequent runs.
Enforcement runs through `ToolManager._check_max_retries` (per-tool), not `consume_output_retry`.
"""
call_count = 0
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
# Always return invalid args → output function raises ModelRetry via validation path
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"bad_field": 1}')])
agent = Agent(FunctionModel(return_model), output_type=ToolOutput(Foo), retries={'output': 10})
shared_toolset = agent._output_toolset # pyright: ignore[reportPrivateUsage]
assert shared_toolset is not None
assert shared_toolset.max_retries == 10
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(2\)'):
agent.run_sync('Hello', retries={'output': 2})
# 3 attempts: initial + 2 retries, capped by the run override at 2 (not the agent default 10)
assert call_count == 3
# The shared agent-level toolset's `max_retries` was preserved — confirms the run cloned before mutating.
assert shared_toolset.max_retries == 10
def test_from_spec_preserves_zero_retry_budgets():
output_call_count = 0
def output_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal output_call_count
output_call_count += 1
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"bad_field": 1}')])
output_agent = Agent.from_spec(
{'model': 'test', 'retries': {'output': 0}},
model=FunctionModel(output_model),
output_type=ToolOutput(Foo),
)
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(0\)'):
output_agent.run_sync('Hello')
assert output_call_count == 1
tool_call_count = 0
def tool_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal tool_call_count
tool_call_count += 1
if tool_call_count == 1:
return ModelResponse(parts=[ToolCallPart('plain_tool', '{"bad_field": 1}')])
if tool_call_count == 2:
return ModelResponse(parts=[ToolCallPart('plain_tool', '{"x": 1}')])
return ModelResponse(parts=[TextPart('done')])
tool_agent = Agent.from_spec(
{'model': 'test', 'retries': {'tools': 0}},
model=FunctionModel(tool_model),
)
@tool_agent.tool_plain
def plain_tool(x: int) -> str:
return str(x)
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'plain_tool' exceeded max retries count of 0"):
tool_agent.run_sync('Hello')
result = tool_agent.run_sync('Hello again')
assert result.output == 'done'
assert tool_call_count == 3
def test_run_retries_cannot_override_tool_budget():
agent = Agent(TestModel())
with pytest.raises(UserError, match=r'Per-run `retries` cannot set tool retries'):
agent.run_sync('Hello', retries={'tools': 1})
def test_override_retries_cannot_override_tool_budget():
agent = Agent(TestModel())
with pytest.raises(UserError, match=r'agent\.override\(retries=\.\.\.\)` cannot set tool retries'):
with agent.override(retries={'tools': 1}):
pass
def test_wrapper_override_forwards_retries():
call_count = 0
def return_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"bad_field": 1}')])
agent = Agent(FunctionModel(return_model), output_type=ToolOutput(Foo), retries={'output': 3})
wrapped = WrapperAgent(agent)
with wrapped.override(retries=0):
with pytest.raises(UnexpectedModelBehavior, match=r'Exceeded maximum output retries \(0\)'):
wrapped.run_sync('Hello')
assert call_count == 1
@pytest.mark.parametrize(
'kwargs',
[
{},
{'retries': 5},
{'retries': {'tools': 5}},
{'retries': {'output': 5}},
{'retries': {'tools': 5, 'output': 3}},
],
ids=[
'no-kwargs',
'int-retries',
'dict-tools-only',
'dict-output-only',
'dict-both',
],
)
def test_agent_init_retries(kwargs: dict[str, Any]):
"""The `retries: int | AgentRetries` API accepts shared and per-category budgets."""
Agent('test', **kwargs)
def test_unknown_tool_with_valid_tool_does_not_exhaust_retries():
"""Unknown tool calls should not increment the global retry counter.
When the model returns both an unknown tool and a valid tool in the same
response, the unknown tool is handled via per-tool retries (ModelRetry)
downstream. The global retry counter should only reflect output validation
retries, not unknown-tool retries, so valid tools keep working.
We set retries=2 (per-tool max for unknown tools) and retries={'output': 1}
(global max for output validation) to isolate the bug: per-tool retries
allow 2 rounds of the unknown tool, but the old code's global increment
would exhaust the output retry budget after just 2 rounds.
"""
call_count = 0
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count <= 2:
return ModelResponse(
parts=[
ToolCallPart('nonexistent_tool', '{}'),
ToolCallPart('valid_tool', '{"x": 1}'),
]
)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(model_function), retries={'tools': 2, 'output': 1})
@agent.tool_plain
def valid_tool(x: int) -> str:
return f'result={x}'
result = agent.run_sync('Hello')
assert result.output == 'done'
assert call_count == 3
# endregion
# region Image output validators
async def test_image_output_validators_run():
"""Test that output validators are called and can modify image output."""
def return_image(_: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'original', media_type='image/png'))])
image_profile = ModelProfile(supports_image_output=True)
agent = Agent(FunctionModel(return_image, profile=image_profile), output_type=BinaryImage)
@agent.output_validator
def validate_output(ctx: RunContext, output: BinaryImage) -> BinaryImage:
return BinaryImage(data=b'modified', media_type=output.media_type)
result = await agent.run('Give me an image')
assert isinstance(result.output, BinaryImage)
assert result.output.data == b'modified', 'validator should be able to modify the output'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Give me an image', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[FilePart(content=BinaryImage(data=b'original', media_type='image/png'))],
usage=RequestUsage(input_tokens=54, output_tokens=8),
model_name='function:return_image:',
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_image_output_validator_model_retry():
"""Test that ModelRetry from image validator raises UnexpectedModelBehavior in streaming."""
class ImageStreamedResponse(StreamedResponse):
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
self._usage = RequestUsage()
yield self._parts_manager.handle_part(
vendor_part_id=0,
part=FilePart(content=BinaryImage(data=b'fake-png', media_type='image/png')),
)
@property
def model_name(self) -> str:
return 'image-model'
@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)
class ImageStreamModel(Model):
@property
def system(self) -> str: # pragma: no cover
return 'test'
@property
def model_name(self) -> str: # pragma: no cover
return 'image-model'
@property
def base_url(self) -> str: # pragma: no cover
return 'https://test.example.com'
async def request( # pragma: no cover
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'fake-png', media_type='image/png'))])
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
run_context: RunContext | None = None,
) -> AsyncGenerator[StreamedResponse]:
yield ImageStreamedResponse(model_request_parameters=model_request_parameters)
image_profile = ModelProfile(supports_image_output=True)
agent = Agent(ImageStreamModel(profile=image_profile), output_type=BinaryImage)
@agent.output_validator
def validate_output(ctx: RunContext, output: BinaryImage) -> BinaryImage:
raise ModelRetry('image validation failed')
with pytest.raises(
UnexpectedModelBehavior,
match='Output validation failed during streaming, and retries are not supported',
):
async with agent.run_stream('Give me an image') as stream:
await stream.get_output()
async def test_image_output_validators_run_stream():
"""Test that output validators are called and can modify image output during streaming."""
class ImageStreamedResponse(StreamedResponse):
async def _get_event_iterator(self) -> AsyncIterator[ModelResponseStreamEvent]:
self._usage = RequestUsage()
yield self._parts_manager.handle_part(
vendor_part_id=0,
part=FilePart(content=BinaryImage(data=b'original', media_type='image/png')),
)
@property
def model_name(self) -> str:
return 'image-model'
@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)
class ImageStreamModel(Model):
@property
def system(self) -> str: # pragma: no cover
return 'test'
@property
def model_name(self) -> str: # pragma: no cover
return 'image-model'
@property
def base_url(self) -> str: # pragma: no cover
return 'https://test.example.com'
async def request( # pragma: no cover
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
) -> ModelResponse:
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'original', media_type='image/png'))])
@asynccontextmanager
async def request_stream(
self,
messages: list[ModelMessage],
model_settings: ModelSettings | None,
model_request_parameters: ModelRequestParameters,
run_context: RunContext | None = None,
) -> AsyncGenerator[StreamedResponse]:
yield ImageStreamedResponse(model_request_parameters=model_request_parameters)
image_profile = ModelProfile(supports_image_output=True)
agent = Agent(ImageStreamModel(profile=image_profile), output_type=BinaryImage)
@agent.output_validator
def validate_output(ctx: RunContext, output: BinaryImage) -> BinaryImage:
return BinaryImage(data=b'modified', media_type=output.media_type)
async with agent.run_stream('Give me an image') as stream:
result = await stream.get_output()
assert isinstance(result, BinaryImage)
assert result.data == b'modified', 'validator should be able to modify the output'
assert stream.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Give me an image', timestamp=IsNow(tz=timezone.utc))],
timestamp=IsNow(tz=timezone.utc),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[FilePart(content=BinaryImage(data=b'original', media_type='image/png'))],
model_name='image-model',
timestamp=IsDatetime(),
provider_name='test',
provider_url='https://test.example.com',
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# endregion
@pytest.mark.parametrize('tool_timeout', [0, -1])
def test_agent_rejects_non_positive_tool_timeout(tool_timeout: float):
with pytest.raises(UserError, match='tool_timeout must be > 0'):
Agent('test', tool_timeout=tool_timeout)