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

21227 lines
852 KiB
Python

from __future__ import annotations
import asyncio
import contextvars
import re
import threading
import warnings
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from importlib.util import find_spec
from pathlib import Path
from typing import Any, cast
import anyio
import pytest
from opentelemetry.trace import NoOpTracer
from pydantic import BaseModel, ValidationError
from pydantic_ai._run_context import RunContext
from pydantic_ai._spec import CapabilitySpec, NamedSpec
from pydantic_ai._tool_search import ToolSearchCallPart, ToolSearchReturnPart
from pydantic_ai._warnings import PydanticAIDeprecationWarning
from pydantic_ai.agent import Agent
from pydantic_ai.agent.spec import AgentSpec
from pydantic_ai.capabilities import (
CAPABILITY_TYPES,
MCP,
Capability,
CapabilityOrdering,
HandleDeferredToolCalls,
ImageGeneration,
IncludeToolReturnSchemas,
Instrumentation,
NativeTool,
PrefixTools,
PrepareTools,
ProcessEventStream,
ReinjectSystemPrompt,
SetToolMetadata,
Thinking,
ThreadExecutor,
ToolSearch,
Toolset,
WebFetch,
WebSearch,
WrapperCapability,
XSearch,
)
from pydantic_ai.capabilities.abstract import AbstractCapability
from pydantic_ai.capabilities.combined import CombinedCapability
from pydantic_ai.capabilities.hooks import Hooks, HookTimeoutError
from pydantic_ai.capabilities.native_tool import NativeTool as NativeToolCap
from pydantic_ai.exceptions import (
ApprovalRequired,
CallDeferred,
ModelRetry,
SkipModelRequest,
SkipToolExecution,
SkipToolValidation,
UndrainedPendingMessagesError,
UnexpectedModelBehavior,
UserError,
)
from pydantic_ai.messages import (
AgentStreamEvent,
BinaryImage,
FilePart,
ImageUrl,
LoadCapabilityCallPart,
LoadCapabilityReturnPart,
ModelMessage,
ModelRequest,
ModelResponse,
PartStartEvent,
RetryPromptPart,
SystemPromptPart,
TextPart,
ToolCallPart,
ToolReturn,
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models import ModelRequestContext, ModelRequestParameters
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, DeltaToolCalls, FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.native_tools import (
AbstractNativeTool,
CodeExecutionTool,
ImageGenerationTool,
MCPServerTool,
WebFetchTool,
WebSearchTool,
XSearchTool,
)
from pydantic_ai.native_tools._tool_search import ToolSearchTool
from pydantic_ai.output import NativeOutput, OutputContext, PromptedOutput, TextOutput, ToolOutput
from pydantic_ai.profiles import ModelProfile
from pydantic_ai.run import AgentRunResult
from pydantic_ai.settings import ModelSettings as _ModelSettings
from pydantic_ai.tool_manager import ToolManager
from pydantic_ai.tools import DeferredToolRequests, DeferredToolResults, ToolApproved, ToolDefinition, ToolDenied
from pydantic_ai.toolsets import AbstractToolset, FunctionToolset
from pydantic_ai.toolsets._capability_owned import resolve_capability_id
from pydantic_ai.toolsets._deferred_capability_loader import (
LOAD_CAPABILITY_ALREADY_AVAILABLE_MESSAGE_TEMPLATE,
LOAD_CAPABILITY_TOOL_NAME,
)
from pydantic_ai.toolsets._dynamic import ToolsetFunc
from pydantic_ai.toolsets._tool_search import _SEARCH_TOOLS_NAME # pyright: ignore[reportPrivateUsage]
from pydantic_ai.usage import RequestUsage, RunUsage
from pydantic_graph import End
from ._inline_snapshot import snapshot
from .conftest import IsDatetime, IsInstance, IsStr, message, remove_schema_descriptions
pytestmark = [
pytest.mark.anyio,
]
def test_capability_types() -> None:
assert CAPABILITY_TYPES == snapshot(
{
'NativeTool': NativeTool,
'ImageGeneration': ImageGeneration,
'IncludeToolReturnSchemas': IncludeToolReturnSchemas,
'Instrumentation': Instrumentation,
'MCP': MCP,
'PrefixTools': PrefixTools,
'ReinjectSystemPrompt': ReinjectSystemPrompt,
'SetToolMetadata': SetToolMetadata,
'Thinking': Thinking,
'ToolSearch': ToolSearch,
'WebFetch': WebFetch,
'WebSearch': WebSearch,
'XSearch': XSearch,
}
)
def test_instrumentation_default_settings() -> None:
"""`Instrumentation()` lazy-imports `InstrumentationSettings` and constructs default settings."""
from pydantic_ai.models.instrumented import InstrumentationSettings
instr = Instrumentation()
assert isinstance(instr.settings, InstrumentationSettings)
def test_agent_from_spec_basic():
"""Test Agent.from_spec with basic capabilities."""
agent = Agent.from_spec(
{
'model': 'test',
'instructions': 'You are a helpful agent.',
'model_settings': {'max_tokens': 4096},
'capabilities': [
{'WebSearch': {'local': 'duckduckgo'}},
],
}
)
assert agent.model is not None
def test_agent_from_spec_no_capabilities():
"""Test Agent.from_spec with no capabilities."""
agent = Agent.from_spec({'model': 'test'})
assert agent.model is not None
def test_agent_from_spec_image_generation():
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [{'ImageGeneration': {'local': False}}],
}
)
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
cap = next(c for c in children if isinstance(c, ImageGeneration))
assert cap.local is False
def test_agent_from_spec_web_fetch():
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [{'WebFetch': {'allowed_domains': ['example.com'], 'max_uses': 5, 'local': True}}],
}
)
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
cap = next(c for c in children if isinstance(c, WebFetch))
assert cap.allowed_domains == ['example.com']
assert cap.max_uses == 5
def test_agent_from_spec_mcp():
pytest.importorskip('mcp', reason='mcp package not installed')
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{
'MCP': {
'url': 'https://mcp.example.com/sse',
'allowed_tools': ['search'],
'native': True,
'id': 'search-mcp',
'description': 'Search MCP server.',
'defer_loading': True,
}
}
],
}
)
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
cap = next(c for c in children if isinstance(c, MCP))
assert cap.url == 'https://mcp.example.com/sse'
assert cap.allowed_tools == ['search']
assert cap.id == 'search-mcp'
assert cap.description == 'Search MCP server.'
assert cap.defer_loading is True
def test_agent_from_spec_unknown_capability():
"""Test Agent.from_spec with an unknown capability name."""
with pytest.raises(ValueError, match="Capability 'Unknown' is not in the provided"):
Agent.from_spec(
{
'model': 'test',
'capabilities': ['Unknown'],
}
)
def test_agent_from_spec_bad_args():
"""Test Agent.from_spec with bad arguments for a capability."""
with pytest.raises(ValueError, match="Failed to instantiate capability 'WebSearch'"):
Agent.from_spec(
{
'model': 'test',
'capabilities': [
{'WebSearch': {'nonexistent_param': 'value'}},
],
}
)
@dataclass
class CustomCapability(AbstractCapability):
greeting: str = 'hello'
@dataclass
class CapabilityWithCallbackParam(AbstractCapability):
"""Custom capability with a mix of serializable and non-serializable params."""
max_retries: int = 3
on_error: Callable[..., Any] = lambda: None # purely Callable, filtered from schema
verbose: Callable[..., Any] | bool = False # Callable | bool, only bool survives in schema
hooks: Callable[..., Any] | Callable[..., None] = lambda: None # union of all non-serializable, entirely filtered
def test_agent_from_spec_custom_capability():
"""Test Agent.from_spec with a custom capability type."""
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{'CustomCapability': 'world'},
],
},
custom_capability_types=[CustomCapability],
)
assert agent.model is not None
def test_agent_from_spec_with_agent_spec_object():
"""Test Agent.from_spec with an AgentSpec instance."""
spec = AgentSpec(
model='test',
instructions='You are helpful.',
capabilities=[
CapabilitySpec(name='WebSearch', arguments={'local': 'duckduckgo'}),
],
)
agent = Agent.from_spec(spec)
assert agent.model is not None
def test_agent_from_spec_output_type():
"""Test Agent.from_spec with output_type parameter."""
from pydantic import BaseModel
class MyOutput(BaseModel):
name: str
value: int
agent = Agent.from_spec({'model': 'test'}, output_type=MyOutput)
assert agent.output_type == MyOutput
def test_agent_from_spec_output_schema():
"""Test Agent.from_spec with output_schema in spec."""
schema = {
'type': 'object',
'properties': {
'name': {'type': 'string'},
'age': {'type': 'integer'},
},
'required': ['name', 'age'],
}
agent = Agent.from_spec({'model': 'test', 'output_schema': schema})
# output_type should be a StructuredDict subclass (dict subclass with JSON schema)
assert agent.output_type is not str
assert isinstance(agent.output_type, type) and issubclass(agent.output_type, dict)
def test_agent_from_spec_output_type_takes_precedence():
"""Test that output_type parameter takes precedence over output_schema in spec."""
from pydantic import BaseModel
class MyOutput(BaseModel):
name: str
schema = {
'type': 'object',
'properties': {'name': {'type': 'string'}},
'required': ['name'],
}
agent = Agent.from_spec({'model': 'test', 'output_schema': schema}, output_type=MyOutput)
assert agent.output_type == MyOutput
def test_agent_from_spec_output_schema_invalid():
"""Test Agent.from_spec with a non-object output_schema raises UserError."""
with pytest.raises(UserError, match='Schema must be an object'):
Agent.from_spec({'model': 'test', 'output_schema': {'type': 'string'}})
async def test_agent_from_spec_output_schema_integration():
"""Test Agent.from_spec with output_schema produces dict output."""
schema = {
'type': 'object',
'properties': {
'city': {'type': 'string'},
'country': {'type': 'string'},
},
'required': ['city', 'country'],
}
agent = Agent.from_spec({'model': 'test', 'output_schema': schema})
result = await agent.run(
'Tell me a city',
model=TestModel(custom_output_args={'city': 'Paris', 'country': 'France'}),
)
assert result.output == {'city': 'Paris', 'country': 'France'}
def test_agent_from_spec_name():
agent = Agent.from_spec({'model': 'test', 'name': 'my-agent'})
assert agent.name == 'my-agent'
def test_agent_from_spec_name_override():
agent = Agent.from_spec({'model': 'test', 'name': 'spec-name'}, name='override-name')
assert agent.name == 'override-name'
def test_agent_from_spec_description():
agent = Agent.from_spec({'model': 'test', 'description': 'A helpful agent'})
assert agent.description == 'A helpful agent'
def test_agent_from_spec_description_override():
agent = Agent.from_spec({'model': 'test', 'description': 'spec-desc'}, description='override-desc')
assert agent.description == 'override-desc'
def test_agent_from_spec_instructions():
agent = Agent.from_spec({'model': 'test', 'instructions': 'Be helpful.'})
assert 'Be helpful.' in agent._instructions # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_instructions_list():
agent = Agent.from_spec({'model': 'test', 'instructions': ['First.', 'Second.']})
assert 'First.' in agent._instructions # pyright: ignore[reportPrivateUsage]
assert 'Second.' in agent._instructions # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_instructions_merged():
agent = Agent.from_spec(
{'model': 'test', 'instructions': 'From spec.'},
instructions='From arg.',
)
assert 'From spec.' in agent._instructions # pyright: ignore[reportPrivateUsage]
assert 'From arg.' in agent._instructions # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_model_settings():
agent = Agent.from_spec({'model': 'test', 'model_settings': {'temperature': 0.5, 'max_tokens': 100}})
ms = agent.model_settings
assert isinstance(ms, dict)
assert ms.get('temperature') == 0.5 # pyright: ignore[reportUnknownMemberType]
assert ms.get('max_tokens') == 100 # pyright: ignore[reportUnknownMemberType]
def test_agent_from_spec_model_settings_merged():
agent = Agent.from_spec(
{'model': 'test', 'model_settings': {'temperature': 0.5, 'max_tokens': 100}},
model_settings={'temperature': 0.9},
)
ms = agent.model_settings
assert isinstance(ms, dict)
assert ms.get('temperature') == 0.9 # pyright: ignore[reportUnknownMemberType]
assert ms.get('max_tokens') == 100 # pyright: ignore[reportUnknownMemberType]
def test_agent_from_spec_retries():
agent = Agent.from_spec({'model': 'test', 'retries': 5})
assert agent._max_tool_retries == 5 # pyright: ignore[reportPrivateUsage]
assert agent._max_output_retries == 5 # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_retries_dict():
agent = Agent.from_spec({'model': 'test', 'retries': {'tools': 2, 'output': 4}})
assert agent._max_tool_retries == 2 # pyright: ignore[reportPrivateUsage]
assert agent._max_output_retries == 4 # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_retries_override():
agent = Agent.from_spec({'model': 'test', 'retries': 5}, retries=2)
assert agent._max_tool_retries == 2 # pyright: ignore[reportPrivateUsage]
assert agent._max_output_retries == 2 # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_no_retries_does_not_warn():
"""`from_spec` without an explicit retry budget uses the default budgets."""
agent = Agent.from_spec({'model': 'test'})
assert agent._max_tool_retries == 1 # pyright: ignore[reportPrivateUsage]
assert agent._max_output_retries == 1 # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_explicit_retries_does_not_warn():
"""`AgentSpec.retries` is canonical."""
agent = Agent.from_spec({'model': 'test', 'retries': 5})
assert agent._max_tool_retries == 5 # pyright: ignore[reportPrivateUsage]
assert agent._max_output_retries == 5 # pyright: ignore[reportPrivateUsage]
def test_agent_spec_retries_field():
"""`AgentSpec.retries` is the canonical field."""
spec = AgentSpec(model='test', retries=5)
assert spec.retries == 5
def test_agent_from_spec_end_strategy():
agent = Agent.from_spec({'model': 'test', 'end_strategy': 'exhaustive'})
assert agent.end_strategy == 'exhaustive'
def test_agent_from_spec_end_strategy_override():
agent = Agent.from_spec({'model': 'test', 'end_strategy': 'exhaustive'}, end_strategy='early')
assert agent.end_strategy == 'early'
def test_agent_from_spec_tool_timeout():
agent = Agent.from_spec({'model': 'test', 'tool_timeout': 30.0})
assert agent._tool_timeout == 30.0 # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_tool_timeout_override():
agent = Agent.from_spec({'model': 'test', 'tool_timeout': 30.0}, tool_timeout=5.0)
assert agent._tool_timeout == 5.0 # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_metadata():
agent = Agent.from_spec({'model': 'test', 'metadata': {'env': 'prod', 'version': '1.0'}})
assert agent._metadata == {'env': 'prod', 'version': '1.0'} # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_metadata_override():
agent = Agent.from_spec(
{'model': 'test', 'metadata': {'env': 'prod'}},
metadata={'env': 'staging'},
)
assert agent._metadata == {'env': 'staging'} # pyright: ignore[reportPrivateUsage]
def test_agent_from_spec_model_override():
agent = Agent.from_spec({'model': 'test'}, model='test')
assert agent.model is not None
def test_agent_from_spec_capabilities_merged():
@dataclass
class ExtraCap(AbstractCapability):
pass
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [{'WebSearch': {'local': 'duckduckgo'}}],
},
capabilities=[ExtraCap()],
)
# Should have both the WebSearch capability from spec and ExtraCap from arg
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
assert any(isinstance(c, WebSearch) for c in children)
assert any(isinstance(c, ExtraCap) for c in children)
def test_model_json_schema_with_capabilities():
pytest.importorskip('mcp', reason='schema varies without mcp package')
schema = AgentSpec.model_json_schema_with_capabilities()
assert remove_schema_descriptions(schema) == snapshot(
{
'$defs': {
'AgentRetries': {
'additionalProperties': False,
'properties': {
'tools': {'title': 'Tools', 'type': 'integer'},
'output': {'title': 'Output', 'type': 'integer'},
},
'title': 'AgentRetries',
'type': 'object',
},
'CodeExecutionTool': {
'properties': {
'kind': {'default': 'code_execution', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'files': {
'anyOf': [{'items': {'$ref': '#/$defs/UploadedFile'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Files',
},
},
'title': 'CodeExecutionTool',
'type': 'object',
},
'FileSearchTool': {
'properties': {
'kind': {'default': 'file_search', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'file_store_ids': {'items': {'type': 'string'}, 'title': 'File Store Ids', 'type': 'array'},
},
'required': ['file_store_ids'],
'title': 'FileSearchTool',
'type': 'object',
},
'ImageGenerationTool': {
'properties': {
'kind': {'default': 'image_generation', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'action': {
'default': 'auto',
'enum': ['generate', 'edit', 'auto'],
'title': 'Action',
'type': 'string',
},
'background': {
'default': 'auto',
'enum': ['transparent', 'opaque', 'auto'],
'title': 'Background',
'type': 'string',
},
'input_fidelity': {
'anyOf': [{'enum': ['high', 'low'], 'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Input Fidelity',
},
'moderation': {
'default': 'auto',
'enum': ['auto', 'low'],
'title': 'Moderation',
'type': 'string',
},
'model': {
'anyOf': [
{
'enum': ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini'],
'type': 'string',
},
{'type': 'string'},
{'type': 'null'},
],
'default': None,
'title': 'Model',
},
'output_compression': {
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'default': None,
'title': 'Output Compression',
},
'output_format': {
'anyOf': [{'enum': ['png', 'webp', 'jpeg'], 'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Output Format',
},
'partial_images': {'default': 0, 'title': 'Partial Images', 'type': 'integer'},
'quality': {
'default': 'auto',
'enum': ['low', 'medium', 'high', 'auto'],
'title': 'Quality',
'type': 'string',
},
'size': {
'anyOf': [
{
'enum': ['auto', '1024x1024', '1024x1536', '1536x1024', '512', '1K', '2K', '4K'],
'type': 'string',
},
{'type': 'null'},
],
'default': None,
'title': 'Size',
},
'aspect_ratio': {
'anyOf': [
{
'enum': ['21:9', '16:9', '4:3', '3:2', '1:1', '9:16', '3:4', '2:3', '5:4', '4:5'],
'type': 'string',
},
{'type': 'null'},
],
'default': None,
'title': 'Aspect Ratio',
},
},
'title': 'ImageGenerationTool',
'type': 'object',
},
'KnownModelName': {
'enum': [
'anthropic:claude-3-haiku-20240307',
'anthropic:claude-fable-5',
'anthropic:claude-haiku-4-5',
'anthropic:claude-haiku-4-5-20251001',
'anthropic:claude-mythos-5',
'anthropic:claude-mythos-preview',
'anthropic:claude-opus-4-0',
'anthropic:claude-opus-4-1',
'anthropic:claude-opus-4-1-20250805',
'anthropic:claude-opus-4-20250514',
'anthropic:claude-opus-4-5',
'anthropic:claude-opus-4-5-20251101',
'anthropic:claude-opus-4-6',
'anthropic:claude-opus-4-7',
'anthropic:claude-opus-4-8',
'anthropic:claude-sonnet-4-0',
'anthropic:claude-sonnet-4-20250514',
'anthropic:claude-sonnet-4-5',
'anthropic:claude-sonnet-4-5-20250929',
'anthropic:claude-sonnet-4-6',
'anthropic:claude-sonnet-5',
'bedrock:amazon.titan-text-express-v1',
'bedrock:amazon.titan-text-lite-v1',
'bedrock:amazon.titan-tg1-large',
'bedrock:anthropic.claude-3-5-haiku-20241022-v1:0',
'bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0',
'bedrock:anthropic.claude-3-5-sonnet-20241022-v2:0',
'bedrock:anthropic.claude-3-7-sonnet-20250219-v1:0',
'bedrock:anthropic.claude-3-haiku-20240307-v1:0',
'bedrock:anthropic.claude-3-opus-20240229-v1:0',
'bedrock:anthropic.claude-3-sonnet-20240229-v1:0',
'bedrock:anthropic.claude-haiku-4-5-20251001-v1:0',
'bedrock:anthropic.claude-instant-v1',
'bedrock:anthropic.claude-opus-4-20250514-v1:0',
'bedrock:anthropic.claude-sonnet-4-20250514-v1:0',
'bedrock:anthropic.claude-sonnet-4-5-20250929-v1:0',
'bedrock:anthropic.claude-sonnet-4-6',
'bedrock:anthropic.claude-v2',
'bedrock:anthropic.claude-v2:1',
'bedrock:cohere.command-light-text-v14',
'bedrock:cohere.command-r-plus-v1:0',
'bedrock:cohere.command-r-v1:0',
'bedrock:cohere.command-text-v14',
'bedrock:deepseek.r1-v1:0',
'bedrock:deepseek.v3.2',
'bedrock:eu.anthropic.claude-haiku-4-5-20251001-v1:0',
'bedrock:eu.anthropic.claude-sonnet-4-20250514-v1:0',
'bedrock:eu.anthropic.claude-sonnet-4-5-20250929-v1:0',
'bedrock:eu.anthropic.claude-sonnet-4-6',
'bedrock:global.amazon.nova-2-lite-v1:0',
'bedrock:global.anthropic.claude-fable-5',
'bedrock:global.anthropic.claude-opus-4-5-20251101-v1:0',
'bedrock:global.anthropic.claude-opus-4-6-v1',
'bedrock:global.anthropic.claude-opus-4-7',
'bedrock:global.anthropic.claude-opus-4-8',
'bedrock:global.anthropic.claude-sonnet-5',
'bedrock:google.gemma-3-12b-it',
'bedrock:google.gemma-3-27b-it',
'bedrock:google.gemma-3-4b-it',
'bedrock:meta.llama3-1-405b-instruct-v1:0',
'bedrock:meta.llama3-1-70b-instruct-v1:0',
'bedrock:meta.llama3-1-8b-instruct-v1:0',
'bedrock:meta.llama3-70b-instruct-v1:0',
'bedrock:meta.llama3-8b-instruct-v1:0',
'bedrock:minimax.minimax-m2',
'bedrock:minimax.minimax-m2.1',
'bedrock:minimax.minimax-m2.5',
'bedrock:mistral.devstral-2-123b',
'bedrock:mistral.magistral-small-2509',
'bedrock:mistral.ministral-3-14b-instruct',
'bedrock:mistral.ministral-3-3b-instruct',
'bedrock:mistral.ministral-3-8b-instruct',
'bedrock:mistral.mistral-7b-instruct-v0:2',
'bedrock:mistral.mistral-large-2402-v1:0',
'bedrock:mistral.mistral-large-2407-v1:0',
'bedrock:mistral.mistral-large-3-675b-instruct',
'bedrock:mistral.mistral-small-2402-v1:0',
'bedrock:mistral.mixtral-8x7b-instruct-v0:1',
'bedrock:mistral.pixtral-large-2502-v1:0',
'bedrock:moonshot.kimi-k2-thinking',
'bedrock:moonshotai.kimi-k2.5',
'bedrock:nvidia.nemotron-nano-12b-v2',
'bedrock:nvidia.nemotron-nano-3-30b',
'bedrock:nvidia.nemotron-nano-9b-v2',
'bedrock:nvidia.nemotron-super-3-120b',
'bedrock:qwen.qwen3-32b-v1:0',
'bedrock:qwen.qwen3-coder-30b-a3b-v1:0',
'bedrock:qwen.qwen3-coder-next',
'bedrock:qwen.qwen3-next-80b-a3b',
'bedrock:qwen.qwen3-vl-235b-a22b',
'bedrock:us.amazon.nova-2-lite-v1:0',
'bedrock:us.amazon.nova-lite-v1:0',
'bedrock:us.amazon.nova-micro-v1:0',
'bedrock:us.amazon.nova-premier-v1:0',
'bedrock:us.amazon.nova-pro-v1:0',
'bedrock:us.anthropic.claude-3-5-haiku-20241022-v1:0',
'bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0',
'bedrock:us.anthropic.claude-3-5-sonnet-20241022-v2:0',
'bedrock:us.anthropic.claude-3-7-sonnet-20250219-v1:0',
'bedrock:us.anthropic.claude-3-haiku-20240307-v1:0',
'bedrock:us.anthropic.claude-3-opus-20240229-v1:0',
'bedrock:us.anthropic.claude-3-sonnet-20240229-v1:0',
'bedrock:us.anthropic.claude-fable-5',
'bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0',
'bedrock:us.anthropic.claude-opus-4-1-20250805-v1:0',
'bedrock:us.anthropic.claude-opus-4-20250514-v1:0',
'bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0',
'bedrock:us.anthropic.claude-opus-4-6-v1',
'bedrock:us.anthropic.claude-opus-4-7',
'bedrock:us.anthropic.claude-opus-4-8',
'bedrock:us.anthropic.claude-sonnet-4-20250514-v1:0',
'bedrock:us.anthropic.claude-sonnet-4-5-20250929-v1:0',
'bedrock:us.anthropic.claude-sonnet-4-6',
'bedrock:us.anthropic.claude-sonnet-5',
'bedrock:us.meta.llama3-1-70b-instruct-v1:0',
'bedrock:us.meta.llama3-1-8b-instruct-v1:0',
'bedrock:us.meta.llama3-2-11b-instruct-v1:0',
'bedrock:us.meta.llama3-2-1b-instruct-v1:0',
'bedrock:us.meta.llama3-2-3b-instruct-v1:0',
'bedrock:us.meta.llama3-2-90b-instruct-v1:0',
'bedrock:us.meta.llama3-3-70b-instruct-v1:0',
'bedrock:us.meta.llama4-maverick-17b-instruct-v1:0',
'bedrock:us.meta.llama4-scout-17b-instruct-v1:0',
'bedrock:us.mistral.pixtral-large-2502-v1:0',
'bedrock:us.writer.palmyra-x4-v1:0',
'bedrock:us.writer.palmyra-x5-v1:0',
'bedrock:zai.glm-4.7',
'bedrock:zai.glm-4.7-flash',
'bedrock:zai.glm-5',
'cerebras:gpt-oss-120b',
'cerebras:llama3.1-8b',
'cerebras:qwen-3-235b-a22b-instruct-2507',
'cerebras:zai-glm-4.7',
'cohere:c4ai-aya-expanse-32b',
'cohere:c4ai-aya-expanse-8b',
'cohere:command-nightly',
'cohere:command-r-08-2024',
'cohere:command-r-plus-08-2024',
'cohere:command-r7b-12-2024',
'deepseek:deepseek-chat',
'deepseek:deepseek-reasoner',
'deepseek:deepseek-v4-flash',
'deepseek:deepseek-v4-pro',
'gateway/anthropic:claude-3-haiku-20240307',
'gateway/anthropic:claude-fable-5',
'gateway/anthropic:claude-haiku-4-5',
'gateway/anthropic:claude-haiku-4-5-20251001',
'gateway/anthropic:claude-mythos-5',
'gateway/anthropic:claude-mythos-preview',
'gateway/anthropic:claude-opus-4-0',
'gateway/anthropic:claude-opus-4-1',
'gateway/anthropic:claude-opus-4-1-20250805',
'gateway/anthropic:claude-opus-4-20250514',
'gateway/anthropic:claude-opus-4-5',
'gateway/anthropic:claude-opus-4-5-20251101',
'gateway/anthropic:claude-opus-4-6',
'gateway/anthropic:claude-opus-4-7',
'gateway/anthropic:claude-opus-4-8',
'gateway/anthropic:claude-sonnet-4-0',
'gateway/anthropic:claude-sonnet-4-20250514',
'gateway/anthropic:claude-sonnet-4-5',
'gateway/anthropic:claude-sonnet-4-5-20250929',
'gateway/anthropic:claude-sonnet-4-6',
'gateway/anthropic:claude-sonnet-5',
'gateway/bedrock:anthropic.claude-3-haiku-20240307-v1:0',
'gateway/bedrock:deepseek.r1-v1:0',
'gateway/bedrock:deepseek.v3.2',
'gateway/bedrock:eu.anthropic.claude-haiku-4-5-20251001-v1:0',
'gateway/bedrock:eu.anthropic.claude-sonnet-4-20250514-v1:0',
'gateway/bedrock:eu.anthropic.claude-sonnet-4-5-20250929-v1:0',
'gateway/bedrock:eu.anthropic.claude-sonnet-4-6',
'gateway/bedrock:global.amazon.nova-2-lite-v1:0',
'gateway/bedrock:global.anthropic.claude-fable-5',
'gateway/bedrock:global.anthropic.claude-opus-4-5-20251101-v1:0',
'gateway/bedrock:global.anthropic.claude-opus-4-6-v1',
'gateway/bedrock:global.anthropic.claude-opus-4-7',
'gateway/bedrock:global.anthropic.claude-opus-4-8',
'gateway/bedrock:global.anthropic.claude-sonnet-5',
'gateway/bedrock:google.gemma-3-12b-it',
'gateway/bedrock:google.gemma-3-27b-it',
'gateway/bedrock:google.gemma-3-4b-it',
'gateway/bedrock:minimax.minimax-m2',
'gateway/bedrock:minimax.minimax-m2.1',
'gateway/bedrock:minimax.minimax-m2.5',
'gateway/bedrock:mistral.devstral-2-123b',
'gateway/bedrock:mistral.magistral-small-2509',
'gateway/bedrock:mistral.ministral-3-14b-instruct',
'gateway/bedrock:mistral.ministral-3-3b-instruct',
'gateway/bedrock:mistral.ministral-3-8b-instruct',
'gateway/bedrock:mistral.mistral-large-3-675b-instruct',
'gateway/bedrock:mistral.mistral-small-2402-v1:0',
'gateway/bedrock:mistral.pixtral-large-2502-v1:0',
'gateway/bedrock:moonshot.kimi-k2-thinking',
'gateway/bedrock:moonshotai.kimi-k2.5',
'gateway/bedrock:nvidia.nemotron-nano-12b-v2',
'gateway/bedrock:nvidia.nemotron-nano-3-30b',
'gateway/bedrock:nvidia.nemotron-nano-9b-v2',
'gateway/bedrock:nvidia.nemotron-super-3-120b',
'gateway/bedrock:qwen.qwen3-32b-v1:0',
'gateway/bedrock:qwen.qwen3-coder-30b-a3b-v1:0',
'gateway/bedrock:qwen.qwen3-coder-next',
'gateway/bedrock:qwen.qwen3-next-80b-a3b',
'gateway/bedrock:qwen.qwen3-vl-235b-a22b',
'gateway/bedrock:us.amazon.nova-premier-v1:0',
'gateway/bedrock:us.anthropic.claude-fable-5',
'gateway/bedrock:us.anthropic.claude-opus-4-1-20250805-v1:0',
'gateway/bedrock:us.anthropic.claude-opus-4-5-20251101-v1:0',
'gateway/bedrock:us.anthropic.claude-opus-4-6-v1',
'gateway/bedrock:us.anthropic.claude-opus-4-7',
'gateway/bedrock:us.anthropic.claude-opus-4-8',
'gateway/bedrock:us.anthropic.claude-sonnet-5',
'gateway/bedrock:us.meta.llama4-maverick-17b-instruct-v1:0',
'gateway/bedrock:us.meta.llama4-scout-17b-instruct-v1:0',
'gateway/bedrock:us.mistral.pixtral-large-2502-v1:0',
'gateway/bedrock:us.writer.palmyra-x4-v1:0',
'gateway/bedrock:us.writer.palmyra-x5-v1:0',
'gateway/bedrock:zai.glm-4.7',
'gateway/bedrock:zai.glm-4.7-flash',
'gateway/bedrock:zai.glm-5',
'gateway/google-cloud:gemini-2.5-flash',
'gateway/google-cloud:gemini-2.5-flash-image',
'gateway/google-cloud:gemini-2.5-flash-lite',
'gateway/google-cloud:gemini-2.5-flash-lite-preview-09-2025',
'gateway/google-cloud:gemini-2.5-pro',
'gateway/google-cloud:gemini-3-flash-preview',
'gateway/google-cloud:gemini-3-pro-image-preview',
'gateway/google-cloud:gemini-3.1-flash-image-preview',
'gateway/google-cloud:gemini-3.1-flash-lite-preview',
'gateway/google-cloud:gemini-3.1-pro-preview',
'gateway/google-cloud:gemini-3.5-flash',
'gateway/google:gemini-2.5-flash',
'gateway/google:gemini-2.5-flash-image',
'gateway/google:gemini-2.5-flash-lite',
'gateway/google:gemini-2.5-flash-lite-preview-09-2025',
'gateway/google:gemini-2.5-pro',
'gateway/google:gemini-3-flash-preview',
'gateway/google:gemini-3-pro-image-preview',
'gateway/google:gemini-3.1-flash-image-preview',
'gateway/google:gemini-3.1-flash-lite-preview',
'gateway/google:gemini-3.1-pro-preview',
'gateway/google:gemini-3.5-flash',
'gateway/groq:llama-3.1-8b-instant',
'gateway/groq:llama-3.3-70b-versatile',
'gateway/groq:meta-llama/llama-4-scout-17b-16e-instruct',
'gateway/groq:moonshotai/kimi-k2-instruct-0905',
'gateway/groq:openai/gpt-oss-120b',
'gateway/groq:openai/gpt-oss-20b',
'gateway/groq:openai/gpt-oss-safeguard-20b',
'gateway/openai:gpt-3.5-turbo',
'gateway/openai:gpt-3.5-turbo-0125',
'gateway/openai:gpt-3.5-turbo-1106',
'gateway/openai:gpt-3.5-turbo-16k',
'gateway/openai:gpt-4',
'gateway/openai:gpt-4-0613',
'gateway/openai:gpt-4-turbo',
'gateway/openai:gpt-4-turbo-2024-04-09',
'gateway/openai:gpt-4.1',
'gateway/openai:gpt-4.1-2025-04-14',
'gateway/openai:gpt-4.1-mini',
'gateway/openai:gpt-4.1-mini-2025-04-14',
'gateway/openai:gpt-4.1-nano',
'gateway/openai:gpt-4.1-nano-2025-04-14',
'gateway/openai:gpt-4o',
'gateway/openai:gpt-4o-2024-05-13',
'gateway/openai:gpt-4o-2024-08-06',
'gateway/openai:gpt-4o-2024-11-20',
'gateway/openai:gpt-4o-mini',
'gateway/openai:gpt-4o-mini-2024-07-18',
'gateway/openai:gpt-4o-mini-search-preview',
'gateway/openai:gpt-4o-mini-search-preview-2025-03-11',
'gateway/openai:gpt-4o-search-preview',
'gateway/openai:gpt-4o-search-preview-2025-03-11',
'gateway/openai:gpt-5',
'gateway/openai:gpt-5-2025-08-07',
'gateway/openai:gpt-5-chat-latest',
'gateway/openai:gpt-5-mini',
'gateway/openai:gpt-5-mini-2025-08-07',
'gateway/openai:gpt-5-nano',
'gateway/openai:gpt-5-nano-2025-08-07',
'gateway/openai:gpt-5.1',
'gateway/openai:gpt-5.1-2025-11-13',
'gateway/openai:gpt-5.1-chat-latest',
'gateway/openai:gpt-5.2',
'gateway/openai:gpt-5.2-2025-12-11',
'gateway/openai:gpt-5.2-chat-latest',
'gateway/openai:gpt-5.4',
'gateway/openai:gpt-5.4-mini',
'gateway/openai:gpt-5.4-mini-2026-03-17',
'gateway/openai:gpt-5.4-nano',
'gateway/openai:gpt-5.4-nano-2026-03-17',
'gateway/openai:gpt-5.6-luna',
'gateway/openai:gpt-5.6-sol',
'gateway/openai:gpt-5.6-terra',
'gateway/openai:o1',
'gateway/openai:o1-2024-12-17',
'gateway/openai:o3',
'gateway/openai:o3-2025-04-16',
'gateway/openai:o3-mini',
'gateway/openai:o3-mini-2025-01-31',
'gateway/openai:o4-mini',
'gateway/openai:o4-mini-2025-04-16',
'google-cloud:gemini-2.0-flash',
'google-cloud:gemini-2.0-flash-lite',
'google-cloud:gemini-2.5-flash',
'google-cloud:gemini-2.5-flash-image',
'google-cloud:gemini-2.5-flash-lite',
'google-cloud:gemini-2.5-flash-lite-preview-09-2025',
'google-cloud:gemini-2.5-flash-preview-09-2025',
'google-cloud:gemini-2.5-pro',
'google-cloud:gemini-3-flash-preview',
'google-cloud:gemini-3-pro-image-preview',
'google-cloud:gemini-3-pro-preview',
'google-cloud:gemini-3.1-flash-image-preview',
'google-cloud:gemini-3.1-flash-lite-preview',
'google-cloud:gemini-3.1-pro-preview',
'google-cloud:gemini-3.5-flash',
'google-cloud:gemini-flash-latest',
'google-cloud:gemini-flash-lite-latest',
'google:gemini-2.0-flash',
'google:gemini-2.0-flash-lite',
'google:gemini-2.5-flash',
'google:gemini-2.5-flash-image',
'google:gemini-2.5-flash-lite',
'google:gemini-2.5-flash-lite-preview-09-2025',
'google:gemini-2.5-flash-preview-09-2025',
'google:gemini-2.5-pro',
'google:gemini-3-flash-preview',
'google:gemini-3-pro-image-preview',
'google:gemini-3-pro-preview',
'google:gemini-3.1-flash-image-preview',
'google:gemini-3.1-flash-lite-preview',
'google:gemini-3.1-pro-preview',
'google:gemini-3.5-flash',
'google:gemini-flash-latest',
'google:gemini-flash-lite-latest',
'groq:llama-3.1-8b-instant',
'groq:llama-3.3-70b-versatile',
'groq:meta-llama/llama-4-maverick-17b-128e-instruct',
'groq:meta-llama/llama-4-scout-17b-16e-instruct',
'groq:meta-llama/llama-guard-4-12b',
'groq:meta-llama/llama-prompt-guard-2-22m',
'groq:meta-llama/llama-prompt-guard-2-86m',
'groq:moonshotai/kimi-k2-instruct-0905',
'groq:openai/gpt-oss-120b',
'groq:openai/gpt-oss-20b',
'groq:openai/gpt-oss-safeguard-20b',
'groq:playai-tts',
'groq:playai-tts-arabic',
'groq:qwen/qwen3-32b',
'groq:whisper-large-v3',
'groq:whisper-large-v3-turbo',
'heroku:claude-3-5-haiku',
'heroku:claude-3-5-sonnet-latest',
'heroku:claude-3-7-sonnet',
'heroku:claude-3-haiku',
'heroku:claude-4-5-haiku',
'heroku:claude-4-5-sonnet',
'heroku:claude-4-6-sonnet',
'heroku:claude-4-sonnet',
'heroku:claude-opus-4-5',
'heroku:claude-opus-4-6',
'heroku:deepseek-v3-2',
'heroku:glm-4-7',
'heroku:glm-4-7-flash',
'heroku:gpt-oss-120b',
'heroku:kimi-k2-5',
'heroku:kimi-k2-thinking',
'heroku:minimax-m2',
'heroku:minimax-m2-1',
'heroku:nova-2-lite',
'heroku:nova-lite',
'heroku:nova-pro',
'heroku:qwen3-235b',
'heroku:qwen3-coder-480b',
'huggingface:Qwen/QwQ-32B',
'huggingface:Qwen/Qwen2.5-72B-Instruct',
'huggingface:Qwen/Qwen3-235B-A22B',
'huggingface:Qwen/Qwen3-32B',
'huggingface:deepseek-ai/DeepSeek-R1',
'huggingface:meta-llama/Llama-3.3-70B-Instruct',
'huggingface:meta-llama/Llama-4-Maverick-17B-128E-Instruct',
'huggingface:meta-llama/Llama-4-Scout-17B-16E-Instruct',
'mistral:codestral-latest',
'mistral:mistral-large-latest',
'mistral:mistral-moderation-latest',
'mistral:mistral-small-latest',
'moonshotai:kimi-k2-0711-preview',
'moonshotai:kimi-k2.5',
'moonshotai:kimi-k2.6',
'moonshotai:kimi-k2.7-code',
'moonshotai:kimi-k2.7-code-highspeed',
'moonshotai:kimi-latest',
'moonshotai:kimi-thinking-preview',
'moonshotai:moonshot-v1-128k',
'moonshotai:moonshot-v1-128k-vision-preview',
'moonshotai:moonshot-v1-32k',
'moonshotai:moonshot-v1-32k-vision-preview',
'moonshotai:moonshot-v1-8k',
'moonshotai:moonshot-v1-8k-vision-preview',
'moonshotai:moonshot-v1-auto',
'openai-chat:computer-use-preview',
'openai-chat:computer-use-preview-2025-03-11',
'openai-chat:gpt-3.5-turbo',
'openai-chat:gpt-3.5-turbo-0125',
'openai-chat:gpt-3.5-turbo-0301',
'openai-chat:gpt-3.5-turbo-0613',
'openai-chat:gpt-3.5-turbo-1106',
'openai-chat:gpt-3.5-turbo-16k',
'openai-chat:gpt-3.5-turbo-16k-0613',
'openai-chat:gpt-4',
'openai-chat:gpt-4-0314',
'openai-chat:gpt-4-0613',
'openai-chat:gpt-4-turbo',
'openai-chat:gpt-4-turbo-2024-04-09',
'openai-chat:gpt-4.1',
'openai-chat:gpt-4.1-2025-04-14',
'openai-chat:gpt-4.1-mini',
'openai-chat:gpt-4.1-mini-2025-04-14',
'openai-chat:gpt-4.1-nano',
'openai-chat:gpt-4.1-nano-2025-04-14',
'openai-chat:gpt-4o',
'openai-chat:gpt-4o-2024-05-13',
'openai-chat:gpt-4o-2024-08-06',
'openai-chat:gpt-4o-2024-11-20',
'openai-chat:gpt-4o-audio-preview',
'openai-chat:gpt-4o-audio-preview-2024-12-17',
'openai-chat:gpt-4o-audio-preview-2025-06-03',
'openai-chat:gpt-4o-mini',
'openai-chat:gpt-4o-mini-2024-07-18',
'openai-chat:gpt-4o-mini-audio-preview',
'openai-chat:gpt-4o-mini-audio-preview-2024-12-17',
'openai-chat:gpt-4o-mini-search-preview',
'openai-chat:gpt-4o-mini-search-preview-2025-03-11',
'openai-chat:gpt-4o-search-preview',
'openai-chat:gpt-4o-search-preview-2025-03-11',
'openai-chat:gpt-5',
'openai-chat:gpt-5-2025-08-07',
'openai-chat:gpt-5-chat-latest',
'openai-chat:gpt-5-codex',
'openai-chat:gpt-5-mini',
'openai-chat:gpt-5-mini-2025-08-07',
'openai-chat:gpt-5-nano',
'openai-chat:gpt-5-nano-2025-08-07',
'openai-chat:gpt-5-pro',
'openai-chat:gpt-5-pro-2025-10-06',
'openai-chat:gpt-5.1',
'openai-chat:gpt-5.1-2025-11-13',
'openai-chat:gpt-5.1-chat-latest',
'openai-chat:gpt-5.1-codex',
'openai-chat:gpt-5.1-codex-max',
'openai-chat:gpt-5.2',
'openai-chat:gpt-5.2-2025-12-11',
'openai-chat:gpt-5.2-chat-latest',
'openai-chat:gpt-5.2-pro',
'openai-chat:gpt-5.2-pro-2025-12-11',
'openai-chat:gpt-5.3-chat-latest',
'openai-chat:gpt-5.4',
'openai-chat:gpt-5.4-mini',
'openai-chat:gpt-5.4-mini-2026-03-17',
'openai-chat:gpt-5.4-nano',
'openai-chat:gpt-5.4-nano-2026-03-17',
'openai-chat:gpt-5.6-luna',
'openai-chat:gpt-5.6-sol',
'openai-chat:gpt-5.6-terra',
'openai-chat:o1',
'openai-chat:o1-2024-12-17',
'openai-chat:o1-pro',
'openai-chat:o1-pro-2025-03-19',
'openai-chat:o3',
'openai-chat:o3-2025-04-16',
'openai-chat:o3-deep-research',
'openai-chat:o3-deep-research-2025-06-26',
'openai-chat:o3-mini',
'openai-chat:o3-mini-2025-01-31',
'openai-chat:o3-pro',
'openai-chat:o3-pro-2025-06-10',
'openai-chat:o4-mini',
'openai-chat:o4-mini-2025-04-16',
'openai-chat:o4-mini-deep-research',
'openai-chat:o4-mini-deep-research-2025-06-26',
'openai:computer-use-preview',
'openai:computer-use-preview-2025-03-11',
'openai:gpt-3.5-turbo',
'openai:gpt-3.5-turbo-0125',
'openai:gpt-3.5-turbo-0301',
'openai:gpt-3.5-turbo-0613',
'openai:gpt-3.5-turbo-1106',
'openai:gpt-3.5-turbo-16k',
'openai:gpt-3.5-turbo-16k-0613',
'openai:gpt-4',
'openai:gpt-4-0314',
'openai:gpt-4-0613',
'openai:gpt-4-turbo',
'openai:gpt-4-turbo-2024-04-09',
'openai:gpt-4.1',
'openai:gpt-4.1-2025-04-14',
'openai:gpt-4.1-mini',
'openai:gpt-4.1-mini-2025-04-14',
'openai:gpt-4.1-nano',
'openai:gpt-4.1-nano-2025-04-14',
'openai:gpt-4o',
'openai:gpt-4o-2024-05-13',
'openai:gpt-4o-2024-08-06',
'openai:gpt-4o-2024-11-20',
'openai:gpt-4o-audio-preview',
'openai:gpt-4o-audio-preview-2024-12-17',
'openai:gpt-4o-audio-preview-2025-06-03',
'openai:gpt-4o-mini',
'openai:gpt-4o-mini-2024-07-18',
'openai:gpt-4o-mini-audio-preview',
'openai:gpt-4o-mini-audio-preview-2024-12-17',
'openai:gpt-4o-mini-search-preview',
'openai:gpt-4o-mini-search-preview-2025-03-11',
'openai:gpt-4o-search-preview',
'openai:gpt-4o-search-preview-2025-03-11',
'openai:gpt-5',
'openai:gpt-5-2025-08-07',
'openai:gpt-5-chat-latest',
'openai:gpt-5-codex',
'openai:gpt-5-mini',
'openai:gpt-5-mini-2025-08-07',
'openai:gpt-5-nano',
'openai:gpt-5-nano-2025-08-07',
'openai:gpt-5-pro',
'openai:gpt-5-pro-2025-10-06',
'openai:gpt-5.1',
'openai:gpt-5.1-2025-11-13',
'openai:gpt-5.1-chat-latest',
'openai:gpt-5.1-codex',
'openai:gpt-5.1-codex-max',
'openai:gpt-5.2',
'openai:gpt-5.2-2025-12-11',
'openai:gpt-5.2-chat-latest',
'openai:gpt-5.2-pro',
'openai:gpt-5.2-pro-2025-12-11',
'openai:gpt-5.3-chat-latest',
'openai:gpt-5.4',
'openai:gpt-5.4-mini',
'openai:gpt-5.4-mini-2026-03-17',
'openai:gpt-5.4-nano',
'openai:gpt-5.4-nano-2026-03-17',
'openai:gpt-5.6-luna',
'openai:gpt-5.6-sol',
'openai:gpt-5.6-terra',
'openai:o1',
'openai:o1-2024-12-17',
'openai:o1-pro',
'openai:o1-pro-2025-03-19',
'openai:o3',
'openai:o3-2025-04-16',
'openai:o3-deep-research',
'openai:o3-deep-research-2025-06-26',
'openai:o3-mini',
'openai:o3-mini-2025-01-31',
'openai:o3-pro',
'openai:o3-pro-2025-06-10',
'openai:o4-mini',
'openai:o4-mini-2025-04-16',
'openai:o4-mini-deep-research',
'openai:o4-mini-deep-research-2025-06-26',
'test',
'xai:grok-3',
'xai:grok-3-fast',
'xai:grok-3-fast-latest',
'xai:grok-3-latest',
'xai:grok-3-mini',
'xai:grok-3-mini-fast',
'xai:grok-3-mini-fast-latest',
'xai:grok-4',
'xai:grok-4-0709',
'xai:grok-4-1-fast',
'xai:grok-4-1-fast-non-reasoning',
'xai:grok-4-1-fast-non-reasoning-latest',
'xai:grok-4-1-fast-reasoning',
'xai:grok-4-1-fast-reasoning-latest',
'xai:grok-4-fast',
'xai:grok-4-fast-non-reasoning',
'xai:grok-4-fast-non-reasoning-latest',
'xai:grok-4-fast-reasoning',
'xai:grok-4-fast-reasoning-latest',
'xai:grok-4-latest',
'xai:grok-4.20',
'xai:grok-4.20-0309',
'xai:grok-4.20-0309-non-reasoning',
'xai:grok-4.20-0309-reasoning',
'xai:grok-4.20-multi-agent',
'xai:grok-4.20-multi-agent-0309',
'xai:grok-4.20-multi-agent-latest',
'xai:grok-4.20-non-reasoning',
'xai:grok-4.20-non-reasoning-latest',
'xai:grok-4.20-reasoning-latest',
'xai:grok-4.3',
'xai:grok-4.3-latest',
'xai:grok-4.5',
'xai:grok-4.5-latest',
'xai:grok-code-fast-1',
'zai:autoglm-phone-multilingual',
'zai:glm-4-32b-0414-128k',
'zai:glm-4.5',
'zai:glm-4.5-air',
'zai:glm-4.5-airx',
'zai:glm-4.5-flash',
'zai:glm-4.5-x',
'zai:glm-4.5v',
'zai:glm-4.6',
'zai:glm-4.6v',
'zai:glm-4.6v-flash',
'zai:glm-4.6v-flashx',
'zai:glm-4.7',
'zai:glm-4.7-flash',
'zai:glm-4.7-flashx',
'zai:glm-5',
'zai:glm-5-turbo',
'zai:glm-5.1',
'zai:glm-5.2',
'zai:glm-5v-turbo',
],
'type': 'string',
},
'MCPServerTool': {
'properties': {
'kind': {'default': 'mcp_server', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'id': {'title': 'Id', 'type': 'string'},
'url': {'title': 'Url', 'type': 'string'},
'authorization_token': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Authorization Token',
},
'description': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Description',
},
'allowed_tools': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Allowed Tools',
},
'headers': {
'anyOf': [{'additionalProperties': {'type': 'string'}, 'type': 'object'}, {'type': 'null'}],
'default': None,
'title': 'Headers',
},
},
'required': ['id', 'url'],
'title': 'MCPServerTool',
'type': 'object',
},
'MemoryTool': {
'properties': {
'kind': {'default': 'memory', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
},
'title': 'MemoryTool',
'type': 'object',
},
'ModelSettings': {
'properties': {
'max_tokens': {'title': 'Max Tokens', 'type': 'integer'},
'temperature': {'title': 'Temperature', 'type': 'number'},
'top_p': {'title': 'Top P', 'type': 'number'},
'top_k': {'title': 'Top K', 'type': 'integer'},
'timeout': {'title': 'Timeout', 'type': 'number'},
'parallel_tool_calls': {'title': 'Parallel Tool Calls', 'type': 'boolean'},
'tool_choice': {
'anyOf': [
{'enum': ['none', 'required', 'auto'], 'type': 'string'},
{'items': {'type': 'string'}, 'type': 'array'},
{'$ref': '#/$defs/ToolOrOutput'},
{'type': 'null'},
],
'title': 'Tool Choice',
},
'seed': {'title': 'Seed', 'type': 'integer'},
'presence_penalty': {'title': 'Presence Penalty', 'type': 'number'},
'frequency_penalty': {'title': 'Frequency Penalty', 'type': 'number'},
'logit_bias': {
'additionalProperties': {'type': 'integer'},
'title': 'Logit Bias',
'type': 'object',
},
'stop_sequences': {'items': {'type': 'string'}, 'title': 'Stop Sequences', 'type': 'array'},
'extra_headers': {
'additionalProperties': {'type': 'string'},
'title': 'Extra Headers',
'type': 'object',
},
'thinking': {
'anyOf': [
{'type': 'boolean'},
{'enum': ['minimal', 'low', 'medium', 'high', 'xhigh'], 'type': 'string'},
],
'title': 'Thinking',
},
'service_tier': {
'enum': ['auto', 'default', 'flex', 'priority'],
'title': 'Service Tier',
'type': 'string',
},
'extra_body': {'title': 'Extra Body'},
},
'title': 'ModelSettings',
'type': 'object',
},
'ToolOrOutput': {
'properties': {
'function_tools': {'items': {'type': 'string'}, 'title': 'Function Tools', 'type': 'array'}
},
'required': ['function_tools'],
'title': 'ToolOrOutput',
'type': 'object',
},
'ToolSearchTool': {
'properties': {
'kind': {'default': 'tool_search', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'strategy': {
'anyOf': [{'enum': ['bm25', 'regex', 'custom'], 'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Strategy',
},
},
'title': 'ToolSearchTool',
'type': 'object',
},
'UploadedFile': {
'properties': {
'file_id': {'title': 'File Id', 'type': 'string'},
'provider_name': {
'enum': [
'anthropic',
'openai',
'google',
'google-cloud',
'google-gla',
'google-vertex',
'bedrock',
'xai',
],
'title': 'Provider Name',
'type': 'string',
},
'vendor_metadata': {
'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}],
'default': None,
'title': 'Vendor Metadata',
},
'media_type': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Media Type',
},
'identifier': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Identifier',
},
'kind': {
'const': 'uploaded-file',
'default': 'uploaded-file',
'title': 'Kind',
'type': 'string',
},
},
'required': ['file_id', 'provider_name'],
'title': 'UploadedFile',
'type': 'object',
},
'WebFetchTool': {
'properties': {
'kind': {'default': 'web_fetch', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'max_uses': {
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'default': None,
'title': 'Max Uses',
},
'allowed_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Allowed Domains',
},
'blocked_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Blocked Domains',
},
'enable_citations': {'default': False, 'title': 'Enable Citations', 'type': 'boolean'},
'max_content_tokens': {
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'default': None,
'title': 'Max Content Tokens',
},
},
'title': 'WebFetchTool',
'type': 'object',
},
'WebSearchTool': {
'properties': {
'kind': {'default': 'web_search', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'search_context_size': {
'default': 'medium',
'enum': ['low', 'medium', 'high'],
'title': 'Search Context Size',
'type': 'string',
},
'user_location': {
'anyOf': [{'$ref': '#/$defs/WebSearchUserLocation'}, {'type': 'null'}],
'default': None,
},
'blocked_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Blocked Domains',
},
'allowed_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Allowed Domains',
},
'max_uses': {
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'default': None,
'title': 'Max Uses',
},
},
'title': 'WebSearchTool',
'type': 'object',
},
'WebSearchUserLocation': {
'additionalProperties': False,
'properties': {
'city': {'title': 'City', 'type': 'string'},
'country': {'title': 'Country', 'type': 'string'},
'region': {'title': 'Region', 'type': 'string'},
'timezone': {'title': 'Timezone', 'type': 'string'},
},
'title': 'WebSearchUserLocation',
'type': 'object',
},
'XSearchTool': {
'properties': {
'kind': {'default': 'x_search', 'title': 'Kind', 'type': 'string'},
'optional': {'default': False, 'title': 'Optional', 'type': 'boolean'},
'allowed_x_handles': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Allowed X Handles',
},
'excluded_x_handles': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Excluded X Handles',
},
'from_date': {
'anyOf': [{'format': 'date-time', 'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'From Date',
},
'to_date': {
'anyOf': [{'format': 'date-time', 'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'To Date',
},
'enable_image_understanding': {
'default': False,
'title': 'Enable Image Understanding',
'type': 'boolean',
},
'enable_video_understanding': {
'default': False,
'title': 'Enable Video Understanding',
'type': 'boolean',
},
'include_output': {
'default': False,
'title': 'Include Output',
'type': 'boolean',
},
},
'title': 'XSearchTool',
'type': 'object',
},
'short_spec_NativeTool': {
'additionalProperties': False,
'properties': {
'NativeTool': {
'anyOf': [
{
'oneOf': [
{'$ref': '#/$defs/WebSearchTool'},
{'$ref': '#/$defs/XSearchTool'},
{'$ref': '#/$defs/CodeExecutionTool'},
{'$ref': '#/$defs/WebFetchTool'},
{'$ref': '#/$defs/ImageGenerationTool'},
{'$ref': '#/$defs/MemoryTool'},
{'$ref': '#/$defs/MCPServerTool'},
{'$ref': '#/$defs/FileSearchTool'},
{'$ref': '#/$defs/ToolSearchTool'},
]
},
{'type': 'null'},
],
'title': 'Nativetool',
}
},
'title': 'short_spec_NativeTool',
'type': 'object',
},
'short_spec_MCP': {
'additionalProperties': False,
'properties': {'MCP': {'title': 'Mcp', 'type': 'string'}},
'required': ['MCP'],
'title': 'short_spec_MCP',
'type': 'object',
},
'spec_IncludeToolReturnSchemas': {
'additionalProperties': False,
'properties': {
'IncludeToolReturnSchemas': {'$ref': '#/$defs/spec_params_IncludeToolReturnSchemas'}
},
'required': ['IncludeToolReturnSchemas'],
'title': 'spec_IncludeToolReturnSchemas',
'type': 'object',
},
'short_spec_SetToolMetadata': {
'additionalProperties': False,
'properties': {
'SetToolMetadata': {
'anyOf': [
{'const': 'all', 'type': 'string'},
{'items': {'type': 'string'}, 'type': 'array'},
{'additionalProperties': True, 'type': 'object'},
],
'title': 'Settoolmetadata',
}
},
'title': 'short_spec_SetToolMetadata',
'type': 'object',
},
'spec_ReinjectSystemPrompt': {
'additionalProperties': False,
'properties': {'ReinjectSystemPrompt': {'$ref': '#/$defs/spec_params_ReinjectSystemPrompt'}},
'required': ['ReinjectSystemPrompt'],
'title': 'spec_ReinjectSystemPrompt',
'type': 'object',
},
'spec_Thinking': {
'additionalProperties': False,
'properties': {'Thinking': {'$ref': '#/$defs/spec_params_Thinking'}},
'required': ['Thinking'],
'title': 'spec_Thinking',
'type': 'object',
},
'spec_ImageGeneration': {
'additionalProperties': False,
'properties': {'ImageGeneration': {'$ref': '#/$defs/spec_params_ImageGeneration'}},
'required': ['ImageGeneration'],
'title': 'spec_ImageGeneration',
'type': 'object',
},
'spec_MCP': {
'additionalProperties': False,
'properties': {'MCP': {'$ref': '#/$defs/spec_params_MCP'}},
'required': ['MCP'],
'title': 'spec_MCP',
'type': 'object',
},
'spec_PrefixTools': {
'additionalProperties': False,
'properties': {'PrefixTools': {'$ref': '#/$defs/spec_params_PrefixTools'}},
'required': ['PrefixTools'],
'title': 'spec_PrefixTools',
'type': 'object',
},
'spec_ToolSearch': {
'additionalProperties': False,
'properties': {'ToolSearch': {'$ref': '#/$defs/spec_params_ToolSearch'}},
'required': ['ToolSearch'],
'title': 'spec_ToolSearch',
'type': 'object',
},
'spec_params_IncludeToolReturnSchemas': {
'additionalProperties': False,
'properties': {
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
'tools': {
'anyOf': [
{'const': 'all', 'type': 'string'},
{'items': {'type': 'string'}, 'type': 'array'},
{'additionalProperties': True, 'type': 'object'},
],
'title': 'Tools',
},
},
'title': 'spec_params_IncludeToolReturnSchemas',
'type': 'object',
},
'spec_WebFetch': {
'additionalProperties': False,
'properties': {'WebFetch': {'$ref': '#/$defs/spec_params_WebFetch'}},
'required': ['WebFetch'],
'title': 'spec_WebFetch',
'type': 'object',
},
'spec_WebSearch': {
'additionalProperties': False,
'properties': {'WebSearch': {'$ref': '#/$defs/spec_params_WebSearch'}},
'required': ['WebSearch'],
'title': 'spec_WebSearch',
'type': 'object',
},
'spec_XSearch': {
'additionalProperties': False,
'properties': {'XSearch': {'$ref': '#/$defs/spec_params_XSearch'}},
'required': ['XSearch'],
'title': 'spec_XSearch',
'type': 'object',
},
'spec_params_ReinjectSystemPrompt': {
'additionalProperties': False,
'properties': {
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
'replace_existing': {'title': 'Replace Existing', 'type': 'boolean'},
},
'title': 'spec_params_ReinjectSystemPrompt',
'type': 'object',
},
'spec_params_Thinking': {
'additionalProperties': False,
'properties': {
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
'effort': {
'anyOf': [
{'type': 'boolean'},
{'enum': ['minimal', 'low', 'medium', 'high', 'xhigh'], 'type': 'string'},
],
'title': 'Effort',
},
},
'title': 'spec_params_Thinking',
'type': 'object',
},
'spec_params_ImageGeneration': {
'additionalProperties': False,
'properties': {
'native': {
'anyOf': [{'$ref': '#/$defs/ImageGenerationTool'}, {'type': 'boolean'}],
'title': 'Native',
},
'local': {'anyOf': [{'const': False, 'type': 'boolean'}, {'type': 'null'}], 'title': 'Local'},
'fallback_model': {
'anyOf': [{'$ref': '#/$defs/KnownModelName'}, {'type': 'string'}, {'type': 'null'}],
'title': 'Fallback Model',
},
'action': {
'anyOf': [{'enum': ['generate', 'edit', 'auto'], 'type': 'string'}, {'type': 'null'}],
'title': 'Action',
},
'background': {
'anyOf': [{'enum': ['transparent', 'opaque', 'auto'], 'type': 'string'}, {'type': 'null'}],
'title': 'Background',
},
'input_fidelity': {
'anyOf': [{'enum': ['high', 'low'], 'type': 'string'}, {'type': 'null'}],
'title': 'Input Fidelity',
},
'moderation': {
'anyOf': [{'enum': ['auto', 'low'], 'type': 'string'}, {'type': 'null'}],
'title': 'Moderation',
},
'image_model': {
'anyOf': [
{
'enum': ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini'],
'type': 'string',
},
{'type': 'string'},
{'type': 'null'},
],
'title': 'Image Model',
},
'output_compression': {
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'title': 'Output Compression',
},
'output_format': {
'anyOf': [{'enum': ['png', 'webp', 'jpeg'], 'type': 'string'}, {'type': 'null'}],
'title': 'Output Format',
},
'quality': {
'anyOf': [{'enum': ['low', 'medium', 'high', 'auto'], 'type': 'string'}, {'type': 'null'}],
'title': 'Quality',
},
'size': {
'anyOf': [
{
'enum': ['auto', '1024x1024', '1024x1536', '1536x1024', '512', '1K', '2K', '4K'],
'type': 'string',
},
{'type': 'null'},
],
'title': 'Size',
},
'aspect_ratio': {
'anyOf': [
{
'enum': ['21:9', '16:9', '4:3', '3:2', '1:1', '9:16', '3:4', '2:3', '5:4', '4:5'],
'type': 'string',
},
{'type': 'null'},
],
'title': 'Aspect Ratio',
},
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
},
'title': 'spec_params_ImageGeneration',
'type': 'object',
},
'spec_params_MCP': {
'additionalProperties': False,
'properties': {
'url': {'title': 'Url', 'type': 'string'},
'native': {
'anyOf': [{'$ref': '#/$defs/MCPServerTool'}, {'type': 'boolean'}],
'title': 'Native',
},
'local': {
'anyOf': [{'type': 'string'}, {'type': 'boolean'}, {'type': 'null'}],
'title': 'Local',
},
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'authorization_token': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'title': 'Authorization Token',
},
'headers': {
'anyOf': [{'additionalProperties': {'type': 'string'}, 'type': 'object'}, {'type': 'null'}],
'title': 'Headers',
},
'allowed_tools': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Allowed Tools',
},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
},
'required': ['url'],
'title': 'spec_params_MCP',
'type': 'object',
},
'spec_params_PrefixTools': {
'additionalProperties': False,
'properties': {
'prefix': {'title': 'Prefix', 'type': 'string'},
'capability': {
'anyOf': [
{'const': 'NativeTool', 'type': 'string'},
{'$ref': '#/$defs/short_spec_NativeTool'},
{'const': 'ImageGeneration', 'type': 'string'},
{'$ref': '#/$defs/spec_ImageGeneration'},
{'const': 'IncludeToolReturnSchemas', 'type': 'string'},
{'$ref': '#/$defs/spec_IncludeToolReturnSchemas'},
{'const': 'Instrumentation', 'type': 'string'},
{'$ref': '#/$defs/short_spec_MCP'},
{'$ref': '#/$defs/spec_MCP'},
{'$ref': '#/$defs/spec_PrefixTools'},
{'const': 'ReinjectSystemPrompt', 'type': 'string'},
{'$ref': '#/$defs/spec_ReinjectSystemPrompt'},
{'const': 'SetToolMetadata', 'type': 'string'},
{'$ref': '#/$defs/short_spec_SetToolMetadata'},
{'const': 'Thinking', 'type': 'string'},
{'$ref': '#/$defs/spec_Thinking'},
{'const': 'ToolSearch', 'type': 'string'},
{'$ref': '#/$defs/spec_ToolSearch'},
{'const': 'WebFetch', 'type': 'string'},
{'$ref': '#/$defs/spec_WebFetch'},
{'const': 'WebSearch', 'type': 'string'},
{'$ref': '#/$defs/spec_WebSearch'},
{'const': 'XSearch', 'type': 'string'},
{'$ref': '#/$defs/spec_XSearch'},
]
},
},
'required': ['prefix', 'capability'],
'title': 'spec_params_PrefixTools',
'type': 'object',
},
'spec_params_ToolSearch': {
'additionalProperties': False,
'properties': {
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'strategy': {
'anyOf': [
{'const': 'keywords', 'type': 'string'},
{'enum': ['bm25', 'regex'], 'type': 'string'},
{'type': 'null'},
],
'title': 'Strategy',
},
'max_results': {'title': 'Max Results', 'type': 'integer'},
'tool_description': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'title': 'Tool Description',
},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
'parameter_description': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'title': 'Parameter Description',
},
},
'title': 'spec_params_ToolSearch',
'type': 'object',
},
'spec_params_WebFetch': {
'additionalProperties': False,
'properties': {
'native': {
'anyOf': [{'$ref': '#/$defs/WebFetchTool'}, {'type': 'boolean'}],
'title': 'Native',
},
'local': {'anyOf': [{'type': 'boolean'}, {'type': 'null'}], 'title': 'Local'},
'allowed_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Allowed Domains',
},
'blocked_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Blocked Domains',
},
'max_uses': {'anyOf': [{'type': 'integer'}, {'type': 'null'}], 'title': 'Max Uses'},
'enable_citations': {
'anyOf': [{'type': 'boolean'}, {'type': 'null'}],
'title': 'Enable Citations',
},
'max_content_tokens': {
'anyOf': [{'type': 'integer'}, {'type': 'null'}],
'title': 'Max Content Tokens',
},
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
},
'title': 'spec_params_WebFetch',
'type': 'object',
},
'spec_params_WebSearch': {
'additionalProperties': False,
'properties': {
'native': {
'anyOf': [{'$ref': '#/$defs/WebSearchTool'}, {'type': 'boolean'}],
'title': 'Native',
},
'local': {
'anyOf': [{'const': 'duckduckgo', 'type': 'string'}, {'type': 'boolean'}, {'type': 'null'}],
'title': 'Local',
},
'search_context_size': {
'anyOf': [{'enum': ['low', 'medium', 'high'], 'type': 'string'}, {'type': 'null'}],
'title': 'Search Context Size',
},
'user_location': {'anyOf': [{'$ref': '#/$defs/WebSearchUserLocation'}, {'type': 'null'}]},
'blocked_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Blocked Domains',
},
'allowed_domains': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Allowed Domains',
},
'max_uses': {'anyOf': [{'type': 'integer'}, {'type': 'null'}], 'title': 'Max Uses'},
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
},
'title': 'spec_params_WebSearch',
'type': 'object',
},
'spec_params_XSearch': {
'additionalProperties': False,
'properties': {
'native': {'anyOf': [{'$ref': '#/$defs/XSearchTool'}, {'type': 'boolean'}], 'title': 'Native'},
'local': {'anyOf': [{'const': False, 'type': 'boolean'}, {'type': 'null'}], 'title': 'Local'},
'fallback_model': {
'anyOf': [{'$ref': '#/$defs/KnownModelName'}, {'type': 'string'}, {'type': 'null'}],
'title': 'Fallback Model',
},
'allowed_x_handles': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Allowed X Handles',
},
'excluded_x_handles': {
'anyOf': [{'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'title': 'Excluded X Handles',
},
'from_date': {
'anyOf': [{'format': 'date-time', 'type': 'string'}, {'type': 'null'}],
'title': 'From Date',
},
'to_date': {
'anyOf': [{'format': 'date-time', 'type': 'string'}, {'type': 'null'}],
'title': 'To Date',
},
'enable_image_understanding': {
'anyOf': [{'type': 'boolean'}, {'type': 'null'}],
'title': 'Enable Image Understanding',
},
'enable_video_understanding': {
'anyOf': [{'type': 'boolean'}, {'type': 'null'}],
'title': 'Enable Video Understanding',
},
'include_output': {'anyOf': [{'type': 'boolean'}, {'type': 'null'}], 'title': 'Include Output'},
'id': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Id'},
'description': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'title': 'Description'},
'defer_loading': {'title': 'Defer Loading', 'type': 'boolean'},
},
'title': 'spec_params_XSearch',
'type': 'object',
},
},
'additionalProperties': False,
'properties': {
'model': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Model'},
'name': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'default': None, 'title': 'Name'},
'description': {
'anyOf': [{'type': 'string'}, {'type': 'null'}],
'default': None,
'title': 'Description',
},
'instructions': {
'anyOf': [{'type': 'string'}, {'items': {'type': 'string'}, 'type': 'array'}, {'type': 'null'}],
'default': None,
'title': 'Instructions',
},
'deps_schema': {
'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}],
'default': None,
'title': 'Deps Schema',
},
'output_schema': {
'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}],
'default': None,
'title': 'Output Schema',
},
'model_settings': {'anyOf': [{'$ref': '#/$defs/ModelSettings'}, {'type': 'null'}], 'default': None},
'retries': {
'anyOf': [{'type': 'integer'}, {'$ref': '#/$defs/AgentRetries'}, {'type': 'null'}],
'default': None,
'title': 'Retries',
},
'end_strategy': {
'default': 'graceful',
'enum': ['early', 'graceful', 'exhaustive'],
'title': 'End Strategy',
'type': 'string',
},
'tool_timeout': {
'anyOf': [{'type': 'number'}, {'type': 'null'}],
'default': None,
'title': 'Tool Timeout',
},
'metadata': {
'anyOf': [{'additionalProperties': True, 'type': 'object'}, {'type': 'null'}],
'default': None,
'title': 'Metadata',
},
'capabilities': {
'default': [],
'items': {
'anyOf': [
{'const': 'NativeTool', 'type': 'string'},
{'$ref': '#/$defs/short_spec_NativeTool'},
{'const': 'ImageGeneration', 'type': 'string'},
{'$ref': '#/$defs/spec_ImageGeneration'},
{'const': 'IncludeToolReturnSchemas', 'type': 'string'},
{'$ref': '#/$defs/spec_IncludeToolReturnSchemas'},
{'const': 'Instrumentation', 'type': 'string'},
{'$ref': '#/$defs/short_spec_MCP'},
{'$ref': '#/$defs/spec_MCP'},
{'$ref': '#/$defs/spec_PrefixTools'},
{'const': 'ReinjectSystemPrompt', 'type': 'string'},
{'$ref': '#/$defs/spec_ReinjectSystemPrompt'},
{'const': 'SetToolMetadata', 'type': 'string'},
{'$ref': '#/$defs/short_spec_SetToolMetadata'},
{'const': 'Thinking', 'type': 'string'},
{'$ref': '#/$defs/spec_Thinking'},
{'const': 'ToolSearch', 'type': 'string'},
{'$ref': '#/$defs/spec_ToolSearch'},
{'const': 'WebFetch', 'type': 'string'},
{'$ref': '#/$defs/spec_WebFetch'},
{'const': 'WebSearch', 'type': 'string'},
{'$ref': '#/$defs/spec_WebSearch'},
{'const': 'XSearch', 'type': 'string'},
{'$ref': '#/$defs/spec_XSearch'},
]
},
'title': 'Capabilities',
'type': 'array',
},
'$schema': {'type': 'string'},
},
'title': 'AgentSpec',
'type': 'object',
}
)
def test_model_json_schema_with_custom_capabilities():
schema = AgentSpec.model_json_schema_with_capabilities(
custom_capability_types=[CustomCapability],
)
any_of = schema['properties']['capabilities']['items']['anyOf']
capability_names: set[str] = set()
for entry in any_of:
if 'const' in entry:
capability_names.add(entry['const'])
elif '$ref' in entry: # pragma: no branch
ref = entry['$ref']
ref_name = ref.rsplit('/', 1)[-1]
for prefix in ('spec_', 'short_spec_'):
if ref_name.startswith(prefix):
capability_names.add(ref_name[len(prefix) :])
assert 'CustomCapability' in capability_names
# Default capabilities should still be present
assert 'WebSearch' in capability_names
def test_model_json_schema_filters_non_serializable_params():
"""Custom capabilities with non-serializable __init__ params get filtered in schema."""
schema = AgentSpec.model_json_schema_with_capabilities(
custom_capability_types=[CapabilityWithCallbackParam],
)
any_of = schema['properties']['capabilities']['items']['anyOf']
# String form: all remaining params are optional
has_string_form = any(e.get('const') == 'CapabilityWithCallbackParam' for e in any_of)
assert has_string_form
# Long form: max_retries and verbose survive; on_error (purely Callable) is filtered out
spec_ref = next(
(e for e in any_of if '$ref' in e and 'spec_CapabilityWithCallbackParam' in e['$ref']),
None,
)
assert spec_ref is not None
params_def = schema['$defs']['spec_params_CapabilityWithCallbackParam']
assert 'max_retries' in params_def['properties']
assert 'verbose' in params_def['properties']
# on_error should not appear — purely Callable, entirely filtered out
assert 'on_error' not in params_def['properties']
# hooks should not appear — union of only non-serializable types, entirely filtered out
assert 'hooks' not in params_def['properties']
# verbose should be boolean only (Callable member was stripped from the union)
assert params_def['properties']['verbose'] == {'title': 'Verbose', 'type': 'boolean'}
def test_agent_spec_schema_field_parity():
"""Ensure the schema model's fields stay in sync with AgentSpec."""
schema = AgentSpec.model_json_schema_with_capabilities()
schema_fields = set(schema['properties'].keys())
# Map AgentSpec field names to their JSON schema names (using aliases)
spec_fields: set[str] = set()
for name, field_info in AgentSpec.model_fields.items():
alias = field_info.alias
spec_fields.add(alias if isinstance(alias, str) else name)
assert schema_fields == spec_fields
def test_native_tools_param_wrapped_as_capabilities():
"""`Agent(capabilities=[NativeTool(...)])` produces NativeTool capabilities."""
agent = Agent('test', capabilities=[NativeTool(WebSearchTool()), NativeTool(CodeExecutionTool())])
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
builtin_caps = [c for c in children if isinstance(c, NativeToolCap)]
assert len(builtin_caps) == 2
assert isinstance(builtin_caps[0].tool, WebSearchTool)
assert isinstance(builtin_caps[1].tool, CodeExecutionTool)
# Also available via _cap_native_tools (ToolSearchTool is auto-injected).
cap_tools = [t for t in agent._cap_native_tools if not isinstance(t, ToolSearchTool)] # pyright: ignore[reportPrivateUsage]
assert len(cap_tools) == 2
def test_agent_from_spec_builtin_tool():
"""NativeTool capability can be constructed from spec."""
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{'NativeTool': {'kind': 'web_search'}},
],
}
)
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
builtin_caps = [c for c in children if isinstance(c, NativeToolCap)]
assert len(builtin_caps) == 1
assert isinstance(builtin_caps[0].tool, WebSearchTool)
def test_agent_from_spec_builtin_tool_with_options():
"""NativeTool spec supports builtin tool configuration options."""
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{'NativeTool': {'kind': 'web_search', 'search_context_size': 'high'}},
],
}
)
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
builtin_caps = [c for c in children if isinstance(c, NativeToolCap)]
assert len(builtin_caps) == 1
tool = builtin_caps[0].tool
assert isinstance(tool, WebSearchTool)
assert tool.search_context_size == 'high'
def test_agent_from_spec_builtin_tool_explicit_form():
"""NativeTool spec supports the explicit {tool: ...} form."""
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{'NativeTool': {'tool': {'kind': 'code_execution'}}},
],
}
)
children = agent._root_capability.capabilities # pyright: ignore[reportPrivateUsage]
builtin_caps = [c for c in children if isinstance(c, NativeToolCap)]
assert len(builtin_caps) == 1
assert isinstance(builtin_caps[0].tool, CodeExecutionTool)
def test_save_schema(tmp_path: str):
schema_path = Path(tmp_path) / 'agent_spec.schema.json'
AgentSpec._save_schema(schema_path) # pyright: ignore[reportPrivateUsage]
assert schema_path.exists()
import json
schema = json.loads(schema_path.read_text(encoding='utf-8'))
assert schema['type'] == 'object'
assert 'model' in schema['properties']
assert 'capabilities' in schema['properties']
# Calling again should not rewrite if content matches
mtime = schema_path.stat().st_mtime
AgentSpec._save_schema(schema_path) # pyright: ignore[reportPrivateUsage]
assert schema_path.stat().st_mtime == mtime
def test_from_file_yaml(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.yaml'
spec_path.write_text('model: test\nname: my-agent\ninstructions: Be helpful\n', encoding='utf-8')
spec = AgentSpec.from_file(spec_path)
assert spec.model == 'test'
assert spec.name == 'my-agent'
assert spec.instructions == 'Be helpful'
def test_from_file_json(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.json'
spec_path.write_text('{"model": "test", "name": "my-agent"}', encoding='utf-8')
spec = AgentSpec.from_file(spec_path)
assert spec.model == 'test'
assert spec.name == 'my-agent'
def test_from_file_with_schema_field(tmp_path: str):
"""$schema field in the file should be accepted and not cause validation errors."""
spec_path = Path(tmp_path) / 'agent.yaml'
spec_path.write_text('model: test\n', encoding='utf-8')
# YAML with $schema comment (ignored by yaml parser)
spec_with_schema = Path(tmp_path) / 'agent_with_schema.json'
spec_with_schema.write_text('{"$schema": "./agent_schema.json", "model": "test"}', encoding='utf-8')
spec = AgentSpec.from_file(spec_with_schema)
assert spec.model == 'test'
assert spec.json_schema_path == './agent_schema.json'
def test_from_file_empty_yaml_raises_user_error(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.yaml'
spec_path.write_text('', encoding='utf-8')
with pytest.raises(UserError, match='Agent spec must parse to an object, got NoneType'):
AgentSpec.from_file(spec_path)
def test_from_file_json_array_raises_user_error(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.json'
spec_path.write_text('[{"model": "test"}]', encoding='utf-8')
with pytest.raises(UserError, match='Agent spec must parse to an object, got list'):
AgentSpec.from_file(spec_path)
def test_agent_from_file_yaml(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.yaml'
spec_path.write_text('model: test\nname: my-agent\ninstructions: Be helpful\n', encoding='utf-8')
agent = Agent.from_file(spec_path)
assert agent.name == 'my-agent'
assert 'Be helpful' in agent._instructions # pyright: ignore[reportPrivateUsage]
def test_agent_from_file_json(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.json'
spec_path.write_text('{"model": "test", "name": "json-agent"}', encoding='utf-8')
agent = Agent.from_file(spec_path)
assert agent.name == 'json-agent'
def test_agent_from_file_with_overrides(tmp_path: str):
spec_path = Path(tmp_path) / 'agent.yaml'
spec_path.write_text('model: test\nname: spec-name\nretries: 5\n', encoding='utf-8')
agent = Agent.from_file(spec_path, name='override-name', retries=2)
assert agent.name == 'override-name'
assert agent._max_tool_retries == 2 # pyright: ignore[reportPrivateUsage]
def test_to_file_yaml(tmp_path: str):
spec = AgentSpec(model='test', name='my-agent', instructions='Be helpful')
spec_path = Path(tmp_path) / 'agent.yaml'
spec.to_file(spec_path)
content = spec_path.read_text(encoding='utf-8')
# Should start with yaml-language-server schema comment
assert content.startswith('# yaml-language-server: $schema=')
assert 'model: test' in content
assert 'name: my-agent' in content
# Schema file should be generated
schema_path = Path(tmp_path) / 'agent_schema.json'
assert schema_path.exists()
def test_to_file_json(tmp_path: str):
import json
spec = AgentSpec(model='test', name='my-agent')
spec_path = Path(tmp_path) / 'agent.json'
spec.to_file(spec_path)
data = json.loads(spec_path.read_text(encoding='utf-8'))
assert data['$schema'] == 'agent_schema.json'
assert data['model'] == 'test'
assert data['name'] == 'my-agent'
# Schema file should be generated
schema_path = Path(tmp_path) / 'agent_schema.json'
assert schema_path.exists()
def test_to_file_json_with_absolute_schema_path(tmp_path: Path):
import json
spec = AgentSpec(model='test', name='my-agent')
spec_path = Path(tmp_path) / 'agent.json'
schema_path = Path(tmp_path) / 'agent_schema.json'
spec.to_file(spec_path, schema_path=schema_path)
data = json.loads(spec_path.read_text(encoding='utf-8'))
assert data['$schema'] == 'agent_schema.json'
assert schema_path.exists()
def test_to_file_yaml_with_absolute_schema_path(tmp_path: Path):
spec = AgentSpec(model='test', name='my-agent')
spec_path = Path(tmp_path) / 'agent.yaml'
schema_path = Path(tmp_path) / 'agent_schema.json'
spec.to_file(spec_path, schema_path=schema_path)
content = spec_path.read_text(encoding='utf-8')
assert content.startswith('# yaml-language-server: $schema=agent_schema.json')
assert schema_path.exists()
def test_to_file_json_with_external_absolute_schema_path(tmp_path: Path):
import json
spec = AgentSpec(model='test', name='my-agent')
spec_dir = tmp_path / 'specs'
schema_dir = tmp_path / 'schemas'
spec_dir.mkdir()
schema_dir.mkdir()
spec_path = spec_dir / 'agent.json'
schema_path = schema_dir / 'agent_schema.json'
spec.to_file(spec_path, schema_path=schema_path)
data = json.loads(spec_path.read_text(encoding='utf-8'))
assert data['$schema'] == str(schema_path)
assert schema_path.exists()
def test_to_file_no_schema(tmp_path: str):
spec = AgentSpec(model='test')
spec_path = Path(tmp_path) / 'agent.yaml'
spec.to_file(spec_path, schema_path=None)
content = spec_path.read_text(encoding='utf-8')
assert '# yaml-language-server' not in content
# No schema file should be generated
schema_path = Path(tmp_path) / 'agent_schema.json'
assert not schema_path.exists()
def test_to_file_roundtrip_yaml(tmp_path: str):
spec = AgentSpec(model='test', name='roundtrip', instructions=['Be helpful', 'Be concise'])
spec_path = Path(tmp_path) / 'agent.yaml'
spec.to_file(spec_path)
loaded = AgentSpec.from_file(spec_path)
assert loaded.model == 'test'
assert loaded.name == 'roundtrip'
assert loaded.instructions == ['Be helpful', 'Be concise']
def test_to_file_roundtrip_json(tmp_path: str):
spec = AgentSpec(model='test', name='roundtrip', retries={'tools': 3})
spec_path = Path(tmp_path) / 'agent.json'
spec.to_file(spec_path)
loaded = AgentSpec.from_file(spec_path)
assert loaded.model == 'test'
assert loaded.name == 'roundtrip'
assert loaded.retries == {'tools': 3}
@dataclass
class ToolsetFuncCapability(AbstractCapability):
"""A capability that returns a ToolsetFunc instead of an AbstractToolset."""
def get_toolset(self) -> ToolsetFunc:
def make_toolset(ctx: RunContext) -> AbstractToolset:
toolset = FunctionToolset()
@toolset.tool_plain
def greet(name: str) -> str:
"""Greet someone by name."""
return f'Hello, {name}!'
return toolset
return make_toolset
async def test_capability_returning_toolset_func():
"""Test that a capability returning a ToolsetFunc works with an agent."""
agent = Agent(
TestModel(),
capabilities=[ToolsetFuncCapability()],
)
result = await agent.run('Greet Alice')
tool_calls = [
part
for msg in result.all_messages()
if isinstance(msg, ModelResponse)
for part in msg.parts
if isinstance(part, ToolCallPart)
]
assert len(tool_calls) == 1
assert tool_calls[0].tool_name == 'greet'
tool_returns = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, ToolReturnPart)
]
assert len(tool_returns) == 1
assert isinstance(tool_returns[0].content, str)
assert tool_returns[0].content.startswith('Hello, ')
async def test_runtime_capability_contributions_applied():
"""Run-time `capabilities=` contributions (tools, instructions, etc.) must be applied.
Regression guard: the `source_cap` selection previously only checked for `override()`
or spec capabilities, so tool contributions from a capability passed only via
`Agent.run(capabilities=[...])` were silently dropped.
"""
agent = Agent(TestModel())
result = await agent.run('Greet Alice', capabilities=[ToolsetFuncCapability()])
tool_calls = [
part
for msg in result.all_messages()
if isinstance(msg, ModelResponse)
for part in msg.parts
if isinstance(part, ToolCallPart)
]
assert [c.tool_name for c in tool_calls] == ['greet']
async def test_capability_returning_toolset_func_combined():
"""Test that a ToolsetFunc capability works alongside other capabilities via CombinedCapability."""
agent = Agent(
TestModel(),
instructions='You are a helpful greeter.',
capabilities=[
ToolsetFuncCapability(),
],
)
result = await agent.run('Greet Bob')
tool_returns = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, ToolReturnPart)
]
assert len(tool_returns) == 1
assert isinstance(tool_returns[0].content, str)
assert tool_returns[0].content.startswith('Hello, ')
def test_abstract_capability_get_model_settings_default():
"""AbstractCapability.get_model_settings() returns None by default."""
@dataclass
class PlainCap(AbstractCapability):
pass
cap = PlainCap()
assert cap.get_model_settings() is None
assert cap.get_description() is None
async def test_abstract_capability_description_field_is_optional_in_deferred_catalog() -> None:
"""Deferred capability catalog entries can include a description but do not require one."""
@dataclass
class AccountSecurityRunbook(AbstractCapability):
id: str | None = 'account-security'
description: str | None = 'Use for suspicious logins, account takeover, or session revocation.'
defer_loading: bool = True
@dataclass
class RefundsRunbook(AbstractCapability):
id: str | None = 'refunds'
defer_loading: bool = True
def model_fn(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(model_fn), capabilities=[AccountSecurityRunbook(), RefundsRunbook()])
result = await agent.run('hi')
request = next(message for message in result.all_messages() if isinstance(message, ModelRequest))
assert request.instructions == snapshot(
'The following capabilities are deferred and can be loaded using the `load_capability` tool:\n'
'- account-security: Use for suspicious logins, account takeover, or session revocation.\n'
'- refunds'
)
async def test_capability_description_can_be_dynamic() -> None:
"""The convenience Capability accepts a CapabilityDescription callable."""
def describe(ctx: RunContext[str]) -> str:
return f'Use for {ctx.deps} questions.'
agent = Agent(
FunctionModel(lambda _messages, _info: ModelResponse(parts=[TextPart('done')])),
deps_type=str,
capabilities=[Capability[str](id='dynamic-description', description=describe, defer_loading=True)],
)
result = await agent.run('hi', deps='billing')
request = next(message for message in result.all_messages() if isinstance(message, ModelRequest))
assert request.instructions == snapshot(
'The following capabilities are deferred and can be loaded using the `load_capability` tool:\n'
'- dynamic-description: Use for billing questions.'
)
def test_combined_capability_get_model_settings_merge():
"""CombinedCapability.get_model_settings() merges settings from all sub-capabilities."""
@dataclass
class MaxTokensCap(AbstractCapability):
def get_model_settings(self) -> _ModelSettings | None:
return _ModelSettings(max_tokens=100)
@dataclass
class TemperatureCap(AbstractCapability):
def get_model_settings(self) -> _ModelSettings | None:
return _ModelSettings(temperature=0.5)
caps = CombinedCapability(
capabilities=[
MaxTokensCap(),
TemperatureCap(),
]
)
merged = caps.get_model_settings()
assert merged is not None
assert not callable(merged)
assert merged.get('max_tokens') == 100
assert merged.get('temperature') == 0.5
def test_combined_capability_get_model_settings_none():
"""CombinedCapability.get_model_settings() returns None when no capabilities provide settings."""
@dataclass
class PlainCap(AbstractCapability):
pass
caps = CombinedCapability(capabilities=[PlainCap()])
assert caps.get_model_settings() is None
def test_combined_capability_get_model_settings_deferred():
"""Deferred capability model settings resolve only after the capability is loaded."""
seen_dynamic_loaded: list[bool | None] = []
@dataclass
class StaticSettingsCap(AbstractCapability):
def get_model_settings(self) -> _ModelSettings:
return _ModelSettings(max_tokens=123)
@dataclass
class DynamicSettingsCap(AbstractCapability):
def get_model_settings(self) -> Callable[[RunContext], _ModelSettings]:
def settings(ctx: RunContext) -> _ModelSettings:
seen_dynamic_loaded.append(ctx.capability_loaded)
return _ModelSettings(temperature=0.2)
return settings
resolver = CombinedCapability(
[
StaticSettingsCap(id='static-settings', defer_loading=True),
DynamicSettingsCap(id='dynamic-settings', defer_loading=True),
]
).get_model_settings()
assert callable(resolver)
def resolve(loaded_capability_ids: set[str]) -> _ModelSettings:
return resolver(
RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
loaded_capability_ids=loaded_capability_ids,
)
)
assert [
resolve(set()),
resolve({'static-settings'}),
resolve({'static-settings', 'dynamic-settings'}),
] == snapshot(
[
{},
{'max_tokens': 123},
{'max_tokens': 123, 'temperature': 0.2},
]
)
assert seen_dynamic_loaded == [True]
async def test_deferred_hooks_do_not_fire_until_capability_is_loaded() -> None:
"""Hooks owned by a deferred capability are skipped until `load_capability` succeeds."""
hooks = Hooks(id='audit', description='Audit request flow.', defer_loading=True)
seen_loaded: list[bool | None] = []
@hooks.on.before_model_request
async def record(ctx: RunContext, request_context: ModelRequestContext) -> ModelRequestContext:
seen_loaded.append(ctx.capability_loaded)
return request_context
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
already_loaded = any(
isinstance(part, LoadCapabilityReturnPart)
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
)
if not already_loaded:
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'audit'},
tool_call_id='load-audit',
)
]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == 'done'
assert seen_loaded == [True]
def test_toolset_capability_get_toolset():
"""Toolset capability returns its toolset."""
ts = FunctionToolset()
cap = Toolset(toolset=ts)
assert cap.get_toolset() is ts
convenience_cap = Capability[object](toolsets=[ts])
assert convenience_cap.get_toolset() is ts
ts_b = FunctionToolset()
combined_cap = Capability[object](toolsets=[ts, ts_b])
from pydantic_ai.toolsets import CombinedToolset
combined = cast(CombinedToolset, combined_cap.get_toolset())
assert list(combined.toolsets) == [ts, ts_b]
def _noop_greet(name: str) -> str:
return f'Hello, {name}!' # pragma: no cover
def _noop_greet_with_context(_ctx: RunContext, name: str) -> str:
return f'Hello, {name}!' # pragma: no cover
def test_capability_combines_toolsets_and_tools_together():
"""`Capability[object](toolsets=..., tools=...)` mirrors `Agent` by combining both."""
toolset = FunctionToolset()
cap = Capability[object](toolsets=[toolset], tools=[_noop_greet])
from pydantic_ai.toolsets import CombinedToolset
combined = cast(CombinedToolset, cap.get_toolset())
function_toolset, provided_toolset = combined.toolsets
assert isinstance(function_toolset, FunctionToolset)
assert function_toolset.tools.keys() == {'_noop_greet'}
assert provided_toolset is toolset
def test_capability_tool_plain_combines_with_toolsets():
"""`Capability.tool_plain()` registers a function toolset alongside provided toolsets."""
toolset = FunctionToolset()
cap = Capability[object](toolsets=[toolset])
cap.tool_plain(_noop_greet)
from pydantic_ai.toolsets import CombinedToolset
combined = cast(CombinedToolset, cap.get_toolset())
function_toolset, provided_toolset = combined.toolsets
assert isinstance(function_toolset, FunctionToolset)
assert function_toolset.tools.keys() == {'_noop_greet'}
assert provided_toolset is toolset
def test_capability_tool_combines_with_toolsets():
"""`Capability.tool()` registers a function toolset alongside provided toolsets."""
toolset = FunctionToolset()
cap = Capability[object](toolsets=[toolset])
cap.tool(_noop_greet_with_context)
from pydantic_ai.toolsets import CombinedToolset
combined = cast(CombinedToolset, cap.get_toolset())
function_toolset, provided_toolset = combined.toolsets
assert isinstance(function_toolset, FunctionToolset)
assert function_toolset.tools.keys() == {'_noop_greet_with_context'}
assert provided_toolset is toolset
def test_capability_opts_out_of_spec_serialization():
"""`Capability` holds non-serializable state (function tools, instructions, callable
descriptions), so it opts out of spec construction like the other non-serializable
capabilities, and passing it as a custom capability type fails loudly."""
from pydantic_ai.agent.spec import get_capability_registry
assert Capability.get_serialization_name() is None
with pytest.raises(ValueError, match='Capability has opted out of serialization'):
get_capability_registry(custom_types=[Capability])
async def test_toolset_capability_in_agent():
"""A Toolset capability's tools are available to the agent."""
ts = FunctionToolset()
@ts.tool_plain
def greet(name: str) -> str:
"""Greet someone by name."""
return f'Hello, {name}!'
agent = Agent(TestModel(), capabilities=[Toolset(toolset=ts)])
result = await agent.run('Greet Alice')
tool_returns = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, ToolReturnPart)
]
assert len(tool_returns) == 1
assert isinstance(tool_returns[0].content, str)
assert tool_returns[0].content.startswith('Hello, ')
async def test_capability_function_tools_shortcuts_in_agent():
"""A Capability can register function tools directly or with decorators."""
def greet(name: str) -> str:
"""Greet someone by name."""
return f'Hello, {name}!'
cap = Capability[int](tools=[greet])
@cap.tool_plain(name='wave')
def wave(name: str) -> str:
"""Wave to someone by name."""
return f'Waving to {name}!'
@cap.tool
def add_deps(ctx: RunContext[int], value: int) -> int:
"""Add the run dependency to a value."""
return ctx.deps + value
agent = Agent(TestModel(call_tools=['greet', 'wave', 'add_deps']), capabilities=[cap], deps_type=int)
result = await agent.run('Use the capability tools', deps=10)
tool_returns = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, ToolReturnPart)
]
assert [part.tool_name for part in tool_returns] == ['greet', 'wave', 'add_deps']
async def test_capability_instructions_decorator_without_parenthesis():
"""A Capability can register instructions with a bare decorator."""
captured_messages: list[ModelMessage] = []
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
captured_messages.extend(messages)
return ModelResponse(parts=[TextPart('done')])
cap = Capability[object]()
@cap.instructions
def instructions() -> str:
return 'Use the capability runbook.'
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('Help me')
assert result.output == 'done'
assert [msg.instructions for msg in captured_messages if isinstance(msg, ModelRequest)] == snapshot(
['Use the capability runbook.']
)
async def test_capability_instructions_decorator_with_parenthesis():
"""A Capability can register instructions with a called decorator."""
captured_messages: list[ModelMessage] = []
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
captured_messages.extend(messages)
return ModelResponse(parts=[TextPart('done')])
cap = Capability[object]()
@cap.instructions()
def instructions_2() -> str:
return 'Use the capability runbook.'
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('Help me')
assert result.output == 'done'
assert [msg.instructions for msg in captured_messages if isinstance(msg, ModelRequest)] == snapshot(
['Use the capability runbook.']
)
async def test_capability_instructions_decorator_combines_with_constructor_instructions():
"""Constructor instructions and decorator instructions are combined."""
captured_messages: list[ModelMessage] = []
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
captured_messages.extend(messages)
return ModelResponse(parts=[TextPart('done')])
cap = Capability[int](instructions='Use the capability runbook.')
@cap.instructions
def add_deps(ctx: RunContext[int]) -> str:
return f'The current account id is {ctx.deps}.'
agent = Agent(FunctionModel(model_fn), capabilities=[cap], deps_type=int)
result = await agent.run('Help me', deps=123)
assert result.output == 'done'
assert [msg.instructions for msg in captured_messages if isinstance(msg, ModelRequest)] == snapshot(
['Use the capability runbook.\n\nThe current account id is 123.']
)
async def test_deferred_capability_instructions_decorator_resolves_on_load() -> None:
"""A deferred capability returns decorator-registered instructions when loaded."""
cap = Capability[int](
id='account',
description='Account-specific guidance.',
defer_loading=True,
)
@cap.instructions
def account_instructions(ctx: RunContext[int]) -> str:
return f'Use account id {ctx.deps}.'
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
already_loaded = any(
isinstance(part, LoadCapabilityReturnPart)
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
)
if not already_loaded:
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'account'},
tool_call_id='load-account',
)
]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[cap], deps_type=int)
result = await agent.run('Help me', deps=123)
assert result.output == 'done'
[load_return] = [
part
for message in result.all_messages()
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, LoadCapabilityReturnPart)
]
assert load_return.instructions == 'Use account id 123.'
first_request = next(message for message in result.all_messages() if isinstance(message, ModelRequest))
assert first_request.instructions == snapshot(
'The following capabilities are deferred and can be loaded using the `load_capability` tool:\n'
'- account: Account-specific guidance.'
)
async def test_deferred_capability_partitions_native_tools() -> None:
"""Deferred native tools are kept out of the baseline request until loaded."""
native_cap = NativeTool(
tool=WebSearchTool(),
id='web-search',
defer_loading=True,
)
[native_tool_func] = CombinedCapability([native_cap]).get_native_tools()
assert callable(native_tool_func)
native_tool_ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
capabilities={'web-search': native_cap},
)
assert native_tool_func(native_tool_ctx) is None
native_tool_ctx.loaded_capability_ids.add('web-search')
assert native_tool_func(native_tool_ctx) == WebSearchTool()
@dataclass
class CallableNativeToolCap(AbstractCapability):
id: str | None = 'callable-web-search'
defer_loading: bool = True
def get_native_tools(self) -> list[Callable[[RunContext], WebSearchTool]]:
return [lambda ctx: WebSearchTool()]
callable_native_cap = CallableNativeToolCap()
[callable_native_tool_func] = CombinedCapability([callable_native_cap]).get_native_tools()
assert callable(callable_native_tool_func)
assert callable_native_tool_func(native_tool_ctx) is None
native_tool_ctx.loaded_capability_ids.add('callable-web-search')
assert callable_native_tool_func(native_tool_ctx) == WebSearchTool()
seen_web_search_tools: list[list[WebSearchTool]] = []
def model_fn(_messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_web_search_tools.append(
[tool for tool in info.model_request_parameters.native_tools if isinstance(tool, WebSearchTool)]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[native_cap])
await agent.run('before load')
await agent.run(
'after load',
message_history=[
ModelResponse(parts=[LoadCapabilityCallPart(args={'id': 'web-search'}, tool_call_id='load-web')]),
ModelRequest(parts=[LoadCapabilityReturnPart(content={}, tool_call_id='load-web')]),
],
)
assert seen_web_search_tools == snapshot([[], [WebSearchTool()]])
async def test_load_capability_tool_name_conflict_raises() -> None:
"""The framework loader must not be shadowed by a user tool with the same name."""
toolset = FunctionToolset()
@toolset.tool_plain
def load_capability() -> str:
return 'user-defined loader' # pragma: no cover
hidden = Capability[object](
id='hidden',
description='Hidden instructions.',
instructions='Hidden instructions.',
defer_loading=True,
)
agent = Agent(TestModel(), toolsets=[toolset], capabilities=[hidden])
with pytest.raises(UserError) as exc_info:
await agent.run('hi')
assert str(exc_info.value) == snapshot(
"Tool name 'load_capability' is reserved for deferred capability loading. Rename your tool to avoid conflicts."
)
def test_duplicate_capability_ids_raise() -> None:
"""Capability ids are used as a run registry, so duplicates must fail loudly — at construction."""
with pytest.raises(UserError) as exc_info:
Agent(
TestModel(),
capabilities=[
Capability[object](id='dup', description='First capability.', instructions='First.'),
Capability[object](id='dup', description='Second capability.', instructions='Second.'),
],
)
assert str(exc_info.value) == snapshot(
"Capability id 'dup' is used by multiple capabilities. Capability ids must be unique within a run."
)
def test_deferred_capability_without_id_raises_at_construction() -> None:
"""A statically-provided deferred capability without an `id` fails fast at construction."""
with pytest.raises(UserError, match='stable explicit `id` values'):
Agent(TestModel(), capabilities=[Capability[object](description='No id.', defer_loading=True)])
async def test_partial_load_capability_history_does_not_mark_loaded() -> None:
"""A partial/stale `load_capability` call in history must not load a capability on replay."""
captured_messages: list[ModelMessage] = []
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
captured_messages.extend(messages)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(
FunctionModel(model_fn),
capabilities=[
Capability[object](
id='reports',
description='Report tools.',
instructions='Report instructions.',
defer_loading=True,
)
],
)
result = await agent.run(
'hi',
message_history=[
ModelResponse(parts=[LoadCapabilityCallPart(args='{"id":', tool_call_id='partial-load')]),
ModelRequest(parts=[LoadCapabilityReturnPart(content={}, tool_call_id='partial-load')]),
],
)
assert result.output == 'done'
# `output == 'done'` alone would pass even if the stale partial load had wrongly marked
# `reports` loaded, so assert the gating directly. The catalog lists `reports` whether or
# not it is loaded (kept stable for prompt caching), so the real discriminator is the
# capability's loaded-only instructions: they must be absent because it never loaded.
final_instructions = next(
msg.instructions for msg in reversed(captured_messages) if isinstance(msg, ModelRequest) and msg.instructions
)
assert 'Report instructions.' not in final_instructions
assert 'reports: Report tools.' in final_instructions
async def test_load_capability_invalid_dict_args_recovers_via_retry() -> None:
"""Schema-violating dict args from the model must produce a retry, not crash the run.
Providers like Anthropic (non-streaming) and Google deliver tool args as parsed
dicts. A dict that doesn't match `LoadCapabilityArgs` fails the typed-subclass
validation when the response is narrowed — promotion must be best-effort (leave
the part plain) so the args validator at execution time can send the model a
retry as designed. Reproduces a live crash with `claude-haiku-4-5` coerced into
sending `{"name": ...}` instead of `{"id": ...}`.
"""
calls = 0
def model_fn(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
nonlocal calls
calls += 1
if calls == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='load_capability', args={'name': 'refunds'})])
if calls == 2:
return ModelResponse(parts=[ToolCallPart(tool_name='load_capability', args={'id': 'refunds'})])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(
FunctionModel(model_fn),
capabilities=[
Capability[object](
id='refunds',
description='Refund tools.',
instructions='Refund instructions.',
defer_loading=True,
)
],
)
result = await agent.run('hi')
assert result.output == 'done'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hi', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions="""\
The following capabilities are deferred and can be loaded using the `load_capability` tool:
- refunds: Refund tools.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='load_capability',
args={'name': 'refunds'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{'type': 'missing', 'loc': ('id',), 'msg': 'Field required', 'input': {'name': 'refunds'}}
],
tool_name='load_capability',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
instructions="""\
The following capabilities are deferred and can be loaded using the `load_capability` tool:
- refunds: Refund tools.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[LoadCapabilityCallPart(args={'id': 'refunds'}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=81, output_tokens=10),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
LoadCapabilityReturnPart(
content={'instructions': 'Refund instructions.'},
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
instructions="""\
The following capabilities are deferred and can be loaded using the `load_capability` tool:
- refunds: Refund tools.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=86, output_tokens=11),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize(
'args,expected_id',
[
pytest.param(None, None, id='partial-stream-no-args'),
pytest.param({'id': 'refunds'}, 'refunds', id='validated-dict'),
pytest.param('{"id": "billing"}', 'billing', id='complete-json-string'),
pytest.param('{"id":', None, id='partial-stream-json'),
pytest.param('[1, 2, 3]', None, id='non-dict-json'),
],
)
def test_load_capability_call_part_typed_args(args: Any, expected_id: str | None) -> None:
"""`typed_args` handles valid, partial, and invalid payloads."""
part = LoadCapabilityCallPart(tool_call_id='c', args=args)
assert part.capability_id == expected_id
if expected_id is None:
assert part.typed_args is None
else:
assert part.typed_args == {'id': expected_id}
def test_load_capability_return_part_accessors() -> None:
"""`instructions` reads the optional return payload field."""
with_instructions = LoadCapabilityReturnPart(
tool_call_id='c',
content={'instructions': 'Use refunds carefully.'},
)
assert with_instructions.instructions == 'Use refunds carefully.'
without_instructions = LoadCapabilityReturnPart(
tool_call_id='c',
content={},
)
assert without_instructions.instructions is None
def test_load_capability_narrow_type_promotes_and_is_idempotent() -> None:
"""Capability-load narrowing is idempotent."""
base_call = ToolCallPart(
tool_name='load_capability',
tool_call_id='c',
args={'id': 'refunds'},
tool_kind='capability-load',
)
promoted_call = ToolCallPart.narrow_type(base_call)
assert isinstance(promoted_call, LoadCapabilityCallPart)
assert ToolCallPart.narrow_type(promoted_call) is promoted_call
base_return = ToolReturnPart(
tool_name='load_capability',
tool_call_id='c',
content={},
tool_kind='capability-load',
)
promoted_return = ToolReturnPart.narrow_type(base_return)
assert isinstance(promoted_return, LoadCapabilityReturnPart)
assert ToolReturnPart.narrow_type(promoted_return) is promoted_return
def test_load_capability_parts_round_trip_through_message_history() -> None:
"""`capability-load` parts survive history (de)serialization as typed subclasses, and a
user tool named `load_capability` without `tool_kind` is left as a plain `ToolCallPart`."""
from pydantic_ai.messages import ModelMessagesTypeAdapter, ModelRequest, ModelResponse
raw: list[dict[str, Any]] = [
{
'kind': 'response',
'parts': [
{
'part_kind': 'tool-call',
'tool_name': 'load_capability',
'tool_kind': 'capability-load',
'args': {'id': 'refunds'},
'tool_call_id': 'c1',
},
# User tool colliding on the name but without `tool_kind`: must stay base.
{
'part_kind': 'tool-call',
'tool_name': 'load_capability',
'args': {'foo': 'bar'},
'tool_call_id': 'c2',
},
],
},
{
'kind': 'request',
'parts': [
{
'part_kind': 'tool-return',
'tool_name': 'load_capability',
'tool_kind': 'capability-load',
'content': {'instructions': 'Confirm the order id.'},
'tool_call_id': 'c1',
},
],
},
]
response, request = ModelMessagesTypeAdapter.validate_python(raw)
assert isinstance(response, ModelResponse)
assert isinstance(response.parts[0], LoadCapabilityCallPart)
assert response.parts[0].capability_id == 'refunds'
# Collision on `tool_name='load_capability'` without `tool_kind` stays a base part.
assert type(response.parts[1]) is ToolCallPart
assert response.parts[1].args == {'foo': 'bar'}
assert isinstance(request, ModelRequest)
assert isinstance(request.parts[0], LoadCapabilityReturnPart)
assert request.parts[0].instructions == 'Confirm the order id.'
# Full JSON dump -> load round-trip preserves the typed subclasses.
rebuilt = ModelMessagesTypeAdapter.validate_json(ModelMessagesTypeAdapter.dump_json([response, request]))
assert isinstance(rebuilt[0].parts[0], LoadCapabilityCallPart)
assert isinstance(rebuilt[1].parts[0], LoadCapabilityReturnPart)
async def test_deferred_capability_loads_instructions_and_tools_e2e() -> None:
"""A deferred capability starts as a catalog entry and becomes usable after `load_capability`."""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
def add_account_context(ctx: RunContext) -> str:
return f'Load-time account context for run step {ctx.run_step}.'
def empty_instruction(ctx: RunContext) -> None:
return None
always_on = Capability[object](
id='always-on',
description='Visible billing guidance.',
instructions='Visible billing instructions.',
)
refunds = Capability[object](
id='refunds',
description='Refund policy tools.',
instructions=[
'Use the refund policy before answering refund questions.',
add_account_context,
empty_instruction,
],
toolsets=[toolset],
defer_loading=True,
)
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
if not any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'refunds'},
tool_call_id='load-refunds',
)
]
)
if not any(part.tool_name == 'lookup_refund_policy' for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(
tool_name='lookup_refund_policy',
args={'order_id': 'order-123'},
tool_call_id='lookup-refund',
)
]
)
refund_result = next(part.content for part in tool_returns if part.tool_name == 'lookup_refund_policy')
return make_text_response(f'final: {refund_result}')
agent = Agent(FunctionModel(model_fn), capabilities=[always_on, refunds])
result = await agent.run('Can I get a refund?')
assert result.output == snapshot('final: order-123: refund allowed for 30 days')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Can I get a refund?', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions="""\
Visible billing instructions.
The following capabilities are deferred and can be loaded using the `load_capability` tool:
- refunds: Refund policy tools.""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
LoadCapabilityCallPart(
tool_name='load_capability',
args={'id': 'refunds'},
tool_call_id='load-refunds',
)
],
usage=RequestUsage(input_tokens=55, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
LoadCapabilityReturnPart(
tool_name='load_capability',
content={
'instructions': 'Use the refund policy before answering refund questions.\n\n'
'Load-time account context for run step 1.',
},
tool_call_id='load-refunds',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
instructions="""\
Visible billing instructions.
The following capabilities are deferred and can be loaded using the `load_capability` tool:
- refunds: Refund policy tools.""",
run_id=IsStr(),
conversation_id=IsStr(),
),
# Synthesized by `ToolSearch.before_model_request` after the capability load.
ModelResponse(
parts=[
ToolSearchCallPart(
args={'queries': ['refunds']},
tool_call_id='auto_load_0f10f8b659c3c105',
)
],
usage=RequestUsage(),
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
ToolSearchReturnPart(
content={'discovered_tools': [{'name': 'lookup_refund_policy'}]},
tool_call_id='auto_load_0f10f8b659c3c105',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='lookup_refund_policy', args={'order_id': 'order-123'}, tool_call_id='lookup-refund'
)
],
usage=RequestUsage(input_tokens=79, output_tokens=16),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='lookup_refund_policy',
content='order-123: refund allowed for 30 days',
tool_call_id='lookup-refund',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
instructions="""\
Visible billing instructions.
The following capabilities are deferred and can be loaded using the `load_capability` tool:
- refunds: Refund policy tools.\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='final: order-123: refund allowed for 30 days')],
usage=RequestUsage(input_tokens=85, output_tokens=23),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_deferred_capability_tool_registered_after_construction_defers_until_load() -> None:
"""A tool registered via `@cap.tool` *after* construction defers like a constructor tool: hidden until load.
Deferred tools stay in the toolset tagged `defer_loading=True` (the wire-level filter in
`Model.prepare_request` is what hides them from a real provider), so the regression signal is the
flag flipping `True` -> `False` once the capability loads, not the tool's mere presence.
"""
refunds = Capability[object](id='refunds', description='Refund policy tools.', defer_loading=True)
# Register on the deferred capability *after* construction (decorator path, not the `tools=` arg).
@refunds.tool_plain
def lookup_refund_policy(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
defer_flag_by_phase: dict[str, bool | None] = {}
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
loaded = any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns)
refund_def = next((tool for tool in info.function_tools if tool.name == 'lookup_refund_policy'), None)
defer_flag_by_phase['after_load' if loaded else 'before_load'] = (
refund_def.defer_loading if refund_def else None
)
if not loaded:
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
if not any(part.tool_name == 'lookup_refund_policy' for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(tool_name='lookup_refund_policy', args={'order_id': 'order-1'}, tool_call_id='look')
]
)
result = next(part.content for part in tool_returns if part.tool_name == 'lookup_refund_policy')
return make_text_response(f'final: {result}')
agent = Agent(FunctionModel(model_fn), capabilities=[refunds])
result = await agent.run('Can I get a refund?')
assert result.output == snapshot('final: order-1: refund allowed for 30 days')
# Deferred before the capability loads, revealed (and callable) afterward.
assert defer_flag_by_phase == snapshot({'before_load': True, 'after_load': False})
async def test_deferred_capability_tool_stays_available_across_turns() -> None:
"""A capability-owned tool stays callable across every turn after `load_capability`.
Regression guard: the `available_tool_names`/`discovered_tool_names` split must keep a
loaded deferred tool non-deferred on the second (and later) post-load model request,
not just on the turn immediately following the load.
"""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
refunds = Capability[object](
id='refunds',
description='Refund policy tools.',
toolsets=[toolset],
defer_loading=True,
)
# Names of non-deferred function tools the model sees on each request.
available_per_turn: list[set[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
available_per_turn.append({td.name for td in info.function_tools if not td.defer_loading})
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
# Turn 1: load the capability.
if not any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns):
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
lookup_calls = [part for part in tool_returns if part.tool_name == 'lookup_refund_policy']
# Turns 2 and 3: call the loaded tool twice, so we exercise two post-load turns.
if len(lookup_calls) < 2:
return ModelResponse(
parts=[
ToolCallPart(
tool_name='lookup_refund_policy',
args={'order_id': f'order-{len(lookup_calls)}'},
tool_call_id=f'lookup-{len(lookup_calls)}',
)
]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[refunds])
result = await agent.run('Can I get a refund?')
assert result.output == 'done'
# First request: tool is still deferred (not yet loaded).
assert 'lookup_refund_policy' not in available_per_turn[0]
# Every request after the load must expose the loaded tool as non-deferred — including
# the second post-load turn, which is what the regression broke.
post_load_turns = available_per_turn[1:]
assert len(post_load_turns) >= 2
for turn_tools in post_load_turns:
assert 'lookup_refund_policy' in turn_tools
async def test_run_context_tools_exposes_deferred_definitions_as_name_keyed_dict() -> None:
"""`ctx.tools` is the full name-keyed dict of `ToolDefinition`s, including entries
that are still deferred (and therefore absent from `ctx.available_tool_names`)."""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str: # pragma: no cover
return f'{order_id}: refund allowed'
refunds = Capability[object](id='refunds', toolsets=[toolset], defer_loading=True)
seen_tools: list[dict[str, ToolDefinition]] = []
@dataclass
class CaptureCtxToolsCap(AbstractCapability):
async def before_model_request(
self, ctx: RunContext, request_context: ModelRequestContext
) -> ModelRequestContext:
seen_tools.append(ctx.tools)
return request_context
def model_fn(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[refunds, CaptureCtxToolsCap()])
await agent.run('hi')
[tools] = seen_tools
# The deferred tool is keyed by its own name and carries `defer_loading=True`,
# even though it's absent from `available_tool_names` until the capability loads.
assert tools['lookup_refund_policy'].name == 'lookup_refund_policy'
assert tools['lookup_refund_policy'].defer_loading is True
async def test_deferred_capability_synthetic_tool_search_persists_in_history() -> None:
"""The synthetic tool-search exchange injected after a capability load persists to
the run's message history, and re-running with that history does not duplicate it."""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str: # pragma: no cover
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
refunds = Capability[object](
id='refunds',
description='Refund policy tools.',
toolsets=[toolset],
defer_loading=True,
)
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
if not any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns):
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[refunds])
result = await agent.run('Can I get a refund?')
def synthetic_pairs(messages: list[ModelMessage]) -> list[str]:
call_ids: list[str] = []
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolSearchCallPart) and part.tool_call_id.startswith('auto_load_'):
call_ids.append(part.tool_call_id)
return call_ids
messages = result.all_messages()
call_ids = synthetic_pairs(messages)
# Exactly one synthetic call part, and its matching return part is present.
assert len(call_ids) == 1
return_ids = [
part.tool_call_id
for message in messages
for part in message.parts
if isinstance(part, ToolSearchReturnPart) and part.tool_call_id == call_ids[0]
]
assert return_ids == [call_ids[0]]
# Idempotence: feeding the resulting history back in does not inject a duplicate pair
# (the deterministic call_id means it's recognized as already discovered).
result2 = await agent.run('And another refund?', message_history=messages)
new_messages = result2.all_messages()[len(messages) :]
assert synthetic_pairs(new_messages) == []
class _NoNativeToolSearchModel(FunctionModel):
"""`FunctionModel` that forces the local `search_tools` function path.
`FunctionModel` reports support for every native tool (including native tool search),
which would route deferred standalone tools through the provider rather than the
synthetic `search_tools` function. Dropping `ToolSearchTool` mirrors a model without
native tool-search support, exercising the function-tool discovery path.
"""
@classmethod
def supported_native_tools(cls) -> frozenset[type[AbstractNativeTool]]:
return frozenset(super().supported_native_tools()) - {ToolSearchTool}
async def test_two_deferred_capabilities_loaded_sequentially_both_stay_available() -> None:
"""Loading a second deferred capability does not drop the first one's tool.
Trajectory: load A and call A's tool, then on a later turn load B and call B's tool,
then one more turn. Both capabilities' tools must be non-deferred on every turn after
their respective loads, proving loads are additive and sticky.
"""
toolset_a = FunctionToolset()
@toolset_a.tool_plain
def alpha_tool() -> str:
"""Capability A's tool."""
return 'alpha-result'
toolset_b = FunctionToolset()
@toolset_b.tool_plain
def beta_tool() -> str:
"""Capability B's tool."""
return 'beta-result'
cap_a = Capability[object](id='alpha', description='Alpha tools.', toolsets=[toolset_a], defer_loading=True)
cap_b = Capability[object](id='beta', description='Beta tools.', toolsets=[toolset_b], defer_loading=True)
available_per_turn: list[set[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
available_per_turn.append({td.name for td in info.function_tools if not td.defer_loading})
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
names = {part.tool_name for part in tool_returns}
# Turn 1: load A.
if 'alpha' not in {part.capability_id for part in _load_calls(messages)}:
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'alpha'}, tool_call_id='load-a')]
)
# Turn 2: use A's tool.
if 'alpha_tool' not in names:
return ModelResponse(parts=[ToolCallPart(tool_name='alpha_tool', args={}, tool_call_id='call-a')])
# Turn 3: load B.
if 'beta' not in {part.capability_id for part in _load_calls(messages)}:
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'beta'}, tool_call_id='load-b')]
)
# Turn 4: use B's tool.
if 'beta_tool' not in names:
return ModelResponse(parts=[ToolCallPart(tool_name='beta_tool', args={}, tool_call_id='call-b')])
# Turn 5+: just respond.
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[cap_a, cap_b])
result = await agent.run('Use both capabilities.')
assert result.output == 'done'
# >= 5 turns: load A, use A, load B, use B, final.
assert len(available_per_turn) >= 5
# Identify the first turn on which each capability's tool became available.
a_loaded_from = next(i for i, tools in enumerate(available_per_turn) if 'alpha_tool' in tools)
b_loaded_from = next(i for i, tools in enumerate(available_per_turn) if 'beta_tool' in tools)
assert a_loaded_from < b_loaded_from
# Once loaded, each tool stays available on every later turn — loading B never drops A.
for tools in available_per_turn[a_loaded_from:]:
assert 'alpha_tool' in tools
for tools in available_per_turn[b_loaded_from:]:
assert 'beta_tool' in tools
# Both present together on the final turn.
assert {'alpha_tool', 'beta_tool'} <= available_per_turn[-1]
async def test_tool_search_discovery_and_capability_load_coexist() -> None:
"""A tool-search-discovered standalone tool and a load_capability tool coexist and persist.
Trajectory: discover a standalone deferred tool via `search_tools`, load a deferred
capability via `load_capability`, then continue for extra turns. Both the searched tool
and the capability's tool must be available together and stay available afterwards.
"""
standalone = FunctionToolset()
@standalone.tool_plain(defer_loading=True)
def searchable_weather(city: str) -> str:
"""Look up the weather for a city."""
return f'{city}: sunny'
cap_toolset = FunctionToolset()
@cap_toolset.tool_plain
def lookup_refund(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refundable'
refunds = Capability[object](id='refunds', description='Refund tools.', toolsets=[cap_toolset], defer_loading=True)
available_per_turn: list[set[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
available_per_turn.append({td.name for td in info.function_tools if not td.defer_loading})
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
names = {part.tool_name for part in tool_returns}
# Turn 1: search for the standalone deferred tool.
if not any(part.tool_name == _SEARCH_TOOLS_NAME for part in tool_returns):
return ModelResponse(
parts=[ToolCallPart(tool_name=_SEARCH_TOOLS_NAME, args={'queries': ['weather']}, tool_call_id='search')]
)
# Turn 2: load the deferred capability.
if not _load_calls(messages):
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
# Turn 3: use the discovered standalone tool.
if 'searchable_weather' not in names:
return ModelResponse(
parts=[ToolCallPart(tool_name='searchable_weather', args={'city': 'Paris'}, tool_call_id='call-w')]
)
# Turn 4: use the capability's tool.
if 'lookup_refund' not in names:
return ModelResponse(
parts=[ToolCallPart(tool_name='lookup_refund', args={'order_id': 'o1'}, tool_call_id='call-r')]
)
# Turn 5+: respond.
return make_text_response('done')
agent = Agent(_NoNativeToolSearchModel(model_fn), capabilities=[standalone_capability(standalone), refunds])
result = await agent.run('Find weather and refund tools.')
assert result.output == 'done'
weather_from = next(i for i, tools in enumerate(available_per_turn) if 'searchable_weather' in tools)
refund_from = next(i for i, tools in enumerate(available_per_turn) if 'lookup_refund' in tools)
# Each reveal mechanism is sticky from the turn it first exposes its tool.
for tools in available_per_turn[weather_from:]:
assert 'searchable_weather' in tools
for tools in available_per_turn[refund_from:]:
assert 'lookup_refund' in tools
# Both available together once both are revealed, including on the final turn.
assert {'searchable_weather', 'lookup_refund'} <= available_per_turn[-1]
async def test_deferred_capability_synthetic_exchange_not_duplicated_over_long_trajectory() -> None:
"""The synthetic tool-search exchange for a loaded capability appears exactly once.
Extends the persistence test to >= 3 model-request turns after the load: the deterministic
`auto_load_*` call_id must keep the synthetic call/return pair singular across the whole
trajectory, and the capability's tool stays available on every post-load turn.
"""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
refunds = Capability[object](
id='refunds', description='Refund policy tools.', toolsets=[toolset], defer_loading=True
)
available_per_turn: list[set[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
available_per_turn.append({td.name for td in info.function_tools if not td.defer_loading})
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
if not any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns):
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
# Three post-load turns that each call the loaded tool, then respond.
lookup_calls = [part for part in tool_returns if part.tool_name == 'lookup_refund_policy']
if len(lookup_calls) < 3:
return ModelResponse(
parts=[
ToolCallPart(
tool_name='lookup_refund_policy',
args={'order_id': f'order-{len(lookup_calls)}'},
tool_call_id=f'lookup-{len(lookup_calls)}',
)
]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[refunds])
result = await agent.run('Refund please.')
assert result.output == 'done'
messages = result.all_messages()
synthetic_call_ids = [
part.tool_call_id
for message in messages
for part in message.parts
if isinstance(part, ToolSearchCallPart) and part.tool_call_id.startswith('auto_load_')
]
synthetic_return_ids = [
part.tool_call_id
for message in messages
for part in message.parts
if isinstance(part, ToolSearchReturnPart) and part.tool_call_id.startswith('auto_load_')
]
# Exactly one synthetic exchange survives the long trajectory — no per-turn duplication.
assert len(synthetic_call_ids) == 1
assert synthetic_return_ids == synthetic_call_ids
# The capability's tool was deferred on turn 1 and available on every post-load turn.
assert 'lookup_refund_policy' not in available_per_turn[0]
post_load_turns = available_per_turn[1:]
assert len(post_load_turns) >= 3
for tools in post_load_turns:
assert 'lookup_refund_policy' in tools
async def test_deferred_capability_tool_available_on_turn_that_does_not_call_it() -> None:
"""A loaded capability's tool stays available on a turn that does not call it.
After loading, the model calls an unrelated visible tool (not the capability's tool) and
then responds. The capability's tool must remain non-deferred on those turns — loading is
sticky, not gated on per-turn usage.
"""
visible_toolset = FunctionToolset()
@visible_toolset.tool_plain
def ping() -> str:
"""An always-visible tool unrelated to the capability."""
return 'pong'
cap_toolset = FunctionToolset()
@cap_toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str: # pragma: no cover
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
refunds = Capability[object](
id='refunds', description='Refund policy tools.', toolsets=[cap_toolset], defer_loading=True
)
available_per_turn: list[set[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
available_per_turn.append({td.name for td in info.function_tools if not td.defer_loading})
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
names = {part.tool_name for part in tool_returns}
# Turn 1: load the capability.
if not any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns):
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
# Turn 2: call an UNRELATED tool, never the capability's tool.
if 'ping' not in names:
return ModelResponse(parts=[ToolCallPart(tool_name='ping', args={}, tool_call_id='call-ping')])
# Turn 3: respond without ever calling the capability's tool.
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), tools=[ping], capabilities=[refunds])
# `ping` is registered via a function tool on the agent; ensure both paths see it.
result = await agent.run('Load refunds but use ping.')
assert result.output == 'done'
assert len(available_per_turn) >= 3
# Turn 1: capability tool still deferred.
assert 'lookup_refund_policy' not in available_per_turn[0]
# Every turn after the load: capability tool available even though it is never called.
for tools in available_per_turn[1:]:
assert 'lookup_refund_policy' in tools
def _load_calls(messages: list[ModelMessage]) -> list[LoadCapabilityCallPart]:
"""All `load_capability` call parts in the message history."""
return [
part
for message in messages
if isinstance(message, ModelResponse)
for part in message.parts
if isinstance(part, LoadCapabilityCallPart)
]
def standalone_capability(toolset: FunctionToolset) -> Capability:
"""Wrap a toolset of standalone deferred tools in an eager capability (tools keep their own defer flag)."""
return Capability[object](id='standalone', description='Standalone searchable tools.', toolsets=[toolset])
async def test_deferred_capability_load_includes_toolset_instructions() -> None:
"""Instructions declared on a deferred capability's toolset surface via the `load_capability` return.
The wrapping `CapabilityOwnedToolset` silences `get_instructions` for deferred-loading
capabilities (so toolset hints don't leak into the prompt), then re-emits them on load
alongside the capability's own instructions.
"""
toolset = FunctionToolset(instructions='Use the refund tool with the order id, not the customer id.')
@toolset.tool_plain
def lookup_refund(order_id: str) -> str:
return f'{order_id}: ok'
refunds = Capability[object](
id='refunds',
description='Refund tools.',
instructions='Quote the refund policy verbatim.',
toolsets=[toolset],
defer_loading=True,
)
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
already_loaded = any(
isinstance(part, LoadCapabilityReturnPart)
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
)
if not already_loaded:
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'refunds'},
tool_call_id='load-refunds',
)
]
)
if not any(part.tool_name == 'lookup_refund' for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(
tool_name='lookup_refund',
args={'order_id': 'order-123'},
tool_call_id='lookup-refund',
)
]
)
refund_result = next(part.content for part in tool_returns if part.tool_name == 'lookup_refund')
return make_text_response(str(refund_result))
agent = Agent(FunctionModel(model_fn), capabilities=[refunds])
result = await agent.run('hi')
assert result.output == 'order-123: ok'
[load_return] = [
part
for message in result.all_messages()
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, LoadCapabilityReturnPart)
]
assert load_return.instructions == snapshot("""\
Quote the refund policy verbatim.
Use the refund tool with the order id, not the customer id.\
""")
first_request = next(message for message in result.all_messages() if isinstance(message, ModelRequest))
assert first_request.instructions == snapshot(
'The following capabilities are deferred and can be loaded using the `load_capability` tool:\n'
'- refunds: Refund tools.'
)
assert first_request.instructions is not None
assert 'Use the refund tool' not in first_request.instructions
async def test_deferred_capability_load_drops_empty_toolset_instructions() -> None:
"""Empty toolset instructions are filtered from load returns."""
from dataclasses import dataclass
from pydantic_ai.messages import InstructionPart
from pydantic_ai.toolsets.wrapper import WrapperToolset
@dataclass
class _LiteralInstructionsToolset(WrapperToolset):
raw: tuple[str | InstructionPart, ...] = ()
async def get_instructions(self, ctx: RunContext) -> list[str | InstructionPart]:
return list(self.raw)
toolset = _LiteralInstructionsToolset(
wrapped=FunctionToolset(),
raw=(
InstructionPart(content=' ', dynamic=False),
InstructionPart(content='Real hint from toolset.', dynamic=False),
'',
),
)
cap = Capability[object](id='cap', description='Custom-toolset cap.', toolsets=[toolset], defer_loading=True)
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
already_loaded = any(
isinstance(part, LoadCapabilityReturnPart)
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
)
if not already_loaded:
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'cap'},
tool_call_id='load',
)
]
)
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('hi')
[load_return] = [
part
for message in result.all_messages()
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, LoadCapabilityReturnPart)
]
assert load_return.instructions == 'Real hint from toolset.'
async def test_unknown_deferred_capability_id_does_not_reveal_hidden_tools() -> None:
toolset = FunctionToolset()
@toolset.tool_plain
def hidden_tool() -> str:
return 'hidden' # pragma: no cover
hidden = Capability[object](
id='hidden',
description='Hidden tool access.',
toolsets=[toolset],
defer_loading=True,
)
seen_tool_state: list[list[tuple[str, bool]]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_tool_state.append([(t.name, bool(t.defer_loading)) for t in info.function_tools])
# Give up on the first signal of tool feedback — either a `ToolReturnPart`
# (success, which can't happen here) or a `RetryPromptPart` (the framework
# signaling the bad cap id). Without the retry branch, we'd loop past
# `max_retries` and raise `UnexpectedModelBehavior` instead of giving up.
if not any(
isinstance(part, (ToolReturnPart, RetryPromptPart))
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
):
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'missing'},
tool_call_id='load-missing',
)
]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[hidden])
result = await agent.run('load missing')
assert result.output == snapshot('done')
assert seen_tool_state == snapshot(
[
[('load_capability', False), ('hidden_tool', True), ('search_tools', False)],
[('load_capability', False), ('hidden_tool', True), ('search_tools', False)],
]
)
history_parts = [part for message in result.all_messages() for part in message.parts]
assert not any(isinstance(part, LoadCapabilityReturnPart) for part in history_parts)
[retry] = [part for part in history_parts if isinstance(part, RetryPromptPart)]
assert retry.content == snapshot("No capability found with id 'missing'.")
async def test_load_capability_retries_for_already_available_capability() -> None:
always_on = Capability[object](
id='always-on',
description='Already visible.',
instructions='Already visible instructions.',
)
deferred = Capability[object](
id='deferred',
description='Deferred.',
instructions='Deferred instructions.',
defer_loading=True,
)
expected_retry = LOAD_CAPABILITY_ALREADY_AVAILABLE_MESSAGE_TEMPLATE.format(capability_id='always-on')
retry_messages: list[str] = []
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
retries = [
part.content
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, RetryPromptPart) and isinstance(part.content, str)
]
if retries:
retry_messages.extend(retries)
return make_text_response('done')
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'always-on'},
tool_call_id='load-always-on',
)
]
)
agent = Agent(FunctionModel(model_fn), capabilities=[always_on, deferred])
result = await agent.run('load always-on')
assert result.output == 'done'
assert retry_messages == [expected_retry]
assert not any(
isinstance(part, LoadCapabilityReturnPart) for message in result.all_messages() for part in message.parts
)
async def test_load_capability_retries_when_capability_is_already_loaded() -> None:
deferred = Capability[object](
id='deferred',
description='Deferred.',
instructions='Deferred instructions.',
defer_loading=True,
)
expected_retry = LOAD_CAPABILITY_ALREADY_AVAILABLE_MESSAGE_TEMPLATE.format(capability_id='deferred')
retry_messages: list[str] = []
def model_fn(messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
retries = [
part.content
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, RetryPromptPart) and isinstance(part.content, str)
]
if retries:
retry_messages.extend(retries)
return make_text_response('done')
load_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, LoadCapabilityReturnPart)
]
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'deferred'},
tool_call_id=f'load-deferred-{len(load_returns)}',
)
]
)
agent = Agent(FunctionModel(model_fn), capabilities=[deferred])
result = await agent.run('load twice')
assert result.output == 'done'
assert retry_messages == [expected_retry]
load_returns = [
part
for message in result.all_messages()
for part in message.parts
if isinstance(part, LoadCapabilityReturnPart)
]
assert len(load_returns) == 1
assert load_returns[0].instructions == 'Deferred instructions.'
def test_infer_fmt_explicit():
"""_infer_fmt returns the explicit fmt when provided."""
from pydantic_ai.agent.spec import _infer_fmt # pyright: ignore[reportPrivateUsage]
assert _infer_fmt(Path('agent.txt'), 'json') == 'json'
assert _infer_fmt(Path('agent.txt'), 'yaml') == 'yaml'
def test_infer_fmt_unknown_extension():
"""_infer_fmt raises ValueError for unknown extension without explicit fmt."""
from pydantic_ai.agent.spec import _infer_fmt # pyright: ignore[reportPrivateUsage]
with pytest.raises(ValueError, match=re.escape("Could not infer format for filename 'agent.txt'")):
_infer_fmt(Path('agent.txt'), None)
def test_invalid_custom_capability_type():
"""Passing a non-AbstractCapability subclass to model_json_schema_with_capabilities raises ValueError."""
with pytest.raises(ValueError, match='must be subclasses of AbstractCapability'):
AgentSpec.model_json_schema_with_capabilities(
custom_capability_types=[str], # type: ignore[list-item]
)
def test_to_file_with_path_schema_path(tmp_path: str):
"""to_file works when schema_path is passed as a relative Path (not str), triggering the non-str branch."""
spec = AgentSpec(model='test', name='path-schema')
spec_path = Path(tmp_path) / 'agent.yaml'
# Pass a relative Path (not str) to exercise the isinstance(schema_path, str) == False branch
schema_path = Path('custom_schema.json')
spec.to_file(spec_path, schema_path=schema_path)
resolved_schema = Path(tmp_path) / 'custom_schema.json'
assert resolved_schema.exists()
content = spec_path.read_text(encoding='utf-8')
assert 'model: test' in content
# --- for_run tests ---
def _build_run_context(deps: Any = None) -> RunContext[Any]:
return RunContext(deps=deps, model=TestModel(), usage=RunUsage(), run_step=0)
def test_resolve_capability_id_scans_run_context_capabilities() -> None:
@dataclass
class SimpleCap(AbstractCapability):
pass
target = SimpleCap()
other = SimpleCap()
ctx = RunContext(
deps=None,
model=TestModel(),
usage=RunUsage(),
capabilities={'other': other, 'target': target},
)
assert resolve_capability_id(ctx, target) == 'target'
async def test_capability_for_run_default_returns_self():
"""Default for_run returns self."""
@dataclass
class SimpleCap(AbstractCapability):
pass
cap = SimpleCap()
ctx = _build_run_context()
assert await cap.for_run(ctx) is cap
async def test_run_context_available_tool_names_empty_before_tool_manager_is_ready() -> None:
"""Early capability hooks can ask for available tool names before the tool manager is populated."""
seen_available_tool_names: list[set[str]] = []
seen_tools: list[dict[str, ToolDefinition]] = []
@dataclass
class AvailableToolsCap(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
seen_available_tool_names.append(ctx.available_tool_names)
seen_tools.append(ctx.tools)
agent = Agent(TestModel(), capabilities=[AvailableToolsCap()])
await agent.run('hello')
assert seen_available_tool_names == [set()]
# The `tools` empty-guard mirrors `available_tool_names`: no tool manager yet → empty dict.
assert seen_tools == [{}]
def test_run_context_available_tool_names_includes_discovered_before_tool_manager() -> None:
ctx = _build_run_context()
ctx.discovered_tool_names = {'discovered_tool'}
assert ctx.tools == {}
assert ctx.available_tool_names == {'discovered_tool'}
async def test_run_context_available_tool_names_unions_discovered_current_tools() -> None:
"""Available tool names are always-visible current tools plus revealed corpus tools."""
toolset = FunctionToolset()
@toolset.tool_plain
def always_tool() -> str: # pragma: no cover
return 'always'
@toolset.tool_plain(defer_loading=True)
def discovered_tool() -> str: # pragma: no cover
return 'discovered'
@toolset.tool_plain(defer_loading=True)
def pending_tool() -> str: # pragma: no cover
return 'pending'
@toolset.tool_plain(defer_loading=True)
def loaded_capability_tool() -> str: # pragma: no cover
return 'loaded'
ctx = _build_run_context()
ctx.discovered_tool_names = {'discovered_tool', 'removed_tool'}
ctx.loaded_capability_ids = {'loaded_capability'}
tools = await toolset.get_tools(ctx)
tools['discovered_tool'] = replace(
tools['discovered_tool'],
tool_def=replace(tools['discovered_tool'].tool_def, with_native=ToolSearchTool.kind, defer_loading=False),
)
tools['pending_tool'] = replace(
tools['pending_tool'],
tool_def=replace(tools['pending_tool'].tool_def, with_native=ToolSearchTool.kind, defer_loading=True),
)
tools['loaded_capability_tool'] = replace(
tools['loaded_capability_tool'],
tool_def=replace(
tools['loaded_capability_tool'].tool_def,
with_native=ToolSearchTool.kind,
defer_loading=False,
capability_id='loaded_capability',
),
)
tool_manager = ToolManager(toolset=toolset, ctx=ctx, tools=tools)
ctx.tool_manager = tool_manager
assert ctx.available_tool_names == {'always_tool', 'discovered_tool', 'loaded_capability_tool'}
_DEFERRED_HOOK_NAMES = {
'prepare_output_tools',
'wrap_run_event_stream',
'on_model_request_error',
'on_tool_validate_error',
'on_tool_execute_error',
'before_output_validate',
'after_output_validate',
'wrap_output_validate',
'on_output_validate_error',
'on_output_process_error',
'handle_deferred_tool_calls',
}
@dataclass
class _FailIfDispatchedDeferredCap(AbstractCapability):
id: str | None = 'deferred'
defer_loading: bool = True
def __getattribute__(self, name: str) -> Any:
if name in _DEFERRED_HOOK_NAMES: # pragma: no cover
raise AssertionError(f'unloaded capability hook should be skipped: {name}')
return super().__getattribute__(name)
@dataclass
class _NoopCap(AbstractCapability):
pass
def _output_context() -> OutputContext:
return OutputContext(mode='text', output_type=str, object_def=None, has_function=False)
async def _empty_event_stream() -> AsyncIterator[AgentStreamEvent]:
if False: # pragma: no cover
yield cast(AgentStreamEvent, None)
async def _validate_output(output: str | dict[str, Any]) -> Any:
return output
async def test_combined_capability_skips_unloaded_deferred_forward_hooks() -> None:
"""Forward-order hook dispatch skips unloaded deferred capabilities."""
combined = CombinedCapability([_FailIfDispatchedDeferredCap(), _NoopCap()])
ctx = _build_run_context()
output_context = _output_context()
tool_def = ToolDefinition(name='tool')
assert await combined.prepare_output_tools(ctx, [tool_def]) == [tool_def]
assert await combined.before_output_validate(ctx, output_context=output_context, output='raw') == 'raw'
assert (
await combined.handle_deferred_tool_calls(
ctx, requests=DeferredToolRequests(calls=[ToolCallPart('tool', {}, tool_call_id='deferred-call')])
)
is None
)
async def test_combined_capability_skips_unloaded_deferred_reverse_hooks() -> None:
"""Reverse-order hook dispatch skips unloaded deferred capabilities."""
combined = CombinedCapability([_NoopCap(), _FailIfDispatchedDeferredCap()])
ctx = _build_run_context()
output_context = _output_context()
tool_def = ToolDefinition(name='tool')
call = ToolCallPart('tool', {}, tool_call_id='tool-call')
request_context = ModelRequestContext(
model=TestModel(),
messages=[],
model_settings=None,
model_request_parameters=ModelRequestParameters(),
)
assert [event async for event in combined.wrap_run_event_stream(ctx, stream=_empty_event_stream())] == []
assert await combined.after_output_validate(ctx, output_context=output_context, output='parsed') == 'parsed'
assert (
await combined.wrap_output_validate(ctx, output_context=output_context, output='raw', handler=_validate_output)
== 'raw'
)
with pytest.raises(RuntimeError, match='model'):
await combined.on_model_request_error(ctx, request_context=request_context, error=RuntimeError('model'))
with pytest.raises(ModelRetry, match='tool validate'):
await combined.on_tool_validate_error(
ctx, call=call, tool_def=tool_def, args={}, error=ModelRetry('tool validate')
)
with pytest.raises(RuntimeError, match='tool execute'):
await combined.on_tool_execute_error(
ctx, call=call, tool_def=tool_def, args={}, error=RuntimeError('tool execute')
)
with pytest.raises(ModelRetry, match='output validate'):
await combined.on_output_validate_error(
ctx, output_context=output_context, output='raw', error=ModelRetry('output validate')
)
with pytest.raises(RuntimeError, match='output process'):
await combined.on_output_process_error(
ctx, output_context=output_context, output='parsed', error=RuntimeError('output process')
)
async def test_combined_capability_for_run_propagates():
"""CombinedCapability propagates for_run to children."""
@dataclass
class SimpleCap(AbstractCapability):
label: str = ''
cap1 = SimpleCap(label='a')
cap2 = SimpleCap(label='b')
combined = CombinedCapability([cap1, cap2])
ctx = _build_run_context()
# No child changes → returns self
result = await combined.for_run(ctx)
assert result is combined
async def test_combined_capability_for_run_returns_new_when_child_changes():
"""CombinedCapability returns new instance when a child's for_run returns different."""
@dataclass
class PerRunCap(AbstractCapability):
run_id: int = 0
async def for_run(self, ctx: RunContext) -> AbstractCapability:
return PerRunCap(run_id=self.run_id + 1)
@dataclass
class StaticCap(AbstractCapability):
pass
static_cap = StaticCap()
per_run_cap = PerRunCap()
combined = CombinedCapability([static_cap, per_run_cap])
ctx = _build_run_context()
result = await combined.for_run(ctx)
assert result is not combined
assert isinstance(result, CombinedCapability)
assert result.capabilities[0] is static_cap # unchanged
new_per_run = result.capabilities[1]
assert isinstance(new_per_run, PerRunCap)
assert new_per_run.run_id == 1
async def test_combined_capability_for_run_cancels_siblings_on_failure():
"""When one child's for_run fails, siblings are cancelled instead of leaking as orphan tasks."""
sibling_completed = False
@dataclass
class FailingCap(AbstractCapability):
async def for_run(self, ctx: RunContext) -> AbstractCapability:
raise RuntimeError('boom')
@dataclass
class SlowCap(AbstractCapability):
async def for_run(self, ctx: RunContext) -> AbstractCapability:
nonlocal sibling_completed
await anyio.sleep(0.1)
sibling_completed = True # pragma: no cover
return self # pragma: no cover
combined = CombinedCapability([FailingCap(), SlowCap()])
ctx = _build_run_context()
with pytest.raises(RuntimeError, match='boom'):
await combined.for_run(ctx)
await anyio.sleep(0.2)
assert sibling_completed is False
def test_apply_single_capability():
"""AbstractCapability.apply() visits just the capability itself."""
@dataclass
class MyCap(AbstractCapability):
pass
cap = MyCap()
visited: list[AbstractCapability] = []
cap.apply(visited.append)
assert visited == [cap]
def test_apply_combined_capability():
"""CombinedCapability.apply() recursively visits all leaf capabilities."""
@dataclass
class CapA(AbstractCapability):
pass
@dataclass
class CapB(AbstractCapability):
pass
cap_a = CapA()
cap_b = CapB()
combined = CombinedCapability([cap_a, cap_b])
visited: list[AbstractCapability] = []
combined.apply(visited.append)
assert visited == [cap_a, cap_b]
def test_apply_nested_combined_capability():
"""CombinedCapability.apply() flattens nested CombinedCapabilities."""
@dataclass
class CapA(AbstractCapability):
pass
@dataclass
class CapB(AbstractCapability):
pass
@dataclass
class CapC(AbstractCapability):
pass
cap_a = CapA()
cap_b = CapB()
cap_c = CapC()
inner = CombinedCapability([cap_a, cap_b])
outer = CombinedCapability([inner, cap_c])
visited: list[AbstractCapability] = []
outer.apply(visited.append)
assert visited == [cap_a, cap_b, cap_c]
def test_apply_wrapper_capability():
"""WrapperCapability.apply() visits the wrapper registered for the wrapped capability."""
inner = Thinking()
wrapper = WrapperCapability(wrapped=inner)
visited: list[AbstractCapability] = []
wrapper.apply(visited.append)
assert visited == [wrapper]
def test_apply_wrapper_over_combined_capability():
"""WrapperCapability.apply() also visits children when the wrapped capability is a container."""
@dataclass
class CapA(AbstractCapability):
pass
@dataclass
class CapB(AbstractCapability):
pass
cap_a = CapA()
cap_b = CapB()
wrapper = WrapperCapability(wrapped=CombinedCapability([cap_a, cap_b]))
visited: list[AbstractCapability] = []
wrapper.apply(visited.append)
assert visited == [wrapper, cap_a, cap_b]
async def test_wrapper_over_combined_capability_registers_child_tool_owners():
"""Child-owned toolsets still resolve capability ids when a wrapper contains a CombinedCapability."""
toolset_a = FunctionToolset()
@toolset_a.tool_plain
def tool_a() -> str:
return 'a' # pragma: no cover
toolset_b = FunctionToolset()
@toolset_b.tool_plain
def tool_b() -> str:
return 'b' # pragma: no cover
wrapper = WrapperCapability(
wrapped=CombinedCapability(
[
Toolset(toolset_a, id='a'),
Toolset(toolset_b, id='b'),
]
)
)
seen_capability_ids: list[str] = []
def respond(_messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for tool in info.function_tools:
assert tool.capability_id is not None
seen_capability_ids.append(tool.capability_id)
return ModelResponse(parts=[TextPart(','.join(sorted(tool.name for tool in info.function_tools)))])
agent = Agent(FunctionModel(respond), capabilities=[wrapper])
result = await agent.run('list tools')
assert result.output == 'tool_a,tool_b'
assert sorted(seen_capability_ids) == ['a', 'b']
def test_apply_prefix_tools():
"""PrefixTools.apply() visits the wrapper registered for the wrapped capability."""
thinking = Thinking()
prefixed = PrefixTools(wrapped=thinking, prefix='ns')
visited: list[AbstractCapability] = []
prefixed.apply(visited.append)
assert visited == [prefixed]
def test_apply_finds_capability_by_type():
"""Realistic usage: use apply() to check if a specific capability type is present."""
thinking = Thinking()
web_search = WebSearch(local='duckduckgo')
combined = CombinedCapability([thinking, web_search])
visited: list[AbstractCapability] = []
combined.apply(visited.append)
assert any(isinstance(c, Thinking) for c in visited)
assert any(isinstance(c, WebSearch) for c in visited)
assert not any(isinstance(c, WebFetch) for c in visited)
def test_apply_finds_wrapped_capability_by_type():
"""apply() registers wrappers themselves because wrapper behavior affects the loaded capability."""
thinking = Thinking()
prefixed = PrefixTools(wrapped=thinking, prefix='ns')
combined = CombinedCapability([prefixed, WebSearch(local='duckduckgo')])
visited: list[AbstractCapability] = []
combined.apply(visited.append)
assert not any(isinstance(c, Thinking) for c in visited)
assert any(isinstance(c, WebSearch) for c in visited)
assert any(isinstance(c, PrefixTools) for c in visited)
def test_apply_empty_combined():
"""CombinedCapability with no children visits nothing."""
combined = CombinedCapability([])
visited: list[AbstractCapability] = []
combined.apply(visited.append)
assert visited == []
async def test_for_run_with_different_toolset():
"""When for_run returns a capability with a different get_toolset(), the per-run toolset is used."""
toolset_a = FunctionToolset(id='a')
@toolset_a.tool_plain
def tool_a() -> str:
return 'a' # pragma: no cover
toolset_b = FunctionToolset(id='b')
@toolset_b.tool_plain
def tool_b() -> str:
return 'b' # pragma: no cover
@dataclass
class SwitchingCap(AbstractCapability):
use_b: bool = False
async def for_run(self, ctx: RunContext) -> AbstractCapability:
return SwitchingCap(use_b=True)
def get_toolset(self) -> AbstractToolset:
return toolset_b if self.use_b else toolset_a
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Check which tools are available
tool_names = [t.name for t in info.function_tools]
return ModelResponse(parts=[TextPart(f'tools: {",".join(sorted(tool_names))}')])
agent = Agent(FunctionModel(respond), capabilities=[SwitchingCap()])
# At run time, for_run switches to toolset_b
result = await agent.run('Hello')
assert 'tool_b' in result.output
async def test_for_run_with_different_instructions():
"""When for_run returns a capability with different get_instructions(), per-run instructions are used."""
@dataclass
class DynamicInstructionsCap(AbstractCapability):
run_instructions: str = 'init-time'
async def for_run(self, ctx: RunContext) -> AbstractCapability:
return DynamicInstructionsCap(run_instructions='per-run')
def get_instructions(self) -> str:
return self.run_instructions
captured_messages: list[ModelMessage] = []
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
captured_messages.extend(messages)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(respond), capabilities=[DynamicInstructionsCap()])
await agent.run('Hello')
# The per-run instructions should appear in the request's instructions field
instructions_found = [
msg.instructions for msg in captured_messages if isinstance(msg, ModelRequest) and msg.instructions
]
assert any('per-run' in i for i in instructions_found), (
f'Expected per-run instructions in messages, got: {captured_messages}'
)
async def test_for_run_receives_populated_run_context():
"""`for_run` hooks receive a `RunContext` with run_id, conversation_id, and resolved metadata."""
captured: dict[str, Any] = {}
class CapturingCap(AbstractCapability):
async def for_run(self, ctx: RunContext) -> AbstractCapability:
captured['run_id'] = ctx.run_id
captured['conversation_id'] = ctx.conversation_id
captured['metadata'] = ctx.metadata
captured['instrumentation_version'] = ctx.instrumentation_version
return self
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('done')])
def metadata_factory(ctx: RunContext) -> dict[str, Any]:
# Factory should be able to read run_id/conversation_id from the early ctx.
return {'run_id_seen': ctx.run_id, 'conversation_id_seen': ctx.conversation_id}
agent = Agent(FunctionModel(respond), capabilities=[CapturingCap()])
await agent.run('Hello', conversation_id='conv-123', metadata=metadata_factory)
assert captured['run_id'] is not None
assert captured['conversation_id'] == 'conv-123'
assert captured['metadata'] == {'run_id_seen': captured['run_id'], 'conversation_id_seen': 'conv-123'}
assert captured['instrumentation_version'] is not None
async def test_concurrent_runs_capability_isolation():
"""Multiple concurrent runs don't share state on stateful capabilities."""
@dataclass
class CountingCap(AbstractCapability):
request_count: int = 0
async def for_run(self, ctx: RunContext) -> AbstractCapability:
return CountingCap()
async def before_model_request(
self,
ctx: RunContext,
request_context: ModelRequestContext,
) -> ModelRequestContext:
self.request_count += 1
assert self.request_count == 1, f'Expected 1, got {self.request_count} — state leaked between runs!'
return request_context
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('Done')])
agent = Agent(FunctionModel(respond), capabilities=[CountingCap()])
# Run two concurrent runs — each should get its own CountingCap with count=0
results = await asyncio.gather(agent.run('A'), agent.run('B'))
assert results[0].output == 'Done'
assert results[1].output == 'Done'
@pytest.mark.parametrize(
'forced_choice',
[
pytest.param('required', id='required'),
pytest.param(['get_weather'], id='list'),
],
)
async def test_capability_can_inject_forcing_tool_choice_per_step(forced_choice: Any):
"""A capability returning a callable from get_model_settings() may inject `tool_choice='required'`
or `list[str]` per step without tripping the agent.run baseline validator.
Forces the tool on step 1, then steps aside so the agent can produce a final response.
"""
class ForceFirstStep(AbstractCapability):
def get_model_settings(self) -> Any:
def settings(ctx: RunContext) -> _ModelSettings:
tool_called = any(
isinstance(part, ToolReturnPart) and part.tool_name == 'get_weather'
for message in ctx.messages
if isinstance(message, ModelRequest)
for part in message.parts
)
if tool_called:
return _ModelSettings()
return _ModelSettings(tool_choice=forced_choice)
return settings
seen_tool_choices: list[Any] = []
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_tool_choices.append((info.model_settings or {}).get('tool_choice'))
if any(isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts):
return ModelResponse(parts=[TextPart(content='sunny')])
return ModelResponse(parts=[ToolCallPart(tool_name='get_weather', args={'city': 'Paris'})])
agent = Agent(FunctionModel(respond), capabilities=[ForceFirstStep()])
@agent.tool_plain
def get_weather(city: str) -> str:
return f'Weather in {city}: sunny'
result = await agent.run('Weather in Paris?')
assert result.output == 'sunny'
assert seen_tool_choices == [forced_choice, None]
# --- Hooks test helpers ---
@dataclass
class _ReplacingCapability(AbstractCapability[Any]):
"""Capability that replaces ModelRequestNode with a fresh copy in before_node_run.
Used to test that streaming + node replacement doesn't cause double model execution.
"""
replaced: bool = field(default=False, init=False)
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
from pydantic_ai import ModelRequestNode
if isinstance(node, ModelRequestNode) and not self.replaced:
self.replaced = True
return ModelRequestNode(request=node.request) # pyright: ignore[reportUnknownVariableType]
return node # pyright: ignore[reportUnknownVariableType]
def make_text_response(text: str = 'hello') -> ModelResponse:
return ModelResponse(parts=[TextPart(content=text)])
def simple_model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('response from model')
async def simple_stream_function(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
yield 'streamed response'
async def tool_calling_stream_function(
messages: list[ModelMessage], info: AgentInfo
) -> AsyncIterator[str | DeltaToolCalls]:
"""A streaming model that calls a tool on first request, then returns text."""
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
yield 'final response'
return
if info.function_tools:
tool = info.function_tools[0]
yield {0: DeltaToolCall(name=tool.name, json_args='{}', tool_call_id='call-1')}
return
yield 'no tools available' # pragma: no cover
# Defined at module scope so pydantic-ai can resolve the annotation under `from __future__ import annotations`.
class SingleBaseModelArg(BaseModel):
label: str = 'default'
def tool_calling_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
"""A model that calls a tool on first request, then returns text."""
# Check if there's already a tool return in messages (i.e., tool was called)
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response('final response')
# First request: call the tool
if info.function_tools:
tool = info.function_tools[0]
return ModelResponse(parts=[ToolCallPart(tool_name=tool.name, args='{}', tool_call_id='call-1')])
return make_text_response('no tools available') # pragma: no cover
# --- Logging capability for testing ---
@dataclass
class LoggingCapability(AbstractCapability[Any]):
"""A capability that logs all hook invocations for testing."""
log: list[str] = field(default_factory=lambda: [])
async def before_run(self, ctx: RunContext[Any]) -> None:
self.log.append('before_run')
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
self.log.append('after_run')
return result
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
self.log.append('wrap_run:before')
result = await handler()
self.log.append('wrap_run:after')
return result
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
self.log.append('before_model_request')
return request_context
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
self.log.append('after_model_request')
return response
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: Any,
handler: Any,
) -> ModelResponse:
self.log.append('wrap_model_request:before')
response = await handler(request_context)
self.log.append('wrap_model_request:after')
return response
async def before_tool_validate(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
) -> str | dict[str, Any]:
self.log.append(f'before_tool_validate:{call.tool_name}')
return args
async def after_tool_validate(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
) -> dict[str, Any]:
self.log.append(f'after_tool_validate:{call.tool_name}')
return args
async def wrap_tool_validate(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: str | dict[str, Any],
handler: Any,
) -> dict[str, Any]:
self.log.append(f'wrap_tool_validate:{call.tool_name}:before')
result = await handler(args)
self.log.append(f'wrap_tool_validate:{call.tool_name}:after')
return result
async def before_tool_execute(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
) -> dict[str, Any]:
self.log.append(f'before_tool_execute:{call.tool_name}')
return args
async def after_tool_execute(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any], result: Any
) -> Any:
self.log.append(f'after_tool_execute:{call.tool_name}')
return result
async def wrap_tool_execute(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any], handler: Any
) -> Any:
self.log.append(f'wrap_tool_execute:{call.tool_name}:before')
result = await handler(args)
self.log.append(f'wrap_tool_execute:{call.tool_name}:after')
return result
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
self.log.append('on_run_error')
raise error
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
self.log.append(f'before_node_run:{type(node).__name__}')
return node
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
self.log.append(f'after_node_run:{type(node).__name__}')
return result
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
self.log.append(f'on_node_run_error:{type(node).__name__}')
raise error
async def on_model_request_error(
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
self.log.append('on_model_request_error')
raise error
async def on_tool_validate_error(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
) -> dict[str, Any]:
self.log.append(f'on_tool_validate_error:{call.tool_name}')
raise error
async def on_tool_execute_error(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
self.log.append(f'on_tool_execute_error:{call.tool_name}')
raise error
# --- Tests ---
class TestRunHooks:
async def test_before_run(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'before_run' in cap.log
async def test_after_run(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'after_run' in cap.log
async def test_wrap_run(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'wrap_run:before' in cap.log
assert 'wrap_run:after' in cap.log
async def test_run_hook_order(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
# wrap_run wraps the run (which includes before_run inside iter),
# then after_run fires at the end (outside wrap_run)
assert cap.log.index('wrap_run:before') < cap.log.index('before_run')
assert cap.log.index('before_run') < cap.log.index('wrap_run:after')
assert cap.log.index('wrap_run:after') <= cap.log.index('after_run')
async def test_after_run_can_modify_result(self):
@dataclass
class ModifyResultCap(AbstractCapability[Any]):
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
return AgentRunResult(output='modified output')
agent = Agent(FunctionModel(simple_model_function), capabilities=[ModifyResultCap()])
result = await agent.run('hello')
assert result.output == 'modified output'
async def test_wrap_run_can_short_circuit(self):
@dataclass
class ShortCircuitRunCap(AbstractCapability[Any]):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
# Don't call handler - short-circuit the run
return AgentRunResult(output='short-circuited')
agent = Agent(FunctionModel(simple_model_function), capabilities=[ShortCircuitRunCap()])
result = await agent.run('hello')
assert result.output == 'short-circuited'
async def test_wrap_run_can_recover_from_error(self):
"""wrap_run can catch errors from handler() and return a recovery result."""
@dataclass
class ErrorRecoveryCap(AbstractCapability[Any]):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
try:
return await handler()
except RuntimeError:
return AgentRunResult(output='recovered from error')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[ErrorRecoveryCap()])
result = await agent.run('hello')
assert result.output == 'recovered from error'
async def test_wrap_run_error_propagates_without_recovery(self):
"""Without recovery in wrap_run, errors propagate normally."""
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model))
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
async def test_wrap_run_recovery_via_iter(self):
"""wrap_run error recovery works when using agent.iter() too."""
@dataclass
class ErrorRecoveryCap(AbstractCapability[Any]):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
try:
return await handler()
except RuntimeError:
return AgentRunResult(output='recovered via iter')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[ErrorRecoveryCap()])
async with agent.iter('hello') as agent_run:
async for _node in agent_run:
pass
assert agent_run.result is not None
assert agent_run.result.output == 'recovered via iter'
class TestModelRequestHooks:
async def test_before_model_request(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'before_model_request' in cap.log
async def test_after_model_request(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'after_model_request' in cap.log
async def test_wrap_model_request(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'wrap_model_request:before' in cap.log
assert 'wrap_model_request:after' in cap.log
async def test_model_request_hook_order(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert cap.log.index('before_model_request') < cap.log.index('wrap_model_request:before')
assert cap.log.index('wrap_model_request:before') < cap.log.index('wrap_model_request:after')
assert cap.log.index('wrap_model_request:after') < cap.log.index('after_model_request')
async def test_after_model_request_can_modify_response(self):
@dataclass
class ModifyResponseCap(AbstractCapability[Any]):
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='modified by after hook')])
agent = Agent(FunctionModel(simple_model_function), capabilities=[ModifyResponseCap()])
result = await agent.run('hello')
assert result.output == 'modified by after hook'
async def test_wrap_model_request_can_modify_response(self):
@dataclass
class WrapModifyCap(AbstractCapability[Any]):
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
) -> ModelResponse:
response = await handler(request_context)
return ModelResponse(parts=[TextPart(content='wrapped: ' + response.parts[0].content)])
agent = Agent(FunctionModel(simple_model_function), capabilities=[WrapModifyCap()])
result = await agent.run('hello')
assert result.output == 'wrapped: response from model'
async def test_skip_model_request(self):
@dataclass
class SkipCap(AbstractCapability[Any]):
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='skipped model')]))
agent = Agent(FunctionModel(simple_model_function), capabilities=[SkipCap()])
result = await agent.run('hello')
assert result.output == 'skipped model'
async def test_before_model_request_swaps_model(self):
call_log: list[str] = []
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
call_log.append('swap_model')
return make_text_response('from swap model')
swap_target = FunctionModel(swap_model_fn)
@dataclass
class SwapModelCap(AbstractCapability[Any]):
async def before_model_request(
self, ctx: RunContext[Any], request_context: ModelRequestContext
) -> ModelRequestContext:
request_context.model = swap_target
return request_context
agent = Agent(FunctionModel(simple_model_function), capabilities=[SwapModelCap()])
result = await agent.run('hello')
assert result.output == 'from swap model'
assert call_log == ['swap_model']
async def test_wrap_model_request_swaps_model(self):
call_log: list[str] = []
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
call_log.append('swap_model')
return make_text_response('from swap model')
swap_target = FunctionModel(swap_model_fn)
@dataclass
class SwapInWrapCap(AbstractCapability[Any]):
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
) -> ModelResponse:
request_context.model = swap_target
return await handler(request_context)
agent = Agent(FunctionModel(simple_model_function), capabilities=[SwapInWrapCap()])
result = await agent.run('hello')
assert result.output == 'from swap model'
assert call_log == ['swap_model']
async def test_before_model_request_swaps_model_streaming(self):
call_log: list[str] = []
async def swap_stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
call_log.append('swap_stream')
yield 'from swap stream'
swap_target = FunctionModel(stream_function=swap_stream_fn)
@dataclass
class SwapModelCap(AbstractCapability[Any]):
async def before_model_request(
self, ctx: RunContext[Any], request_context: ModelRequestContext
) -> ModelRequestContext:
request_context.model = swap_target
return request_context
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[SwapModelCap()],
)
async with agent.run_stream('hello') as stream:
output = await stream.get_output()
assert output == 'from swap stream'
assert call_log == ['swap_stream']
async def test_run_context_model_unchanged_after_swap(self):
observed_models: list[Any] = []
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('from swap model')
original_model = FunctionModel(simple_model_function)
swap_target = FunctionModel(swap_model_fn)
@dataclass
class SwapAndObserveCap(AbstractCapability[Any]):
async def before_model_request(
self, ctx: RunContext[Any], request_context: ModelRequestContext
) -> ModelRequestContext:
observed_models.append(ctx.model)
request_context.model = swap_target
return request_context
agent = Agent(original_model, capabilities=[SwapAndObserveCap()])
result = await agent.run('hello')
assert result.output == 'from swap model'
assert observed_models[0] is original_model
async def test_hooks_before_model_request_swaps_model(self):
call_log: list[str] = []
hooks = Hooks()
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
call_log.append('swap_model')
return make_text_response('from swap model')
swap_target = FunctionModel(swap_model_fn)
@hooks.on.before_model_request
async def _(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
request_context.model = swap_target
return request_context
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == 'from swap model'
assert call_log == ['swap_model']
async def test_after_model_request_sees_wrap_swap(self):
"""after_model_request sees the model swapped during wrap_model_request."""
after_models: list[Any] = []
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('from swap model')
swap_target = FunctionModel(swap_model_fn)
@dataclass
class SwapInWrapAndObserveCap(AbstractCapability[Any]):
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
) -> ModelResponse:
request_context.model = swap_target
return await handler(request_context)
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
after_models.append(request_context.model)
return response
agent = Agent(FunctionModel(simple_model_function), capabilities=[SwapInWrapAndObserveCap()])
result = await agent.run('hello')
assert result.output == 'from swap model'
assert after_models[0] is swap_target
class TestToolValidateHooks:
async def test_tool_validate_hooks_fire(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
await agent.run('call the tool')
assert 'before_tool_validate:my_tool' in cap.log
assert 'after_tool_validate:my_tool' in cap.log
assert 'wrap_tool_validate:my_tool:before' in cap.log
assert 'wrap_tool_validate:my_tool:after' in cap.log
async def test_before_tool_validate_can_modify_args(self):
@dataclass
class ModifyArgsCap(AbstractCapability[Any]):
async def before_tool_validate(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
) -> str | dict[str, Any]:
# Inject an argument
if isinstance(args, dict):
return {**args, 'name': 'injected'} # pragma: no cover
return {'name': 'injected'}
agent = Agent(FunctionModel(tool_calling_model), capabilities=[ModifyArgsCap()])
received_name = None
@agent.tool_plain
def greet(name: str) -> str:
nonlocal received_name
received_name = name
return f'hello {name}'
await agent.run('greet someone')
assert received_name == 'injected'
async def test_skip_tool_validation(self):
@dataclass
class SkipValidateCap(AbstractCapability[Any]):
async def before_tool_validate(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
) -> str | dict[str, Any]:
raise SkipToolValidation({'name': 'skip-validated'})
agent = Agent(FunctionModel(tool_calling_model), capabilities=[SkipValidateCap()])
received_name = None
@agent.tool_plain
def greet(name: str) -> str:
nonlocal received_name
received_name = name
return f'hello {name}'
await agent.run('greet someone')
assert received_name == 'skip-validated'
async def test_tool_def_matches_called_tool(self):
"""Verify tool_def is the correct ToolDefinition for the tool being called."""
received_tool_defs: list[ToolDefinition] = []
@dataclass
class CaptureCap(AbstractCapability[Any]):
async def before_tool_validate(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
) -> str | dict[str, Any]:
received_tool_defs.append(tool_def)
return args
agent = Agent(FunctionModel(tool_calling_model), capabilities=[CaptureCap()])
@agent.tool_plain(description='Say hello')
def my_tool() -> str:
return 'tool result'
await agent.run('call the tool')
assert len(received_tool_defs) == 1
td = received_tool_defs[0]
assert td.name == 'my_tool'
assert td.description == 'Say hello'
assert td.kind == 'function'
class TestToolExecuteHooks:
async def test_tool_execute_hooks_fire(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
await agent.run('call the tool')
assert 'before_tool_execute:my_tool' in cap.log
assert 'after_tool_execute:my_tool' in cap.log
assert 'wrap_tool_execute:my_tool:before' in cap.log
assert 'wrap_tool_execute:my_tool:after' in cap.log
async def test_after_tool_execute_can_modify_result(self):
@dataclass
class ModifyResultCap(AbstractCapability[Any]):
async def after_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> Any:
return f'modified: {result}'
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(model_fn), capabilities=[ModifyResultCap()])
@agent.tool_plain
def my_tool() -> str:
return 'original'
result = await agent.run('call tool')
assert 'modified: original' in result.output
async def test_skip_tool_execution(self):
@dataclass
class SkipExecCap(AbstractCapability[Any]):
async def before_tool_execute(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
) -> dict[str, Any]:
raise SkipToolExecution('denied')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(model_fn), capabilities=[SkipExecCap()])
tool_was_called = False
@agent.tool_plain
def my_tool() -> str:
nonlocal tool_was_called
tool_was_called = True # pragma: no cover
return 'should not be called' # pragma: no cover
result = await agent.run('call tool')
assert not tool_was_called
assert 'denied' in result.output
async def test_wrap_tool_execute_with_error_handling(self):
@dataclass
class ErrorHandlingCap(AbstractCapability[Any]):
caught_error: str | None = None
async def wrap_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
handler: Any,
) -> Any:
try:
return await handler(args)
except Exception as e:
self.caught_error = str(e)
return 'recovered from error'
cap = ErrorHandlingCap()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
await agent.run('call tool')
assert cap.caught_error == 'tool failed'
async def test_hooks_receive_dict_args_for_single_base_model_tool(self):
"""Validate and execute hooks receive dict-shaped args when the tool has a single BaseModel parameter.
The JSON schema sent to the model unwraps the BaseModel, so the model generates its fields at the
top level. Pydantic's validator returns a BaseModel instance directly, but the framework wraps it
as `{param_name: model}` so hooks and `call_tool` always see a dict.
"""
captured_args: list[tuple[str, dict[str, Any]]] = []
@dataclass
class CapturingCap(AbstractCapability[Any]):
async def after_tool_validate(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
) -> dict[str, Any]:
captured_args.append(('validate', args))
return args
async def wrap_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
handler: Any,
) -> Any:
captured_args.append(('execute', args))
return await handler(args)
agent = Agent(FunctionModel(tool_calling_model), capabilities=[CapturingCap()])
@agent.tool_plain
def my_tool(payload: SingleBaseModelArg) -> str:
return f'got {payload.label}'
await agent.run('call the tool')
assert captured_args == [
('validate', {'payload': SingleBaseModelArg()}),
('execute', {'payload': SingleBaseModelArg()}),
]
async def test_tool_hooks_skip_output_tools(self):
"""Tool hooks don't fire for internal output tools (#5111).
Output tools deliver structured output to the user via `result.output`; they're not
user-facing tool calls. Firing hooks on them lets e.g. `after_tool_execute` return a
`ToolReturn` that leaks through to `result.output` instead of the typed value.
"""
class MyOutput(BaseModel):
answer: str
hooks = Hooks()
@hooks.on.after_tool_execute
async def wrap_result(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> ToolReturn:
return ToolReturn(return_value=result, content='extra context')
cap = LoggingCapability()
agent = Agent(
TestModel(custom_output_args={'answer': 'hi'}),
output_type=MyOutput,
capabilities=[cap, hooks],
)
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
result = await agent.run('call tool and answer')
# Function tool still fires every tool hook.
assert 'before_tool_validate:my_tool' in cap.log
assert 'after_tool_validate:my_tool' in cap.log
assert 'wrap_tool_validate:my_tool:before' in cap.log
assert 'wrap_tool_validate:my_tool:after' in cap.log
assert 'before_tool_execute:my_tool' in cap.log
assert 'after_tool_execute:my_tool' in cap.log
assert 'wrap_tool_execute:my_tool:before' in cap.log
assert 'wrap_tool_execute:my_tool:after' in cap.log
# Output tool does not appear in any hook log entry.
assert all('final_result' not in entry for entry in cap.log)
# Regression for #5111: the ToolReturn from `after_tool_execute` would have corrupted
# `result.output` if output tool hooks still fired.
assert result.output == MyOutput(answer='hi')
class TestCompositionOrder:
async def test_multiple_capabilities_model_request_order(self):
"""Test that multiple capabilities compose in the correct order."""
log: list[str] = []
@dataclass
class Cap1(AbstractCapability[Any]):
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
log.append('cap1:before')
return request_context
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
log.append('cap1:after')
return response
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
) -> ModelResponse:
log.append('cap1:wrap:before')
response = await handler(request_context)
log.append('cap1:wrap:after')
return response
@dataclass
class Cap2(AbstractCapability[Any]):
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
log.append('cap2:before')
return request_context
async def after_model_request(
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, response: ModelResponse
) -> ModelResponse:
log.append('cap2:after')
return response
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
) -> ModelResponse:
log.append('cap2:wrap:before')
response = await handler(request_context)
log.append('cap2:wrap:after')
return response
agent = Agent(FunctionModel(simple_model_function), capabilities=[Cap1(), Cap2()])
await agent.run('hello')
# before hooks: forward order (cap1 then cap2)
assert log.index('cap1:before') < log.index('cap2:before')
# wrap hooks: cap1 outermost, cap2 innermost
assert log.index('cap1:wrap:before') < log.index('cap2:wrap:before')
assert log.index('cap2:wrap:after') < log.index('cap1:wrap:after')
# after hooks: reverse order (cap2 then cap1)
assert log.index('cap2:after') < log.index('cap1:after')
async def test_multiple_capabilities_run_hooks_order(self):
log: list[str] = []
@dataclass
class Cap1(AbstractCapability[Any]):
async def before_run(self, ctx: RunContext[Any]) -> None:
log.append('cap1:before_run')
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
log.append('cap1:after_run')
return result
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
log.append('cap1:wrap_run:before')
result = await handler()
log.append('cap1:wrap_run:after')
return result
@dataclass
class Cap2(AbstractCapability[Any]):
async def before_run(self, ctx: RunContext[Any]) -> None:
log.append('cap2:before_run')
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
log.append('cap2:after_run')
return result
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
log.append('cap2:wrap_run:before')
result = await handler()
log.append('cap2:wrap_run:after')
return result
agent = Agent(FunctionModel(simple_model_function), capabilities=[Cap1(), Cap2()])
await agent.run('hello')
# before_run: forward order
assert log.index('cap1:before_run') < log.index('cap2:before_run')
# wrap_run: cap1 outermost
assert log.index('cap1:wrap_run:before') < log.index('cap2:wrap_run:before')
assert log.index('cap2:wrap_run:after') < log.index('cap1:wrap_run:after')
# after_run: reverse order
assert log.index('cap2:after_run') < log.index('cap1:after_run')
class TestCombinedBeforeWrapAfter:
async def test_all_hook_types_on_same_capability(self):
"""Test before + wrap + after all fire correctly on a single capability."""
cap = LoggingCapability()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'result'
await agent.run('call tool')
# Check run hooks
assert 'before_run' in cap.log
assert 'wrap_run:before' in cap.log
assert 'wrap_run:after' in cap.log
assert 'after_run' in cap.log
# Check model request hooks (should fire twice: once for tool call, once for final)
model_request_before_count = cap.log.count('before_model_request')
assert model_request_before_count == 2
# Check tool hooks
assert 'before_tool_validate:my_tool' in cap.log
assert 'wrap_tool_validate:my_tool:before' in cap.log
assert 'wrap_tool_validate:my_tool:after' in cap.log
assert 'after_tool_validate:my_tool' in cap.log
assert 'before_tool_execute:my_tool' in cap.log
assert 'wrap_tool_execute:my_tool:before' in cap.log
assert 'wrap_tool_execute:my_tool:after' in cap.log
assert 'after_tool_execute:my_tool' in cap.log
class TestRunHooksRunStream:
"""Test that wrap_run and after_run fire for run_stream()."""
async def test_wrap_run_fires_for_run_stream(self):
cap = LoggingCapability()
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[cap],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert 'wrap_run:before' in cap.log
assert 'wrap_run:after' in cap.log
async def test_after_run_fires_for_run_stream(self):
cap = LoggingCapability()
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[cap],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert 'after_run' in cap.log
async def test_wrap_run_fires_for_iter(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
async with agent.iter('hello') as agent_run:
async for _node in agent_run:
pass
assert 'wrap_run:before' in cap.log
assert 'wrap_run:after' in cap.log
assert 'after_run' in cap.log
async def test_after_run_can_modify_result_via_iter(self):
@dataclass
class ModifyResultCap(AbstractCapability[Any]):
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
return AgentRunResult(output='modified by after_run')
agent = Agent(FunctionModel(simple_model_function), capabilities=[ModifyResultCap()])
async with agent.iter('hello') as agent_run:
async for _node in agent_run:
pass
assert agent_run.result is not None
assert agent_run.result.output == 'modified by after_run'
async def test_run_hook_order_via_run_stream(self):
cap = LoggingCapability()
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[cap],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert cap.log.index('wrap_run:before') < cap.log.index('before_run')
assert cap.log.index('before_run') < cap.log.index('wrap_run:after')
assert cap.log.index('wrap_run:after') <= cap.log.index('after_run')
class TestStreamingHooks:
"""Test that SkipModelRequest and wrap_model_request work in streaming paths."""
async def test_skip_model_request_streaming(self):
@dataclass
class SkipCap(AbstractCapability[Any]):
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='skipped in stream')]))
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[SkipCap()],
)
async with agent.run_stream('hello') as stream:
output = await stream.get_output()
assert output == 'skipped in stream'
async def test_skip_model_request_from_wrap_model_request(self):
"""SkipModelRequest raised inside wrap_model_request is handled in non-streaming."""
@dataclass
class WrapSkipCap(AbstractCapability[Any]):
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
handler: Any,
) -> ModelResponse:
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='wrap-skipped')]))
agent = Agent(FunctionModel(simple_model_function), capabilities=[WrapSkipCap()])
result = await agent.run('hello')
assert result.output == 'wrap-skipped'
async def test_skip_model_request_from_wrap_model_request_streaming(self):
"""SkipModelRequest raised inside wrap_model_request during streaming is handled."""
@dataclass
class WrapSkipCap(AbstractCapability[Any]):
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
handler: Any,
) -> ModelResponse:
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='wrap-skipped in stream')]))
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[WrapSkipCap()],
)
async with agent.run_stream('hello') as stream:
output = await stream.get_output()
assert output == 'wrap-skipped in stream'
async def test_wrap_model_request_streaming(self):
cap = LoggingCapability()
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[cap],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert 'wrap_model_request:before' in cap.log
assert 'wrap_model_request:after' in cap.log
async def test_wrap_model_request_modifies_result_via_run_with_streaming(self):
"""wrap_model_request modification affects the final result when using run() with streaming."""
@dataclass
class WrapModifyCap(AbstractCapability[Any]):
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
) -> ModelResponse:
response = await handler(request_context)
return ModelResponse(parts=[TextPart(content='wrapped: ' + response.parts[0].content)])
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[WrapModifyCap()],
)
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in stream:
pass
result = await agent.run('hello', event_stream_handler=handler)
assert result.output == 'wrapped: streamed response'
async def test_after_model_request_fires_streaming(self):
cap = LoggingCapability()
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[cap],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert 'after_model_request' in cap.log
class TestWrapRunEventStream:
"""Tests for the wrap_run_event_stream hook."""
async def test_wrap_run_event_stream_observes(self):
"""Hook sees events from model streaming."""
observed_events: list[AgentStreamEvent] = []
@dataclass
class ObserverCap(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
observed_events.append(event)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ObserverCap()],
)
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in stream:
pass
await agent.run('hello', event_stream_handler=handler)
assert len(observed_events) > 0
async def test_wrap_run_event_stream_transforms(self):
"""Modifications by the hook are visible to event_stream_handler."""
handler_events: list[AgentStreamEvent] = []
@dataclass
class TransformCap(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[TransformCap()],
)
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
handler_events.append(event)
await agent.run('hello', event_stream_handler=handler)
assert len(handler_events) > 0
async def test_wrap_run_event_stream_composition(self):
"""Multiple capabilities compose in correct order (first = outermost)."""
log: list[str] = []
@dataclass
class Cap1(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
log.append('cap1:enter')
async for event in stream:
yield event
log.append('cap1:exit')
@dataclass
class Cap2(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
log.append('cap2:enter')
async for event in stream:
yield event
log.append('cap2:exit')
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[Cap1(), Cap2()],
)
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in stream:
pass
await agent.run('hello', event_stream_handler=handler)
# Cap1 is outermost, so enters first and exits last
assert log.index('cap1:enter') < log.index('cap2:enter')
assert log.index('cap2:exit') < log.index('cap1:exit')
async def test_wrap_run_event_stream_tool_events(self):
"""HandleResponseEvents from CallToolsNode flow through the hook."""
observed_events: list[AgentStreamEvent] = []
@dataclass
class ObserverCap(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
observed_events.append(event)
yield event
agent = Agent(
FunctionModel(tool_calling_model, stream_function=tool_calling_stream_function),
capabilities=[ObserverCap()],
)
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in stream:
pass
await agent.run('call tool', event_stream_handler=handler)
# Should have observed events from both ModelRequestNode and CallToolsNode streams
assert len(observed_events) > 0
async def test_wrap_run_event_stream_fires_in_run_stream_without_handler(self):
"""wrap_run_event_stream fires in run_stream() even without an event_stream_handler."""
observed_events: list[AgentStreamEvent] = []
@dataclass
class ObserverCap(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
observed_events.append(event)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ObserverCap()],
)
# No event_stream_handler — hook should still fire
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert len(observed_events) > 0
async def test_wrap_run_event_stream_fires_in_run_without_handler(self):
"""wrap_run_event_stream fires in run() even without an event_stream_handler."""
observed_events: list[AgentStreamEvent] = []
@dataclass
class ObserverCap(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
observed_events.append(event)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ObserverCap()],
)
# No event_stream_handler — hook should still fire via forced streaming
result = await agent.run('hello')
assert result.output is not None
assert any(isinstance(e, PartStartEvent) for e in observed_events)
class TestProcessEventStream:
"""Tests for the ProcessEventStream capability."""
async def test_handler_receives_events(self):
"""Handler registered via capability receives events from model streaming."""
handler_events: list[AgentStreamEvent] = []
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
handler_events.append(event)
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ProcessEventStream(handler=handler)],
)
# No event_stream_handler arg — capability should drive streaming
result = await agent.run('hello')
assert result.output is not None
assert any(isinstance(e, PartStartEvent) for e in handler_events)
async def test_multiple_handlers_and_param_all_observe(self):
"""Multiple ProcessEventStream capabilities and an explicit event_stream_handler all see the same events."""
cap1_events: list[AgentStreamEvent] = []
cap2_events: list[AgentStreamEvent] = []
param_events: list[AgentStreamEvent] = []
async def cap1_handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
cap1_events.append(event)
async def cap2_handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
cap2_events.append(event)
async def param_handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
param_events.append(event)
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ProcessEventStream(handler=cap1_handler), ProcessEventStream(handler=cap2_handler)],
)
await agent.run('hello', event_stream_handler=param_handler)
assert len(cap1_events) > 0
assert cap1_events == cap2_events == param_events
async def test_handler_sees_events_after_inner_wrappers(self):
"""Events passed to the handler go through inner wrap_run_event_stream wrappers."""
transformed_calls: list[AgentStreamEvent] = []
handler_events: list[AgentStreamEvent] = []
@dataclass
class InnerWrapper(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
transformed_calls.append(event)
yield event
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
handler_events.append(event)
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ProcessEventStream(handler=handler), InnerWrapper()],
)
await agent.run('hello')
assert handler_events == transformed_calls
assert len(handler_events) > 0
async def test_transformer_handler_replaces_stream(self):
"""An async-generator handler transforms the stream seen by downstream wrappers and the param handler."""
downstream_events: list[AgentStreamEvent] = []
async def transformer(
_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]
) -> AsyncIterator[AgentStreamEvent]:
async for event in stream:
if isinstance(event, PartStartEvent):
# Drop PartStart events — downstream should never see them.
continue
yield event
async def param_handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
downstream_events.append(event)
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ProcessEventStream(handler=transformer)],
)
await agent.run('hello', event_stream_handler=param_handler)
assert len(downstream_events) > 0
assert not any(isinstance(e, PartStartEvent) for e in downstream_events)
async def test_callable_instance_processor(self):
"""A callable-class processor (not a plain async-generator function) is detected via its return type."""
captured: list[AgentStreamEvent] = []
class Transformer:
async def __call__(
self, _ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]
) -> AsyncIterator[AgentStreamEvent]:
async for event in stream:
captured.append(event)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ProcessEventStream(handler=Transformer())],
)
await agent.run('hello')
assert any(isinstance(e, PartStartEvent) for e in captured)
async def test_observer_bailout_does_not_break_downstream(self):
"""If an observer stops iterating early, downstream consumers still see all events."""
received_by_observer: list[AgentStreamEvent] = []
received_downstream: list[AgentStreamEvent] = []
async def bail_after_first(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
received_by_observer.append(event)
return
async def downstream(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
received_downstream.append(event)
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ProcessEventStream(handler=bail_after_first)],
)
await agent.run('hello', event_stream_handler=downstream)
assert len(received_by_observer) == 1
assert len(received_downstream) > 1
async def test_not_spec_serializable(self):
"""ProcessEventStream holds a callable so it cannot participate in spec-based construction."""
assert ProcessEventStream.get_serialization_name() is None
class TestWrapRunShortCircuit:
"""Test short-circuiting wrap_run via iter() and run_stream()."""
async def test_wrap_run_short_circuit_via_iter(self):
@dataclass
class ShortCircuitRunCap(AbstractCapability[Any]):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
return AgentRunResult(output='short-circuited')
agent = Agent(FunctionModel(simple_model_function), capabilities=[ShortCircuitRunCap()])
async with agent.iter('hello') as agent_run:
nodes: list[Any] = []
async for node in agent_run:
nodes.append(node) # pragma: no cover
# Iteration should stop immediately (no graph nodes executed)
assert nodes == []
assert agent_run.result is not None
assert agent_run.result.output == 'short-circuited'
async def test_wrap_run_short_circuit_via_run_stream(self):
@dataclass
class ShortCircuitRunCap(AbstractCapability[Any]):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
return AgentRunResult(output='short-circuited')
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ShortCircuitRunCap()],
)
async with agent.run_stream('hello') as stream:
output = await stream.get_output()
assert output == 'short-circuited'
class TestSkipModelRequestInteraction:
"""Test SkipModelRequest interaction with after_model_request."""
async def test_skip_model_request_still_calls_after_model_request(self):
log: list[str] = []
@dataclass
class SkipAndLogCap(AbstractCapability[Any]):
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
log.append('before_model_request')
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='skipped')]))
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
log.append('after_model_request')
return response
agent = Agent(FunctionModel(simple_model_function), capabilities=[SkipAndLogCap()])
result = await agent.run('hello')
assert result.output == 'skipped'
# after_model_request should still fire via _finish_handling
assert 'after_model_request' in log
async def test_wrap_model_request_short_circuit_streaming(self):
"""wrap_model_request can return without calling handler in streaming path."""
@dataclass
class ShortCircuitModelCap(AbstractCapability[Any]):
async def wrap_model_request(
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
) -> ModelResponse:
# Don't call handler — return a response directly
return ModelResponse(parts=[TextPart(content='model short-circuited')])
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[ShortCircuitModelCap()],
)
async with agent.run_stream('hello') as stream:
output = await stream.get_output()
assert output == 'model short-circuited'
class TestPrepareToolsHook:
async def test_filter_function_tools(self):
"""Capability can filter out function tools by name."""
@dataclass
class HideToolCap(AbstractCapability[Any]):
async def prepare_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
return [td for td in tool_defs if td.name != 'hidden_tool']
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = [t.name for t in info.function_tools]
return make_text_response(f'tools: {sorted(tool_names)}')
agent = Agent(FunctionModel(model_fn), capabilities=[HideToolCap()])
@agent.tool_plain
def hidden_tool() -> str:
return 'hidden' # pragma: no cover
@agent.tool_plain
def visible_tool() -> str:
return 'visible' # pragma: no cover
result = await agent.run('hello')
assert result.output == "tools: ['visible_tool']"
async def test_receives_function_tools_only(self):
"""`prepare_tools` receives **function** tools only. Output tools route to
`prepare_output_tools` (with `ctx.max_retries` reflecting the output retry budget)."""
@dataclass
class CountKindsCap(AbstractCapability[Any]):
seen_kinds: list[str] = field(default_factory=list[str])
async def prepare_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
self.seen_kinds = sorted({td.kind for td in tool_defs})
return tool_defs
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
)
cap = CountKindsCap()
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
await agent.run('hello')
assert cap.seen_kinds == ['function']
async def test_modify_tool_description(self):
"""Capability can modify tool descriptions."""
from dataclasses import replace as dc_replace
@dataclass
class PrefixDescriptionCap(AbstractCapability[Any]):
async def prepare_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
return [dc_replace(td, description=f'[PREFIXED] {td.description}') for td in tool_defs]
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
descs = [t.description for t in info.function_tools]
return make_text_response(f'descriptions: {descs}')
agent = Agent(FunctionModel(model_fn), capabilities=[PrefixDescriptionCap()])
@agent.tool_plain
def my_tool() -> str:
"""Original description."""
return 'result' # pragma: no cover
result = await agent.run('hello')
assert '[PREFIXED] Original description.' in result.output
async def test_chaining_order(self):
"""Multiple capabilities chain prepare_tools in forward order."""
@dataclass
class AddSuffixCap(AbstractCapability[Any]):
suffix: str
async def prepare_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
from dataclasses import replace as dc_replace
return [dc_replace(td, description=f'{td.description}{self.suffix}') for td in tool_defs]
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
descs = [t.description for t in info.function_tools]
return make_text_response(f'{descs}')
agent = Agent(
FunctionModel(model_fn),
capabilities=[AddSuffixCap(suffix='_A'), AddSuffixCap(suffix='_B')],
)
@agent.tool_plain
def tool() -> str:
"""desc"""
return 'r' # pragma: no cover
result = await agent.run('hello')
# A runs first, then B, so suffix order is _A_B
assert 'desc_A_B' in result.output
class TestPrepareOutputToolsHook:
async def test_only_receives_output_tools(self):
"""`prepare_output_tools` receives only output tools — function tools route to
`prepare_tools`."""
@dataclass
class CountKindsCap(AbstractCapability[Any]):
seen_kinds: list[str] = field(default_factory=list[str])
async def prepare_output_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
self.seen_kinds = [td.kind for td in tool_defs]
return tool_defs
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
)
cap = CountKindsCap()
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
await agent.run('hello')
assert cap.seen_kinds == ['output'], f'expected only output tools, got {cap.seen_kinds}'
async def test_filter_output_tools(self):
"""Capability can hide output tools from the model."""
class Out(BaseModel):
value: str
@dataclass
class HideCap(AbstractCapability[Any]):
async def prepare_output_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
return [] # hide all output tools
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response(f'output_tools: {len(info.output_tools)}')
agent = Agent(
FunctionModel(model_fn),
output_type=[str, ToolOutput(Out, name='out')],
capabilities=[HideCap()],
)
result = await agent.run('hello')
assert result.output == 'output_tools: 0'
async def test_run_context_carries_output_max_retries(self):
"""`prepare_output_tools` ctx.max_retries reflects the agent-level output retry budget,
matching the contract of output hooks (and unlike `prepare_tools` which doesn't have
a tool-specific retry budget at preparation time)."""
seen: list[tuple[int, int]] = []
@dataclass
class CaptureCtxCap(AbstractCapability[Any]):
async def prepare_output_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
seen.append((ctx.retry, ctx.max_retries))
return tool_defs
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 7}', tool_call_id='c1')]
)
agent = Agent(
FunctionModel(model_fn),
output_type=MyOutput,
retries={'tools': 4, 'output': 4},
capabilities=[CaptureCtxCap()],
)
await agent.run('hello')
assert seen == [(0, 4)]
async def test_chaining_order(self):
"""Multiple capabilities chain `prepare_output_tools` in forward order."""
from dataclasses import replace as dc_replace
@dataclass
class AddSuffixCap(AbstractCapability[Any]):
suffix: str
async def prepare_output_tools(
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
) -> list[ToolDefinition]:
return [dc_replace(td, description=f'{td.description or ""}{self.suffix}') for td in tool_defs]
descs: list[str | None] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
descs.extend(t.description for t in info.output_tools)
return ModelResponse(
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
)
agent = Agent(
FunctionModel(model_fn),
output_type=MyOutput,
capabilities=[AddSuffixCap(suffix='_A'), AddSuffixCap(suffix='_B')],
)
await agent.run('hello')
assert descs and descs[0] is not None and descs[0].endswith('_A_B')
class TestWrapNodeRunHook:
async def test_observe_nodes(self):
"""wrap_node_run can observe all nodes in the agent run."""
@dataclass
class NodeObserverCap(AbstractCapability[Any]):
nodes: list[str] = field(default_factory=lambda: [])
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
self.nodes.append(type(node).__name__)
return await handler(node)
cap = NodeObserverCap()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
async def test_observe_nodes_with_tools(self):
"""wrap_node_run fires for each node including tool call round-trips."""
@dataclass
class NodeObserverCap(AbstractCapability[Any]):
nodes: list[str] = field(default_factory=lambda: [])
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
self.nodes.append(type(node).__name__)
return await handler(node)
cap = NodeObserverCap()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
await agent.run('hello')
# UserPrompt -> ModelRequest (calls tool) -> CallTools (executes tool) ->
# ModelRequest (gets final response) -> CallTools (produces End)
assert cap.nodes == [
'UserPromptNode',
'ModelRequestNode',
'CallToolsNode',
'ModelRequestNode',
'CallToolsNode',
]
async def test_works_with_iter_next(self):
"""wrap_node_run fires when driving iter() with next()."""
from pydantic_graph import End
@dataclass
class NodeObserverCap(AbstractCapability[Any]):
nodes: list[str] = field(default_factory=lambda: [])
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
self.nodes.append(type(node).__name__)
return await handler(node)
cap = NodeObserverCap()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
while not isinstance(node, End):
node = await agent_run.next(node)
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
async def test_bare_async_for_warns_with_wrap_node_run(self):
"""Using bare async for on iter() warns when a capability has wrap_node_run."""
@dataclass
class NodeObserverCap(AbstractCapability[Any]):
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
return await handler(node) # pragma: no cover — bare async for doesn't call this
agent = Agent(FunctionModel(simple_model_function), capabilities=[NodeObserverCap()])
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
async with agent.iter('hello') as agent_run:
async for _node in agent_run:
pass
assert len(w) == 1
assert 'wrap_node_run' in str(w[0].message)
async def test_works_with_manual_next(self):
"""wrap_node_run fires when using manual next() driving."""
from pydantic_graph import End
@dataclass
class NodeObserverCap(AbstractCapability[Any]):
nodes: list[str] = field(default_factory=lambda: [])
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
self.nodes.append(type(node).__name__)
return await handler(node)
cap = NodeObserverCap()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
while not isinstance(node, End):
node = await agent_run.next(node)
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
async def test_chaining_nests_correctly(self):
"""Multiple capabilities compose wrap_node_run as nested middleware."""
log: list[str] = []
@dataclass
class OrderedCap(AbstractCapability[Any]):
name: str
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
log.append(f'{self.name}:before:{type(node).__name__}')
result = await handler(node)
log.append(f'{self.name}:after:{type(result).__name__}')
return result
agent = Agent(
FunctionModel(simple_model_function),
capabilities=[OrderedCap(name='outer'), OrderedCap(name='inner')],
)
await agent.run('hello')
# For UserPromptNode: outer wraps inner
assert log[0] == 'outer:before:UserPromptNode'
assert log[1] == 'inner:before:UserPromptNode'
assert log[2] == 'inner:after:ModelRequestNode'
assert log[3] == 'outer:after:ModelRequestNode'
# --- NativeOrLocalTool tests ---
class TestWebSearchCapability:
def test_websearch_default_no_local(self):
"""WebSearch() defaults to builtin-only — no local fallback unless explicitly requested."""
cap = WebSearch()
builtins = cap.get_native_tools()
assert len(builtins) == 1
assert isinstance(builtins[0], WebSearchTool)
# No local fallback by default in v2
assert cap.get_toolset() is None
def test_websearch_default_with_nonsupporting_model_raises(self, allow_model_requests: None):
"""WebSearch() with a model that doesn't support builtin → UserError (no auto-fallback)."""
model = FunctionModel(lambda m, i: None, profile=ModelProfile(supported_native_tools=frozenset())) # type: ignore
agent = Agent(model, capabilities=[WebSearch()])
with pytest.raises(UserError, match='not supported'):
agent.run_sync('search')
def test_websearch_local_string_strategy(self, allow_model_requests: None):
"""WebSearch(local='duckduckgo') with non-supporting model → DuckDuckGo fallback used."""
from unittest.mock import patch
pytest.importorskip('duckduckgo_search', reason='duckduckgo extra not installed')
from pydantic_ai.common_tools.duckduckgo import DDGS
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return ModelResponse(parts=[TextPart(content=f'Tool result: {part.content}')])
if info.function_tools:
return ModelResponse(
parts=[
ToolCallPart(tool_name=info.function_tools[0].name, args='{"query": "test"}', tool_call_id='c1')
]
)
return ModelResponse(parts=[TextPart(content='no tools')]) # pragma: no cover
model = FunctionModel(model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(model, capabilities=[WebSearch(local='duckduckgo')])
# `ddgs` calls Bing/DuckDuckGo via the Rust `primp` HTTP client, so VCR can't intercept it.
# Mock the result at the library boundary to keep the test hermetic.
fake_results = [{'title': 'Example', 'href': 'https://example.com', 'body': 'Example body'}]
with patch.object(DDGS, 'text', return_value=fake_results):
result = agent.run_sync('search for something')
assert 'Tool result' in result.output
def test_websearch_unknown_strategy_raises(self):
"""WebSearch(local='unknown_name') → UserError."""
with pytest.raises(UserError, match='not a known strategy'):
WebSearch(local='not_a_real_strategy') # type: ignore[arg-type]
def test_websearch_local_false_with_nonsupporting_model(self, allow_model_requests: None):
"""WebSearch(local=False) with non-supporting model → UserError."""
model = FunctionModel(lambda m, i: None, profile=ModelProfile(supported_native_tools=frozenset())) # type: ignore
agent = Agent(model, capabilities=[WebSearch(local=False)])
with pytest.raises(UserError, match='not supported'):
agent.run_sync('search')
def test_websearch_native_false_without_local_raises(self):
"""WebSearch(native=False) without an explicit local → UserError at construction."""
with pytest.raises(UserError, match='requires an explicit local tool'):
WebSearch(native=False)
def test_websearch_native_false_with_local_string(self):
"""WebSearch(native=False, local='duckduckgo') → only local, no native registered."""
cap = WebSearch(native=False, local='duckduckgo')
assert cap.get_native_tools() == []
toolset = cap.get_toolset()
# Plain toolset (no PreparedToolset wrapping since native is disabled)
assert toolset is not None
def test_websearch_requires_native_with_constraints(self, allow_model_requests: None):
"""WebSearch(allowed_domains=...) with non-supporting model → UserError."""
model = FunctionModel(lambda m, i: None, profile=ModelProfile(supported_native_tools=frozenset())) # type: ignore
agent = Agent(model, capabilities=[WebSearch(allowed_domains=['example.com'], local='duckduckgo')])
with pytest.raises(UserError, match='not supported'):
agent.run_sync('search')
def test_websearch_both_false_raises(self):
"""WebSearch(native=False, local=False) → UserError at construction."""
with pytest.raises(UserError, match='both `native` and `local` cannot be False'):
WebSearch(native=False, local=False)
def test_websearch_native_false_with_constraints_raises(self):
"""WebSearch(native=False, local='duckduckgo', allowed_domains=...) → UserError at construction."""
with pytest.raises(UserError, match='constraint fields require the native tool'):
WebSearch(native=False, local='duckduckgo', allowed_domains=['example.com'])
def test_websearch_local_callable(self):
"""WebSearch(local=some_function) → bare callable wrapped in Tool."""
from pydantic_ai.tools import Tool
def my_search(query: str) -> str:
return f'results for {query}' # pragma: no cover
cap = WebSearch(local=my_search)
assert isinstance(cap.local, Tool)
class TestXSearchCapability:
def test_xsearch_default(self):
"""XSearch() with defaults → native XSearchTool, no local."""
cap = XSearch()
assert cap.get_native_tools() == snapshot([XSearchTool()])
assert cap.fallback_model is None
assert cap.get_toolset() is None
def test_xsearch_with_fallback_model(self):
"""XSearch(fallback_model=...) → native XSearchTool, local subagent fallback."""
cap = XSearch(fallback_model='xai:grok-4-1-fast-non-reasoning')
assert cap.get_native_tools() == snapshot([XSearchTool()])
assert cap.get_toolset() is not None
def test_xsearch_with_all_constraints(self):
"""XSearch with all constraint fields → XSearchTool configured."""
cap = XSearch(
allowed_x_handles=['handle1'],
from_date=datetime(2024, 1, 1),
to_date=datetime(2024, 12, 31),
enable_image_understanding=True,
enable_video_understanding=True,
include_output=True,
)
assert cap.get_native_tools() == snapshot(
[
XSearchTool(
allowed_x_handles=['handle1'],
from_date=datetime(2024, 1, 1),
to_date=datetime(2024, 12, 31),
enable_image_understanding=True,
enable_video_understanding=True,
include_output=True,
)
]
)
def test_xsearch_requires_native_with_handles(self):
"""XSearch with handle constraints requires builtin."""
assert XSearch(allowed_x_handles=['h']).get_native_tools() == snapshot([XSearchTool(allowed_x_handles=['h'])])
assert XSearch(excluded_x_handles=['h']).get_native_tools() == snapshot([XSearchTool(excluded_x_handles=['h'])])
def test_xsearch_native_false_local_false_raises(self):
"""XSearch(native=False, local=False) → UserError."""
with pytest.raises(UserError, match='both `native` and `local` cannot be False'):
XSearch(native=False, local=False)
def test_xsearch_native_false_with_constraints_raises(self):
"""XSearch(native=False, allowed_x_handles=...) without fallback_model → UserError."""
with pytest.raises(UserError, match='constraint fields require the native tool'):
XSearch(native=False, allowed_x_handles=['handle1'])
def test_xsearch_resolved_native_merges_overrides(self):
"""Capability-level kwargs override fields on a passed native instance."""
base = XSearchTool(allowed_x_handles=['a'], enable_image_understanding=True)
cap = XSearch(native=base, from_date=datetime(2024, 1, 1), enable_image_understanding=False)
resolved = cap._resolved_native() # pyright: ignore[reportPrivateUsage]
assert resolved == snapshot(
XSearchTool(
allowed_x_handles=['a'],
from_date=datetime(2024, 1, 1),
enable_image_understanding=False,
)
)
def test_xsearch_fallback_model_and_local_conflict(self):
"""XSearch(fallback_model=..., local=func) raises UserError."""
def my_search(query: str) -> str:
return 'result' # pragma: no cover
with pytest.raises(UserError, match='cannot specify both `fallback_model` and `local`'):
XSearch(fallback_model='xai:grok-4-1-fast-non-reasoning', local=my_search)
def test_xsearch_fallback_model_with_local_false(self):
"""XSearch(fallback_model=..., local=False) raises UserError."""
with pytest.raises(UserError, match='cannot specify both `fallback_model` and `local`'):
XSearch(fallback_model='xai:grok-4-1-fast-non-reasoning', local=False)
def test_xsearch_callable_native_with_fallback(self):
"""Callable native with fallback_model still creates a local fallback tool."""
from pydantic_ai.tools import Tool
cap = XSearch(
native=lambda ctx: XSearchTool(enable_image_understanding=True),
fallback_model='xai:grok-4-1-fast-non-reasoning',
)
assert isinstance(cap.local, Tool)
assert cap.get_toolset() is not None
async def test_xsearch_callable_fallback_model(self, allow_model_requests: None):
"""XSearch with callable fallback_model resolves the model per-run."""
def inner_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='summary of recent tweets')])
inner_model = FunctionModel(
inner_model_fn, profile=ModelProfile(supported_native_tools=frozenset({XSearchTool}))
)
async def model_factory(ctx: RunContext) -> FunctionModel:
return inner_model
def outer_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts):
return ModelResponse(parts=[TextPart(content='done')])
return ModelResponse(parts=[ToolCallPart(tool_name='x_search', args='{"query": "latest news"}')])
outer_model = FunctionModel(outer_model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[XSearch(fallback_model=model_factory)])
result = await agent.run('What is happening on X?')
assert result.output == 'done'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='What is happening on X?', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='x_search',
args='{"query": "latest news"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=55, output_tokens=6),
model_name='function:outer_model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='x_search',
content='summary of recent tweets',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=59, output_tokens=7),
model_name='function:outer_model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_xsearch_sync_callable_fallback_model(self, allow_model_requests: None):
"""XSearch with sync callable fallback_model resolves the model per-run."""
def inner_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='summary')])
inner_model = FunctionModel(
inner_model_fn, profile=ModelProfile(supported_native_tools=frozenset({XSearchTool}))
)
def model_factory(ctx: RunContext) -> FunctionModel:
return inner_model
def outer_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts):
return ModelResponse(parts=[TextPart(content='done')])
return ModelResponse(parts=[ToolCallPart(tool_name='x_search', args='{"query": "news"}')])
outer_model = FunctionModel(outer_model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[XSearch(fallback_model=model_factory)])
result = await agent.run('search X')
assert result.output == 'done'
tool_returns = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, ToolReturnPart)
]
assert len(tool_returns) == 1
assert tool_returns[0].content == 'summary'
async def test_xsearch_subagent_error_becomes_model_retry(self, allow_model_requests: None):
"""UnexpectedModelBehavior from the subagent becomes a retry prompt to the outer model."""
# Inner model returns an empty response → triggers UnexpectedModelBehavior in the subagent.
def empty_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[])
inner_model = FunctionModel(
empty_model_fn, profile=ModelProfile(supported_native_tools=frozenset({XSearchTool}))
)
call_count = 0
def outer_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='x_search', args='{"query": "test"}')])
return ModelResponse(parts=[TextPart(content='gave up')])
outer_model = FunctionModel(outer_model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[XSearch(fallback_model=inner_model)])
result = await agent.run('search X')
assert result.output == 'gave up'
retry_parts = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, RetryPromptPart)
]
assert len(retry_parts) == 1
assert retry_parts[0].tool_name == 'x_search'
def test_x_search_tool_unknown_kwarg_raises(self):
"""`x_search_tool(unknown=...)` raises TypeError naming the offending kwarg."""
from pydantic_ai.common_tools.x_search import x_search_tool
with pytest.raises(TypeError, match=r"unexpected keyword argument '?bogus'?"):
x_search_tool('xai:grok-4-1-fast-non-reasoning', native_tool=XSearchTool(), bogus=1) # type: ignore[call-arg]
def test_x_search_tool_missing_native_tool_raises(self):
"""`x_search_tool()` without `native_tool=` raises TypeError."""
from pydantic_ai.common_tools.x_search import x_search_tool
with pytest.raises(TypeError, match=r"missing 1 required positional argument: 'native_tool'"):
x_search_tool('xai:grok-4-1-fast-non-reasoning') # type: ignore[call-arg]
def test_xsearch_subagent_tool_unknown_attr_raises(self):
"""Unknown attribute access on `XSearchSubagentTool` raises AttributeError as usual."""
from pydantic_ai.common_tools.x_search import XSearchSubagentTool
subagent = XSearchSubagentTool(model='xai:grok-4-1-fast-non-reasoning', native_tool=XSearchTool())
with pytest.raises(AttributeError, match='no_such_field'):
subagent.no_such_field # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
class TestWebFetchCapability:
def test_webfetch_default_no_local(self):
"""WebFetch() defaults to builtin-only — no local fallback unless explicitly requested."""
cap = WebFetch()
builtins = cap.get_native_tools()
assert len(builtins) == 1
assert isinstance(builtins[0], WebFetchTool)
# No local fallback by default in v2
assert cap.local is None
assert cap.get_toolset() is None
def test_webfetch_default_with_nonsupporting_model_raises(self, allow_model_requests: None):
"""WebFetch() with a model that doesn't support builtin → UserError (no auto-fallback)."""
model = FunctionModel(lambda m, i: None, profile=ModelProfile(supported_native_tools=frozenset())) # type: ignore
agent = Agent(model, capabilities=[WebFetch()])
with pytest.raises(UserError, match='not supported'):
agent.run_sync('fetch')
def test_webfetch_local_true_fallback(self, allow_model_requests: None):
"""WebFetch(local=True) with non-supporting model → markdownify fallback used."""
from unittest.mock import AsyncMock, patch
import httpx
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return ModelResponse(parts=[TextPart(content=f'Tool result: {part.content}')])
if info.function_tools:
return ModelResponse(
parts=[
ToolCallPart(
tool_name=info.function_tools[0].name,
args='{"url": "https://example.com"}',
tool_call_id='c1',
)
]
)
return ModelResponse(parts=[TextPart(content='no tools')]) # pragma: no cover
mock_response = httpx.Response(
200,
text='<html><head><title>Test</title></head><body><p>Hello</p></body></html>',
headers={'content-type': 'text/html'},
request=httpx.Request('GET', 'https://example.com'),
)
model = FunctionModel(model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(model, capabilities=[WebFetch(local=True)])
with patch(
'pydantic_ai.common_tools.web_fetch.safe_download', new_callable=AsyncMock, return_value=mock_response
):
result = agent.run_sync('fetch something')
tool_calls = [
part
for msg in result.all_messages()
if isinstance(msg, ModelResponse)
for part in msg.parts
if isinstance(part, ToolCallPart)
]
assert len(tool_calls) == 1
assert tool_calls[0].tool_name == 'web_fetch'
def test_webfetch_unknown_strategy_raises(self):
"""WebFetch(local='unknown_name') → UserError."""
with pytest.raises(UserError, match='not a known strategy'):
WebFetch(local='not_a_real_strategy') # type: ignore[arg-type]
def test_webfetch_local_false_with_nonsupporting_model(self, allow_model_requests: None):
"""WebFetch(local=False) with non-supporting model → UserError."""
model = FunctionModel(lambda m, i: None, profile=ModelProfile(supported_native_tools=frozenset())) # type: ignore
agent = Agent(model, capabilities=[WebFetch(local=False)])
with pytest.raises(UserError, match='not supported'):
agent.run_sync('fetch')
def test_webfetch_native_false_without_local_raises(self):
"""WebFetch(native=False) without explicit local → UserError at construction."""
with pytest.raises(UserError, match='requires an explicit local tool'):
WebFetch(native=False)
def test_webfetch_native_false_with_local_string(self):
"""WebFetch(native=False, local=True) → only local, no native registered."""
cap = WebFetch(native=False, local=True)
assert cap.get_native_tools() == []
toolset = cap.get_toolset()
assert toolset is not None
def test_webfetch_max_uses_requires_native(self, allow_model_requests: None):
"""WebFetch(max_uses=...) with non-supporting model → UserError."""
model = FunctionModel(lambda m, i: None, profile=ModelProfile(supported_native_tools=frozenset())) # type: ignore
agent = Agent(model, capabilities=[WebFetch(max_uses=5, local=True)])
with pytest.raises(UserError, match='not supported'):
agent.run_sync('fetch')
def test_webfetch_domains_forwarded_to_local(self, allow_model_requests: None):
"""WebFetch(allowed_domains=..., local=True) with non-supporting model → falls back to local with domain filtering."""
from unittest.mock import AsyncMock, patch
import httpx
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return ModelResponse(parts=[TextPart(content=f'Tool result: {part.content}')])
if info.function_tools:
return ModelResponse(
parts=[
ToolCallPart(
tool_name=info.function_tools[0].name,
args='{"url": "https://example.com"}',
tool_call_id='c1',
)
]
)
return ModelResponse(parts=[TextPart(content='no tools')]) # pragma: no cover
mock_response = httpx.Response(
200,
text='<html><body><p>Hello</p></body></html>',
headers={'content-type': 'text/html'},
request=httpx.Request('GET', 'https://example.com'),
)
model = FunctionModel(model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(model, capabilities=[WebFetch(allowed_domains=['example.com'], local=True)])
with patch(
'pydantic_ai.common_tools.web_fetch.safe_download', new_callable=AsyncMock, return_value=mock_response
):
result = agent.run_sync('fetch example.com')
tool_calls = [
part
for msg in result.all_messages()
if isinstance(msg, ModelResponse)
for part in msg.parts
if isinstance(part, ToolCallPart)
]
assert len(tool_calls) == 1
assert tool_calls[0].tool_name == 'web_fetch'
def test_webfetch_both_false_raises(self):
"""WebFetch(native=False, local=False) → UserError at construction."""
with pytest.raises(UserError, match='both `native` and `local` cannot be False'):
WebFetch(native=False, local=False)
def test_webfetch_native_false_with_max_uses_raises(self):
"""WebFetch(native=False, local=True, max_uses=...) → UserError at construction."""
with pytest.raises(UserError, match='constraint fields require the native tool'):
WebFetch(native=False, local=True, max_uses=5)
def test_webfetch_local_callable(self):
"""WebFetch(local=some_function) → bare callable wrapped in Tool."""
from pydantic_ai.tools import Tool
def my_fetch(url: str) -> str:
return f'fetched {url}' # pragma: no cover
cap = WebFetch(local=my_fetch)
assert isinstance(cap.local, Tool)
class TestImageGenerationCapability:
def test_image_gen_init_params_match_builtin_tool(self):
"""ImageGeneration.__init__ accepts all ImageGenerationTool configurable fields."""
import dataclasses
import inspect
# partial_images is excluded — not useful for subagent fallback (no streaming).
# optional is excluded — applies to wire-side dropping, not local-fallback config.
builtin_fields = {
f.name
for f in dataclasses.fields(ImageGenerationTool)
if f.name not in ('kind', 'optional', 'partial_images')
}
builtin_fields.remove('model')
builtin_fields.add('image_model')
# Subtract framework-inherited kw-only params from `AbstractCapability`
# (forwarded so `dataclasses.replace` round-trips through the custom `__init__`).
init_params = set(inspect.signature(ImageGeneration.__init__).parameters.keys()) - {
'self',
'native',
'local',
'fallback_model',
'id',
'defer_loading',
'description',
}
assert init_params == builtin_fields
def test_image_generation_default(self):
"""ImageGeneration() provides only builtin, no local fallback."""
cap = ImageGeneration()
builtins = cap.get_native_tools()
assert len(builtins) == 1
assert isinstance(builtins[0], ImageGenerationTool)
# No default local
assert cap.local is None
assert cap.get_toolset() is None
def test_image_generation_with_custom_local(self):
"""ImageGeneration(local=custom) → provides custom local fallback."""
from pydantic_ai.tools import Tool
def my_gen(prompt: str) -> str:
return 'image_url' # pragma: no cover
cap = ImageGeneration(local=my_gen)
assert isinstance(cap.local, Tool)
assert cap.get_toolset() is not None
def test_image_generation_with_fallback_model(self):
"""ImageGeneration(fallback_model=...) creates a local fallback tool."""
from pydantic_ai.tools import Tool
cap = ImageGeneration(fallback_model='openai-responses:gpt-5.4')
assert isinstance(cap.local, Tool)
assert cap.get_toolset() is not None
builtins = cap.get_native_tools()
assert len(builtins) == 1
assert isinstance(builtins[0], ImageGenerationTool)
def test_image_generation_forwards_config_to_builtin(self):
"""ImageGeneration config fields are forwarded to the ImageGenerationTool builtin."""
cap = ImageGeneration(
action='generate',
background='opaque',
input_fidelity='high',
moderation='low',
image_model='gpt-image-2',
output_compression=80,
output_format='jpeg',
quality='high',
size='1024x1024',
aspect_ratio='16:9',
)
builtins = cap.get_native_tools()
assert len(builtins) == 1
tool = builtins[0]
assert isinstance(tool, ImageGenerationTool)
assert tool.action == 'generate'
assert tool.background == 'opaque'
assert tool.input_fidelity == 'high'
assert tool.moderation == 'low'
assert tool.model == 'gpt-image-2'
assert tool.output_compression == 80
assert tool.output_format == 'jpeg'
assert tool.quality == 'high'
assert tool.size == '1024x1024'
assert tool.aspect_ratio == '16:9'
def test_image_generation_fallback_merges_custom_native_with_overrides(self):
"""Custom native tool settings are merged with capability-level overrides for the fallback."""
from pydantic_ai.tools import Tool
custom_native = ImageGenerationTool(quality='high', size='1024x1024')
cap = ImageGeneration(
native=custom_native,
fallback_model='openai-responses:gpt-5.4',
output_format='jpeg', # capability-level override
)
# The local fallback should exist and contain the merged config
assert isinstance(cap.local, Tool)
assert cap.get_toolset() is not None
def test_image_generation_callable_native_with_fallback(self):
"""When native is a callable, the fallback local tool still gets created."""
from pydantic_ai.tools import Tool
cap = ImageGeneration(
native=lambda ctx: ImageGenerationTool(quality='high'),
fallback_model='openai-responses:gpt-5.4',
)
# Callable native can't be resolved at init time, but local fallback is still created
assert isinstance(cap.local, Tool)
assert cap.get_toolset() is not None
def test_image_generation_fallback_model_and_local_conflict(self):
"""ImageGeneration(fallback_model=..., local=func) raises UserError."""
def my_gen(prompt: str) -> str:
return 'image_url' # pragma: no cover
with pytest.raises(UserError, match='cannot specify both `fallback_model` and `local`'):
ImageGeneration(fallback_model='openai-responses:gpt-5.4', local=my_gen)
def test_image_generation_fallback_model_with_local_false(self):
"""ImageGeneration(fallback_model=..., local=False) raises UserError."""
with pytest.raises(UserError, match='cannot specify both `fallback_model` and `local`'):
ImageGeneration(fallback_model='openai-responses:gpt-5.4', local=False)
async def test_image_generation_callable_fallback_model(self, allow_model_requests: None):
"""ImageGeneration with async callable fallback_model resolves the model per-run."""
from pydantic_ai.messages import BinaryImage, FilePart
image_data = b'\x89PNG\r\n\x1a\n' # minimal PNG header
def inner_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[FilePart(content=BinaryImage(data=image_data, media_type='image/png'))])
inner_model = FunctionModel(inner_model_fn, profile=ModelProfile(supports_image_output=True))
async def model_factory(ctx: RunContext) -> FunctionModel:
return inner_model
def outer_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts):
return ModelResponse(parts=[TextPart(content='done')])
return ModelResponse(parts=[ToolCallPart(tool_name='generate_image', args='{"prompt": "test"}')])
outer_model = FunctionModel(outer_model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[ImageGeneration(fallback_model=model_factory)])
result = await agent.run('Generate a test image')
assert result.output == 'done'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Generate a test image', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='generate_image',
args='{"prompt": "test"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=54, output_tokens=5),
model_name='function:outer_model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='generate_image',
content=BinaryImage(data=b'\x89PNG\r\n\x1a\n', media_type='image/png'),
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=54, output_tokens=6),
model_name='function:outer_model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_image_generation_callable_returns_image_only_model(self, allow_model_requests: None):
"""Callable fallback_model returning an image-only model name is caught at call time."""
def model_factory(ctx: RunContext) -> str:
return 'openai-responses:gpt-image-1'
def outer_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart(tool_name='generate_image', args='{"prompt": "test"}')])
outer_model = FunctionModel(outer_model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[ImageGeneration(fallback_model=model_factory)])
with pytest.raises(UserError, match="'gpt-image-1' is a dedicated image generation model"):
await agent.run('Generate a test image')
async def test_image_generation_subagent_error_becomes_model_retry(self, allow_model_requests: None):
"""UnexpectedModelBehavior from subagent becomes a retry prompt to the outer model."""
# FunctionModel that returns text but no image — triggers UnexpectedModelBehavior
def no_image_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='No image generated.')])
inner_model = FunctionModel(no_image_model_fn, profile=ModelProfile(supports_image_output=True))
call_count = 0
def outer_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='generate_image', args='{"prompt": "test"}')])
return ModelResponse(parts=[TextPart(content='gave up')])
outer_model = FunctionModel(outer_model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[ImageGeneration(fallback_model=inner_model)])
result = await agent.run('Generate a test image')
assert result.output == 'gave up'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Generate a test image', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='generate_image',
args='{"prompt": "test"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=54, output_tokens=5),
model_name='function:outer_model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Exceeded maximum output retries (1)',
tool_name='generate_image',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='gave up')],
usage=RequestUsage(input_tokens=66, output_tokens=7),
model_name='function:outer_model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.parametrize('model_name', ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini'])
def test_image_generation_rejects_image_only_model(self, model_name: str):
"""Using a dedicated image model like gpt-image-2 raises a clear error at construction."""
with pytest.raises(UserError, match=f'{model_name!r} is a dedicated image generation model'):
ImageGeneration(fallback_model=f'openai-responses:{model_name}')
@pytest.mark.vcr()
async def test_image_generation_local_fallback(self, allow_model_requests: None, openai_api_key: str):
"""ImageGeneration(fallback_model=...) with non-supporting outer model uses subagent fallback."""
from pydantic_ai.messages import BinaryImage
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.providers.openai import OpenAIProvider
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# If we see a tool return, the image was generated — return final text
if any(
isinstance(part, ToolReturnPart)
for msg in messages
if isinstance(msg, ModelRequest)
for part in msg.parts
):
return ModelResponse(parts=[TextPart(content='Here is the generated image.')])
# First call: invoke the generate_image tool
assert info.function_tools, 'Expected generate_image tool to be available'
tool = info.function_tools[0]
return ModelResponse(parts=[ToolCallPart(tool_name=tool.name, args='{"prompt": "A cute baby sea otter"}')])
inner_model = OpenAIResponsesModel('gpt-5.4', provider=OpenAIProvider(api_key=openai_api_key))
outer_model = FunctionModel(model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(
outer_model,
capabilities=[
ImageGeneration(fallback_model=inner_model),
],
)
result = await agent.run('Generate an image of a cute baby sea otter')
assert result.output == 'Here is the generated image.'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Generate an image of a cute baby sea otter', timestamp=IsDatetime())
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='generate_image',
args='{"prompt": "A cute baby sea otter"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=59, output_tokens=9),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='generate_image',
content=IsInstance(BinaryImage),
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Here is the generated image.')],
usage=RequestUsage(input_tokens=59, output_tokens=15),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
@pytest.mark.vcr()
async def test_image_generation_local_fallback_google(self, allow_model_requests: None, gemini_api_key: str):
"""ImageGeneration fallback with Google image model."""
pytest.importorskip('google.genai', reason='google extra not installed')
from pydantic_ai.messages import BinaryImage
from pydantic_ai.models.google import GoogleModel
from pydantic_ai.providers.google import GoogleProvider
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts):
return ModelResponse(parts=[TextPart(content='Here is the generated image.')])
assert info.function_tools, 'Expected generate_image tool to be available'
tool = info.function_tools[0]
return ModelResponse(parts=[ToolCallPart(tool_name=tool.name, args='{"prompt": "A cute baby sea otter"}')])
inner_model = GoogleModel('gemini-3-pro-image-preview', provider=GoogleProvider(api_key=gemini_api_key))
outer_model = FunctionModel(model_fn, profile=ModelProfile(supported_native_tools=frozenset()))
agent = Agent(outer_model, capabilities=[ImageGeneration(fallback_model=inner_model)])
result = await agent.run('Generate an image of a cute baby sea otter')
assert result.output == 'Here is the generated image.'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[
UserPromptPart(content='Generate an image of a cute baby sea otter', timestamp=IsDatetime())
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='generate_image',
args='{"prompt": "A cute baby sea otter"}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=59, output_tokens=9),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='generate_image',
content=IsInstance(BinaryImage),
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Here is the generated image.')],
usage=RequestUsage(input_tokens=59, output_tokens=15),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
has_mcp = find_spec('mcp') is not None
@pytest.mark.skipif(not has_mcp, reason='mcp is not installed')
class TestMCPCapability:
def test_mcp_default_local_only(self):
"""MCP(url=...) defaults to local-only via the MCP SDK — no native advertised."""
cap = MCP(url='https://mcp.example.com/api')
assert cap.get_native_tools() == []
assert cap.get_toolset() is not None
def test_mcp_native_true_advertises_both(self):
"""MCP(url=..., native=True) advertises native + keeps local as fallback."""
cap = MCP(url='https://mcp.example.com/api', native=True)
native_tools = cap.get_native_tools()
assert len(native_tools) == 1
assert isinstance(native_tools[0], MCPServerTool)
assert native_tools[0].url == 'https://mcp.example.com/api'
assert cap.get_toolset() is not None
def test_mcp_native_only(self):
"""MCP(url=..., native=True, local=False) advertises only the native tool."""
cap = MCP(url='https://mcp.example.com/api', native=True, local=False)
native_tools = cap.get_native_tools()
assert len(native_tools) == 1
assert isinstance(native_tools[0], MCPServerTool)
assert cap.get_toolset() is None
def test_mcp_id_from_url(self):
"""MCP auto-derives id from URL including hostname to avoid collisions."""
cap = MCP(url='https://mcp.example.com/api', native=True)
native = cap.get_native_tools()[0]
assert isinstance(native, MCPServerTool)
assert native.id == 'mcp.example.com-api'
# SSE URLs include hostname to avoid collisions between different servers
cap_sse = MCP(url='https://server1.example.com/sse', native=True)
native_sse = cap_sse.get_native_tools()[0]
assert isinstance(native_sse, MCPServerTool)
assert native_sse.id == 'server1.example.com-sse'
async def test_mcp_explicit_native_id_marks_local_fallback(self):
"""An explicit native MCP tool keeps the local fallback tied to that server id."""
def local_tool() -> str:
return 'local result' # pragma: no cover
cap = MCP(
url='https://mcp.example.com/api',
native=MCPServerTool(id='custom-mcp', url='https://mcp.example.com/api'),
local=local_tool,
)
toolset = cap.get_toolset()
assert toolset is not None
tools = await toolset.get_tools(_build_run_context())
assert tools['local_tool'].tool_def.unless_native == 'mcp_server:custom-mcp'
async def test_mcp_dynamic_native_id_marks_local_fallback(self):
"""A dynamic native MCP tool still marks the local fallback with the stable capability id."""
def local_tool() -> str:
return 'local result' # pragma: no cover
async def native_tool(ctx: RunContext) -> MCPServerTool:
return MCPServerTool(id='dynamic-mcp', url='https://mcp.example.com/api')
cap = MCP(url='https://mcp.example.com/api', id='dynamic-mcp', native=native_tool, local=local_tool)
toolset = cap.get_toolset()
assert toolset is not None
tools = await toolset.get_tools(_build_run_context())
assert tools['local_tool'].tool_def.unless_native == 'mcp_server:dynamic-mcp'
def test_mcp_sse_transport(self):
"""MCP with /sse URL routes to an MCPToolset using FastMCP's SSE transport."""
from fastmcp.client.transports import SSETransport
from pydantic_ai.mcp import MCPToolset
cap = MCP(url='https://mcp.example.com/sse', native=True)
assert isinstance(cap.local, MCPToolset)
assert isinstance(cap.local.client.transport, SSETransport) # pyright: ignore[reportUnknownMemberType]
def test_mcp_streamable_transport(self):
"""MCP with non-/sse URL routes to an MCPToolset using FastMCP's Streamable HTTP transport."""
from fastmcp.client.transports import StreamableHttpTransport
from pydantic_ai.mcp import MCPToolset
cap = MCP(url='https://mcp.example.com/api', native=True)
assert isinstance(cap.local, MCPToolset)
assert isinstance(cap.local.client.transport, StreamableHttpTransport) # pyright: ignore[reportUnknownMemberType]
def test_mcp_authorization_token_in_local_headers(self):
"""MCP passes authorization_token as Authorization header through to the transport."""
from fastmcp.client.transports import StreamableHttpTransport
from pydantic_ai.mcp import MCPToolset
cap = MCP(url='https://mcp.example.com/api', authorization_token='Bearer xyz', native=True)
assert isinstance(cap.local, MCPToolset)
transport = cap.local.client.transport # pyright: ignore[reportUnknownMemberType]
assert isinstance(transport, StreamableHttpTransport)
assert transport.headers == {'Authorization': 'Bearer xyz'}
def test_mcp_allowed_tools_filters_local(self):
"""MCP(allowed_tools=...) applies FilteredToolset to the local toolset."""
from pydantic_ai.toolsets.filtered import FilteredToolset
cap = MCP(url='https://mcp.example.com/api', allowed_tools=['tool1'], native=True)
toolset = cap.get_toolset()
assert toolset is not None
# The outer toolset should be a FilteredToolset wrapping the prepared toolset
assert isinstance(toolset, FilteredToolset)
def test_mcp_no_url_no_local_raises(self):
"""MCP() with neither `url=` nor `local=` raises — no way to construct a usable capability."""
with pytest.raises(UserError, match='requires an explicit local tool'):
MCP()
def test_mcp_wraps_non_toolset_local_into_mcptoolset(self):
"""A bare `fastmcp.FastMCP` server passed as `local=` is wrapped in `MCPToolset` automatically."""
# `FastMCP` needs server deps; the `mcp` extra only pulls `fastmcp-slim[client]`.
pytest.importorskip('fastmcp.server')
from fastmcp import FastMCP
from pydantic_ai.mcp import MCPToolset
cap = MCP(url='https://mcp.example.com/api', native=True, local=FastMCP(name='in_process'))
assert isinstance(cap.local, MCPToolset)
class TestNamedSpecDictRoundTrip:
"""Test that NamedSpec correctly round-trips various argument forms."""
def test_dict_positional_arg_uses_long_form(self):
"""A dict positional arg falls back to long form to avoid kwargs misinterpretation on round-trip."""
spec = NamedSpec(name='CustomCap', arguments=({'key': 'value', 'other': 42},))
serialized = spec.model_dump(context={'use_short_form': True})
# Dict with string keys would be ambiguous in short form, so long form is used
assert serialized['name'] == 'CustomCap'
assert len(serialized['arguments']) == 1
assert serialized['arguments'][0] == {'key': 'value', 'other': 42}
# Round-trip preserves the dict as a positional arg
deserialized = NamedSpec.model_validate(serialized)
assert deserialized.args == ({'key': 'value', 'other': 42},)
assert deserialized.kwargs == {}
def test_non_dict_positional_arg_uses_short_form(self):
"""A non-dict positional arg still uses the compact short form."""
spec = NamedSpec(name='WebSearch', arguments=(True,))
serialized = spec.model_dump(context={'use_short_form': True})
assert serialized == {'WebSearch': True}
def test_kwargs_use_short_form(self):
"""Kwargs (dict arguments) use the short form correctly."""
spec = NamedSpec(name='WebSearch', arguments={'local': True})
serialized = spec.model_dump(context={'use_short_form': True})
assert serialized == {'WebSearch': {'local': True}}
class TestPrepareToolsCapability:
async def test_prepare_tools_filters(self):
"""PrepareTools capability filters tools using the provided callable."""
from pydantic_ai.capabilities import PrepareTools
async def hide_secret_tools(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [td for td in tool_defs if td.name != 'secret_tool']
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = [t.name for t in info.function_tools]
return make_text_response(f'tools: {sorted(tool_names)}')
agent = Agent(FunctionModel(model_fn), capabilities=[PrepareTools(hide_secret_tools)])
@agent.tool_plain
def secret_tool() -> str:
return 'secret' # pragma: no cover
@agent.tool_plain
def public_tool() -> str:
return 'public' # pragma: no cover
result = await agent.run('hello')
assert result.output == "tools: ['public_tool']"
async def test_prepare_tools_rejects_none(self):
"""PrepareTools rejects `None`; return [] to disable all tools explicitly."""
from pydantic_ai.capabilities import PrepareTools
async def invalid(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition] | None:
return None
agent = Agent('test', capabilities=[PrepareTools(invalid)]) # pyright: ignore[reportArgumentType]
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
with pytest.raises(UserError, match="Prepare function 'invalid' returned `None`"):
await agent.run('hello')
async def test_prepare_tools_modifies_definitions(self):
"""PrepareTools can modify tool definitions (e.g. set strict mode)."""
from dataclasses import replace as dc_replace
from pydantic_ai.capabilities import PrepareTools
async def set_strict(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [dc_replace(td, strict=True) for td in tool_defs]
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
strictness = [t.strict for t in info.function_tools]
return make_text_response(f'strict: {strictness}')
agent = Agent(FunctionModel(model_fn), capabilities=[PrepareTools(set_strict)])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
result = await agent.run('hello')
assert result.output == 'strict: [True]'
def test_prepare_tools_not_serializable(self):
"""PrepareTools opts out of spec serialization."""
from pydantic_ai.capabilities import PrepareTools
assert PrepareTools.get_serialization_name() is None
async def test_prepare_tools_rejects_added_tools(self):
"""`prepare_func` may filter or modify tools but cannot add or rename."""
from dataclasses import replace as dc_replace
from pydantic_ai.capabilities import PrepareTools
from pydantic_ai.exceptions import UserError
async def rename(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [dc_replace(td, name='renamed') for td in tool_defs]
agent = Agent('test', capabilities=[PrepareTools(rename)])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
with pytest.raises(UserError, match='cannot add or rename'):
await agent.run('hello')
async def test_prepare_tools_filtering_blocks_hallucinated_calls(self):
"""A tool filtered out by `prepare_tools` must be unreachable, even if the model
hallucinates a call to it. Regression test: the hook must affect `ToolManager.tools`,
not just the model's `ModelRequestParameters` — otherwise the model could (re)call
a filtered tool and `ToolManager` would happily execute it."""
from pydantic_ai.capabilities import PrepareTools
executed: list[str] = []
async def hide_secret(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [td for td in tool_defs if td.name != 'secret_tool']
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
# First turn: hallucinate a call to the filtered tool. Even though the model
# doesn't see `secret_tool` in `info.function_tools`, simulate it doing so anyway
# (this can also happen via leftover history).
if call_count == 1:
return ModelResponse(parts=[ToolCallPart('secret_tool', {})])
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[PrepareTools(hide_secret)])
@agent.tool_plain
def secret_tool() -> str:
executed.append('secret') # pragma: no cover
return 'secret' # pragma: no cover
result = await agent.run('hello')
# `secret_tool` was never executed — the hallucinated call resolved to "unknown tool"
# because `prepare_tools` filtering also removed it from `ToolManager.tools`.
assert executed == []
# Snapshot the message flow: the hallucinated call should produce a "Unknown tool"
# retry prompt referencing only the visible tools, and the second turn should succeed.
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='secret_tool', args={}, tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content="Unknown tool name: 'secret_tool'. No tools available.",
tool_name='secret_tool',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=65, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class TestPrepareOutputToolsCapability:
async def test_filters_output_tools(self):
"""`PrepareOutputTools` capability filters output tools using a callable."""
from pydantic_ai.capabilities import PrepareOutputTools
class Out(BaseModel):
value: str
async def disable_all(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response(f'output_tools: {len(info.output_tools)}')
agent = Agent(
FunctionModel(model_fn),
output_type=[str, ToolOutput(Out, name='out')],
capabilities=[PrepareOutputTools(disable_all)],
)
result = await agent.run('hello')
assert result.output == 'output_tools: 0'
async def test_prepare_output_tools_rejects_none(self):
"""PrepareOutputTools rejects `None`; return [] to disable all output tools explicitly."""
from pydantic_ai.capabilities import PrepareOutputTools
class Out(BaseModel):
value: str
async def invalid(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition] | None:
return None
agent = Agent(
'test',
output_type=[str, ToolOutput(Out, name='out')],
capabilities=[PrepareOutputTools(invalid)], # pyright: ignore[reportArgumentType]
)
with pytest.raises(UserError, match="Prepare function 'invalid' returned `None`"):
await agent.run('hello')
async def test_only_sees_output_tools(self):
"""`PrepareOutputTools` only receives output tools — function tools route to `PrepareTools`."""
from pydantic_ai.capabilities import PrepareOutputTools
seen_kinds: list[str] = []
async def capture(ctx: RunContext, tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
seen_kinds.extend(td.kind for td in tool_defs)
return tool_defs
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
)
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[PrepareOutputTools(capture)])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
await agent.run('hello')
assert seen_kinds == ['output']
def test_not_serializable(self):
"""`PrepareOutputTools` opts out of spec serialization."""
from pydantic_ai.capabilities import PrepareOutputTools
assert PrepareOutputTools.get_serialization_name() is None
class TestOverrideWithSpec:
async def test_override_with_spec_instructions_and_model(self):
"""Spec instructions and model replace the agent's when used via override."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn), instructions='original')
with agent.override(spec={'instructions': 'from spec'}):
result = await agent.run('hello')
assert 'from spec' in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions='from spec',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='instructions: from spec')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_override_with_spec_explicit_param_wins(self):
"""Explicit override param beats spec value."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn), instructions='original')
with agent.override(spec={'instructions': 'from spec'}, instructions='explicit'):
result = await agent.run('hello')
assert 'explicit' in result.output
assert 'from spec' not in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions='explicit',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='instructions: explicit')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_override_with_spec_instructions(self):
"""Override with spec instructions replaces agent's existing instructions."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn), instructions='agent-instructions')
with agent.override(spec={'instructions': 'from-spec-instructions'}):
result = await agent.run('hello')
# Override replaces: only spec instructions, not agent's
assert 'from-spec-instructions' in result.output
assert 'agent-instructions' not in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions='from-spec-instructions',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='instructions: from-spec-instructions')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_override_with_spec_capabilities(self):
"""Override with spec providing capabilities uses them for the run."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn))
with agent.override(spec={'capabilities': [{'WebSearch': {'local': False}}]}):
result = await agent.run('hello')
assert result.output == 'ok'
class TestRunWithSpec:
async def test_run_with_spec_instructions_added(self):
"""Spec instructions are added additively at run time."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn), instructions='original')
result = await agent.run('hello', spec={'instructions': 'also from spec'})
# Both original and spec instructions should be present
assert 'original' in result.output
assert 'also from spec' in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions="""\
original
also from spec\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content="""\
instructions: original
also from spec\
"""
)
],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_run_with_spec_model_as_fallback(self):
"""Spec model is used as fallback when no run-time model is provided."""
agent = Agent(None) # No model set
result = await agent.run('hello', spec={'model': 'test'})
assert result.output == 'success (no tool calls)'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='success (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(),
),
]
)
async def test_run_with_spec_model_settings_merged(self):
"""Spec model_settings are merged with run model_settings."""
def model_fn(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 make_text_response(f'max_tokens={max_tokens} temperature={temperature}')
agent = Agent(FunctionModel(model_fn))
result = await agent.run(
'hello',
spec={'model_settings': {'max_tokens': 100}},
model_settings={'temperature': 0.5},
)
assert 'max_tokens=100' in result.output
assert 'temperature=0.5' in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='max_tokens=100 temperature=0.5')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_run_with_spec_partial_no_model(self):
"""Partial spec without model works if agent has a model."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn))
result = await agent.run('hello', spec={'instructions': 'be helpful'})
assert 'be helpful' in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions='be helpful',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='instructions: be helpful')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_run_with_spec_capabilities(self):
"""Run with spec capabilities merges them with agent's root capability."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn), instructions='agent-level')
result = await agent.run(
'hello',
spec={'capabilities': [{'WebSearch': {'local': False}}]},
)
# Agent-level instructions should be present; spec capabilities are merged additively
assert 'agent-level' in result.output
async def test_run_with_spec_instructions(self):
"""Run with spec instructions adds to agent's instructions."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn), instructions='agent-level')
result = await agent.run(
'hello',
spec={
'instructions': 'from-spec',
},
)
# Both should be present (additive)
assert 'agent-level' in result.output
assert 'from-spec' in result.output
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
instructions="""\
agent-level
from-spec\
""",
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
TextPart(
content="""\
instructions: agent-level
from-spec\
"""
)
],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_run_with_spec_metadata_merged(self):
"""Spec metadata is merged with run metadata."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn), metadata={'agent_key': 'agent_val'})
result = await agent.run(
'hello',
spec={'metadata': {'spec_key': 'spec_val'}},
metadata={'run_key': 'run_val'},
)
assert result.output == 'ok'
# Run metadata should take precedence, spec metadata should be present
assert result.metadata is not None
assert result.metadata == snapshot({'agent_key': 'agent_val', 'spec_key': 'spec_val', 'run_key': 'run_val'})
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='ok')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_spec_unsupported_fields_warns(self):
"""Non-default unsupported fields produce warnings."""
agent = Agent('test')
with pytest.warns(UserWarning, match='end_strategy'):
await agent.run('hello', spec={'end_strategy': 'exhaustive'})
async def test_spec_tool_retry_override_warns(self):
"""Run-time specs can only override the output retry budget."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn))
with pytest.warns(UserWarning, match=r"retry field 'tools'.*ignored"):
result = await agent.run('hello', spec={'retries': {'tools': 5}})
assert result.output == 'ok'
class TestGetWrapperToolsetHook:
async def test_wrapper_prefixes_tools(self):
"""Capability can wrap the toolset to prefix tool names."""
from pydantic_ai.toolsets.prefixed import PrefixedToolset
@dataclass
class PrefixCap(AbstractCapability[Any]):
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return PrefixedToolset(toolset, prefix='cap')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return make_text_response(f'tools: {tool_names}')
agent = Agent(FunctionModel(model_fn), capabilities=[PrefixCap()])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
result = await agent.run('hello')
assert result.output == "tools: ['cap_my_tool']"
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content="tools: ['cap_my_tool']")],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrapper_prefixes_tools_streaming(self):
"""Wrapper toolset works correctly with streaming runs."""
from pydantic_ai.toolsets.prefixed import PrefixedToolset
@dataclass
class PrefixCap(AbstractCapability[Any]):
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return PrefixedToolset(toolset, prefix='cap')
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
tool_names = sorted(t.name for t in info.function_tools)
yield f'tools: {tool_names}'
agent = Agent(FunctionModel(stream_function=stream_fn), capabilities=[PrefixCap()])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
async with agent.run_stream('hello') as result:
output = await result.get_output()
assert output == "tools: ['cap_my_tool']"
async def test_wrapper_does_not_affect_output_tools(self):
"""Wrapper toolset does not wrap output tools."""
from pydantic_ai.toolsets.wrapper import WrapperToolset
seen_tool_names: list[list[str]] = []
@dataclass
class SpyWrapperToolset(WrapperToolset[Any]):
async def get_tools(self, ctx: RunContext[Any]) -> dict[str, Any]:
tools = await super().get_tools(ctx)
seen_tool_names.append(sorted(tools.keys()))
return tools
@dataclass
class SpyWrapperCap(AbstractCapability[Any]):
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return SpyWrapperToolset(toolset)
agent = Agent(
TestModel(),
output_type=int,
capabilities=[SpyWrapperCap()],
)
@agent.tool_plain
def add_one(x: int) -> int:
"""Add one to x."""
return x + 1
await agent.run('hello')
# The wrapper should only see function tools, not output tools
for tool_names in seen_tool_names:
assert 'add_one' in tool_names
# Output tool names should not appear in the wrapped toolset
assert all(not name.startswith('final_result') for name in tool_names)
async def test_wrapper_none_is_noop(self):
"""Returning None from get_wrapper_toolset leaves the toolset unchanged."""
@dataclass
class NoopCap(AbstractCapability[Any]):
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return None
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return make_text_response(f'tools: {tool_names}')
agent = Agent(FunctionModel(model_fn), capabilities=[NoopCap()])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
result = await agent.run('hello')
assert result.output == "tools: ['my_tool']"
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content="tools: ['my_tool']")],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrapper_chaining_order(self):
"""Multiple capabilities' wrappers compose by nesting: first wraps outermost."""
from pydantic_ai.toolsets.prefixed import PrefixedToolset
@dataclass
class PrefixCap(AbstractCapability[Any]):
prefix: str
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return PrefixedToolset(toolset, prefix=self.prefix)
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return make_text_response(f'tools: {tool_names}')
agent = Agent(
FunctionModel(model_fn),
capabilities=[PrefixCap(prefix='a'), PrefixCap(prefix='b')],
)
@agent.tool_plain
def tool() -> str:
return 'r' # pragma: no cover
result = await agent.run('hello')
# First cap wraps outermost (matching wrap_* hooks): a_b_tool
assert result.output == "tools: ['a_b_tool']"
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content="tools: ['a_b_tool']")],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrapper_with_per_run_capability(self):
"""Wrapper works correctly with capabilities returning new instances from for_run."""
from pydantic_ai.toolsets.prefixed import PrefixedToolset
@dataclass
class PerRunPrefixCap(AbstractCapability[Any]):
prefix: str = 'default'
async def for_run(self, ctx: RunContext[Any]) -> AbstractCapability[Any]:
return PerRunPrefixCap(prefix='runtime')
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return PrefixedToolset(toolset, prefix=self.prefix)
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return make_text_response(f'tools: {tool_names}')
agent = Agent(FunctionModel(model_fn), capabilities=[PerRunPrefixCap()])
@agent.tool_plain
def my_tool() -> str:
return 'result' # pragma: no cover
result = await agent.run('hello')
# The per-run instance should use 'runtime' prefix, not 'default'
assert result.output == "tools: ['runtime_my_tool']"
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content="tools: ['runtime_my_tool']")],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrapper_with_agent_prepare_tools(self):
"""Agent-level prepare_tools is applied before capability wrapper."""
from dataclasses import replace as dc_replace
from pydantic_ai.toolsets.prefixed import PrefixedToolset
@dataclass
class PrefixCap(AbstractCapability[Any]):
def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) -> AbstractToolset[Any] | None:
return PrefixedToolset(toolset, prefix='cap')
async def agent_prepare(ctx: RunContext[Any], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [dc_replace(td, description=f'[prepared] {td.description}') for td in tool_defs]
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
descs = [t.description for t in info.function_tools]
return make_text_response(f'tools: {tool_names}, descs: {descs}')
agent = Agent(FunctionModel(model_fn), capabilities=[PrepareTools(agent_prepare), PrefixCap()])
@agent.tool_plain
def my_tool() -> str:
"""Original."""
return 'result' # pragma: no cover
result = await agent.run('hello')
# Both agent prepare_tools (description) and capability wrapper (prefix) should apply
assert result.output == "tools: ['cap_my_tool'], descs: ['[prepared] Original.']"
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content="tools: ['cap_my_tool'], descs: ['[prepared] Original.']")],
usage=RequestUsage(input_tokens=51, output_tokens=6),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# --- from_spec error cases ---
def test_from_spec_no_model_raises():
"""from_spec() without model raises UserError."""
with pytest.raises(UserError, match='`model` must be provided'):
Agent.from_spec({'instructions': 'hello'})
# --- run() with spec: additional merge scenarios ---
class TestRunWithSpecAdditional:
async def test_run_with_spec_and_run_instructions_merged(self):
"""When run() passes both instructions and spec instructions, they merge."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
instructions = next(
(m.instructions for m in messages if isinstance(m, ModelRequest) and m.instructions), None
)
return make_text_response(f'instructions: {instructions}')
agent = Agent(FunctionModel(model_fn))
result = await agent.run(
'hello',
spec={'instructions': 'spec instructions'},
instructions='run instructions',
)
assert 'run instructions' in result.output
assert 'spec instructions' in result.output
async def test_run_with_spec_metadata_only(self):
"""Spec metadata is used when run() doesn't pass metadata."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn))
result = await agent.run('hello', spec={'metadata': {'from': 'spec'}})
assert result.metadata == {'from': 'spec'}
async def test_run_with_spec_metadata_callable_merged(self):
"""Callable metadata from run() merges with spec metadata."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn))
def dynamic_metadata(ctx: RunContext) -> dict[str, Any]:
return {'dynamic': 'value'}
result = await agent.run(
'hello',
spec={'metadata': {'spec_key': 'spec_val'}},
metadata=dynamic_metadata,
)
assert result.metadata is not None
assert result.metadata['spec_key'] == 'spec_val'
assert result.metadata['dynamic'] == 'value'
async def test_run_with_spec_model_settings_callable_passthrough(self):
"""Callable model_settings from run() bypasses spec model_settings merge."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
temperature = info.model_settings.get('temperature') if info.model_settings else None
max_tokens = info.model_settings.get('max_tokens') if info.model_settings else None
return make_text_response(f'temperature={temperature} max_tokens={max_tokens}')
agent = Agent(FunctionModel(model_fn))
def dynamic_settings(ctx: RunContext) -> _ModelSettings:
return {'temperature': 0.9}
result = await agent.run(
'hello',
spec={'model_settings': {'max_tokens': 100}},
model_settings=dynamic_settings,
)
# Callable model_settings bypass spec merge — spec model_settings are handled
# via the capability layer instead
assert 'temperature=0.9' in result.output
# --- override() with spec: additional field tests ---
class TestOverrideWithSpecAdditional:
async def test_override_with_spec_name(self):
"""Override with spec providing agent name."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn), name='original')
with agent.override(spec={'name': 'spec-name'}):
assert agent.name == 'spec-name'
result = await agent.run('hello')
assert result.output == 'ok'
assert agent.name == 'original'
async def test_override_with_spec_model(self):
"""Override with spec providing model."""
agent = Agent('test', name='test-agent')
with agent.override(spec={'model': 'test'}):
result = await agent.run('hello')
assert result.output == 'success (no tool calls)'
async def test_override_with_spec_model_settings(self):
"""Override with spec providing model_settings."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
max_tokens = info.model_settings.get('max_tokens') if info.model_settings else None
return make_text_response(f'max_tokens={max_tokens}')
agent = Agent(FunctionModel(model_fn))
with agent.override(spec={'model_settings': {'max_tokens': 42}}):
result = await agent.run('hello')
assert 'max_tokens=42' in result.output
async def test_override_with_spec_metadata(self):
"""Override with spec providing metadata."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('ok')
agent = Agent(FunctionModel(model_fn))
with agent.override(spec={'metadata': {'env': 'test'}}):
result = await agent.run('hello')
assert result.metadata == {'env': 'test'}
# --- Capability construction tests ---
def test_web_fetch_with_constraints():
"""WebFetch capability populates native tool with all constraint kwargs."""
cap = WebFetch(
local=True,
allowed_domains=['example.com'],
blocked_domains=['bad.com'],
max_uses=5,
enable_citations=True,
max_content_tokens=1000,
)
builtin_tools = cap.get_native_tools()
assert len(builtin_tools) == 1
tool = builtin_tools[0]
assert isinstance(tool, WebFetchTool)
assert tool.allowed_domains == ['example.com']
assert tool.blocked_domains == ['bad.com']
assert tool.max_uses == 5
assert tool.enable_citations is True
assert tool.max_content_tokens == 1000
# `max_uses` requires native support; domains are handled locally.
assert cap._requires_native() is True # pyright: ignore[reportPrivateUsage]
def test_web_fetch_unique_id():
"""WebFetch returns the correct native unique_id."""
cap = WebFetch(local=True)
assert cap._native_unique_id() == 'web_fetch' # pyright: ignore[reportPrivateUsage]
def test_xsearch_unique_id():
"""XSearch returns the correct builtin unique_id."""
cap = XSearch()
assert cap._native_unique_id() == 'x_search' # pyright: ignore[reportPrivateUsage]
def test_web_search_with_constraints():
"""WebSearch capability populates native tool with all constraint kwargs."""
from pydantic_ai.native_tools import WebSearchUserLocation
cap = WebSearch(
local='duckduckgo',
search_context_size='high',
user_location=WebSearchUserLocation(city='NYC', country='US'),
blocked_domains=['bad.com'],
allowed_domains=['good.com'],
max_uses=3,
)
builtin_tools = cap.get_native_tools()
assert len(builtin_tools) == 1
tool = builtin_tools[0]
assert isinstance(tool, WebSearchTool)
assert tool.search_context_size == 'high'
assert tool.user_location is not None
assert tool.blocked_domains == ['bad.com']
assert tool.allowed_domains == ['good.com']
assert tool.max_uses == 3
assert cap._requires_native() is True # pyright: ignore[reportPrivateUsage]
def test_web_search_duckduckgo_raises_without_extra(monkeypatch: pytest.MonkeyPatch):
"""WebSearch(local='duckduckgo') raises with install hint when [duckduckgo] extra is missing."""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.common_tools.duckduckgo':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[duckduckgo\]'):
WebSearch(local='duckduckgo')
def test_web_fetch_local_true_raises_without_extra(monkeypatch: pytest.MonkeyPatch):
"""WebFetch(local=True) raises with install hint when [web-fetch] extra is missing."""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.common_tools.web_fetch':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[web-fetch\]'):
WebFetch(local=True)
def test_mcp_default_local_only():
"""MCP(url=...) defaults to local-only via the MCP SDK — no native advertised."""
pytest.importorskip('mcp', reason='mcp package not installed')
cap = MCP(url='http://example.com/mcp', id='my-mcp')
assert cap.get_native_tools() == []
assert cap.get_toolset() is not None
def test_mcp_native_true_default_construction():
"""MCP(url=..., native=True) constructs MCPServerTool with id from url."""
pytest.importorskip('mcp', reason='mcp package not installed')
cap = MCP(url='http://example.com/mcp', id='my-mcp', native=True)
native_tools = cap.get_native_tools()
assert len(native_tools) == 1
tool = native_tools[0]
assert isinstance(tool, MCPServerTool)
assert tool.url == 'http://example.com/mcp'
assert tool.id == 'my-mcp'
def test_mcp_default_raises_user_error_when_mcp_extra_missing(monkeypatch: pytest.MonkeyPatch):
"""`MCP(url=...)` raises a `UserError` with install hint when the MCP extra is missing.
MCP defaults to running the server locally, so the extra is required. To run without it,
the user must opt into native-only (`native=True, local=False`).
"""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.mcp':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[mcp\]'):
MCP(url='http://example.com/mcp')
def test_mcp_native_only_constructs_without_mcp_extra():
"""`MCP(url=..., native=True, local=False)` constructs cleanly — local resolution is skipped."""
# Note: no need to mock the import. `local=False` short-circuits before `_build_local()`,
# so the test exercises the same path whether or not the MCP extra is installed.
cap = MCP(url='http://example.com/mcp', native=True, local=False)
assert cap.local is False
assert len(cap.get_native_tools()) == 1
def test_mcp_local_true_raises_user_error_when_mcp_extra_missing(monkeypatch: pytest.MonkeyPatch):
"""`MCP(url=..., local=True)` raises a `UserError` with install hint when MCP extra is missing."""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.mcp':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[mcp\]'):
MCP(url='http://example.com/mcp', local=True, native=True)
def test_mcp_local_string_raises_user_error_when_mcp_extra_missing(monkeypatch: pytest.MonkeyPatch):
"""`MCP(url=..., local='https://override...')` raises a `UserError` when MCP extra is missing."""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.mcp':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[mcp\]'):
MCP(url='http://example.com/mcp', local='https://override.example.com/mcp', native=True)
def test_mcp_native_default_raises_user_error_when_mcp_extra_missing(monkeypatch: pytest.MonkeyPatch):
"""`MCP(url=..., native=True)` (default `local`) now raises when `[mcp]` is missing.
Previously `_default_local` swallowed `ImportError` and returned None, so
`MCP(url=..., native=True)` would silently work as native-only. Locking in the new
construction-time error so users get a clear migration to `native=True, local=False`.
"""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.mcp':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[mcp\]'):
MCP(url='http://example.com/mcp', native=True)
def test_mcp_without_url_with_local_toolset():
"""`MCP(local=MCPToolset(...))` constructs without `url=` — the primary path for non-URL clients."""
pytest.importorskip('mcp', reason='mcp package not installed')
from pydantic_ai.mcp import MCPToolset
toolset = MCPToolset('http://example.com/mcp', include_instructions=True)
cap = MCP(local=toolset)
assert cap.url is None
assert cap.local is toolset
assert cap.get_native_tools() == []
def test_mcp_without_url_with_native_true_raises():
"""`MCP(native=True)` without `url=` raises — capability needs a URL to auto-construct an MCPServerTool."""
with pytest.raises(UserError, match=r'MCP\(native=True\) requires `url=`'):
MCP(native=True, local=False)
def test_mcp_without_url_with_explicit_native_instance():
"""`MCP(native=MCPServerTool(...))` constructs without capability `url=` — the instance carries the URL."""
cap = MCP(
native=MCPServerTool(id='my-mcp', url='http://example.com/mcp'),
local=False,
)
assert cap.url is None
natives = cap.get_native_tools()
assert len(natives) == 1
assert isinstance(natives[0], MCPServerTool)
assert natives[0].url == 'http://example.com/mcp'
def test_mcp_without_url_local_true_raises():
"""`MCP(local=True)` without `url=` raises — no URL to derive the local transport from."""
with pytest.raises(UserError, match=r'requires `url=`'):
MCP(local=True)
def test_native_or_local_constraint_check_precedes_no_local_check():
"""`WebSearch(native=False, allowed_domains=...)` raises the constraint error, not the no-local error.
Regression test for validation-order bug — the constraint case is unfixable by adding `local=`,
so it must fire before the `requires an explicit local tool` check.
"""
with pytest.raises(UserError, match='constraint fields require the native tool'):
WebSearch(native=False, allowed_domains=['example.com'])
def test_web_search_local_string_strategy_silent():
"""WebSearch(local='duckduckgo') resolves silently to the DDG tool — no PydanticAIDeprecationWarning."""
pytest.importorskip('duckduckgo_search', reason='duckduckgo extra not installed')
with warnings.catch_warnings():
warnings.simplefilter('error', PydanticAIDeprecationWarning)
cap = WebSearch(local='duckduckgo')
assert cap.local is not None and cap.local is not False
def test_web_search_local_true_silent():
"""WebSearch(local=True) resolves silently to the default strategy (DDG)."""
pytest.importorskip('duckduckgo_search', reason='duckduckgo extra not installed')
with warnings.catch_warnings():
warnings.simplefilter('error', PydanticAIDeprecationWarning)
cap = WebSearch(local=True)
assert cap.local is not None and cap.local is not False
def test_web_fetch_local_true_silent():
"""WebFetch(local=True) resolves silently to the default markdownify-based tool."""
pytest.importorskip('markdownify', reason='web-fetch extra not installed')
with warnings.catch_warnings():
warnings.simplefilter('error', PydanticAIDeprecationWarning)
cap = WebFetch(local=True)
assert cap.local is not None and cap.local is not False
def test_mcp_local_true_silent_with_explicit_native():
"""MCP(url=..., local=True, native=True) resolves silently — no PydanticAIDeprecationWarning."""
pytest.importorskip('mcp', reason='mcp package not installed')
with warnings.catch_warnings():
warnings.simplefilter('error', PydanticAIDeprecationWarning)
cap = MCP(url='http://example.com/mcp', local=True, native=True)
assert cap.local is not None and cap.local is not False
assert len(cap.get_native_tools()) == 1
def test_native_or_local_base_no_default_native():
"""NativeOrLocalTool base class with native=True raises (no _default_native)."""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
with pytest.raises(UserError, match='native=True requires a subclass'):
NativeOrLocalTool()
def test_native_tool_from_spec_no_args():
"""NativeTool.from_spec() with no arguments raises TypeError."""
from pydantic_ai.capabilities.native_tool import NativeTool as NativeToolCapDirect
with pytest.raises(TypeError, match='requires either a `tool` argument'):
NativeToolCapDirect.from_spec()
def test_native_or_local_no_default_local():
"""NativeOrLocalTool base class _default_local() returns None."""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
cap = NativeOrLocalTool(native=WebSearchTool())
# Base class _default_local() returns None — no local fallback
assert cap.local is None
assert cap.get_toolset() is None
def test_native_or_local_with_explicit_native():
"""NativeOrLocalTool used directly with an explicit native and local tool."""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
def my_local_tool() -> str:
"""A local fallback tool."""
return 'local result' # pragma: no cover
cap = NativeOrLocalTool(native=WebSearchTool(), local=my_local_tool)
# get_native_tools returns the explicit native tool
assert len(cap.get_native_tools()) == 1
assert isinstance(cap.get_native_tools()[0], WebSearchTool)
# get_toolset wraps local with unless_native from _native_unique_id()
toolset = cap.get_toolset()
assert toolset is not None
def test_native_or_local_native_unique_id_non_abstract():
"""_native_unique_id() raises when native is callable (not AbstractNativeTool)."""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
cap = NativeOrLocalTool.__new__(NativeOrLocalTool)
cap.native = lambda ctx: WebSearchTool()
cap.local = False
with pytest.raises(UserError, match='cannot derive native unique_id'):
cap._native_unique_id() # pyright: ignore[reportPrivateUsage]
def test_native_or_local_base_unknown_strategy_raises():
"""`NativeOrLocalTool(local='foo')` raises a UserError from the default `_resolve_local_strategy`."""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
with pytest.raises(UserError, match=r"`local='foo'` is not supported"):
NativeOrLocalTool(native=WebSearchTool(), local='foo')
def test_native_or_local_preserves_passed_tool_instance():
"""A pre-wrapped `Tool` passed as `local` is preserved (not re-wrapped or treated as a callable)."""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
from pydantic_ai.tools import Tool as ToolDirect
def my_search(query: str) -> str:
return f'results for {query}' # pragma: no cover
tool = ToolDirect(my_search)
cap = NativeOrLocalTool(native=WebSearchTool(), local=tool)
assert cap.local is tool
def test_native_or_local_id_kwarg_overrides_default():
"""`id=` overrides the auto-derived capability id across `NativeOrLocalTool` subclasses.
The id is the wire-side identifier (used in `ctx.capabilities` lookup and surfaced to the model
in the deferred-capability catalog), so users need a way to disambiguate when they instantiate
the same capability twice in one agent.
"""
from pydantic_ai.capabilities.native_or_local import NativeOrLocalTool
from pydantic_ai.tools import Tool as ToolDirect
def _nop() -> None:
return None # pragma: no cover
nop = ToolDirect(_nop)
assert NativeOrLocalTool(native=WebSearchTool(), local=nop, id='custom').id == 'custom'
assert WebFetch(local=nop, id='custom').id == 'custom'
assert ImageGeneration(local=False, id='custom').id == 'custom'
def test_websearch_unknown_strategy_raises():
"""WebSearch(local='not_a_real_strategy') → UserError naming the unknown strategy."""
with pytest.raises(UserError, match='not a known strategy'):
WebSearch(local='not_a_real_strategy') # type: ignore[arg-type]
def test_websearch_duckduckgo_missing_install_hint(monkeypatch: pytest.MonkeyPatch):
"""`WebSearch(local='duckduckgo')` raises a UserError with install hint when the extra is missing."""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.common_tools.duckduckgo':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[duckduckgo\]'):
WebSearch(local='duckduckgo')
def test_webfetch_unknown_strategy_raises():
"""WebFetch(local='not_a_real_strategy') → UserError naming the unknown strategy."""
with pytest.raises(UserError, match='not a known strategy'):
WebFetch(local='not_a_real_strategy') # type: ignore[arg-type]
def test_webfetch_local_true_install_hint(monkeypatch: pytest.MonkeyPatch):
"""`WebFetch(local=True)` raises a UserError with install hint when the `web-fetch` extra is missing."""
import builtins
original_import = builtins.__import__
def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
if name == 'pydantic_ai.common_tools.web_fetch':
raise ImportError('mocked')
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, '__import__', mock_import)
with pytest.raises(UserError, match=r'pydantic-ai-slim\[web-fetch\]'):
WebFetch(local=True)
def test_mcp_local_string_must_be_url_raises_user_error():
"""`MCP(url=..., local='not-a-url')` raises a `UserError` directing the user to `local=MCPToolset(...)`."""
pytest.importorskip('mcp', reason='mcp package not installed')
with pytest.raises(UserError, match=r"MCP\(local='not_a_real_strategy'\) must be an `http\(s\)://` URL"):
MCP(url='http://example.com/mcp', local='not_a_real_strategy', native=True)
def test_mcp_local_url_string_override_uses_provided_url():
"""`MCP(url=..., local='https://override...')` builds an `MCPToolset` from the override URL."""
pytest.importorskip('mcp', reason='mcp package not installed')
pytest.importorskip('fastmcp', reason='fastmcp package not installed')
from pydantic_ai.mcp import MCPToolset
cap = MCP(
url='http://primary.example.com/mcp',
local='https://override.example.com/mcp',
native=True,
)
assert isinstance(cap.local, MCPToolset)
def test_validate_capability_not_dataclass():
"""Custom capability type without @dataclass raises ValueError."""
from pydantic_ai.agent.spec import get_capability_registry
class NotADataclass(AbstractCapability[Any]):
pass
with pytest.raises(ValueError, match='must be decorated with `@dataclass`'):
get_capability_registry(custom_types=(NotADataclass,))
async def _registered_capability_context(
*capabilities: AbstractCapability,
) -> tuple[dict[str, AbstractCapability], set[str]]:
captured_capabilities: dict[str, AbstractCapability] = {}
captured_available_ids: set[str] = set()
@dataclass
class CaptureCapabilities(AbstractCapability):
async def before_model_request(
self, ctx: RunContext, request_context: ModelRequestContext
) -> ModelRequestContext:
captured_capabilities.update(ctx.capabilities)
captured_available_ids.update(ctx.available_capability_ids)
return request_context
agent = Agent(
FunctionModel(lambda _messages, _info: make_text_response('done')),
capabilities=[*capabilities, CaptureCapabilities()],
)
await agent.run('capture capabilities')
capability_ids = {id(capability) for capability in capabilities}
captured_capabilities = {
capability_id: capability
for capability_id, capability in captured_capabilities.items()
if id(capability) in capability_ids
}
captured_available_ids &= set(captured_capabilities)
return captured_capabilities, captured_available_ids
async def test_deferred_capability_without_id_set_after_construction_raises_at_run() -> None:
"""`defer_loading` flipped on after construction escapes the eager check, so the run-time guard still fires."""
@dataclass
class DeferredCap(AbstractCapability):
pass
cap = DeferredCap()
# Not deferred at construction, so the eager check passes; the run-time check is what catches it.
agent = Agent(TestModel(), capabilities=[cap])
cap.defer_loading = True
assert cap.id is None
with pytest.raises(UserError, match='stable explicit `id` values'):
await agent.run('hi')
assert DeferredCap(id='stable', defer_loading=True).id == 'stable'
async def test_plain_class_capability_can_use_class_metadata() -> None:
"""A plain class subclass can declare metadata without dataclass or super calls."""
class DeferredCap(AbstractCapability):
id = 'plain-deferred'
description = 'Plain class deferred capability.'
defer_loading = True
cap = DeferredCap()
capability_map, available_ids = await _registered_capability_context(cap)
assert capability_map == {'plain-deferred': cap}
assert 'plain-deferred' not in available_ids
assert cap.defer_loading is True
assert cap.get_description() == 'Plain class deferred capability.'
async def test_custom_init_capability_can_initialize_metadata_without_post_init() -> None:
"""Custom capability init can initialize metadata without a base-class ritual."""
class DeferredCap(AbstractCapability):
def __init__(self, *, id: str | None = None, defer_loading: bool = False) -> None:
self.id = id
self.description = None
self.defer_loading = defer_loading
cap = DeferredCap(id='stable', defer_loading=True)
capability_map, available_ids = await _registered_capability_context(cap)
assert cap.id == 'stable'
assert cap.defer_loading is True
assert capability_map == {'stable': cap}
assert 'stable' not in available_ids
non_deferred_cap = DeferredCap()
non_deferred_capability_map, non_deferred_available_ids = await _registered_capability_context(non_deferred_cap)
assert non_deferred_cap.id is None
assert non_deferred_cap.description is None
assert non_deferred_cap.defer_loading is False
assert non_deferred_capability_map == {'deferred_cap': non_deferred_cap}
assert 'deferred_cap' in non_deferred_available_ids
async def test_duplicate_explicit_capability_ids_set_after_construction_raise_at_run() -> None:
"""Ids that only collide after construction escape the eager check, so run registration still rejects them."""
@dataclass
class FirstCap(AbstractCapability):
pass
@dataclass
class SecondCap(AbstractCapability):
pass
first = FirstCap(id='same')
second = SecondCap() # no id at construction, so the eager check passes
agent = Agent(TestModel(), capabilities=[first, second])
second.id = 'same' # collision introduced after construction
with pytest.raises(UserError, match="Capability id 'same' is used by multiple capabilities"):
await agent.run('hi')
async def test_anonymous_non_deferred_capabilities_get_run_local_ids() -> None:
"""Anonymous non-deferred capabilities are still present in run context."""
@dataclass
class PlainCap(AbstractCapability):
pass
first = PlainCap()
second = PlainCap()
capability_map, available_ids = await _registered_capability_context(first, second)
assert list(capability_map) == ['plain_cap', 'plain_cap_2']
assert first.id is None
assert second.id is None
assert {'plain_cap', 'plain_cap_2'} <= available_ids
# --- Node run lifecycle hook tests ---
class TestNodeRunHooks:
async def test_before_node_run_fires(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'before_node_run:UserPromptNode' in cap.log
assert 'before_node_run:ModelRequestNode' in cap.log
assert 'before_node_run:CallToolsNode' in cap.log
async def test_after_node_run_fires(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'after_node_run:UserPromptNode' in cap.log
assert 'after_node_run:ModelRequestNode' in cap.log
assert 'after_node_run:CallToolsNode' in cap.log
async def test_node_hook_order(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
# For each node, before fires before after
for node_name in ('UserPromptNode', 'ModelRequestNode', 'CallToolsNode'):
before_idx = cap.log.index(f'before_node_run:{node_name}')
after_idx = cap.log.index(f'after_node_run:{node_name}')
assert before_idx < after_idx
# --- Run error hook tests ---
class TestRunErrorHooks:
async def test_on_run_error_fires_on_failure(self):
cap = LoggingCapability()
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
assert 'on_run_error' in cap.log
async def test_on_run_error_not_called_on_success(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'on_run_error' not in cap.log
async def test_on_run_error_can_transform_error(self):
@dataclass
class TransformErrorCap(AbstractCapability[Any]):
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
raise ValueError('transformed error')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[TransformErrorCap()])
with pytest.raises(ValueError, match='transformed error'):
await agent.run('hello')
async def test_on_run_error_can_recover(self):
@dataclass
class RecoverRunCap(AbstractCapability[Any]):
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
return AgentRunResult(output='recovered')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[RecoverRunCap()])
result = await agent.run('hello')
assert result.output == 'recovered'
async def test_on_run_error_not_called_when_wrap_run_recovers(self):
@dataclass
class WrapRecoveryCap(AbstractCapability[Any]):
log: list[str] = field(default_factory=lambda: [])
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
try:
return await handler()
except RuntimeError:
self.log.append('wrap_run:caught')
return AgentRunResult(output='wrap_recovered')
async def on_run_error( # pragma: no cover — verifying this is NOT called
self, ctx: RunContext[Any], *, error: BaseException
) -> AgentRunResult[Any]:
self.log.append('on_run_error')
raise error
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
cap = WrapRecoveryCap()
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
result = await agent.run('hello')
assert result.output == 'wrap_recovered'
assert 'wrap_run:caught' in cap.log
assert 'on_run_error' not in cap.log
async def test_on_run_error_fires_via_iter(self):
from pydantic_graph import End
@dataclass
class RecoverRunCap(AbstractCapability[Any]):
called: bool = False
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
self.called = True
return AgentRunResult(output='recovered via iter')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
cap = RecoverRunCap()
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
while not isinstance(node, End): # pragma: no branch
node = await agent_run.next(node)
assert cap.called
assert agent_run.result is not None
assert agent_run.result.output == 'recovered via iter'
# --- Node run error hook tests ---
class TestNodeRunErrorHooks:
async def test_on_node_run_error_fires(self):
cap = LoggingCapability()
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
assert 'on_node_run_error:ModelRequestNode' in cap.log
async def test_on_node_run_error_can_recover_with_end(self):
from pydantic_ai.result import FinalResult
from pydantic_graph import End
@dataclass
class RecoverNodeCap(AbstractCapability[Any]):
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: BaseException) -> Any:
return End(FinalResult(output='recovered'))
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
cap = RecoverNodeCap()
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
while not isinstance(node, End):
node = await agent_run.next(node)
assert isinstance(node, End)
assert node.data.output == 'recovered'
async def test_on_node_run_error_not_called_on_success(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert all('on_node_run_error' not in entry for entry in cap.log)
# --- Model request error hook tests ---
class TestModelRequestErrorHooks:
async def test_on_model_request_error_fires(self):
cap = LoggingCapability()
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
assert 'on_model_request_error' in cap.log
async def test_on_model_request_error_can_recover(self):
@dataclass
class RecoverModelCap(AbstractCapability[Any]):
async def on_model_request_error(
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='recovered response')])
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[RecoverModelCap()])
result = await agent.run('hello')
assert result.output == 'recovered response'
async def test_on_model_request_error_not_called_on_success(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
await agent.run('hello')
assert 'on_model_request_error' not in cap.log
async def test_default_on_model_request_error_reraises(self):
"""Default on_model_request_error re-raises, exercised with a minimal capability."""
@dataclass
class MinimalCap(AbstractCapability[Any]):
def get_instructions(self):
return 'Be helpful.'
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[MinimalCap()])
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
async def test_default_on_model_request_error_reraises_streaming(self):
"""Default on_model_request_error re-raises in streaming path (wrap_task error after stream consumed)."""
@dataclass
class PostProcessFailCap(AbstractCapability[Any]):
"""wrap_model_request that fails AFTER handler returns (post-processing error)."""
def get_instructions(self):
return 'Be helpful.'
async def wrap_model_request(self, ctx: RunContext[Any], *, request_context: Any, handler: Any) -> Any:
await handler(request_context)
raise RuntimeError('post-processing exploded')
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[PostProcessFailCap()],
)
with pytest.raises(RuntimeError, match='post-processing exploded'):
async with agent.run_stream('hello') as stream:
await stream.get_output()
# --- Tool validate error hook tests ---
class TestToolValidateErrorHooks:
async def test_on_tool_validate_error_fires_on_validation_failure(self):
cap = LoggingCapability()
call_count = 0
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
tool = info.function_tools[0]
if call_count <= 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"name": "correct"}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(bad_args_model), capabilities=[cap])
@agent.tool_plain
def greet(name: str) -> str:
return f'hello {name}'
await agent.run('greet someone')
assert 'on_tool_validate_error:greet' in cap.log
async def test_on_tool_validate_error_not_called_on_success(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
await agent.run('call the tool')
assert all('on_tool_validate_error' not in entry for entry in cap.log)
async def test_on_tool_validate_error_can_recover(self):
@dataclass
class RecoverValidateCap(AbstractCapability[Any]):
async def on_tool_validate_error(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
) -> dict[str, Any]:
return {'name': 'recovered-name'}
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
tool = info.function_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(bad_args_model), capabilities=[RecoverValidateCap()])
received_name = None
@agent.tool_plain
def greet(name: str) -> str:
nonlocal received_name
received_name = name
return f'hello {name}'
result = await agent.run('greet someone')
assert received_name == 'recovered-name'
assert 'hello recovered-name' in result.output
async def test_default_on_tool_validate_error_reraises(self):
"""The default on_tool_validate_error re-raises, exercised with a minimal capability."""
@dataclass
class MinimalCap(AbstractCapability[Any]):
def get_instructions(self):
return 'Be helpful.'
call_count = 0
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
tool = info.function_tools[0]
if call_count <= 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"name": "correct"}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(bad_args_model), capabilities=[MinimalCap()])
@agent.tool_plain
def greet(name: str) -> str:
return f'hello {name}'
result = await agent.run('greet someone')
assert 'hello correct' in result.output
# --- Tool execute error hook tests ---
class TestToolExecuteErrorHooks:
async def test_on_tool_execute_error_fires(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
with pytest.raises(ValueError, match='tool failed'):
await agent.run('call the tool')
assert 'on_tool_execute_error:my_tool' in cap.log
async def test_on_tool_execute_error_not_called_on_success(self):
cap = LoggingCapability()
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
await agent.run('call the tool')
assert all('on_tool_execute_error' not in entry for entry in cap.log)
async def test_on_tool_execute_error_can_recover(self):
@dataclass
class RecoverExecCap(AbstractCapability[Any]):
async def on_tool_execute_error(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
return 'fallback result'
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(model_fn), capabilities=[RecoverExecCap()])
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
result = await agent.run('call tool')
assert 'fallback result' in result.output
# --- Hooks capability tests ---
class TestHooksCapability:
"""Tests for the Hooks decorator-based capability."""
async def test_decorator_registration(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_model_request
async def log_request(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('before_model_request')
return request_context
@hooks.on.after_model_request
async def log_response(
ctx: RunContext[Any], *, request_context: ModelRequestContext, response: ModelResponse
) -> ModelResponse:
call_log.append('after_model_request')
return response
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert call_log == ['before_model_request', 'after_model_request']
async def test_constructor_form(self):
call_log: list[str] = []
async def log_request(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('before_model_request')
return request_context
agent = Agent(FunctionModel(simple_model_function), capabilities=[Hooks(before_model_request=log_request)])
await agent.run('hello')
assert call_log == ['before_model_request']
async def test_multiple_hooks_same_event(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_model_request
async def first(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('first')
return request_context
@hooks.on.before_model_request
async def second(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('second')
return request_context
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert call_log == ['first', 'second']
async def test_tool_names_filtering(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_tool_execute(tools=['target_tool'])
async def filtered(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
) -> dict[str, Any]:
call_log.append(f'filtered:{call.tool_name}')
return args
@hooks.on.after_tool_execute
async def unfiltered(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any], result: Any
) -> Any:
call_log.append(f'unfiltered:{call.tool_name}')
return result
agent = Agent(FunctionModel(tool_calling_model), capabilities=[hooks])
@agent.tool_plain
def target_tool() -> str:
return 'result'
await agent.run('call tool')
assert 'filtered:target_tool' in call_log
assert 'unfiltered:target_tool' in call_log
async def test_wrap_model_request(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.model_request
async def wrap(ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any) -> ModelResponse:
call_log.append('wrap_start')
result = await handler(request_context)
call_log.append('wrap_end')
return result
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert call_log == ['wrap_start', 'wrap_end']
async def test_wrap_run(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.run
async def wrap(ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
call_log.append('wrap_run_start')
result = await handler()
call_log.append('wrap_run_end')
return result
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert call_log == ['wrap_run_start', 'wrap_run_end']
async def test_on_error_recovery(self):
hooks = Hooks()
@hooks.on.model_request_error
async def recover(
ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='recovered')])
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == 'recovered'
async def test_sync_function_auto_wrapping(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_model_request
def sync_hook(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('sync_hook')
return request_context
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert call_log == ['sync_hook']
async def test_timeout(self):
hooks = Hooks()
@hooks.on.before_model_request(timeout=0.01)
async def slow_hook(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
await asyncio.sleep(10)
return request_context # pragma: no cover
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
with pytest.raises(HookTimeoutError) as exc_info:
await agent.run('hello')
assert exc_info.value.hook_name == 'before_model_request'
assert exc_info.value.func_name == 'slow_hook'
assert exc_info.value.timeout == 0.01
async def test_has_wrap_node_run(self):
hooks = Hooks()
assert hooks.has_wrap_node_run is False
nodes_seen: list[str] = []
@hooks.on.node_run
async def wrap(ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
nodes_seen.append(type(node).__name__)
return await handler(node)
assert hooks.has_wrap_node_run is True
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert len(nodes_seen) > 0
async def test_composition_with_other_capabilities(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_model_request
async def hooks_before(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('hooks_before')
return request_context
cap = LoggingCapability()
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks, cap])
await agent.run('hello')
assert 'hooks_before' in call_log
assert 'before_model_request' in cap.log
async def test_before_run(self):
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_run
async def on_start(ctx: RunContext[Any]) -> None:
call_log.append('before_run')
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert call_log == ['before_run']
async def test_after_run(self):
hooks = Hooks()
outputs: list[str] = []
@hooks.on.after_run
async def on_end(ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
outputs.append(result.output)
return result
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
result = await agent.run('hello')
assert outputs == [result.output]
async def test_repr(self):
hooks = Hooks()
assert repr(hooks) == 'Hooks({})'
@hooks.on.before_model_request
async def hook(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
return request_context
assert repr(hooks) == "Hooks({'before_model_request': 1})"
# Verify the registered hook actually works
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
async def test_on_model_request_error_reraise(self):
"""Error hooks that re-raise propagate the error to the caller."""
hooks = Hooks()
@hooks.on.model_request_error
async def log_and_reraise(
ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
raise error
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
async def test_on_run_error_reraise(self):
"""on_run_error hooks that re-raise propagate the error."""
hooks = Hooks()
@hooks.on.run_error
async def log_and_reraise(ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
raise error
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
with pytest.raises(RuntimeError, match='model exploded'):
await agent.run('hello')
async def test_on_run_error_recovery(self):
hooks = Hooks()
@hooks.on.run_error
async def recover(ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
return AgentRunResult(output='recovered from run error')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == 'recovered from run error'
async def test_on_run_error_chaining(self):
hooks = Hooks()
@hooks.on.run_error
async def first_handler(ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
raise ValueError('transformed by first')
@hooks.on.run_error
async def second_handler(ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
return AgentRunResult(output=f'caught: {error}')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('original error')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
result = await agent.run('hello')
assert 'transformed by first' in result.output
async def test_error_hook_chaining(self):
hooks = Hooks()
@hooks.on.model_request_error
async def first(
ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
raise ValueError('transformed')
@hooks.on.model_request_error
async def second(
ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
return ModelResponse(parts=[TextPart(content=f'recovered: {error}')])
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('original')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
result = await agent.run('hello')
assert 'transformed' in result.output
async def test_wrap_run_event_stream(self):
hooks = Hooks()
events_seen: list[str] = []
@hooks.on.run_event_stream
async def observe_stream(
ctx: RunContext[Any], *, stream: AsyncIterable[AgentStreamEvent]
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
events_seen.append(type(event).__name__)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[hooks],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert len(events_seen) > 0
async def test_hooks_with_streaming_run(self):
"""Hooks capability used during a streaming run exercises the default wrap_run_event_stream path."""
hooks = Hooks()
call_log: list[str] = []
@hooks.on.before_model_request
async def log_request(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
call_log.append('before_model_request')
return request_context
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[hooks],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert 'before_model_request' in call_log
async def test_node_run_hooks(self):
"""Exercise before_node_run, after_node_run, and node_run (wrap) via .on namespace."""
hooks = Hooks()
nodes_seen: list[str] = []
@hooks.on.before_node_run
async def before(ctx: RunContext[Any], *, node: Any) -> Any:
nodes_seen.append(f'before:{type(node).__name__}')
return node
@hooks.on.after_node_run
async def after(ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
nodes_seen.append(f'after:{type(node).__name__}')
return result
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
await agent.run('hello')
assert any('before:' in n for n in nodes_seen)
assert any('after:' in n for n in nodes_seen)
async def test_node_run_error_hook(self):
"""on.node_run_error fires when a node fails."""
hooks = Hooks()
error_log: list[str] = []
@hooks.on.node_run_error
async def handle(ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
error_log.append(f'error:{type(error).__name__}')
raise error
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('node exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[hooks])
with pytest.raises(RuntimeError, match='node exploded'):
await agent.run('hello')
assert any('error:RuntimeError' in e for e in error_log)
async def test_on_event_hook(self):
"""on.event fires for each stream event and can modify events."""
hooks = Hooks()
events_seen: list[str] = []
@hooks.on.event
async def observe(ctx: RunContext[Any], event: AgentStreamEvent) -> AgentStreamEvent:
events_seen.append(type(event).__name__)
return event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[hooks],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert len(events_seen) > 0
async def test_on_event_hook_fires_in_run(self):
"""on.event fires in run() even without an event_stream_handler."""
hooks = Hooks()
events_seen: list[str] = []
@hooks.on.event
async def observe(ctx: RunContext[Any], event: AgentStreamEvent) -> AgentStreamEvent:
events_seen.append(type(event).__name__)
return event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[hooks],
)
result = await agent.run('hello')
assert result.output is not None
assert 'PartStartEvent' in events_seen
async def test_wrap_run_event_stream_fires_in_run(self):
"""on.run_event_stream fires in run() even without an event_stream_handler."""
hooks = Hooks()
events_seen: list[str] = []
@hooks.on.run_event_stream
async def observe_stream(
ctx: RunContext[Any], *, stream: AsyncIterable[AgentStreamEvent]
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
events_seen.append(type(event).__name__)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[hooks],
)
result = await agent.run('hello')
assert result.output is not None
assert 'PartStartEvent' in events_seen
async def test_on_event_with_run_event_stream(self):
"""on.event and on.run_event_stream can be used together."""
hooks = Hooks()
event_log: list[str] = []
stream_log: list[str] = []
@hooks.on.event
async def per_event(ctx: RunContext[Any], event: AgentStreamEvent) -> AgentStreamEvent:
event_log.append(type(event).__name__)
return event
@hooks.on.run_event_stream
async def wrap_stream(
ctx: RunContext[Any], *, stream: AsyncIterable[AgentStreamEvent]
) -> AsyncIterable[AgentStreamEvent]:
stream_log.append('started')
async for event in stream:
yield event
stream_log.append('finished')
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[hooks],
)
async with agent.run_stream('hello') as stream:
await stream.get_output()
assert len(event_log) > 0
assert stream_log == ['started', 'finished']
async def test_prepare_tools_hook(self):
"""on.prepare_tools filters tool definitions."""
hooks = Hooks()
@hooks.on.prepare_tools
async def hide_tools(ctx: RunContext[Any], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [td for td in tool_defs if not td.name.startswith('hidden_')]
tool_called = False
agent = Agent(FunctionModel(tool_calling_model), capabilities=[hooks])
@agent.tool_plain
def visible_tool() -> str:
nonlocal tool_called
tool_called = True
return 'visible'
@agent.tool_plain
def hidden_tool() -> str:
return 'hidden' # pragma: no cover
await agent.run('call tool')
assert tool_called
async def test_prepare_output_tools_hook(self):
"""`on.prepare_output_tools` filters output tool definitions — model only sees the
non-filtered ones."""
hooks = Hooks()
@hooks.on.prepare_output_tools
async def hide_secret(ctx: RunContext[Any], tool_defs: list[ToolDefinition]) -> list[ToolDefinition]:
return [td for td in tool_defs if td.name != 'secret_output']
seen_output_tools: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
seen_output_tools.extend(td.name for td in info.output_tools)
# Call the only remaining (non-filtered) output tool
return ModelResponse(parts=[ToolCallPart('public_output', {'value': 'ok'})])
class SecretOutput(BaseModel):
value: str
class PublicOutput(BaseModel):
value: str
agent = Agent(
FunctionModel(model_fn),
output_type=[
ToolOutput(SecretOutput, name='secret_output'),
ToolOutput(PublicOutput, name='public_output'),
],
capabilities=[hooks],
)
result = await agent.run('hello')
assert isinstance(result.output, PublicOutput)
assert seen_output_tools == ['public_output']
async def test_tool_validate_hooks(self):
"""Exercise before/after/wrap tool_validate and on_tool_validate_error."""
hooks = Hooks()
validate_log: list[str] = []
@hooks.on.before_tool_validate
async def before_validate(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any
) -> Any:
validate_log.append('before_validate')
return args
@hooks.on.after_tool_validate
async def after_validate(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
) -> dict[str, Any]:
validate_log.append('after_validate')
return args
agent = Agent(FunctionModel(tool_calling_model), capabilities=[hooks])
@agent.tool_plain
def my_tool() -> str:
return 'result'
await agent.run('call tool')
assert 'before_validate' in validate_log
assert 'after_validate' in validate_log
async def test_wrap_tool_validate_hook(self):
"""Exercise on.tool_validate (wrap) via decorator."""
hooks = Hooks()
wrap_log: list[str] = []
@hooks.on.tool_validate
async def wrap_validate(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, handler: Any
) -> dict[str, Any]:
wrap_log.append('wrap_start')
result = await handler(args)
wrap_log.append('wrap_end')
return result
agent = Agent(FunctionModel(tool_calling_model), capabilities=[hooks])
@agent.tool_plain
def my_tool() -> str:
return 'result'
await agent.run('call tool')
assert wrap_log == ['wrap_start', 'wrap_end']
async def test_tool_validate_error_hook(self):
"""on.tool_validate_error can recover from validation failures."""
hooks = Hooks()
@hooks.on.tool_validate_error
async def recover_validate(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
) -> dict[str, Any]:
return {'name': 'recovered'}
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
tool = info.function_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(bad_args_model), capabilities=[hooks])
@agent.tool_plain
def greet(name: str) -> str:
return f'hello {name}'
result = await agent.run('greet someone')
assert 'hello recovered' in result.output
async def test_wrap_tool_execute_hook(self):
"""Exercise on.tool_execute (wrap) via decorator."""
hooks = Hooks()
wrap_log: list[str] = []
@hooks.on.tool_execute
async def wrap_exec(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any], handler: Any
) -> Any:
wrap_log.append('exec_start')
result = await handler(args)
wrap_log.append('exec_end')
return result
agent = Agent(FunctionModel(tool_calling_model), capabilities=[hooks])
@agent.tool_plain
def my_tool() -> str:
return 'result'
await agent.run('call tool')
assert wrap_log == ['exec_start', 'exec_end']
async def test_tool_execute_error_hook(self):
"""on.tool_execute_error can recover from tool execution failures."""
hooks = Hooks()
@hooks.on.tool_execute_error
async def recover_exec(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
return 'fallback result'
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(model_fn), capabilities=[hooks])
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
result = await agent.run('call tool')
assert 'fallback result' in result.output
async def test_tool_validate_error_reraise(self):
"""on.tool_validate_error that re-raises propagates the error."""
hooks = Hooks()
@hooks.on.tool_validate_error
async def reraise(
ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
) -> dict[str, Any]:
raise error
call_count = 0
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
if info.function_tools:
tool = info.function_tools[0]
if call_count <= 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"name": "ok"}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(bad_args_model), capabilities=[hooks])
@agent.tool_plain
def greet(name: str) -> str:
return f'hello {name}'
await agent.run('greet someone')
async def test_tool_execute_error_reraise(self):
"""on.tool_execute_error that re-raises propagates the error."""
hooks = Hooks()
@hooks.on.tool_execute_error
async def reraise(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
raise error
agent = Agent(FunctionModel(tool_calling_model), capabilities=[hooks])
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
with pytest.raises(ValueError, match='tool failed'):
await agent.run('call tool')
async def test_get_serialization_name(self):
assert Hooks.get_serialization_name() is None
async def test_default_on_tool_execute_error_reraises(self):
"""The default on_tool_execute_error just re-raises, exercised with a minimal capability."""
@dataclass
class MinimalCap(AbstractCapability[Any]):
"""Capability that doesn't override error hooks."""
def get_instructions(self):
return 'Be helpful.'
agent = Agent(FunctionModel(tool_calling_model), capabilities=[MinimalCap()])
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
with pytest.raises(ValueError, match='tool failed'):
await agent.run('call the tool')
# --- Context var propagation tests ---
_test_cv: contextvars.ContextVar[str] = contextvars.ContextVar('_test_cv')
class TestContextVarPropagation:
"""Context vars set in wrap_run propagate to all hooks in the outer task."""
async def test_wrap_run_contextvar_visible_in_node_hooks(self):
"""A capability that sets a contextvar in wrap_run should have it
visible in another capability's node-level hooks via agent.run()."""
@dataclass
class Setter(AbstractCapability):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
token = _test_cv.set('from-wrap-run')
try:
return await handler()
finally:
_test_cv.reset(token)
@dataclass
class Reader(AbstractCapability):
seen: list[tuple[str, str | None]] = field(default_factory=lambda: [])
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
self.seen.append(('before_node_run', _test_cv.get(None)))
return node
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
self.seen.append(('wrap_node_run', _test_cv.get(None)))
return await handler(node)
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
self.seen.append(('after_node_run', _test_cv.get(None)))
return result
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
self.seen.append(('after_run', _test_cv.get(None)))
return result
reader = Reader()
agent = Agent(TestModel(), capabilities=[Setter(), reader])
await agent.run('hello')
for hook_name, value in reader.seen:
assert value == 'from-wrap-run', f'{hook_name} did not see contextvar'
async def test_wrap_run_contextvar_visible_via_iter_next(self):
"""Context vars set in wrap_run are visible when using agent.iter() + next()."""
@dataclass
class Setter(AbstractCapability):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
token = _test_cv.set('from-iter')
try:
return await handler()
finally:
_test_cv.reset(token)
@dataclass
class Reader(AbstractCapability):
seen: list[tuple[str, str | None]] = field(default_factory=lambda: [])
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
self.seen.append(('before_node_run', _test_cv.get(None)))
return node
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
self.seen.append(('after_run', _test_cv.get(None)))
return result
reader = Reader()
agent = Agent(TestModel(), capabilities=[Setter(), reader])
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
while not isinstance(node, End):
node = await agent_run.next(node)
for hook_name, value in reader.seen:
assert value == 'from-iter', f'{hook_name} did not see contextvar'
async def test_contextvar_cleaned_up_after_run(self):
"""Context vars set in wrap_run are restored after the run completes."""
@dataclass
class Setter(AbstractCapability):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
token = _test_cv.set('temporary')
try:
return await handler()
finally:
_test_cv.reset(token)
agent = Agent(TestModel(), capabilities=[Setter()])
assert _test_cv.get(None) is None
await agent.run('hello')
# After the run, the contextvar should be cleaned up
assert _test_cv.get(None) is None
async def test_contextvar_cleaned_up_on_early_iter_exit(self):
"""Context vars are restored even when the caller exits iter() early."""
@dataclass
class Setter(AbstractCapability):
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
token = _test_cv.set('early-exit')
try:
return await handler()
finally:
_test_cv.reset(token)
agent = Agent(TestModel(), capabilities=[Setter()])
assert _test_cv.get(None) is None
async with agent.iter('hello') as agent_run:
# Exit immediately without driving any nodes
_ = agent_run.next_node
# Context var must be cleaned up even though we abandoned the run
assert _test_cv.get(None) is None
async def test_before_run_contextvar_propagates(self):
"""Context vars set in before_run (not wrap_run) also propagate."""
@dataclass
class Setter(AbstractCapability):
async def before_run(self, ctx: RunContext[Any]) -> None:
_test_cv.set('from-before-run')
@dataclass
class Reader(AbstractCapability):
seen: list[tuple[str, str | None]] = field(default_factory=lambda: [])
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
self.seen.append(('before_node_run', _test_cv.get(None)))
return node
reader = Reader()
agent = Agent(TestModel(), capabilities=[Setter(), reader])
await agent.run('hello')
for hook_name, value in reader.seen:
assert value == 'from-before-run', f'{hook_name} did not see contextvar'
async def test_contextvar_visible_in_on_run_error(self):
"""Context vars set in wrap_run are visible in on_run_error."""
@dataclass
class SetterWithRecovery(AbstractCapability):
seen_in_error: str | None = None
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
token = _test_cv.set('error-path')
try:
return await handler()
finally:
_test_cv.reset(token)
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
self.seen_in_error = _test_cv.get(None)
return AgentRunResult(output='recovered')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
cap = SetterWithRecovery()
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
result = await agent.run('hello')
assert result.output == 'recovered'
assert cap.seen_in_error == 'error-path'
# --- WrapperCapability and PrefixTools tests ---
async def test_prefix_tools_prefixes_wrapped_capability_tools():
"""PrefixTools prefixes only the wrapped capability's tools, not other agent tools."""
toolset = FunctionToolset()
@toolset.tool_plain
def inner_tool() -> str:
return 'inner' # pragma: no cover
cap = PrefixTools(wrapped=Toolset(toolset), prefix='ns')
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return ModelResponse(parts=[TextPart(','.join(tool_names))])
agent = Agent(FunctionModel(respond), capabilities=[cap])
@agent.tool_plain
def outer_tool() -> str:
return 'outer' # pragma: no cover
result = await agent.run('list tools')
# inner_tool should be prefixed, outer_tool should not
assert result.output == 'ns_inner_tool,outer_tool'
async def test_prefix_tools_from_spec():
"""PrefixTools from spec supports both dict-form and bare-name nested capabilities."""
# Dict form (kwargs): nested capability with arguments
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{
'PrefixTools': {
'prefix': 'search',
'capability': {'NativeTool': {'kind': 'web_search'}},
}
},
],
},
)
assert agent.model is not None
# Bare name form with custom_capability_types forwarded through contextvar
agent = Agent.from_spec(
{
'model': 'test',
'capabilities': [
{
'PrefixTools': {
'prefix': 'custom',
'capability': 'CustomCapability',
}
},
],
},
custom_capability_types=[CustomCapability],
)
assert agent.model is not None
async def test_prefix_tools_from_spec_direct():
"""PrefixTools.from_spec works outside Agent.from_spec (no contextvar), using default registry."""
cap = PrefixTools.from_spec(prefix='ws', capability={'WebSearch': {'local': 'duckduckgo'}}) # pyright: ignore[reportArgumentType]
assert isinstance(cap, PrefixTools)
assert cap.prefix == 'ws'
async def test_prefix_tools_returns_none_when_no_toolset():
"""PrefixTools.get_toolset() returns None if the wrapped capability has no toolset."""
cap = PrefixTools(wrapped=CustomCapability(), prefix='ns')
assert cap.get_toolset() is None
async def test_prefix_tools_with_callable_toolset():
"""PrefixTools handles a wrapped capability that returns a callable toolset."""
toolset = FunctionToolset()
@toolset.tool_plain
def dynamic_tool() -> str:
return 'dynamic' # pragma: no cover
def toolset_func(ctx: RunContext) -> FunctionToolset:
return toolset
cap = PrefixTools(wrapped=Toolset(toolset_func), prefix='dyn')
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return ModelResponse(parts=[TextPart(','.join(tool_names))])
agent = Agent(FunctionModel(respond), capabilities=[cap])
result = await agent.run('list tools')
assert result.output == 'dyn_dynamic_tool'
async def test_prefix_tools_inherits_wrapped_metadata_for_registration():
"""A wrapper with no id of its own delegates identity to the capability it wraps.
This is what lets a wrapper sit over a deferred capability without losing its deferral or
its place in the load catalog: the wrapper registers under the wrapped capability's id and
keeps `defer_loading` and `description`.
"""
toolset = FunctionToolset()
wrapped = Toolset(
toolset,
id='leaf-tools',
description='Leaf tool bundle.',
defer_loading=True,
)
cap = PrefixTools(wrapped=wrapped, prefix='leaf')
visited: list[AbstractCapability] = []
cap.apply(visited.append)
capability_map, available_ids = await _registered_capability_context(cap)
assert cap.id == 'leaf-tools'
assert cap.defer_loading is True
assert cap.get_description() == 'Leaf tool bundle.'
assert capability_map == {'leaf-tools': cap}
# Deferred and not yet loaded, so it is registered but not available this turn.
assert 'leaf-tools' not in available_ids
assert visited == [cap]
async def test_prefix_tools_can_override_metadata():
"""A wrapper with explicit metadata becomes its own registered capability."""
wrapped = Toolset(FunctionToolset(), id='leaf-tools', description='Leaf tool bundle.', defer_loading=True)
cap = PrefixTools(
wrapped=wrapped,
prefix='leaf',
id='prefixed-leaf-tools',
description='Prefixed leaf tools.',
defer_loading=False,
)
visited: list[AbstractCapability] = []
cap.apply(visited.append)
capability_map, available_ids = await _registered_capability_context(cap)
assert cap.id == 'prefixed-leaf-tools'
assert cap.description == 'Prefixed leaf tools.'
assert capability_map == {'prefixed-leaf-tools': cap}
assert 'prefixed-leaf-tools' in available_ids
assert cap.defer_loading is False
assert visited == [cap]
async def test_prefix_tools_registration_inherits_or_overrides_wrapper_metadata():
"""A wrapper inherits the wrapped capability's identity, unless it sets its own id."""
github = Capability[object](
id='github',
description='GitHub MCP server.',
defer_loading=True,
)
# No id of its own: inherit the wrapped capability's id, deferral, and description, so the
# deferred capability still shows up in the load catalog under its own id.
prefixed = PrefixTools(github, prefix='github')
registered, available_ids = await _registered_capability_context(prefixed)
assert registered['github'] is prefixed
assert 'github' not in available_ids
assert prefixed.id == 'github'
assert prefixed.defer_loading is True
assert prefixed.get_description() == 'GitHub MCP server.'
# An explicit id makes the wrapper its own capability: it no longer inherits the wrapped
# capability's id or deferral, though it still falls back to its description.
explicit_id = PrefixTools(github, prefix='github', id='github_prefixed')
registered, available_ids = await _registered_capability_context(explicit_id)
assert registered['github_prefixed'] is explicit_id
assert 'github_prefixed' in available_ids
assert explicit_id.defer_loading is False
assert explicit_id.get_description() == 'GitHub MCP server.'
async def test_wrapper_over_deferred_capability_preserves_deferral_end_to_end() -> None:
"""Wrapping a deferred capability keeps it deferred through a full run.
Regression guard for metadata delegation: a wrapper with no id of its own must surface the
wrapped deferred capability in the load catalog and reveal its (prefixed) tools after
`load_capability`, rather than silently becoming an always-available capability.
"""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed for 30 days'
refunds = Capability[object](
id='refunds',
description='Refund policy tools.',
toolsets=[toolset],
defer_loading=True,
)
wrapped = PrefixTools(refunds, prefix='refunds')
first_request_instructions: list[str | None] = []
available_per_turn: list[set[str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
available_per_turn.append({td.name for td in info.function_tools if not td.defer_loading})
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
if not any(part.tool_name == LOAD_CAPABILITY_TOOL_NAME for part in tool_returns):
first_request = message(messages, ModelRequest)
first_request_instructions.append(first_request.instructions)
return ModelResponse(
parts=[ToolCallPart(tool_name=LOAD_CAPABILITY_TOOL_NAME, args={'id': 'refunds'}, tool_call_id='load')]
)
if not any(part.tool_name == 'refunds_lookup_refund_policy' for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(
tool_name='refunds_lookup_refund_policy',
args={'order_id': 'order-1'},
tool_call_id='lookup',
)
]
)
return make_text_response('done')
agent = Agent(FunctionModel(model_fn), capabilities=[wrapped])
result = await agent.run('Can I get a refund?')
assert result.output == 'done'
# The deferred capability is surfaced in the catalog under the wrapped capability's id.
assert first_request_instructions == [
'The following capabilities are deferred and can be loaded using the `load_capability` tool:\n'
'- refunds: Refund policy tools.'
]
# The prefixed tool is hidden until the capability is loaded, then becomes callable.
assert 'refunds_lookup_refund_policy' not in available_per_turn[0]
assert 'refunds_lookup_refund_policy' in available_per_turn[-1]
async def test_prefix_tools_explicit_defer_loading_overrides_anonymous_wrapped() -> None:
"""`PrefixTools(..., id='github', defer_loading=True)` over an anonymous wrapped
capability registers as deferred under the wrapper's own id, not the wrapped's."""
explicit_deferred = PrefixTools(
Capability[object](),
prefix='github',
id='github',
defer_loading=True,
)
registered, available_ids = await _registered_capability_context(explicit_deferred)
assert registered['github'] is explicit_deferred
assert 'github' not in available_ids
assert explicit_deferred.defer_loading is True
async def test_prefix_tools_can_be_deferred():
"""A deferred PrefixTools wrapper keeps its prefixed tools deferred until load."""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str:
return f'{order_id}: refund allowed'
cap = PrefixTools(
wrapped=Toolset(
toolset,
),
prefix='billing',
id='refunds',
description='Refund policy tools.',
defer_loading=True,
)
seen_tool_state: list[list[tuple[str, bool]]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_tool_state.append([(t.name, bool(t.defer_loading)) for t in info.function_tools])
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
if not any(isinstance(part, LoadCapabilityReturnPart) for message in messages for part in message.parts):
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'refunds'},
tool_call_id='load-refunds',
)
]
)
if not any(part.tool_name == 'billing_lookup_refund_policy' for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(
tool_name='billing_lookup_refund_policy',
args={'order_id': 'order-123'},
tool_call_id='lookup-refund',
)
]
)
refund_result = next(part.content for part in tool_returns if part.tool_name == 'billing_lookup_refund_policy')
return make_text_response(f'done: {refund_result}')
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('Can I get a refund?')
assert result.output == 'done: order-123: refund allowed'
assert seen_tool_state == snapshot(
[
[('load_capability', False), ('billing_lookup_refund_policy', True), ('search_tools', False)],
[('load_capability', False), ('billing_lookup_refund_policy', False), ('search_tools', False)],
[('load_capability', False), ('billing_lookup_refund_policy', False), ('search_tools', False)],
]
)
async def test_prefix_tools_convenience_method():
"""AbstractCapability.prefix_tools() returns a PrefixTools wrapping self."""
toolset = FunctionToolset()
@toolset.tool_plain
def inner_tool() -> str:
return 'inner' # pragma: no cover
cap = Toolset(toolset).prefix_tools('ns')
assert isinstance(cap, PrefixTools)
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return ModelResponse(parts=[TextPart(','.join(tool_names))])
agent = Agent(FunctionModel(respond), capabilities=[cap])
result = await agent.run('list tools')
assert result.output == 'ns_inner_tool'
async def test_wrapper_capability_delegates_hooks():
"""WrapperCapability delegates lifecycle hooks to the wrapped capability."""
hook_calls: list[str] = []
@dataclass
class HookCap(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
hook_calls.append('before_run')
async def after_run(self, ctx: RunContext, *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
hook_calls.append('after_run')
return result
wrapper = WrapperCapability(wrapped=HookCap())
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(respond), capabilities=[wrapper])
await agent.run('Hello')
assert 'before_run' in hook_calls
assert 'after_run' in hook_calls
async def test_wrapper_capability_for_run_replaces():
"""WrapperCapability.for_run replaces wrapped when it changes."""
toolset_a = FunctionToolset(id='a')
@toolset_a.tool_plain
def tool_a() -> str:
return 'a' # pragma: no cover
toolset_b = FunctionToolset(id='b')
@toolset_b.tool_plain
def tool_b() -> str:
return 'b' # pragma: no cover
@dataclass
class SwitchCap(AbstractCapability):
use_b: bool = False
async def for_run(self, ctx: RunContext) -> AbstractCapability:
return SwitchCap(use_b=True)
def get_toolset(self) -> AbstractToolset:
return toolset_b if self.use_b else toolset_a
wrapper = WrapperCapability(wrapped=SwitchCap())
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_names = sorted(t.name for t in info.function_tools)
return ModelResponse(parts=[TextPart(','.join(tool_names))])
agent = Agent(FunctionModel(respond), capabilities=[wrapper])
result = await agent.run('Hello')
# for_run switches to toolset_b
assert 'tool_b' in result.output
async def test_wrapper_capability_for_run_preserves_explicit_metadata() -> None:
"""WrapperCapability.for_run preserves explicit wrapper metadata."""
@dataclass
class SwitchCap(AbstractCapability):
name: str = 'before'
async def for_run(self, ctx: RunContext) -> AbstractCapability:
return SwitchCap(name='after')
wrapper = WrapperCapability(
wrapped=SwitchCap(),
id='explicit-wrapper',
description='Explicit wrapper metadata.',
defer_loading=False,
)
result = await wrapper.for_run(_build_run_context())
assert result is not wrapper
assert isinstance(result, WrapperCapability)
assert result.id == 'explicit-wrapper'
assert result.description == 'Explicit wrapper metadata.'
assert result.defer_loading is False
assert isinstance(result.wrapped, SwitchCap)
assert result.wrapped.name == 'after'
async def test_wrapper_capability_has_wrap_node_run():
"""WrapperCapability.has_wrap_node_run delegates to the wrapped capability."""
plain = CustomCapability()
assert WrapperCapability(wrapped=plain).has_wrap_node_run is False
@dataclass
class NodeRunCap(AbstractCapability):
async def wrap_node_run(self, ctx: RunContext, *, node: Any, handler: Any) -> Any:
return await handler(node) # pragma: no cover
assert WrapperCapability(wrapped=NodeRunCap()).has_wrap_node_run is True
async def test_wrapper_capability_delegates_model_request_hooks():
"""WrapperCapability delegates before/after model request hooks."""
hook_calls: list[str] = []
@dataclass
class ModelRequestHookCap(AbstractCapability):
async def before_model_request(
self, ctx: RunContext, request_context: ModelRequestContext
) -> ModelRequestContext:
hook_calls.append('before_model_request')
return request_context
async def after_model_request(
self, ctx: RunContext, *, request_context: ModelRequestContext, response: ModelResponse
) -> ModelResponse:
hook_calls.append('after_model_request')
return response
wrapper = WrapperCapability(wrapped=ModelRequestHookCap())
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(respond), capabilities=[wrapper])
await agent.run('Hello')
assert 'before_model_request' in hook_calls
assert 'after_model_request' in hook_calls
async def test_prefix_tools_tool_call_strips_prefix():
"""PrefixTools correctly strips the prefix when calling the underlying tool."""
toolset = FunctionToolset()
@toolset.tool_plain
def greet(name: str) -> str:
return f'hello {name}'
cap = PrefixTools(wrapped=Toolset(toolset), prefix='ns')
call_count = 0
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart('ns_greet', {'name': 'world'})])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(respond), capabilities=[cap])
result = await agent.run('greet world')
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='greet world', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='ns_greet',
args={'name': 'world'},
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='function:respond:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='ns_greet',
content='hello world',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=54, output_tokens=6),
model_name='function:respond:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_wrapper_capability_get_serialization_name():
"""WrapperCapability.get_serialization_name returns None (abstract base)."""
assert WrapperCapability.get_serialization_name() is None
async def test_wrapper_capability_delegates_on_run_error():
"""WrapperCapability delegates on_run_error to the wrapped capability."""
@dataclass
class RecoverCap(AbstractCapability[Any]):
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
return AgentRunResult(output='recovered')
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[WrapperCapability(wrapped=RecoverCap())])
result = await agent.run('hello')
assert result.output == 'recovered'
async def test_wrapper_capability_delegates_on_node_run_error():
"""WrapperCapability delegates on_node_run_error to the wrapped capability."""
from pydantic_ai.result import FinalResult
from pydantic_graph import End
@dataclass
class NodeRecoverCap(AbstractCapability[Any]):
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
return End(FinalResult(output='node recovered'))
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model exploded')
agent = Agent(FunctionModel(failing_model), capabilities=[WrapperCapability(wrapped=NodeRecoverCap())])
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
while not isinstance(node, End):
node = await agent_run.next(node)
assert isinstance(node, End)
assert node.data.output == 'node recovered'
async def test_wrapper_capability_delegates_wrap_run_event_stream():
"""WrapperCapability delegates wrap_run_event_stream to the wrapped capability."""
observed_events: list[AgentStreamEvent] = []
@dataclass
class StreamObserverCap(AbstractCapability[Any]):
async def wrap_run_event_stream(
self,
ctx: RunContext[Any],
*,
stream: AsyncIterable[AgentStreamEvent],
) -> AsyncIterable[AgentStreamEvent]:
async for event in stream:
observed_events.append(event)
yield event
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[WrapperCapability(wrapped=StreamObserverCap())],
)
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in stream:
pass
await agent.run('hello', event_stream_handler=handler)
assert len(observed_events) > 0
async def test_wrapper_capability_delegates_on_model_request_error():
"""WrapperCapability delegates on_model_request_error to the wrapped capability."""
@dataclass
class ModelErrorRecoverCap(AbstractCapability[Any]):
async def on_model_request_error(
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='recovered from model error')])
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise RuntimeError('model request failed')
agent = Agent(FunctionModel(failing_model), capabilities=[WrapperCapability(wrapped=ModelErrorRecoverCap())])
result = await agent.run('hello')
assert result.output == 'recovered from model error'
async def test_wrapper_capability_delegates_on_tool_validate_error():
"""WrapperCapability delegates on_tool_validate_error to the wrapped capability."""
@dataclass
class ValidateErrorCap(AbstractCapability[Any]):
async def on_tool_validate_error(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
) -> dict[str, Any]:
# Recover by providing valid args
return {'x': 1}
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return ModelResponse(parts=[TextPart(content='done')])
if info.function_tools:
return ModelResponse(parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='invalid json!!')])
return ModelResponse(parts=[TextPart(content='no tools')]) # pragma: no cover
agent = Agent(FunctionModel(model_fn), capabilities=[WrapperCapability(wrapped=ValidateErrorCap())])
@agent.tool_plain
def my_tool(x: int) -> str:
return f'result: {x}'
result = await agent.run('call tool')
assert result.output == 'done'
async def test_wrapper_capability_delegates_on_tool_execute_error():
"""WrapperCapability delegates on_tool_execute_error to the wrapped capability."""
@dataclass
class ExecuteErrorCap(AbstractCapability[Any]):
async def on_tool_execute_error(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
return 'recovered tool result'
agent = Agent(
FunctionModel(tool_calling_model),
capabilities=[WrapperCapability(wrapped=ExecuteErrorCap())],
)
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
result = await agent.run('call tool')
assert result.output == 'final response'
# --- Tests for double-execution bug fix (streaming + before_node_run replacement) ---
class TestNodeStreamingWithHooks:
"""Tests that node streaming with event_stream_handler doesn't cause double model execution
when before_node_run replaces a node."""
async def test_before_node_run_replacement_no_double_execution(self):
"""When before_node_run replaces a ModelRequestNode and event_stream_handler is set,
the model should be called exactly once (not twice)."""
model_call_count = 0
async def counting_stream(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
nonlocal model_call_count
model_call_count += 1
yield 'streamed response'
cap = _ReplacingCapability()
agent = Agent(FunctionModel(simple_model_function, stream_function=counting_stream), capabilities=[cap])
events_received: list[AgentStreamEvent] = []
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for event in stream:
events_received.append(event)
result = await agent.run('hello', event_stream_handler=handler)
assert result.output == 'streamed response'
assert model_call_count == 1, f'Model was called {model_call_count} times, expected 1'
assert len(events_received) > 0
async def test_hook_ordering_with_event_stream_handler(self):
"""before_node_run fires BEFORE streaming events, wrap_node_run wraps the streaming,
and after_node_run fires after graph advancement."""
log: list[str] = []
@dataclass
class OrderTrackingCapability(AbstractCapability[Any]):
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
log.append(f'before:{type(node).__name__}')
return node
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
log.append(f'wrap:enter:{type(node).__name__}')
result = await handler(node)
log.append(f'wrap:exit:{type(node).__name__}')
return result
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
log.append(f'after:{type(node).__name__}')
return result
agent = Agent(
FunctionModel(simple_model_function, stream_function=simple_stream_function),
capabilities=[OrderTrackingCapability()],
)
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
async for _ in stream:
pass
log.append('stream:consumed')
await agent.run('hello', event_stream_handler=handler)
# For ModelRequestNode: before → wrap:enter → stream:consumed → wrap:exit → after
mr_before = log.index('before:ModelRequestNode')
mr_wrap_enter = log.index('wrap:enter:ModelRequestNode')
stream_consumed_idx = log.index('stream:consumed')
mr_wrap_exit = log.index('wrap:exit:ModelRequestNode')
mr_after = log.index('after:ModelRequestNode')
assert mr_before < mr_wrap_enter < stream_consumed_idx < mr_wrap_exit < mr_after
async def test_run_stream_before_node_run_replacement_no_double_execution(self):
"""Same as the run() test but for run_stream(): before_node_run replacement
should not cause double model execution."""
model_call_count = 0
async def counting_stream(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
nonlocal model_call_count
model_call_count += 1
yield 'streamed response'
cap = _ReplacingCapability()
agent = Agent(FunctionModel(simple_model_function, stream_function=counting_stream), capabilities=[cap])
async with agent.run_stream('hello') as streamed:
output = await streamed.get_output()
assert output == 'streamed response'
assert model_call_count == 1, f'Model was called {model_call_count} times, expected 1'
async def test_on_node_run_error_fires_in_run_stream(self):
"""on_node_run_error in run_stream() fires when wrap_node_run raises during graph advancement."""
error_log: list[str] = []
@dataclass
class WrapErrorCap(AbstractCapability[Any]):
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
# Raise on CallToolsNode — after UserPromptNode and ModelRequestNode pass through.
# ModelRequestNode with tool calls doesn't produce a FinalResultEvent in run_stream(),
# so it falls through to wrap_node_run; CallToolsNode is next and triggers the error.
from pydantic_ai._agent_graph import CallToolsNode
if isinstance(node, CallToolsNode):
raise RuntimeError('wrap error')
return await handler(node)
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
error_log.append(type(node).__name__)
raise error
agent = Agent(
FunctionModel(tool_calling_model, stream_function=tool_calling_stream_function),
capabilities=[WrapErrorCap()],
)
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
with pytest.raises(RuntimeError, match='wrap error'):
async with agent.run_stream('hello') as _streamed:
pass
assert error_log == ['CallToolsNode']
# --- ModelRetry from hooks tests ---
class TestModelRetryFromHooks:
"""Tests for raising ModelRetry from capability hooks."""
async def test_after_model_request_model_retry(self):
"""after_model_request raises ModelRetry — model is called again with retry prompt."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return make_text_response('bad response')
return make_text_response('good response')
@dataclass
class RetryCap(AbstractCapability[Any]):
retried: bool = False
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
if not self.retried:
self.retried = True
raise ModelRetry('Response was bad, please try again')
return response
cap = RetryCap()
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('hello')
assert result.output == 'good response'
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='bad response')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Response was bad, please try again',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='good response')],
usage=RequestUsage(input_tokens=66, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_after_model_request_model_retry_max_retries(self):
"""after_model_request raises ModelRetry repeatedly — hits output_retries."""
@dataclass
class AlwaysRetryCap(AbstractCapability[Any]):
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
raise ModelRetry('always bad')
agent = Agent(
FunctionModel(simple_model_function),
capabilities=[AlwaysRetryCap()],
retries={'output': 2},
)
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries'):
await agent.run('hello')
async def test_after_model_request_model_retry_streaming(self):
"""after_model_request raises ModelRetry during streaming with tool calls — model is called again."""
call_count = 0
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: return a tool call that after_model_request will reject
yield {0: DeltaToolCall(name='my_tool', json_args='{}', tool_call_id='call-1')}
elif call_count == 2:
# Second call (after retry): return text
yield 'good response'
else:
yield 'unexpected' # pragma: no cover
@dataclass
class RetryCap(AbstractCapability[Any]):
retried: bool = False
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
if not self.retried:
self.retried = True
raise ModelRetry('Response was bad, please try again')
return response
cap = RetryCap()
agent = Agent(
FunctionModel(simple_model_function, stream_function=stream_fn),
capabilities=[cap],
)
@agent.tool_plain
def my_tool() -> str:
return 'tool result' # pragma: no cover
async with agent.run_stream('hello') as streamed:
result = await streamed.get_output()
assert result == 'good response'
assert call_count == 2
assert streamed.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function:simple_model_function:stream_fn',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Response was bad, please try again',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='good response')],
usage=RequestUsage(input_tokens=50, output_tokens=2),
model_name='function:simple_model_function:stream_fn',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrap_model_request_model_retry_streaming_short_circuit(self):
"""wrap_model_request raises ModelRetry without calling handler during streaming."""
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
yield 'good response'
@dataclass
class ShortCircuitRetryCap(AbstractCapability[Any]):
call_count: int = 0
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
handler: Any,
) -> ModelResponse:
self.call_count += 1
if self.call_count == 1:
# Short-circuit: don't call handler, raise ModelRetry
raise ModelRetry('Short-circuit retry')
return await handler(request_context)
cap = ShortCircuitRetryCap()
agent = Agent(FunctionModel(simple_model_function, stream_function=stream_fn), capabilities=[cap])
async with agent.run_stream('hello') as streamed:
result = await streamed.get_output()
assert result == 'good response'
assert cap.call_count == 2
assert streamed.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Short-circuit retry',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='good response')],
usage=RequestUsage(input_tokens=50, output_tokens=2),
model_name='function:simple_model_function:stream_fn',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrap_model_request_model_retry_streaming_after_handler(self):
"""wrap_model_request raises ModelRetry after calling handler during streaming (tool call scenario)."""
call_count = 0
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
nonlocal call_count
call_count += 1
if call_count == 1:
# First call: tool call that wrap hook will reject
yield {0: DeltaToolCall(name='my_tool', json_args='{}', tool_call_id='call-1')}
else:
yield 'good response'
@dataclass
class AfterHandlerRetryCap(AbstractCapability[Any]):
retried: bool = False
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
handler: Any,
) -> ModelResponse:
response = await handler(request_context)
if not self.retried:
self.retried = True
raise ModelRetry('Post-handler retry')
return response
cap = AfterHandlerRetryCap()
agent = Agent(FunctionModel(simple_model_function, stream_function=stream_fn), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
return 'tool result' # pragma: no cover
async with agent.run_stream('hello') as streamed:
result = await streamed.get_output()
assert result == 'good response'
assert call_count == 2
assert streamed.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function:simple_model_function:stream_fn',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Post-handler retry',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='good response')],
usage=RequestUsage(input_tokens=50, output_tokens=2),
model_name='function:simple_model_function:stream_fn',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrap_model_request_model_retry(self):
"""wrap_model_request raises ModelRetry after calling handler — triggers retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return make_text_response('first attempt')
return make_text_response('second attempt')
@dataclass
class WrapRetryCap(AbstractCapability[Any]):
retried: bool = False
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
handler: Any,
) -> ModelResponse:
response = await handler(request_context)
if not self.retried:
self.retried = True
raise ModelRetry('Wrap says retry')
return response
cap = WrapRetryCap()
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('hello')
assert result.output == 'second attempt'
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='first attempt')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Wrap says retry',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='second attempt')],
usage=RequestUsage(input_tokens=63, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrap_model_request_model_retry_skips_on_error(self):
"""wrap_model_request raising ModelRetry should NOT call on_model_request_error."""
on_error_called = False
@dataclass
class WrapRetrySkipErrorCap(AbstractCapability[Any]):
async def wrap_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
handler: Any,
) -> ModelResponse:
raise ModelRetry('retry please')
async def on_model_request_error( # pragma: no cover — verifying this is NOT called
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
error: Exception,
) -> ModelResponse:
nonlocal on_error_called
on_error_called = True
raise error
agent = Agent(
FunctionModel(simple_model_function), capabilities=[WrapRetrySkipErrorCap()], retries={'output': 1}
)
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries'):
await agent.run('hello')
assert not on_error_called
async def test_on_model_request_error_model_retry(self):
"""on_model_request_error raises ModelRetry to recover via retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError('model failed')
return make_text_response('recovered response')
@dataclass
class ErrorRetryCap(AbstractCapability[Any]):
async def on_model_request_error(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
error: Exception,
) -> ModelResponse:
raise ModelRetry('Model failed, please try again')
cap = ErrorRetryCap()
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
result = await agent.run('hello')
assert result.output == 'recovered response'
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Model failed, please try again',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='recovered response')],
usage=RequestUsage(input_tokens=65, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_after_tool_execute_model_retry(self):
"""after_tool_execute raises ModelRetry — tool retry prompt sent to model, tool retried on success."""
tool_call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Always call the tool — after retry, the hook won't raise again
if info.function_tools:
# Check if we already got a tool return (second call succeeded)
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class AfterExecRetryCap(AbstractCapability[Any]):
retried: bool = False
async def after_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> Any:
if not self.retried:
self.retried = True
raise ModelRetry('Tool result is bad, try again')
return result
cap = AfterExecRetryCap()
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
nonlocal tool_call_count
tool_call_count += 1
return 'tool result'
result = await agent.run('call tool')
assert result.output == 'got: tool result'
assert tool_call_count == 2 # Tool called twice: first rejected by hook, second succeeds
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Tool result is bad, try again',
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=65, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got: tool result')],
usage=RequestUsage(input_tokens=67, output_tokens=7),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_before_tool_execute_model_retry(self):
"""before_tool_execute raises ModelRetry — tool execution is skipped, then succeeds on retry."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Always call the tool — after retry, the hook won't raise again
if info.function_tools:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
hooks = Hooks[Any]()
hook_called = False
@hooks.on.before_tool_execute
async def reject_first(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
) -> dict[str, Any]:
nonlocal hook_called
if not hook_called:
hook_called = True
raise ModelRetry('Not ready to execute, try again')
return args
agent = Agent(FunctionModel(model_fn), capabilities=[hooks], retries={'tools': 2, 'output': 2})
@agent.tool_plain
def my_tool() -> str:
return 'tool result'
result = await agent.run('call tool')
assert result.output == 'got: tool result'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Not ready to execute, try again',
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=65, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got: tool result')],
usage=RequestUsage(input_tokens=67, output_tokens=7),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_after_tool_execute_validation_error(self):
"""after_tool_execute raises ValidationError — converted to ToolRetryError for retry."""
from pydantic import TypeAdapter
tool_call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.function_tools:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class ValErrCap(AbstractCapability[Any]):
retried: bool = False
async def after_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> Any:
if not self.retried:
self.retried = True
# Simulate a user hook doing additional Pydantic validation
TypeAdapter(int).validate_python('not_an_int')
return result
cap = ValErrCap()
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
nonlocal tool_call_count
tool_call_count += 1
return 'tool result'
result = await agent.run('call tool')
assert result.output == 'got: tool result'
assert tool_call_count == 2 # Retried after ValidationError
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'int_parsing',
'loc': (),
'msg': 'Input should be a valid integer, unable to parse string as an integer',
'input': 'not_an_int',
}
],
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=88, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got: tool result')],
usage=RequestUsage(input_tokens=90, output_tokens=7),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_before_tool_execute_validation_error(self):
"""before_tool_execute raises ValidationError — converted to ToolRetryError for retry."""
from pydantic import TypeAdapter
tool_call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.function_tools:
for msg in messages:
for part in msg.parts:
if isinstance(part, ToolReturnPart):
return make_text_response(f'got: {part.content}')
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class ValErrCap(AbstractCapability[Any]):
retried: bool = False
async def before_tool_execute(
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
) -> dict[str, Any]:
if not self.retried:
self.retried = True
TypeAdapter(int).validate_python('not_an_int')
return args
cap = ValErrCap()
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
@agent.tool_plain
def my_tool() -> str:
nonlocal tool_call_count
tool_call_count += 1
return 'tool result'
result = await agent.run('call tool')
assert result.output == 'got: tool result'
# Tool only called once — before_tool_execute ValidationError prevented first call
assert tool_call_count == 1
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'int_parsing',
'loc': (),
'msg': 'Input should be a valid integer, unable to parse string as an integer',
'input': 'not_an_int',
}
],
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=88, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got: tool result')],
usage=RequestUsage(input_tokens=90, output_tokens=7),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrap_tool_execute_model_retry_skips_on_error(self):
"""wrap_tool_execute raising ModelRetry should NOT call on_tool_execute_error."""
on_error_called = False
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, RetryPromptPart):
return make_text_response('got retry')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class WrapExecRetryCap(AbstractCapability[Any]):
async def wrap_tool_execute(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
handler: Any,
) -> Any:
raise ModelRetry('Wrap says retry tool')
async def on_tool_execute_error( # pragma: no cover — verifying this is NOT called
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
nonlocal on_error_called
on_error_called = True
raise error
agent = Agent(FunctionModel(model_fn), capabilities=[WrapExecRetryCap()], retries={'tools': 2, 'output': 2})
@agent.tool_plain
def my_tool() -> str:
return 'tool result' # pragma: no cover
result = await agent.run('call tool')
assert result.output == 'got retry'
assert not on_error_called
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Wrap says retry tool',
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got retry')],
usage=RequestUsage(input_tokens=63, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_on_tool_execute_error_model_retry(self):
"""on_tool_execute_error raises ModelRetry to recover via retry."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, RetryPromptPart):
return make_text_response('got retry after error')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class ErrorRetryCap(AbstractCapability[Any]):
async def on_tool_execute_error(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
error: Exception,
) -> Any:
raise ModelRetry('Tool errored, please retry')
agent = Agent(FunctionModel(model_fn), capabilities=[ErrorRetryCap()], retries={'tools': 2, 'output': 2})
@agent.tool_plain
def my_tool() -> str:
raise ValueError('tool failed')
result = await agent.run('call tool')
assert result.output == 'got retry after error'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Tool errored, please retry',
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got retry after error')],
usage=RequestUsage(input_tokens=63, output_tokens=6),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_after_tool_validate_model_retry(self):
"""after_tool_validate raises ModelRetry — validation retry sent to model."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, RetryPromptPart):
return make_text_response('got validation retry')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class AfterValRetryCap(AbstractCapability[Any]):
async def after_tool_validate(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
) -> dict[str, Any]:
raise ModelRetry('Validated args are bad')
agent = Agent(FunctionModel(model_fn), capabilities=[AfterValRetryCap()], retries={'tools': 2, 'output': 2})
@agent.tool_plain
def my_tool() -> str:
return 'tool result' # pragma: no cover
result = await agent.run('call tool')
assert result.output == 'got validation retry'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Validated args are bad',
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got validation retry')],
usage=RequestUsage(input_tokens=63, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_before_tool_validate_model_retry(self):
"""before_tool_validate raises ModelRetry — validation retry sent to model."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
for msg in messages:
for part in msg.parts:
if isinstance(part, RetryPromptPart):
return make_text_response('got pre-validation retry')
if info.function_tools:
return ModelResponse(
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class BeforeValRetryCap(AbstractCapability[Any]):
async def before_tool_validate(
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: str | dict[str, Any],
) -> str | dict[str, Any]:
raise ModelRetry('Args look bad before validation')
agent = Agent(FunctionModel(model_fn), capabilities=[BeforeValRetryCap()], retries={'tools': 2, 'output': 2})
@agent.tool_plain
def my_tool() -> str:
return 'tool result' # pragma: no cover
result = await agent.run('call tool')
assert result.output == 'got pre-validation retry'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=52, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Args look bad before validation',
tool_name='my_tool',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='got pre-validation retry')],
usage=RequestUsage(input_tokens=64, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class TestCtxAgentInCapability:
"""Test that ctx.agent is available in capability hooks."""
async def test_ctx_agent_in_hooks(self):
hook_agent_names: list[str | None] = []
@dataclass
class AgentTrackingCap(AbstractCapability[Any]):
async def before_run(self, ctx: RunContext[Any]) -> None:
assert ctx.agent is not None
hook_agent_names.append(ctx.agent.name)
async def before_model_request(
self,
ctx: RunContext[Any],
request_context: ModelRequestContext,
) -> ModelRequestContext:
assert ctx.agent is not None
hook_agent_names.append(ctx.agent.name)
return request_context
agent = Agent(FunctionModel(simple_model_function), name='hook_test_agent', capabilities=[AgentTrackingCap()])
await agent.run('hello')
assert hook_agent_names == ['hook_test_agent', 'hook_test_agent']
# region --- Compaction capability tests ---
class TestCompaction:
def test_compaction_part_serialization(self):
"""CompactionPart round-trips through Pydantic serialization."""
from pydantic_ai.messages import CompactionPart, ModelMessagesTypeAdapter, ModelResponse
# Anthropic-style (text content)
anthropic_part = CompactionPart(content='Summary of conversation', provider_name='anthropic')
assert anthropic_part.has_content()
assert anthropic_part.part_kind == 'compaction'
# OpenAI-style (encrypted, no content)
openai_part = CompactionPart(
content=None,
id='cmp_123',
provider_name='openai',
provider_details={'encrypted_content': 'abc123', 'type': 'compaction'},
)
assert not openai_part.has_content()
assert openai_part.part_kind == 'compaction'
# Round-trip through serialization
response = ModelResponse(parts=[anthropic_part, openai_part])
messages: list[ModelMessage] = [response]
serialized = ModelMessagesTypeAdapter.dump_json(messages)
deserialized = ModelMessagesTypeAdapter.validate_json(serialized)
assert len(deserialized) == 1
assert isinstance(deserialized[0], ModelResponse)
parts = deserialized[0].parts
assert len(parts) == 2
assert isinstance(parts[0], CompactionPart)
assert parts[0].content == 'Summary of conversation'
assert parts[0].provider_name == 'anthropic'
assert isinstance(parts[1], CompactionPart)
assert parts[1].content is None
assert parts[1].id == 'cmp_123'
assert parts[1].provider_details == {'encrypted_content': 'abc123', 'type': 'compaction'}
async def test_openai_compaction_with_wrong_model(self):
"""OpenAICompaction raises UserError when used with a non-OpenAI model."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
agent = Agent(
FunctionModel(simple_model_function),
capabilities=[OpenAICompaction(message_count_threshold=0)],
)
with pytest.raises(UserError, match='OpenAICompaction requires OpenAIResponsesModel'):
await agent.run('hello')
async def test_openai_compaction_with_wrapped_wrong_model(self):
"""OpenAICompaction unwraps WrapperModel and raises for non-OpenAI model."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
from pydantic_ai.models.wrapper import WrapperModel
wrapped = WrapperModel(FunctionModel(simple_model_function))
agent = Agent(
wrapped,
capabilities=[OpenAICompaction(message_count_threshold=0)],
)
with pytest.raises(UserError, match='OpenAICompaction requires OpenAIResponsesModel'):
await agent.run('hello')
def test_openai_compaction_should_compact_with_trigger(self):
"""OpenAICompaction._should_compact delegates to custom trigger."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
cap = OpenAICompaction(trigger=lambda msgs: len(msgs) > 2)
assert not cap._should_compact([ModelRequest(parts=[UserPromptPart(content='hi')])]) # pyright: ignore[reportPrivateUsage]
assert cap._should_compact( # pyright: ignore[reportPrivateUsage]
[
ModelRequest(parts=[UserPromptPart(content='1')]),
ModelResponse(parts=[TextPart(content='r1')]),
ModelRequest(parts=[UserPromptPart(content='2')]),
]
)
def test_openai_compaction_should_compact_no_config(self):
"""Bare `OpenAICompaction()` is stateful mode and never triggers the before_model_request hook."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
cap = OpenAICompaction()
assert cap.stateless is False
assert not cap._should_compact([ModelRequest(parts=[UserPromptPart(content='hi')])]) # pyright: ignore[reportPrivateUsage]
def test_openai_compaction_mode_inference(self):
"""`stateless` is inferred from which mode-specific fields are passed."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
assert OpenAICompaction().stateless is False
assert OpenAICompaction(token_threshold=1000).stateless is False
assert OpenAICompaction(message_count_threshold=5).stateless is True
assert OpenAICompaction(trigger=lambda _msgs: True).stateless is True
def test_openai_compaction_stateful_model_settings(self):
"""Stateful mode returns `openai_context_management` via get_model_settings."""
pytest.importorskip('openai')
from types import SimpleNamespace
from typing import cast
from pydantic_ai.models.openai import OpenAICompaction
def _resolve(cap: OpenAICompaction, model_settings: dict[str, Any] | None = None) -> dict[str, Any]:
resolver = cap.get_model_settings()
assert resolver is not None
ctx = SimpleNamespace(model_settings=model_settings)
return cast(dict[str, Any], resolver(cast(Any, ctx)))
assert _resolve(OpenAICompaction()) == {'openai_context_management': [{'type': 'compaction'}]}
assert _resolve(OpenAICompaction(token_threshold=50_000)) == {
'openai_context_management': [{'type': 'compaction', 'compact_threshold': 50_000}]
}
# If the user already configured `openai_context_management` directly, we defer
# to them entirely and don't append our own entry. OpenAI's context_management
# list only meaningfully supports one `compaction` entry, so mixing the capability
# with manual config would produce ambiguous/conflicting state.
assert (
_resolve(
OpenAICompaction(token_threshold=50_000),
model_settings={'openai_context_management': [{'type': 'compaction', 'compact_threshold': 200_000}]},
)
== {}
)
# When user has other model settings but no `openai_context_management`,
# the capability's compaction entry is injected normally.
assert _resolve(
OpenAICompaction(token_threshold=50_000),
model_settings={'temperature': 0.5},
) == {'openai_context_management': [{'type': 'compaction', 'compact_threshold': 50_000}]}
# Stateless mode does not inject model settings
assert OpenAICompaction(message_count_threshold=5).get_model_settings() is None
def test_openai_compaction_rejects_mixed_fields(self):
"""Mixing stateful-only and stateless-only fields raises UserError."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
with pytest.raises(UserError, match='`token_threshold` is only valid for stateful compaction'):
OpenAICompaction(stateless=True, token_threshold=1000, message_count_threshold=5)
with pytest.raises(UserError, match='only valid for stateless compaction'):
OpenAICompaction(stateless=False, message_count_threshold=5)
with pytest.raises(UserError, match='only valid for stateless compaction'):
OpenAICompaction(stateless=False, trigger=lambda _msgs: True)
def test_openai_compaction_stateless_requires_trigger(self):
"""`stateless=True` without message_count_threshold or trigger raises UserError."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
with pytest.raises(UserError, match='requires `message_count_threshold` or `trigger`'):
OpenAICompaction(stateless=True)
def test_openai_compaction_serialization_name(self):
"""OpenAICompaction has the correct serialization name."""
pytest.importorskip('openai')
from pydantic_ai.models.openai import OpenAICompaction
assert OpenAICompaction.get_serialization_name() == 'OpenAICompaction'
def test_anthropic_compaction_serialization_name(self):
"""AnthropicCompaction has the correct serialization name."""
pytest.importorskip('anthropic')
from pydantic_ai.models.anthropic import AnthropicCompaction
assert AnthropicCompaction.get_serialization_name() == 'AnthropicCompaction'
async def test_compaction_part_in_function_model_history(self):
"""FunctionModel handles message history containing CompactionPart."""
from pydantic_ai.messages import CompactionPart
compaction_response = ModelResponse(
parts=[CompactionPart(content='Summary: user greeted.', provider_name='anthropic')],
provider_name='anthropic',
)
history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='Hello!')]),
compaction_response,
ModelRequest(parts=[UserPromptPart(content='How are you?')]),
]
agent = Agent(FunctionModel(simple_model_function))
result = await agent.run('Follow up', message_history=history)
assert result.output == 'response from model'
async def test_compaction_part_without_content_in_response(self):
"""CompactionPart with content=None (OpenAI-style) is handled alongside text."""
from pydantic_ai.messages import CompactionPart
def model_with_compaction(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
CompactionPart(content=None, id='cmp_123', provider_name='openai'),
TextPart(content='actual response'),
]
)
agent = Agent(FunctionModel(model_with_compaction))
result = await agent.run('hello')
assert result.output == 'actual response'
# endregion
def test_thread_executor_not_serializable() -> None:
assert ThreadExecutor.get_serialization_name() is None
async def test_thread_executor_capability() -> None:
tool_threads: list[str] = []
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts):
return ModelResponse(parts=[TextPart(content='done')])
return ModelResponse(parts=[ToolCallPart(tool_name='check_thread', args='{}')])
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='cap-pool')
try:
agent = Agent(FunctionModel(model_function), capabilities=[ThreadExecutor(executor)])
@agent.tool_plain
def check_thread() -> str:
tool_threads.append(threading.current_thread().name)
return 'ok'
result = await agent.run('test')
assert result.output == 'done'
assert len(tool_threads) == 1
assert tool_threads[0].startswith('cap-pool')
finally:
executor.shutdown(wait=True)
async def test_thread_executor_static_method() -> None:
tool_threads: list[str] = []
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts):
return ModelResponse(parts=[TextPart(content='done')])
return ModelResponse(parts=[ToolCallPart(tool_name='check_thread', args='{}')])
agent = Agent(FunctionModel(model_function))
@agent.tool_plain
def check_thread() -> str:
tool_threads.append(threading.current_thread().name)
return 'ok'
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='static-pool')
try:
with Agent.using_thread_executor(executor):
result = await agent.run('test')
assert result.output == 'done'
assert len(tool_threads) == 1
assert tool_threads[0].startswith('static-pool')
finally:
executor.shutdown(wait=True)
# --- Capability ordering tests ---
@dataclass
class OutermostCap(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position='outermost')
@dataclass
class InnermostCap(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position='innermost')
@dataclass
class PlainCapA(AbstractCapability[Any]):
pass
@dataclass
class PlainCapB(AbstractCapability[Any]):
pass
@dataclass
class WrapsACap(AbstractCapability[Any]):
"""Must wrap around PlainCapA."""
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wraps=[PlainCapA])
@dataclass
class RequiresOutermostCap(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(requires=[OutermostCap])
def _cap_names(combined: CombinedCapability) -> list[str]:
return [type(c).__name__ for c in combined.capabilities]
def test_ordering_outermost():
"""Capability declaring 'outermost' ends up at index 0."""
combined = CombinedCapability([PlainCapA(), OutermostCap(), PlainCapB()])
assert _cap_names(combined) == ['OutermostCap', 'PlainCapA', 'PlainCapB']
def test_ordering_innermost():
"""Capability declaring 'innermost' ends up last."""
combined = CombinedCapability([InnermostCap(), PlainCapA(), PlainCapB()])
assert _cap_names(combined) == ['PlainCapA', 'PlainCapB', 'InnermostCap']
def test_ordering_both_outermost_and_innermost():
"""Both outermost and innermost present."""
combined = CombinedCapability([PlainCapA(), InnermostCap(), OutermostCap()])
assert combined.capabilities[0].__class__ is OutermostCap
assert combined.capabilities[-1].__class__ is InnermostCap
def test_ordering_multiple_outermost_tier():
"""Multiple outermost capabilities form a tier; original order breaks ties."""
@dataclass
class OutermostCap2(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position='outermost')
combined = CombinedCapability([PlainCapA(), OutermostCap2(), OutermostCap()])
# Both outermost caps before PlainCapA; original order (OutermostCap2 before OutermostCap) preserved
assert _cap_names(combined) == ['OutermostCap2', 'OutermostCap', 'PlainCapA']
def test_ordering_multiple_innermost_tier():
"""Multiple innermost capabilities form a tier; original order breaks ties."""
@dataclass
class InnermostCap2(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position='innermost')
combined = CombinedCapability([InnermostCap(), InnermostCap2(), PlainCapA()])
# PlainCapA first, then both innermost in original order
assert _cap_names(combined) == ['PlainCapA', 'InnermostCap', 'InnermostCap2']
def test_ordering_outermost_tier_with_wraps():
"""wraps/wrapped_by refines order within the outermost tier."""
@dataclass
class OuterA(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position='outermost')
@dataclass
class OuterB(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(position='outermost', wraps=[OuterA])
# OuterB listed after OuterA, but wraps=[OuterA] overrides tiebreaker
combined = CombinedCapability([OuterA(), PlainCapA(), OuterB()])
assert _cap_names(combined) == ['OuterB', 'OuterA', 'PlainCapA']
def test_ordering_wraps():
"""Explicit 'wraps' edge is respected."""
combined = CombinedCapability([PlainCapA(), WrapsACap()])
assert _cap_names(combined) == ['WrapsACap', 'PlainCapA']
def test_ordering_wrapped_by():
"""Explicit 'wrapped_by' edge is respected."""
@dataclass
class WrappedByACap(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wrapped_by=[PlainCapA])
combined = CombinedCapability([WrappedByACap(), PlainCapA()])
assert _cap_names(combined) == ['PlainCapA', 'WrappedByACap']
def test_ordering_requires_present():
"""No error when required capability is present."""
combined = CombinedCapability([RequiresOutermostCap(), OutermostCap()])
assert len(combined.capabilities) == 2
def test_ordering_requires_missing():
with pytest.raises(UserError, match='`RequiresOutermostCap` requires `OutermostCap`'):
CombinedCapability([RequiresOutermostCap(), PlainCapA()])
def test_ordering_preserves_user_order():
"""Capabilities without constraints keep their relative order."""
a, b = PlainCapB(), PlainCapA()
combined = CombinedCapability([a, b])
assert list(combined.capabilities) == [a, b]
def test_ordering_nested_combined():
"""Leaves of a nested `CombinedCapability` participate as siblings in the outer sort.
`CombinedCapability` auto-flattens nested instances so each leaf is sorted
independently rather than as a group. Here `OutermostCap` (inside `inner`)
sorts to the front; its former sibling `PlainCapB` is unconstrained.
"""
inner = CombinedCapability([PlainCapB(), OutermostCap()])
combined = CombinedCapability([PlainCapA(), inner])
# `inner` is splatted; `OutermostCap` sorts first.
assert [type(c) for c in combined.capabilities] == [OutermostCap, PlainCapA, PlainCapB]
def test_ordering_nested_combined_no_constraints():
"""A nested `CombinedCapability` with no ordering leaves is splatted as flat siblings."""
inner = CombinedCapability([PlainCapA(), PlainCapB()])
combined = CombinedCapability([inner, OutermostCap()])
# `OutermostCap` first; `inner`'s leaves follow as flat siblings in their original order.
assert [type(c) for c in combined.capabilities] == [OutermostCap, PlainCapA, PlainCapB]
def test_ordering_nested_combined_wraps_without_position():
"""A `wraps` constraint on a leaf inside a nested `CombinedCapability` applies to that leaf only."""
inner = CombinedCapability([PlainCapB(), WrapsACap()])
combined = CombinedCapability([PlainCapA(), inner])
# `WrapsACap` is splatted and sorts before `PlainCapA`; `PlainCapB` is unconstrained
# and keeps its insertion order (it sits between PlainCapA and WrapsACap in the
# post-flatten input list, so the topo sort surfaces it first as ready-without-deps).
assert [type(c) for c in combined.capabilities] == [PlainCapB, WrapsACap, PlainCapA]
def test_ordering_single_capability():
"""Single capability in CombinedCapability is unchanged."""
cap = OutermostCap()
combined = CombinedCapability([cap])
assert list(combined.capabilities) == [cap]
def test_ordering_no_constraints_noop():
"""When no capability declares ordering, list is unchanged."""
a, b = PlainCapA(), PlainCapB()
combined = CombinedCapability([a, b])
assert list(combined.capabilities) == [a, b]
def test_ordering_cycle_detection():
@dataclass
class CycleA(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wraps=[CycleB])
@dataclass
class CycleB(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wraps=[CycleA])
with pytest.raises(UserError, match='Circular ordering constraints'):
CombinedCapability([CycleA(), CycleB()])
def test_ordering_mixed_positions_in_nested():
"""Mixed positions in a nested `CombinedCapability` work — leaves are splatted into the outer sort."""
inner = CombinedCapability([OutermostCap(), InnermostCap()])
combined = CombinedCapability([inner, PlainCapA()])
# `OutermostCap` first (outermost tier), `PlainCapA` middle, `InnermostCap` last (innermost tier).
assert [type(c) for c in combined.capabilities] == [OutermostCap, PlainCapA, InnermostCap]
def test_ordering_conflicting_positions_in_custom_nested_capability():
"""A custom capability tree cannot collapse outermost and innermost leaves into one ordered group."""
@dataclass
class NestedCapabilityGroup(AbstractCapability[Any]):
leaves: tuple[AbstractCapability[Any], ...]
def apply(self, visitor: Callable[[AbstractCapability[Any]], None]) -> None:
for leaf in self.leaves:
leaf.apply(visitor)
nested = NestedCapabilityGroup((OutermostCap(), InnermostCap()))
with pytest.raises(UserError, match='Conflicting positions among nested leaves'):
CombinedCapability([nested, PlainCapA()])
def test_ordering_hooks_ordering_parameter():
"""Hooks with ordering= are sorted according to those constraints."""
hooks = Hooks(ordering=CapabilityOrdering(position='outermost'))
combined = CombinedCapability([PlainCapA(), hooks, PlainCapB()])
assert combined.capabilities[0] is hooks
def test_ordering_hooks_ordering_wraps():
"""Hooks with ordering wraps= are placed before the referenced type."""
hooks = Hooks(ordering=CapabilityOrdering(wraps=[PlainCapA]))
combined = CombinedCapability([PlainCapA(), hooks])
assert combined.capabilities[0] is hooks
def test_ordering_hooks_ordering_wrapped_by():
"""Hooks with ordering wrapped_by= are placed after the referenced type."""
hooks = Hooks(ordering=CapabilityOrdering(wrapped_by=[PlainCapA]))
combined = CombinedCapability([hooks, PlainCapA()])
assert combined.capabilities[0].__class__ is PlainCapA
assert combined.capabilities[1] is hooks
def test_ordering_hooks_no_ordering():
"""Hooks without ordering= preserve their list position."""
hooks = Hooks()
combined = CombinedCapability([PlainCapA(), hooks, PlainCapB()])
assert combined.capabilities[1] is hooks
def test_ordering_hooks_ordering_requires():
"""Hooks with ordering requires= validates that the required type is present."""
hooks = Hooks(ordering=CapabilityOrdering(requires=[OutermostCap]))
with pytest.raises(UserError, match='`Hooks` requires `OutermostCap`'):
CombinedCapability([hooks, PlainCapA()])
def test_ordering_wraps_instance_ref():
"""wraps= with an instance ref only constrains the specific instance, not all instances of that type."""
target = PlainCapA()
other_a = PlainCapA()
@dataclass
class WrapsInstance(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wraps=[target])
# Arrange so that instance ref vs type ref produces a distinguishable result:
# - Instance ref wraps=[target] → only target must come after WrapsInstance
# - A type ref wraps=[PlainCapA] would constrain both other_a and target
combined = CombinedCapability([other_a, target, WrapsInstance()])
# other_a stays before WrapsInstance (no constraint), WrapsInstance before target
assert combined.capabilities[0] is other_a
assert combined.capabilities[1].__class__ is WrapsInstance
assert combined.capabilities[2] is target
def test_ordering_wrapped_by_instance_ref():
"""wrapped_by= can reference a specific capability instance."""
wrapper = PlainCapA()
@dataclass
class WrappedByInstance(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wrapped_by=[wrapper])
combined = CombinedCapability([WrappedByInstance(), wrapper])
assert combined.capabilities[0] is wrapper
assert combined.capabilities[1].__class__ is WrappedByInstance
def test_ordering_hooks_wraps_instance():
"""Hooks can order relative to a specific capability instance via wraps=."""
target = PlainCapA()
hooks = Hooks(ordering=CapabilityOrdering(wraps=[target]))
combined = CombinedCapability([target, hooks])
assert combined.capabilities[0] is hooks
assert combined.capabilities[1] is target
def test_ordering_hooks_wrapped_by_instance():
"""Hooks can order relative to a specific capability instance via wrapped_by=."""
outer = PlainCapA()
hooks = Hooks(ordering=CapabilityOrdering(wrapped_by=[outer]))
combined = CombinedCapability([hooks, outer])
assert combined.capabilities[0] is outer
assert combined.capabilities[1] is hooks
def test_ordering_instance_ref_not_present():
"""Instance ref in wraps= that isn't in the list has no effect (no edge added)."""
absent = PlainCapA()
hooks = Hooks(ordering=CapabilityOrdering(wraps=[absent]))
# absent is NOT in the capabilities list — the wraps ref should be a no-op
combined = CombinedCapability([PlainCapB(), hooks])
# Order preserved since the instance ref doesn't match anything
assert combined.capabilities[0].__class__ is PlainCapB
assert combined.capabilities[1] is hooks
def test_ordering_mixed_type_and_instance_refs():
"""wraps= can mix type refs and instance refs."""
target_instance = PlainCapB()
@dataclass
class MixedRefs(AbstractCapability[Any]):
def get_ordering(self) -> CapabilityOrdering:
return CapabilityOrdering(wraps=[PlainCapA, target_instance])
combined = CombinedCapability([PlainCapA(), target_instance, MixedRefs()])
assert combined.capabilities[0].__class__ is MixedRefs
# --- Hook recovery tests (after_node_run End→node, ErrorMarker in next_node) ---
async def test_after_node_run_end_to_node_override():
"""after_node_run can convert an End result back to a node, continuing execution."""
from pydantic_ai import ModelRequestNode
call_count = 0
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart('first answer')])
return ModelResponse(parts=[TextPart('second answer')])
redirected = False
@dataclass
class RedirectOnFirstEnd(AbstractCapability[Any]):
"""Redirects the first End back to a ModelRequestNode to force a second model call."""
_redirected: bool = field(default=False, init=False)
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
nonlocal redirected
if isinstance(result, End) and not self._redirected:
self._redirected = True
redirected = True
return ModelRequestNode(ModelRequest(parts=[UserPromptPart(content='try again')])) # pyright: ignore[reportUnknownVariableType]
return result # pyright: ignore[reportUnknownVariableType]
agent = Agent(FunctionModel(llm), capabilities=[RedirectOnFirstEnd()])
result = await agent.run('hello')
assert redirected
assert call_count == 2
assert result.output == 'second answer'
async def test_next_node_raises_on_error_marker():
"""Accessing next_node after a node error re-raises the original exception."""
call_count = 0
def failing_then_ok_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
raise ValueError('model failure')
agent = Agent(FunctionModel(failing_then_ok_model))
async with agent.iter('hello') as agent_run:
node = agent_run.next_node
node = cast(Any, await agent_run.next(cast(Any, node)))
with pytest.raises(ValueError, match='model failure'):
await agent_run.next(node)
# After an unrecovered error, next_node should re-raise
with pytest.raises(ValueError, match='model failure'):
_ = agent_run.next_node
async def test_on_node_run_error_returns_end():
"""on_node_run_error can recover from an exception by returning End, completing the run."""
from pydantic_ai.result import FinalResult
def always_fails(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
raise ValueError('model exploded')
@dataclass
class RecoverWithEnd(AbstractCapability[Any]):
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
return End(FinalResult('recovered output'))
agent = Agent(FunctionModel(always_fails), capabilities=[RecoverWithEnd()])
result = await agent.run('hello')
assert result.output == 'recovered output'
async def test_on_node_run_error_returns_node():
"""on_node_run_error can recover by returning a retry node, continuing execution."""
from pydantic_ai import ModelRequestNode
call_count = 0
def fails_then_succeeds(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
raise ValueError('transient failure')
return ModelResponse(parts=[TextPart('recovered')])
@dataclass
class RetryOnError(AbstractCapability[Any]):
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
# Retry by returning a new ModelRequestNode with the same request
return ModelRequestNode(request=node.request) # pyright: ignore[reportUnknownVariableType]
agent = Agent(FunctionModel(fails_then_succeeds), capabilities=[RetryOnError()])
result = await agent.run('hello')
assert call_count == 2
assert result.output == 'recovered'
async def test_after_node_run_node_to_end():
"""after_node_run can short-circuit a run by converting a continuation node to End."""
from pydantic_ai.result import FinalResult
model_call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal model_call_count
model_call_count += 1
# Always request a tool call, producing a CallToolsNode (not End)
return ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args='{}')])
@dataclass
class ShortCircuitAfterModelRequest(AbstractCapability[Any]):
"""Short-circuit after the first model request node by converting the continuation to End."""
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
from pydantic_ai import ModelRequestNode
# The ModelRequestNode produces a CallToolsNode (not End); convert it to End.
if isinstance(node, ModelRequestNode) and not isinstance(result, End):
return End(FinalResult('short-circuited'))
return result # pyright: ignore[reportUnknownVariableType]
agent = Agent(FunctionModel(model_fn), capabilities=[ShortCircuitAfterModelRequest()])
@agent.tool_plain
def my_tool() -> str:
return 'tool result' # pragma: no cover
result = await agent.run('hello')
assert result.output == 'short-circuited'
assert model_call_count == 1
# ===== Pending Message Queue Tests =====
async def test_enqueue_asap_message_from_tool():
"""`'asap'` messages enqueued from a tool are injected before the next model request."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_msg', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject_msg(ctx: RunContext[object]) -> str:
ctx.enqueue('Injected asap message')
return 'ok'
result = await agent.run('Hello')
assert result.output == 'done'
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='inject_msg', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='inject_msg',
content='ok',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='Injected asap message', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_enqueue_when_idle_message_prevents_end():
"""`'when_idle'` messages prevent the agent from ending and are drained into a new ModelRequest."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_follow_up', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
elif call_count == 2:
# Agent produces final result, but follow-up is pending
return ModelResponse(
parts=[TextPart(content='premature end')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
else:
# After follow-up is drained, agent produces real final result
return ModelResponse(
parts=[TextPart(content='final answer after follow-up')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject_follow_up(ctx: RunContext[object]) -> str:
ctx.enqueue('Follow-up context', priority='when_idle')
return 'ok'
result = await agent.run('Hello')
assert result.output == 'final answer after follow-up'
assert call_count == 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='inject_follow_up', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='inject_follow_up',
content='ok',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='premature end')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='Follow-up context', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='final answer after follow-up')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_enqueue_when_idle_redirects_after_output_tool_end():
"""A `when_idle` message redirects the run even when it would end via an output tool.
The run terminates when the model calls an output tool (`ToolOutput` mode), which produces
an `End` from `CallToolsNode`. The drain's `after_node_run` still sees that `End` and
redirects into a fresh request, so the agent gets another turn after the structured output —
and the final `result.output` comes from that later turn.
"""
class Answer(BaseModel):
value: int
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
output_tool = info.output_tools[0].name
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_follow_up', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
if call_count == 2:
# Would end the run via the output tool, but a `when_idle` message is pending.
return ModelResponse(
parts=[ToolCallPart(tool_name=output_tool, args='{"value": 1}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
# After the follow-up is drained, the model produces the real final output.
return ModelResponse(
parts=[ToolCallPart(tool_name=output_tool, args='{"value": 2}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn), output_type=Answer)
@agent.tool
def inject_follow_up(ctx: RunContext[object]) -> str:
ctx.enqueue('Follow-up context', priority='when_idle')
return 'ok'
result = await agent.run('Hello')
assert result.output == Answer(value=2)
assert call_count == 3
# The `when_idle` follow-up lands as its own request after the first (superseded) output-tool
# call, redirecting the run so the second output-tool call produces the real output.
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='inject_follow_up', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='inject_follow_up',
content='ok',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"value": 1}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
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(),
),
ModelRequest(
parts=[UserPromptPart(content='Follow-up context', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(
tool_name='final_result',
args='{"value": 2}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
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_enqueue_from_agent_run():
"""Messages can be enqueued from external code via AgentRun.enqueue."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
return ModelResponse(
parts=[TextPart(content=f'response {call_count}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
async with agent.iter('Hello') as agent_run:
assert agent_run.pending_messages == []
# Enqueue a when_idle message from external code before iteration
agent_run.enqueue('External follow-up', priority='when_idle')
assert len(agent_run.pending_messages) == 1
# Use next() to drive iteration so after_node_run fires
node = agent_run.next_node
while not isinstance(node, End):
node = await agent_run.next(node)
assert agent_run.result is not None
assert call_count == 2 # First response triggers End, follow-up prevents it, second response is final
assert agent_run.result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='response 1')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='External follow-up', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='response 2')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_bare_async_for_raises_with_undrained_pending_messages():
"""Bare `async for` reaching End with undrained `when_idle` messages raises rather than stranding them.
`when_idle` (and end-of-step `asap` leftovers) drain in `after_node_run`, which bare
iteration skips — so they'd be silently lost. `__anext__` raises
`UndrainedPendingMessagesError` when it would yield the `End` node with a non-empty queue,
pointing the user at `next()` driving.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
async with agent.iter('hi') as agent_run:
agent_run.enqueue('stranded follow-up', priority='when_idle')
with pytest.raises(UndrainedPendingMessagesError, match='undrained pending messages'):
async for _ in agent_run:
pass
# The message was never delivered: it's still queued.
assert len(agent_run.pending_messages) == 1
async def test_pending_messages_accessible_on_run_context():
"""RunContext.pending_messages is accessible and initially empty."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='check_queue', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def check_queue(ctx: RunContext[object]) -> str:
# The queue must be live (mutations from inside a tool reach the drain).
assert ctx.pending_messages is not None
assert len(ctx.pending_messages) == 0
ctx.enqueue('observed', priority='asap')
assert len(ctx.pending_messages) == 1
return 'done'
result = await agent.run('Test')
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='check_queue', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='check_queue',
content='done',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='observed', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_enqueue_with_no_args_is_a_noop():
"""`ctx.enqueue()` and `agent_run.enqueue()` with no content are silent no-ops.
Producers that conditionally enqueue (e.g. "announce if new tools were discovered")
can call `enqueue(*maybe_items)` without guarding for the empty case — `enqueue`
simply doesn't append a `PendingMessage` when there's nothing to send.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='from_tool', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def from_tool(ctx: RunContext[object]) -> str:
ctx.enqueue() # no-op, no exception
assert ctx.pending_messages == []
return 'ok'
async with agent.iter('hi') as agent_run:
agent_run.enqueue() # no-op, no exception
assert agent_run.pending_messages == []
async for _ in agent_run:
pass
async def test_enqueue_coerces_string_to_user_prompt():
"""A bare string passed to `enqueue` is wrapped in a `UserPromptPart`."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_msg', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject_msg(ctx: RunContext[object]) -> str:
ctx.enqueue('steering as plain string')
return 'ok'
result = await agent.run('Hello')
injected = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart) and part.content == 'steering as plain string'
]
assert len(injected) == 1, 'string-coerced enqueue did not land as a UserPromptPart'
async def test_enqueue_accepts_multimodal_user_content():
"""Adjacent user-content args (text + multi-modal) are gathered into one `UserPromptPart`."""
image = ImageUrl(url='https://example.com/image.png')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_msg', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject_msg(ctx: RunContext[object]) -> str:
ctx.enqueue('look at this', image)
return 'ok'
result = await agent.run('Hello')
injected = [
part
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
for part in msg.parts
if isinstance(part, UserPromptPart) and part.content == ['look at this', image]
]
assert len(injected) == 1
async def test_enqueue_accepts_model_request_passthrough():
"""A full `ModelRequest` is enqueued verbatim, preserving `instructions`/`metadata`.
Two passthroughs cover both branches of the fill-in-if-unset stamping logic:
one with `timestamp`/`run_id` unset (drain stamps them); one with both set
(drain leaves them alone).
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_msg', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
unstamped = ModelRequest(
parts=[UserPromptPart(content='wire-level payload')],
instructions='do this carefully',
metadata={'origin': 'webhook-42'},
)
preset_timestamp = datetime(2024, 1, 1, tzinfo=timezone.utc)
prestamped = ModelRequest(
parts=[UserPromptPart(content='already stamped')],
instructions='preserve me',
timestamp=preset_timestamp,
run_id='caller-run-id',
conversation_id='caller-conv-id',
)
@agent.tool
def inject_msg(ctx: RunContext[object]) -> str:
ctx.enqueue(unstamped)
ctx.enqueue(prestamped)
return 'ok'
result = await agent.run('Hello')
injected_unstamped = next(
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest) and msg.instructions == 'do this carefully'
)
assert injected_unstamped.metadata == {'origin': 'webhook-42'}
# Drain should have stamped timestamp/run_id/conversation_id since the user didn't set them.
assert injected_unstamped.timestamp is not None
assert injected_unstamped.run_id is not None
assert injected_unstamped.conversation_id is not None
injected_prestamped = next(
msg for msg in result.all_messages() if isinstance(msg, ModelRequest) and msg.instructions == 'preserve me'
)
# Producer-supplied timestamp/run_id/conversation_id are preserved (drain doesn't overwrite).
assert injected_prestamped.timestamp == preset_timestamp
assert injected_prestamped.run_id == 'caller-run-id'
assert injected_prestamped.conversation_id == 'caller-conv-id'
def test_pending_message_drain_capability_is_not_spec_constructible():
"""`PendingMessageDrainCapability` is auto-injected only; can't be in an `AgentSpec`."""
from pydantic_ai.capabilities._pending_messages import PendingMessageDrainCapability
assert PendingMessageDrainCapability.get_serialization_name() is None
def test_pending_message_allows_empty_request():
"""`PendingMessage` doesn't validate its `messages`; empty-parts requests are tolerated.
`enqueue()` already filters out the no-args case (no `PendingMessage` is appended).
An empty `ModelRequest` reaching the queue is harmless — the drain stamps and forwards
it, and downstream wire-merging absorbs zero-part messages as a natural no-op.
"""
from pydantic_ai._enqueue import PendingMessage
msg = PendingMessage(messages=[ModelRequest(parts=[])])
assert msg.priority == 'asap'
assert msg.messages[0].parts == []
def test_enqueue_without_live_queue_raises():
"""`ctx.enqueue` raises when the `RunContext` isn't backed by a running agent's queue.
Synthetic contexts (e.g. the one `Agent.system_prompt_parts` builds to resolve system
prompts outside a run) have no queue to drain to, so enqueue fails loudly instead of
silently dropping the message.
"""
ctx = RunContext[None](deps=None, model=TestModel(), usage=RunUsage(), prompt=None, messages=[])
assert ctx.pending_messages is None
with pytest.raises(UserError, match='only available during an agent run'):
ctx.enqueue('this has nowhere to go')
async def test_enqueue_parts_style_calls_produce_one_request_per_call():
"""Each `enqueue` call produces its own `ModelRequest` in history.
Each `enqueue` call pre-packages its content into a `ModelRequest` at enqueue time,
so two calls produce two `PendingMessage`s with two separate `ModelRequest`s. The
history reflects per-call structure; wire-level `_clean_message_history` still merges
adjacent compatible `ModelRequest`s so the model sees one turn. Producers wanting a
single message should pass a single `ModelRequest(parts=[...])` themselves.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_msg', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject_msg(ctx: RunContext[object]) -> str:
ctx.enqueue('first hint')
ctx.enqueue('second hint')
return 'ok'
result = await agent.run('Hello')
drained = [
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
and any(isinstance(p, UserPromptPart) and p.content in ('first hint', 'second hint') for p in msg.parts)
]
assert len(drained) == 2, 'expected one ModelRequest per enqueue call'
assert [p.content for msg in drained for p in msg.parts if isinstance(p, UserPromptPart)] == [
'first hint',
'second hint',
]
async def test_enqueue_passthrough_stays_separate_from_parts_style():
"""A passthrough `ModelRequest` stays its own message even when surrounded by parts-style enqueues."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_msg', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject_msg(ctx: RunContext[object]) -> str:
ctx.enqueue('before')
ctx.enqueue(
ModelRequest(parts=[UserPromptPart(content='passthrough')], instructions='careful'),
)
ctx.enqueue('after')
return 'ok'
result = await agent.run('Hello')
# Three drained requests: synthesized(["before"]), passthrough, synthesized(["after"]).
drained = [
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
and any(isinstance(p, UserPromptPart) and p.content in ('before', 'passthrough', 'after') for p in msg.parts)
]
assert len(drained) == 3
contents = [
next(
p.content
for p in r.parts
if isinstance(p, UserPromptPart) and p.content in ('before', 'passthrough', 'after')
)
for r in drained
]
assert contents == ['before', 'passthrough', 'after']
# Passthrough preserved its instructions.
assert drained[1].instructions == 'careful'
assert drained[0].instructions is None
assert drained[2].instructions is None
async def test_enqueue_system_prompt_part():
"""A bare `SystemPromptPart` is coalesced into a `ModelRequest` and delivered.
Now that mid-conversation `SystemPromptPart`s are rendered inline (not hoisted) on all
providers, `enqueue` accepts request parts directly — no `ModelRequest` wrapper needed.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='announce', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def announce(ctx: RunContext[object]) -> str:
ctx.enqueue(SystemPromptPart(content='New tools are now available.'))
return 'ok'
result = await agent.run('Hello')
injected = next(
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
and any(isinstance(p, SystemPromptPart) and p.content == 'New tools are now available.' for p in msg.parts)
)
assert injected is not None
async def test_enqueue_interleaved_response_and_request():
"""One `enqueue` call can inject an interleaved `ModelResponse` + `ModelRequest` exchange.
This is the synthetic "tool-search call + result" shape (a `ModelResponse` carrying the call
followed by a `ModelRequest` carrying the return). Both land in history in order, and the
trailing request is what the agent responds to next.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject_exchange', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
synthetic_response = ModelResponse(
parts=[TextPart(content='synthetic prior turn')],
usage=RequestUsage(input_tokens=1, output_tokens=1),
)
@agent.tool
def inject_exchange(ctx: RunContext[object]) -> str:
ctx.enqueue(
synthetic_response,
ModelRequest(parts=[UserPromptPart(content='follow-up after synthetic turn')]),
priority='when_idle',
)
return 'ok'
result = await agent.run('Hello')
# The synthetic response is appended to history immediately before its paired request.
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='inject_exchange', args='{}', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='inject_exchange',
content='ok',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='synthetic prior turn')],
usage=RequestUsage(input_tokens=1, output_tokens=1),
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='follow-up after synthetic turn', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_enqueue_rejects_content_not_ending_in_request():
"""Enqueued content must end in a `ModelRequest`; a lone `ModelResponse` is rejected.
The agent needs a request to respond to — content that ends in a `ModelResponse` (with no
trailing request/part-style items) would leave nothing for the model to react to.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(msg, ModelResponse) for msg in messages):
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='from_tool', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
lone_response = ModelResponse(
parts=[TextPart(content='synthetic')], usage=RequestUsage(input_tokens=1, output_tokens=1)
)
@agent.tool
def from_tool(ctx: RunContext[object]) -> str:
with pytest.raises(UserError, match='must end with a `ModelRequest`'):
ctx.enqueue(lone_response)
return 'ok'
async with agent.iter('hi') as agent_run:
with pytest.raises(UserError, match='must end with a `ModelRequest`'):
agent_run.enqueue(lone_response)
async for _ in agent_run:
pass
async def test_drain_rejects_directly_queued_content_not_ending_in_request():
"""Directly appending a malformed `PendingMessage` raises a `UserError` at end-of-run drain.
`enqueue` enforces the "ends in a `ModelRequest`" rule up front, but `RunContext.pending_messages`
is public, so a producer can append a `PendingMessage` directly. The end-of-run drain catches a
request-less message with a helpful `UserError` rather than a bare assertion.
"""
from pydantic_ai._enqueue import PendingMessage
lone_response = ModelResponse(
parts=[TextPart(content='synthetic')], usage=RequestUsage(input_tokens=1, output_tokens=1)
)
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if any(isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts):
return ModelResponse(parts=[TextPart(content='done')], usage=RequestUsage(input_tokens=10, output_tokens=5))
return ModelResponse(
parts=[ToolCallPart(tool_name='queue_bad', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def queue_bad(ctx: RunContext[object]) -> str:
assert ctx.pending_messages is not None
ctx.pending_messages.append(PendingMessage(messages=[lone_response], priority='when_idle'))
return 'ok'
with pytest.raises(UserError, match='must end with a `ModelRequest`'):
await agent.run('hi')
async def test_enqueue_asap_with_rich_message_history_tail():
"""`'asap'` enqueue lands as its own `ModelRequest` in history *and* gets wire-merged into the rich tail.
The history keeps the un-merged view (drain's request is a separate `ModelRequest`
after the rich tail) so `all_messages()` reflects per-call structure. On the wire,
`_clean_message_history` merges the two adjacent `ModelRequest`s and sorts
`ToolReturnPart`/`RetryPromptPart` first — non-tool parts keep arrival order, so the
enqueued content lands at the *end* of the merged turn (not interleaved between
existing parts). Captures the `messages` arg `FunctionModel` actually received to
validate the wire-level merge through the public path.
"""
captured_wire_messages: list[list[ModelMessage]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
captured_wire_messages.append(messages)
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
history: list[ModelMessage] = [
ModelRequest(parts=[UserPromptPart(content='original prompt')]),
ModelResponse(
parts=[ToolCallPart(tool_name='hint', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
),
ModelRequest(
parts=[
ToolReturnPart(tool_name='hint', content='ok', tool_call_id='call-1'),
UserPromptPart(content='follow-up question'),
],
),
]
async with agent.iter(message_history=history) as agent_run:
agent_run.enqueue('injected after rich tail')
async for _ in agent_run:
pass
assert agent_run.result is not None
# `all_messages()` keeps the un-merged view (drain's request is a separate
# `ModelRequest` after the rich tail).
assert agent_run.result.all_messages() == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='original prompt', timestamp=IsDatetime())]),
ModelResponse(
parts=[ToolCallPart(tool_name='hint', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
ToolReturnPart(tool_name='hint', content='ok', tool_call_id='call-1', timestamp=IsDatetime()),
UserPromptPart(content='follow-up question', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
),
ModelRequest(
parts=[UserPromptPart(content='injected after rich tail', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
# And the wire-level view: the rich tail and the drained request merged into one
# `ModelRequest`, with `ToolReturnPart` first and the user-prompt parts in arrival
# order (so the enqueued content lands at the end, not interleaved).
assert len(captured_wire_messages) == 1
assert captured_wire_messages[0] == snapshot(
[
ModelRequest(parts=[UserPromptPart(content='original prompt', timestamp=IsDatetime())]),
ModelResponse(
parts=[ToolCallPart(tool_name='hint', args='{}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
),
ModelRequest(
parts=[
ToolReturnPart(tool_name='hint', content='ok', tool_call_id='call-1', timestamp=IsDatetime()),
UserPromptPart(content='follow-up question', timestamp=IsDatetime()),
UserPromptPart(content='injected after rich tail', timestamp=IsDatetime()),
],
timestamp=IsDatetime(),
),
]
)
async def test_enqueue_asap_drains_at_end_if_arrived_during_final_step():
"""`'asap'` arriving during the final step (after its `before_model_request` drain) still gets delivered.
Simulates the background-tools pattern: a long-running task completes *during* what
would have been the model's final response. The enqueue happens after the step's
`before_model_request` drain has already fired, so the message can only be picked up
by the end-of-run drain (matching pi-mono's drain-on-end). Without this fallback the
message would be lost. `'asap'` semantically means "deliver at the earliest opportunity"
— including redirecting if the agent would otherwise terminate before another call.
"""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(
parts=[TextPart(content='would-have-ended')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[TextPart(content='final after late asap')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
@dataclass
class BackgroundTaskCap(AbstractCapability[Any]):
"""Simulates a background task that completes mid-model-response on the first call only."""
fired: bool = False
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
if not self.fired:
ctx.enqueue('background task result', priority='asap')
self.fired = True
return response
agent = Agent(FunctionModel(model_fn), capabilities=[BackgroundTaskCap()])
result = await agent.run('Hello')
assert result.output == 'final after late asap'
assert call_count == 2
# The 'asap' message landed in its own ModelRequest before the final response,
# not lost despite the agent producing a no-tool-call response.
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='Hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='would-have-ended')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[UserPromptPart(content='background task result', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='final after late asap')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_enqueue_when_idle_drains_after_leftover_asap():
"""If both `'asap'` and `'when_idle'` are queued at end-of-run, `'asap'` drains first."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Only fire enqueues once.
already_enqueued = any(
isinstance(p, UserPromptPart) and p.content in ('A', 'B')
for msg in messages
if isinstance(msg, ModelRequest)
for p in msg.parts
)
# If we've already seen our injected messages, just terminate.
if already_enqueued:
return ModelResponse(
parts=[TextPart(content='done')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[ToolCallPart(tool_name='inject', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
agent = Agent(FunctionModel(model_fn))
@agent.tool
def inject(ctx: RunContext[object]) -> str:
ctx.enqueue('B', priority='when_idle')
ctx.enqueue('A', priority='asap')
return 'ok'
result = await agent.run('Hello')
# Both A and B should appear in history. `'asap'` (A) drains in `before_model_request`
# before the second call. `'when_idle'` (B) drains at end-of-run when the second
# response has no tool calls.
requests_with_injected = [
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
and any(isinstance(p, UserPromptPart) and p.content in ('A', 'B') for p in msg.parts)
]
contents = [
[p.content for p in r.parts if isinstance(p, UserPromptPart) and p.content in ('A', 'B')]
for r in requests_with_injected
]
assert contents == [['A'], ['B']], f'expected A before B in separate requests, got {contents}'
async def test_enqueue_priorities_stay_separate_when_both_drain_at_end_of_run():
"""When both `'asap'` and `'when_idle'` parts-style payloads drain together at end-of-run,
they land in separate `ModelRequest`s — the priority split stays visible in history.
Reaches the case Devin flagged: a tool enqueues `'when_idle'` (which sits until
end-of-run), and a capability `after_model_request` hook enqueues `'asap'` during the
final step (after that step's `before_model_request` drain has already fired). Both
arrive at `after_node_run`. Without the per-priority split they'd merge into one
synthesized request, blurring the priority distinction in the persisted history.
On the wire `_clean_message_history` still merges them for the model.
"""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name='inject', args='{}')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
if call_count == 2:
return ModelResponse(
parts=[TextPart(content='would-have-ended')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
return ModelResponse(
parts=[TextPart(content='final')],
usage=RequestUsage(input_tokens=10, output_tokens=5),
)
@dataclass
class LateAsapCap(AbstractCapability[Any]):
"""Enqueues an `'asap'` message during `after_model_request` of the no-tool-call step.
Fires after the step's `before_model_request` drain, so the message can only be
delivered via the end-of-run drain in `after_node_run`.
"""
fired: bool = False
async def after_model_request(
self,
ctx: RunContext[Any],
*,
request_context: ModelRequestContext,
response: ModelResponse,
) -> ModelResponse:
if not self.fired and any(
isinstance(p, TextPart) and p.content == 'would-have-ended' for p in response.parts
):
ctx.enqueue('asap-from-cap')
self.fired = True
return response
agent = Agent(FunctionModel(model_fn), capabilities=[LateAsapCap()])
@agent.tool
def inject(ctx: RunContext[object]) -> str:
ctx.enqueue('when-idle-from-tool', priority='when_idle')
return 'ok'
result = await agent.run('Hello')
assert result.output == 'final'
# Find the two end-of-run drained requests: one with the 'asap' content, one with 'when_idle'.
drained = [
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
and any(
isinstance(p, UserPromptPart) and p.content in ('asap-from-cap', 'when-idle-from-tool') for p in msg.parts
)
]
contents = [
next(
p.content
for p in r.parts
if isinstance(p, UserPromptPart) and p.content in ('asap-from-cap', 'when-idle-from-tool')
)
for r in drained
]
assert contents == ['asap-from-cap', 'when-idle-from-tool'], (
f'asap and when_idle should land in separate ModelRequests with asap first, got {contents}'
)
# Each priority bucket got its own ModelRequest (not merged into one).
assert all(len([p for p in r.parts if isinstance(p, UserPromptPart)]) == 1 for r in drained)
# --- Output hook tests ---
class MyOutput(BaseModel):
value: int
class TestBeforeOutputValidate:
"""before_output_validate can transform raw output before parsing."""
async def test_structured_prompted_output(self):
"""before_output_validate transforms raw text before Pydantic validation for PromptedOutput."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": "not_a_number"}')])
@dataclass
class FixJsonCap(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
if isinstance(output, str):
return output.replace('"not_a_number"', '42')
return output # pragma: no cover
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[FixJsonCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
async def test_plain_str_output(self):
"""For plain str output, validate hooks are skipped; process hooks fire instead."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('hello world')
@dataclass
class LogCap(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('validate') # pragma: no cover — should NOT fire for plain text
return output # pragma: no cover
async def before_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append(f'process:{output}')
assert output_context.mode == 'text'
assert output_context.output_type is str
assert output_context.has_function is False
return output
agent = Agent(FunctionModel(model_fn), capabilities=[LogCap()])
result = await agent.run('hello')
assert result.output == 'hello world'
# Validate hooks do NOT fire for plain text; only process hooks fire
assert log == ['process:hello world']
async def test_text_output_function(self):
"""For TextOutput, validate hooks are skipped; process hooks fire and call the function."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('world')
def upcase(text: str) -> str:
return text.upper()
@dataclass
class LogCap(AbstractCapability[Any]):
async def before_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append(f'before:{output}')
assert output_context.has_function is True
return output
agent = Agent(FunctionModel(model_fn), output_type=TextOutput(upcase), capabilities=[LogCap()])
result = await agent.run('hello')
assert result.output == 'WORLD'
assert log == ['before:world']
async def test_can_transform_text_before_function(self):
"""before_output_process can modify text before the TextOutput function runs."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('world')
def upcase(text: str) -> str:
return text.upper()
@dataclass
class PrependCap(AbstractCapability[Any]):
async def before_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
assert isinstance(output, str)
return f'hello {output}'
agent = Agent(FunctionModel(model_fn), output_type=TextOutput(upcase), capabilities=[PrependCap()])
result = await agent.run('greet')
assert result.output == 'HELLO WORLD'
class TestOnOutputValidateError:
"""on_output_validate_error can recover from validation errors."""
async def test_recover_from_invalid_json(self):
"""on_output_validate_error can fix raw output and return corrected data."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": "bad"}')])
@dataclass
class RecoverCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
# Recovery replaces the validation result; for structured output
# the execute step (call()) returns this as-is when there's no function.
return {'value': 99}
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RecoverCap()])
result = await agent.run('hello')
# The error hook bypasses Pydantic validation, so the output is the raw dict
assert result.output == {'value': 99}
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": "bad"}')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_default_reraises(self):
"""Without an error hook, validation errors propagate normally as retries."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": "bad"}')])
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput))
result = await agent.run('hello')
# Model retries and eventually gets it right
assert result.output == MyOutput(value=42)
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": "bad"}')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'int_parsing',
'loc': ('value',),
'msg': 'Input should be a valid integer, unable to parse string as an integer',
'input': 'bad',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 42}')],
usage=RequestUsage(input_tokens=87, output_tokens=7),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class TestOnOutputValidateErrorModelRetry:
"""on_output_validate_error can raise ModelRetry to trigger a retry with a custom message."""
async def test_error_hook_raises_model_retry(self):
"""on_output_validate_error raises ModelRetry, which becomes a retry prompt."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": "bad"}')])
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
@dataclass
class RetryHookCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
raise ModelRetry('Please return a valid integer for value')
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RetryHookCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": "bad"}')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please return a valid integer for value',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 42}')],
usage=RequestUsage(input_tokens=67, output_tokens=7),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class TestModelRetryFromOutputHooks:
"""Hooks can raise ModelRetry to trigger a model retry."""
async def test_before_output_validate_raises_model_retry(self):
"""before_output_validate can raise ModelRetry to skip validation and retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": -1}')])
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
@dataclass
class RejectNegativeCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
if isinstance(output, str) and '-1' in output:
raise ModelRetry('Negative values are not allowed')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RejectNegativeCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": -1}')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Negative values are not allowed',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 42}')],
usage=RequestUsage(input_tokens=65, output_tokens=6),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_after_output_validate_raises_model_retry(self):
"""after_output_validate can raise ModelRetry to reject validated output."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": 0}')])
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
@dataclass
class RejectZeroCap(AbstractCapability[Any]):
async def after_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
# Validated output is a MyOutput instance (Pydantic returns model instances)
if isinstance(output, MyOutput) and output.value == 0:
raise ModelRetry('Zero is not a valid value')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RejectZeroCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 0}')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Zero is not a valid value',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 42}')],
usage=RequestUsage(input_tokens=66, output_tokens=6),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_after_output_process_raises_model_retry(self):
"""after_output_process can raise ModelRetry to reject the execution result."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='short')])
return ModelResponse(parts=[TextPart(content='this is long enough')])
@dataclass
class MinLengthCap(AbstractCapability[Any]):
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, str) and len(output) < 10:
raise ModelRetry('Output too short, please elaborate')
return output
agent = Agent(FunctionModel(model_fn), capabilities=[MinLengthCap()])
result = await agent.run('hello')
assert result.output == 'this is long enough'
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='short')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Output too short, please elaborate',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='this is long enough')],
usage=RequestUsage(input_tokens=65, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_wrap_output_process_model_retry_skips_error_hook(self):
"""ModelRetry from wrap_output_process bypasses on_output_process_error."""
error_hook_called = False
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='bad')])
return ModelResponse(parts=[TextPart(content='good')])
@dataclass
class WrapRetryCap(AbstractCapability[Any]):
async def wrap_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any, handler: Any
) -> Any:
result = await handler(output)
if result == 'bad':
raise ModelRetry('Bad output, please try again')
return result
async def on_output_process_error( # pragma: no cover — verifying this is NOT called
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any, error: Exception
) -> Any:
nonlocal error_hook_called
error_hook_called = True
raise error
agent = Agent(FunctionModel(model_fn), capabilities=[WrapRetryCap()])
result = await agent.run('hello')
assert result.output == 'good'
assert call_count == 2
assert not error_hook_called # ModelRetry skips on_output_process_error
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='bad')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Bad output, please try again',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='good')],
usage=RequestUsage(input_tokens=65, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_before_output_process_raises_model_retry(self):
"""before_output_process can raise ModelRetry to skip execution."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": 0}')])
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
@dataclass
class RejectBeforeExecCap(AbstractCapability[Any]):
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, MyOutput) and output.value == 0:
raise ModelRetry('Cannot execute with zero value')
return output
agent = Agent(
FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RejectBeforeExecCap()]
)
result = await agent.run('hello')
assert result.output == MyOutput(value=5)
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 0}')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Cannot execute with zero value',
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 5}')],
usage=RequestUsage(input_tokens=65, output_tokens=6),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_output_tool_before_validate_raises_model_retry(self):
"""ModelRetry from before_output_validate on a tool output includes tool_call_id."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if info.output_tools:
tool = info.output_tools[0]
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": -1}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 42}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class RejectNegativeCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
if (
isinstance(output, str)
and '-1' in output
or isinstance(output, dict)
and output.get('value', 0) < 0
):
raise ModelRetry('Negative values not allowed')
return output
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[RejectNegativeCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
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='{"value": -1}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Negative values not allowed',
tool_name='final_result',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"value": 42}', tool_call_id='call-2')],
usage=RequestUsage(input_tokens=62, output_tokens=8),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='call-2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_output_tool_after_execute_raises_model_retry(self):
"""ModelRetry from after_output_process on a tool output triggers retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if info.output_tools:
tool = info.output_tools[0]
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 0}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 10}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class RejectZeroCap(AbstractCapability[Any]):
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, MyOutput) and output.value == 0:
raise ModelRetry('Zero not allowed')
return output
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[RejectZeroCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=10)
assert call_count == 2
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='{"value": 0}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Zero not allowed',
tool_name='final_result',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"value": 10}', tool_call_id='call-2')],
usage=RequestUsage(input_tokens=61, output_tokens=8),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='call-2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_output_tool_validation_failure(self):
"""Invalid output tool args trigger retry through output validate hooks."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if info.output_tools:
tool = info.output_tools[0]
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": "bad"}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 42}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
agent = Agent(FunctionModel(model_fn), output_type=MyOutput)
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
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='{"value": "bad"}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'int_parsing',
'loc': ('value',),
'msg': 'Input should be a valid integer, unable to parse string as an integer',
'input': 'bad',
}
],
tool_name='final_result',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"value": 42}', tool_call_id='call-2')],
usage=RequestUsage(input_tokens=89, output_tokens=9),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='call-2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_output_tool_error_hook_raises_model_retry(self):
"""on_output_validate_error raises ModelRetry for output tool, includes tool_call_id."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if info.output_tools:
tool = info.output_tools[0]
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": "bad"}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 42}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
@dataclass
class RetryOnErrorCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
raise ModelRetry('Please provide a valid integer')
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[RetryOnErrorCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
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='{"value": "bad"}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=51, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content='Please provide a valid integer',
tool_name='final_result',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='final_result', args='{"value": 42}', tool_call_id='call-2')],
usage=RequestUsage(input_tokens=63, output_tokens=9),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='call-2',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class TestOutputToolWithOutputFunction:
"""Output tools with output functions that raise ModelRetry."""
async def test_output_function_model_retry(self):
"""An output function on a tool output type that raises ModelRetry triggers a retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if info.output_tools:
tool = info.output_tools[0]
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 1}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 10}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
def my_output_fn(output: MyOutput) -> MyOutput:
if output.value < 5:
raise ModelRetry('Value must be >= 5')
return output
agent = Agent(FunctionModel(model_fn), output_type=my_output_fn)
result = await agent.run('hello')
assert result.output == MyOutput(value=10)
assert call_count == 2
async def test_output_function_model_retry_with_hooks(self):
"""Output function ModelRetry works correctly when output hooks are present."""
log: list[str] = []
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if info.output_tools:
tool = info.output_tools[0]
if call_count == 1:
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 1}', tool_call_id='call-1')]
)
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 10}', tool_call_id='call-2')]
)
return make_text_response('no tools') # pragma: no cover
def my_output_fn(output: MyOutput) -> MyOutput:
if output.value < 5:
raise ModelRetry('Value must be >= 5')
return output
@dataclass
class LogCap(AbstractCapability[Any]):
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append(f'execute:{output}')
return output
agent = Agent(FunctionModel(model_fn), output_type=my_output_fn, capabilities=[LogCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=10)
assert call_count == 2
# Execute hook fires for both attempts (retry + success)
assert len(log) == 2
class TestWrapOutputValidate:
"""wrap_output_validate provides full middleware control around validation."""
async def test_wrap_can_observe(self):
"""wrap_output_validate can observe without modifying."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 10}')])
@dataclass
class WrapCap(AbstractCapability[Any]):
async def wrap_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
log.append('before')
result = await handler(output)
log.append('after')
return result
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[WrapCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=10)
assert log == ['before', 'after']
async def test_wrap_can_transform_input(self):
"""wrap_output_validate can transform the output before passing to handler."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": "oops"}')])
@dataclass
class TransformCap(AbstractCapability[Any]):
async def wrap_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
# Fix the input before validation
fixed = '{"value": 7}' if isinstance(output, str) else output
return await handler(fixed)
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[TransformCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=7)
async def test_wrap_can_catch_and_recover(self):
"""wrap_output_validate can catch validation errors and return a fallback."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='not json at all')])
@dataclass
class RecoverWrapCap(AbstractCapability[Any]):
async def wrap_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
try:
return await handler(output)
except (ValidationError, ModelRetry):
return {'value': 0}
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RecoverWrapCap()])
result = await agent.run('hello')
# The wrap recovery bypasses Pydantic validation, so the output is the raw dict
assert result.output == {'value': 0}
class TestAfterOutputProcess:
"""after_output_process can transform the final result after execution."""
async def test_transform_structured_result(self):
"""after_output_process transforms the result of structured output."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
@dataclass
class DoubleResultCap(AbstractCapability[Any]):
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
assert isinstance(output, MyOutput)
return MyOutput(value=output.value * 2)
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[DoubleResultCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=10)
async def test_transform_plain_text_result(self):
"""after_output_process can transform plain text output."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('hello')
@dataclass
class UpperCap(AbstractCapability[Any]):
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
return output.upper() if isinstance(output, str) else output
agent = Agent(FunctionModel(model_fn), capabilities=[UpperCap()])
result = await agent.run('hello')
assert result.output == 'HELLO'
async def test_transform_text_function_result(self):
"""after_output_process fires after TextOutput function has executed."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('world')
def upcase(text: str) -> str:
return text.upper()
@dataclass
class WrapResultCap(AbstractCapability[Any]):
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
# output is already 'WORLD' from upcase
return f'[{output}]'
agent = Agent(FunctionModel(model_fn), output_type=TextOutput(upcase), capabilities=[WrapResultCap()])
result = await agent.run('hello')
assert result.output == '[WORLD]'
class TestToolOutputWithOutputHooks:
"""Output hooks fire for tool-based output, nested inside tool hooks."""
async def test_output_hooks_fire_for_tool_output(self):
"""Output hooks fire when the output type uses tool mode."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.output_tools:
tool = info.output_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 42}', tool_call_id='call-1')]
)
return make_text_response('no output tools') # pragma: no cover
@dataclass
class OutputLogCap(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append(f'before_output_validate:{output_context.mode}')
return output
async def after_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_output_validate')
return output
async def before_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('before_output_process')
return output
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_output_process')
return output
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[OutputLogCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert 'before_output_validate:tool' in log
assert 'after_output_validate' in log
assert 'before_output_process' in log
assert 'after_output_process' in log
async def test_output_hooks_fire_without_tool_hooks(self):
"""Output tools use output hooks only — tool hooks do NOT fire."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.output_tools:
tool = info.output_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 42}', tool_call_id='call-1')]
)
return make_text_response('no output tools') # pragma: no cover
@dataclass
class BothHooksCap(AbstractCapability[Any]):
async def before_tool_validate( # pragma: no cover — verifying this is NOT called
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append(f'tool_validate:{call.tool_name}')
return args
async def before_tool_execute( # pragma: no cover — verifying this is NOT called
self,
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
) -> dict[str, Any]:
log.append(f'tool_execute:{call.tool_name}')
return args
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('output_validate')
return output
async def before_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('output_process')
return output
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[BothHooksCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
# Only output hooks fire for output tools — tool hooks are skipped
assert 'tool_validate:final_result' not in log
assert 'tool_execute:final_result' not in log
assert 'output_validate' in log
assert 'output_process' in log
async def test_after_output_process_transforms_tool_output(self):
"""after_output_process can transform the result of tool-based output."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.output_tools:
tool = info.output_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 5}', tool_call_id='call-1')]
)
return make_text_response('no output tools') # pragma: no cover
@dataclass
class DoubleOutputCap(AbstractCapability[Any]):
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
if isinstance(output, MyOutput):
return MyOutput(value=output.value * 2)
return output # pragma: no cover
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[DoubleOutputCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=10)
class TestHookComposition:
"""Multiple capabilities with output hooks compose correctly."""
async def test_multiple_before_output_validate(self):
"""Multiple capabilities' before_output_validate hooks chain in order."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 1}')])
@dataclass
class Cap1(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('cap1')
return output
@dataclass
class Cap2(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('cap2')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[Cap1(), Cap2()])
result = await agent.run('hello')
assert result.output == MyOutput(value=1)
assert log == ['cap1', 'cap2']
async def test_chained_transformations(self):
"""Multiple capabilities can chain transformations in before_output_validate."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('hello')
@dataclass
class AddExclamation(AbstractCapability[Any]):
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
return f'{output}!' if isinstance(output, str) else output
@dataclass
class AddQuestion(AbstractCapability[Any]):
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
return f'{output}?' if isinstance(output, str) else output
agent = Agent(FunctionModel(model_fn), capabilities=[AddExclamation(), AddQuestion()])
result = await agent.run('hello')
# after hooks run in reversed order: AddQuestion first, then AddExclamation
assert result.output == 'hello?!'
class TestHooksClassOutputDecorators:
"""Test decorator registration for output hooks with Hooks class."""
async def test_before_output_validate_decorator(self):
"""Hooks.on.before_output_validate registers correctly."""
hooks = Hooks()
log: list[str] = []
@hooks.on.before_output_validate
def fix_output(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('before_output_validate')
return output
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 3}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == MyOutput(value=3)
assert log == ['before_output_validate']
async def test_after_output_validate_decorator(self):
"""Hooks.on.after_output_validate registers correctly."""
hooks = Hooks()
log: list[str] = []
@hooks.on.after_output_validate
async def after_validate(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_output_validate')
return output
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 4}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == MyOutput(value=4)
assert log == ['after_output_validate']
async def test_wrap_output_validate_decorator(self):
"""Hooks.on.output_validate (wrap) registers correctly."""
hooks = Hooks()
log: list[str] = []
@hooks.on.output_validate
async def wrap_validate(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
log.append('wrap_start')
result = await handler(output)
log.append('wrap_end')
return result
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == MyOutput(value=5)
assert log == ['wrap_start', 'wrap_end']
async def test_on_output_validate_error_decorator(self):
"""Hooks.on.output_validate_error can recover from validation failures."""
hooks = Hooks()
@hooks.on.output_validate_error
async def recover(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
return {'value': 999}
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='not valid json')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
# Error recovery bypasses Pydantic validation, so the output is the raw dict
assert result.output == {'value': 999}
async def test_before_output_process_decorator(self):
"""Hooks.on.before_output_process registers correctly."""
hooks = Hooks()
log: list[str] = []
@hooks.on.before_output_process
async def before_exec(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('before_output_process')
return output
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 6}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == MyOutput(value=6)
assert log == ['before_output_process']
async def test_after_output_process_decorator(self):
"""Hooks.on.after_output_process transforms the final result."""
hooks = Hooks()
@hooks.on.after_output_process
async def double_output(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: Any,
) -> Any:
if isinstance(output, MyOutput):
return MyOutput(value=output.value * 2)
return output # pragma: no cover
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 7}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == MyOutput(value=14)
async def test_wrap_output_process_decorator(self):
"""Hooks.on.output_process (wrap) registers correctly."""
hooks = Hooks()
log: list[str] = []
@hooks.on.output_process
async def wrap_exec(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
log.append('exec_start')
result = await handler(output)
log.append('exec_end')
return result
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 8}')])
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == MyOutput(value=8)
assert log == ['exec_start', 'exec_end']
async def test_sync_hook_auto_wrapping(self):
"""Sync output hook functions are auto-wrapped to async."""
hooks = Hooks()
log: list[str] = []
@hooks.on.before_output_process
def sync_hook(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('sync_before')
return output
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('hello')
agent = Agent(FunctionModel(model_fn), capabilities=[hooks])
result = await agent.run('hello')
assert result.output == 'hello'
assert log == ['sync_before']
class TestOutputHookFullLifecycle:
"""Test the full output hook lifecycle fires in the correct order."""
async def test_full_validate_and_execute_order(self):
"""All output hooks fire in the expected order for structured text output."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 1}')])
@dataclass
class FullLifecycleCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append('before_validate')
return output
async def wrap_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
log.append('wrap_validate:before')
result = await handler(output)
log.append('wrap_validate:after')
return result
async def after_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_validate')
return output
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append('before_execute')
return output
async def wrap_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
log.append('wrap_execute:before')
result = await handler(output)
log.append('wrap_execute:after')
return result
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_execute')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[FullLifecycleCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=1)
assert log == [
'before_validate',
'wrap_validate:before',
'wrap_validate:after',
'after_validate',
'before_execute',
'wrap_execute:before',
'wrap_execute:after',
'after_execute',
]
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 1}')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_full_lifecycle_with_tool_output(self):
"""All output hooks fire in order for tool-based output."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.output_tools:
tool = info.output_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 100}', tool_call_id='call-1')]
)
return make_text_response('no output tools') # pragma: no cover
@dataclass
class FullLifecycleCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append('before_validate')
assert output_context.mode == 'tool'
assert output_context.tool_call is not None
assert output_context.tool_def is not None
return output
async def after_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_validate')
return output
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append('before_execute')
return output
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_execute')
return output
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[FullLifecycleCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=100)
assert log == [
'before_validate',
'after_validate',
'before_execute',
'after_execute',
]
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='{"value": 100}', tool_call_id='call-1')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='final_result',
content='Final result processed.',
tool_call_id='call-1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
class TestOutputContext:
"""OutputContext is populated correctly for different output modes."""
async def test_output_context_for_prompted_output(self):
"""OutputContext has correct fields for prompted text output."""
captured: list[OutputContext] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 1}')])
@dataclass
class CaptureCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
captured.append(output_context)
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[CaptureCap()])
await agent.run('hello')
assert len(captured) == 1
oc = captured[0]
assert oc.mode == 'prompted'
assert oc.output_type is MyOutput
assert oc.object_def is not None
assert oc.has_function is False
assert oc.tool_call is None
assert oc.tool_def is None
async def test_output_context_for_plain_text(self):
"""OutputContext has correct fields for plain text output (via process hooks)."""
captured: list[OutputContext] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('hello')
@dataclass
class CaptureCap(AbstractCapability[Any]):
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
captured.append(output_context)
return output
agent = Agent(FunctionModel(model_fn), capabilities=[CaptureCap()])
await agent.run('hello')
assert len(captured) == 1
oc = captured[0]
assert oc.mode == 'text'
assert oc.output_type is str
assert oc.object_def is None
assert oc.has_function is False
async def test_output_context_for_text_function(self):
"""OutputContext has correct fields for TextOutput function (via process hooks)."""
captured: list[OutputContext] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('hello')
def upcase(text: str) -> str:
return text.upper()
@dataclass
class CaptureCap(AbstractCapability[Any]):
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
captured.append(output_context)
return output
agent = Agent(FunctionModel(model_fn), output_type=TextOutput(upcase), capabilities=[CaptureCap()])
await agent.run('hello')
assert len(captured) == 1
oc = captured[0]
assert oc.mode == 'text'
assert oc.output_type is str
assert oc.has_function is True
async def test_output_context_for_tool_output(self):
"""OutputContext has correct fields for tool-based output, including tool_call and tool_def."""
captured: list[OutputContext] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if info.output_tools:
tool = info.output_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 1}', tool_call_id='call-1')]
)
return make_text_response('no output tools') # pragma: no cover
@dataclass
class CaptureCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
captured.append(output_context)
return output
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[CaptureCap()])
await agent.run('hello')
assert len(captured) == 1
oc = captured[0]
assert oc.mode == 'tool'
assert oc.output_type is MyOutput
assert oc.object_def is not None
assert oc.has_function is False
assert oc.tool_call is not None
assert oc.tool_call.tool_name == 'final_result'
assert oc.tool_def is not None
assert oc.tool_def.name == 'final_result'
assert oc.tool_def.kind == 'output'
class TestWrapOutputProcess:
"""wrap_output_process provides full middleware control around execution."""
async def test_wrap_can_observe(self):
"""wrap_output_process can observe without modifying."""
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
@dataclass
class WrapCap(AbstractCapability[Any]):
async def wrap_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
log.append('before')
result = await handler(output)
log.append('after')
return result
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[WrapCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
assert log == ['before', 'after']
async def test_wrap_can_replace_result(self):
"""wrap_output_process can replace the result entirely."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
@dataclass
class ReplaceCap(AbstractCapability[Any]):
async def wrap_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
await handler(output) # Call handler but ignore result
return MyOutput(value=0)
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[ReplaceCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=0)
class TestOnOutputProcessError:
"""on_output_process_error can recover from execution failures."""
async def test_recover_from_output_function_error(self):
"""on_output_process_error catches errors from output functions."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('trigger error')
def failing_func(text: str) -> str:
raise ValueError('output function failed')
@dataclass
class RecoverCap(AbstractCapability[Any]):
async def on_output_process_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: Exception,
) -> Any:
return 'recovered'
agent = Agent(FunctionModel(model_fn), output_type=TextOutput(failing_func), capabilities=[RecoverCap()])
result = await agent.run('hello')
assert result.output == 'recovered'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='trigger error')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_default_reraises(self):
"""Without a recovery hook, output execution errors propagate."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return make_text_response('trigger error')
def failing_func(text: str) -> str:
raise ValueError('output function failed')
agent = Agent(FunctionModel(model_fn), output_type=TextOutput(failing_func))
with pytest.raises(ValueError, match='output function failed'):
await agent.run('hello')
class TestRunSync:
"""Output hooks work with run_sync as well as run."""
def test_before_output_validate_with_run_sync(self):
"""Output hooks fire correctly with agent.run_sync."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 77}')])
hooks = Hooks()
log: list[str] = []
@hooks.on.before_output_validate
def log_hook(
ctx: RunContext[Any],
/,
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('before_validate')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = agent.run_sync('hello')
assert result.output == MyOutput(value=77)
assert log == ['before_validate']
class TestOutputHookErrorPaths:
"""Test error paths to ensure correct error wrapping and hook firing."""
def test_on_output_validate_error_reraise_wraps_in_tool_retry(self):
"""When on_output_validate_error re-raises ValidationError, it's wrapped in ToolRetryError causing retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='not valid json')])
return ModelResponse(parts=[TextPart(content='{"value": 42}')])
error_log: list[str] = []
@dataclass
class ErrorLogCapability(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
error_log.append(f'validate_error: {type(error).__name__}')
raise error # Re-raise — should cause retry
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput(MyOutput),
capabilities=[ErrorLogCapability()],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=42)
assert call_count == 2
assert len(error_log) == 1
assert error_log[0] == 'validate_error: ValidationError'
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='not valid json')],
usage=RequestUsage(input_tokens=51, output_tokens=3),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'json_invalid',
'loc': (),
'msg': 'Invalid JSON: expected ident at line 1 column 2',
'input': 'not valid json',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 42}')],
usage=RequestUsage(input_tokens=81, output_tokens=6),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_on_output_process_error_recovery(self):
"""on_output_process_error can recover from output function failure."""
def bad_function(value: int) -> str:
raise ValueError('value too small')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 42}')])
@dataclass
class RecoverCapability(AbstractCapability[Any]):
async def on_output_process_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: Exception,
) -> Any:
return 'recovered value'
agent = Agent(
FunctionModel(model_fn),
output_type=bad_function,
capabilities=[RecoverCapability()],
)
result = agent.run_sync('hello')
assert result.output == 'recovered value'
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='{"value": 42}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
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_composed_on_output_validate_error_chain(self):
"""Multiple capabilities' on_output_validate_error hooks chain correctly."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[TextPart(content='invalid')])
return ModelResponse(parts=[TextPart(content='{"value": 1}')])
error_log: list[str] = []
@dataclass
class FirstCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
error_log.append('first_error')
raise error
@dataclass
class SecondCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
error_log.append('second_error')
raise error
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput(MyOutput),
capabilities=[FirstCap(), SecondCap()],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=1)
# Both error hooks should have been called (reverse order per composition)
assert 'second_error' in error_log
assert 'first_error' in error_log
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='invalid')],
usage=RequestUsage(input_tokens=51, output_tokens=1),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'json_invalid',
'loc': (),
'msg': 'Invalid JSON: expected value at line 1 column 1',
'input': 'invalid',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 1}')],
usage=RequestUsage(input_tokens=81, output_tokens=4),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_composed_on_output_process_error_chain(self):
"""Multiple capabilities' on_output_process_error hooks chain correctly."""
def failing_func(value: int) -> str:
raise ValueError('intentional')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 42}')])
@dataclass
class FirstCap(AbstractCapability[Any]):
async def on_output_process_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: Exception,
) -> Any:
return 'recovered_by_first'
@dataclass
class SecondCap(AbstractCapability[Any]):
async def on_output_process_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: Exception,
) -> Any:
raise error # Don't recover, pass to next cap
agent = Agent(
FunctionModel(model_fn),
output_type=failing_func,
capabilities=[FirstCap(), SecondCap()],
)
result = agent.run_sync('hello')
assert result.output == 'recovered_by_first'
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='{"value": 42}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
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_hooks_output_validate_error_decorator(self):
"""Test on_output_validate_error via Hooks decorator API."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[TextPart(content='bad json')])
return ModelResponse(parts=[TextPart(content='{"value": 99}')])
hooks = Hooks()
@hooks.on.output_validate_error
async def handle_error(
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
raise error # Re-raise to trigger retry
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput(MyOutput),
capabilities=[hooks],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=99)
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='bad json')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'json_invalid',
'loc': (),
'msg': 'Invalid JSON: expected value at line 1 column 1',
'input': 'bad json',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 99}')],
usage=RequestUsage(input_tokens=81, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_hooks_output_process_error_decorator(self):
"""Test on_output_process_error via Hooks decorator API."""
def bad_function(value: int) -> str:
raise ValueError('intentional failure')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 10}')])
hooks = Hooks()
@hooks.on.output_process_error
async def handle_error(
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: Exception,
) -> Any:
return 'fallback result'
agent = Agent(
FunctionModel(model_fn),
output_type=bad_function,
capabilities=[hooks],
)
result = agent.run_sync('hello')
assert result.output == 'fallback result'
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='{"value": 10}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_fn:',
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_validate_error_hook_not_triggered_on_valid_data(self):
"""For tool output with valid data, on_output_validate_error does not fire."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 42}')])
hooks = Hooks()
error_log: list[str] = []
@hooks.on.before_output_validate
def log_validate(
ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
error_log.append('before_validate')
return output
agent = Agent(
FunctionModel(model_fn),
output_type=MyOutput,
capabilities=[hooks],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=42)
assert error_log == ['before_validate'] # Validate fires but no error
def test_wrapper_capability_output_hooks_delegate(self):
"""WrapperCapability delegates output hooks to wrapped capability."""
from pydantic_ai.capabilities.wrapper import WrapperCapability
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
log: list[str] = []
@dataclass
class InnerCap(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
log.append('inner_before_validate')
return output
async def after_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('inner_after_execute')
return output
@dataclass
class OuterCap(WrapperCapability[Any]):
pass
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput(MyOutput),
capabilities=[OuterCap(wrapped=InnerCap())],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=5)
assert 'inner_before_validate' in log
assert 'inner_after_execute' in log
class TestDefaultOutputErrorHooks:
"""Test that default (no override) error hooks work correctly via retry."""
def test_default_on_output_validate_error_causes_retry(self):
"""Default on_output_validate_error re-raises, triggering model retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='not json')])
return ModelResponse(parts=[TextPart(content='{"value": 7}')])
# Hooks with only a before_output_validate hook (no error hook override).
# Default on_output_validate_error re-raises → ToolRetryError → model retry.
hooks = Hooks()
@hooks.on.before_output_validate
def noop(
ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[hooks])
result = agent.run_sync('hello')
assert result.output == MyOutput(value=7)
assert call_count == 2
assert result.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='not json')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
RetryPromptPart(
content=[
{
'type': 'json_invalid',
'loc': (),
'msg': 'Invalid JSON: expected ident at line 1 column 2',
'input': 'not json',
}
],
tool_call_id=IsStr(),
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='{"value": 7}')],
usage=RequestUsage(input_tokens=81, output_tokens=5),
model_name='function:model_fn:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
def test_default_on_output_process_error_reraises(self):
"""Default on_output_process_error re-raises the error."""
def failing_func(value: int) -> str:
raise ValueError('intentional')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 1}')])
# Hooks with only a before_output_process hook (no error hook override).
hooks = Hooks()
@hooks.on.before_output_process
def noop(
ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
return output
agent = Agent(FunctionModel(model_fn), output_type=failing_func, capabilities=[hooks])
with pytest.raises(ValueError, match='intentional'):
agent.run_sync('hello')
class TestStreamingOutputHooks:
"""Output hooks fire during streaming (partial and final validation)."""
async def test_output_hooks_fire_during_streaming(self):
"""Validate hooks fire on partial attempts; execute hooks fire only when partial validation succeeds."""
hook_calls: list[tuple[str, bool]] = []
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
# Stream the JSON response in chunks
yield {0: DeltaToolCall(name='final_result', json_args='{"val')}
yield {0: DeltaToolCall(json_args='ue": 42}')}
@dataclass
class StreamLogCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
hook_calls.append(('before_validate', ctx.partial_output))
return output
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
hook_calls.append(('after_execute', ctx.partial_output))
return output
agent = Agent(FunctionModel(stream_function=stream_fn), output_type=MyOutput, capabilities=[StreamLogCap()])
async with agent.run_stream('hello') as stream:
outputs = [o async for o in stream.stream_output(debounce_by=None)]
assert outputs[-1] == MyOutput(value=42)
# Validate hooks fire on partial attempts AND the final result
validate_calls = [(phase, partial) for phase, partial in hook_calls if phase == 'before_validate']
assert any(partial for _, partial in validate_calls), 'Expected at least one partial validation call'
assert any(not partial for _, partial in validate_calls), 'Expected at least one final validation call'
# Execute hooks fire only when validation succeeds (partial or final)
execute_calls = [(phase, partial) for phase, partial in hook_calls if phase == 'after_execute']
assert any(not partial for _, partial in execute_calls), 'Expected at least one final execute call'
assert stream.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='{"value": 42}',
tool_call_id=IsStr(),
)
],
usage=RequestUsage(input_tokens=50, output_tokens=4),
model_name='function::stream_fn',
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_union_output_hooks_fire_during_streaming(self):
"""Union output types: hooks fire during partial and final validation, with the kind
resolved per-invocation so concurrent streams can't clobber each other."""
class TypeA(BaseModel):
value: int
class TypeB(BaseModel):
name: str
hook_calls: list[tuple[str, bool]] = []
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
yield {0: DeltaToolCall(name='final_result_TypeA', json_args='{"va')}
yield {0: DeltaToolCall(json_args='lue": 7}')}
@dataclass
class StreamLogCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
hook_calls.append(('before_validate', ctx.partial_output))
return output
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
hook_calls.append(('after_execute', ctx.partial_output))
return output
agent = Agent(
FunctionModel(stream_function=stream_fn),
output_type=[TypeA, TypeB],
capabilities=[StreamLogCap()],
)
async with agent.run_stream('hello') as stream:
outputs = [o async for o in stream.stream_output(debounce_by=None)]
assert isinstance(outputs[-1], TypeA)
assert outputs[-1].value == 7
# Validate hooks fire on partial attempts AND final
assert any(partial for phase, partial in hook_calls if phase == 'before_validate')
assert any(not partial for phase, partial in hook_calls if phase == 'before_validate')
# Execute hooks fire on final at minimum
assert any(not partial for phase, partial in hook_calls if phase == 'after_execute')
class TestOutputHookEdgeCases:
"""Tests for edge cases to ensure full coverage of output hook code paths."""
def test_before_output_validate_transforms_text_to_dict(self):
"""before_output_validate can transform raw text to a pre-parsed dict."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='ignored raw text')])
@dataclass
class PreParseCapability(AbstractCapability[Any]):
async def before_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
) -> str | dict[str, Any]:
# Transform text to a pre-parsed dict
return {'value': 99}
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput(MyOutput),
capabilities=[PreParseCapability()],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=99)
def test_streaming_output_hooks_fire_on_partial(self):
"""Process hooks fire for plain text output (validate hooks are skipped)."""
from pydantic_ai.models.function import FunctionModel
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='hello world')])
@dataclass
class StreamLogCapability(AbstractCapability[Any]):
async def before_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append(f'before_process partial={ctx.partial_output}')
return output
agent = Agent(FunctionModel(model_fn), capabilities=[StreamLogCapability()])
result = agent.run_sync('hello')
assert result.output == 'hello world'
assert any('before_process' in entry for entry in log)
def test_no_capability_fast_path_structured_raw_validation_error(self):
"""`ObjectOutputProcessor.hook_validate` — used by streaming paths without retries —
must let `ValidationError` propagate unwrapped.
"""
from pydantic_ai._output import ObjectOutputProcessor
processor = ObjectOutputProcessor(output=MyOutput)
ctx = RunContext(
deps=None,
model=None, # type: ignore
usage=None, # type: ignore
prompt='test',
run_step=0,
retry=0,
max_retries=3,
trace_include_content=False,
tracer=NoOpTracer(),
instrumentation_version=0,
)
with pytest.raises(ValidationError):
processor.hook_validate('not valid json', run_context=ctx)
def test_no_capability_fast_path_union_raw_validation_error(self):
"""Same as above but for `UnionOutputProcessor.hook_validate`."""
from pydantic_ai._output import UnionOutputProcessor
processor = UnionOutputProcessor(outputs=[MyOutput])
ctx = RunContext(
deps=None,
model=None, # type: ignore
usage=None, # type: ignore
prompt='test',
run_step=0,
retry=0,
max_retries=3,
trace_include_content=False,
tracer=NoOpTracer(),
instrumentation_version=0,
)
with pytest.raises(ValidationError):
processor.hook_validate('not valid json', run_context=ctx)
def test_output_toolset_call_tool_raises(self):
"""`OutputToolset.call_tool` exists only to satisfy `AbstractToolset` — output tools go
through `ToolManager.validate_output_tool_call` / `execute_output_tool_call`, never
through the normal toolset path. Calling `call_tool` directly must raise.
"""
import asyncio
from pydantic_ai._output import OutputToolset
toolset = OutputToolset.build([MyOutput])
assert toolset is not None
toolset.max_retries = 1 # Agent normally sets this; required by `get_tools`
async def run():
ctx = RunContext(
deps=None,
model=None, # type: ignore
usage=None, # type: ignore
prompt='test',
run_step=0,
retry=0,
max_retries=3,
trace_include_content=False,
tracer=NoOpTracer(),
instrumentation_version=0,
)
tools = await toolset.get_tools(ctx)
tool_name = next(iter(tools))
tool = tools[tool_name]
await toolset.call_tool(tool_name, {}, ctx, tool)
with pytest.raises(NotImplementedError, match='validate_output_tool_call'):
asyncio.run(run())
def test_hooks_on_output_process_via_hooks_class(self):
"""Test wrap_output_process via Hooks decorator API."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 10}')])
hooks = Hooks()
execute_log: list[str] = []
@hooks.on.output_process
async def wrap_exec(
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
handler: Any,
) -> Any:
execute_log.append('wrap_execute_before')
result = await handler(output)
execute_log.append('wrap_execute_after')
return result
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput(MyOutput),
capabilities=[hooks],
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=10)
assert execute_log == ['wrap_execute_before', 'wrap_execute_after']
class TestErrorHookCoveragePaths:
"""Tests to exercise error hook delegation paths (abstract defaults, wrapper, hooks chaining)."""
def test_bare_capability_default_on_output_validate_error(self):
"""A bare AbstractCapability subclass with no error hook override exercises default `raise error`."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='not json')])
return ModelResponse(parts=[TextPart(content='{"value": 3}')])
@dataclass
class BareCap(AbstractCapability[Any]):
"""Has no hook overrides — uses all defaults."""
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[BareCap()])
result = agent.run_sync('hello')
assert result.output == MyOutput(value=3)
assert call_count == 2 # First attempt failed, retried
def test_bare_capability_default_on_output_process_error(self):
"""A bare AbstractCapability subclass with no error hook override lets execute errors propagate."""
def failing_func(value: int) -> str:
raise ValueError('execute fail')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 1}')])
@dataclass
class BareCap(AbstractCapability[Any]):
pass
agent = Agent(FunctionModel(model_fn), output_type=failing_func, capabilities=[BareCap()])
with pytest.raises(ValueError, match='execute fail'):
agent.run_sync('hello')
def test_wrapper_on_output_validate_error_delegates(self):
"""WrapperCapability delegates on_output_validate_error to the wrapped capability."""
from pydantic_ai.capabilities.wrapper import WrapperCapability
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='invalid')])
return ModelResponse(parts=[TextPart(content='{"value": 8}')])
error_log: list[str] = []
@dataclass
class InnerCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
error_log.append('inner_error')
raise error
@dataclass
class OuterWrap(WrapperCapability[Any]):
pass
agent = Agent(
FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[OuterWrap(wrapped=InnerCap())]
)
result = agent.run_sync('hello')
assert result.output == MyOutput(value=8)
assert 'inner_error' in error_log
def test_wrapper_on_output_process_error_delegates(self):
"""WrapperCapability delegates on_output_process_error to the wrapped capability."""
from pydantic_ai.capabilities.wrapper import WrapperCapability
def failing_func(value: int) -> str:
raise ValueError('exec fail')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 1}')])
@dataclass
class InnerCap(AbstractCapability[Any]):
async def on_output_process_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: Exception,
) -> Any:
return 'wrapper_recovered'
@dataclass
class OuterWrap(WrapperCapability[Any]):
pass
agent = Agent(FunctionModel(model_fn), output_type=failing_func, capabilities=[OuterWrap(wrapped=InnerCap())])
result = agent.run_sync('hello')
assert result.output == 'wrapper_recovered'
def test_hooks_on_output_process_error_chaining(self):
"""Hooks class on_output_process_error re-raises, chaining errors."""
def failing_func(value: int) -> str:
raise ValueError('original')
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
return ModelResponse(parts=[ToolCallPart(info.output_tools[0].name, '{"value": 1}')])
hooks = Hooks()
@hooks.on.output_process_error
async def first_handler(
ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any], error: Exception
) -> Any:
raise ValueError('chained') # Re-raise different error
@hooks.on.output_process_error
async def second_handler(
ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any], error: Exception
) -> Any:
return 'recovered' # This one recovers
agent = Agent(FunctionModel(model_fn), output_type=failing_func, capabilities=[hooks])
result = agent.run_sync('hello')
assert result.output == 'recovered'
class TestUnionOutputWithHooks:
"""Tests for UnionOutputProcessor with output hooks — verifying clean validate/call decomposition."""
def test_union_output_hooks_fire_for_both_phases(self):
"""Union output types properly split into validate (Pydantic) and execute (function call) phases."""
class TypeA(BaseModel):
kind: str = 'a'
value: int
class TypeB(BaseModel):
kind: str = 'b'
name: str
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"result": {"kind": "TypeA", "data": {"value": 42}}}')])
@dataclass
class LogCapability(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append('before_validate')
return output
async def after_output_validate(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
) -> Any:
log.append('after_validate')
return output
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append('before_execute')
return output
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append('after_execute')
return output
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([TypeA, TypeB]),
capabilities=[LogCapability()],
)
result = agent.run_sync('hello')
assert isinstance(result.output, TypeA)
assert result.output.value == 42
# Both validate and execute hooks should fire
assert 'before_validate' in log
assert 'after_validate' in log
assert 'before_execute' in log
assert 'after_execute' in log
def test_union_output_process_hook_transforms_result(self):
"""Execute hooks can transform the result for union output types."""
class TypeA(BaseModel):
value: int
class TypeB(BaseModel):
name: str
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"result": {"kind": "TypeA", "data": {"value": 5}}}')])
@dataclass
class DoubleCapability(AbstractCapability[Any]):
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
assert isinstance(output, TypeA)
output.value *= 2
return output
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([TypeA, TypeB]),
capabilities=[DoubleCapability()],
)
result = agent.run_sync('hello')
assert isinstance(result.output, TypeA)
assert result.output.value == 10
def test_union_with_multi_arg_output_function_runs(self):
"""A multi-arg output function in a union must actually execute.
Regression: `UnionOutputProcessor.hook_execute` previously isinstance-checked the
validated dict against the function's first-arg type, which always failed for
multi-arg functions, so the function was silently bypassed.
"""
executed: list[tuple[int, str]] = []
def combine(x: int, y: str) -> str:
executed.append((x, y))
return f'{x}:{y}'
class Other(BaseModel):
value: int
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Emit the discriminated union shape that PromptedOutput expects, selecting the
# `combine` branch with the dict the multi-arg function will receive.
return ModelResponse(
parts=[TextPart(content='{"result": {"kind": "combine", "data": {"x": 7, "y": "ok"}}}')]
)
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput([combine, Other]))
result = agent.run_sync('hello')
assert result.output == '7:ok'
assert executed == [(7, 'ok')]
def test_union_resolve_by_type_skips_multi_arg_inners(self):
"""When a process hook swaps the semantic value to a different type, `hook_execute`
falls through to `_resolve_inner_for_value`. That fallback can't pick a multi-arg
function inner because its `output_type` is just the first arg's type — it should
skip multi-arg inners and only consider single-value inners (BaseModel, primitives).
"""
def combine(x: int, y: str) -> str: # pragma: no cover
return f'{x}:{y}'
class Single(BaseModel):
value: int
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"result": {"kind": "Single", "data": {"value": 1}}}')])
@dataclass
class SwapToInt(AbstractCapability[Any]):
"""Swap the validated `Single` instance for a bare `int` during the process
phase, so the value no longer matches `Single`'s type. The fallthrough resolver
should iterate inners — skip `combine` (multi-arg, can't isinstance-check),
and not find any matching single-value inner for `int` since `Single` is the
only single-value inner and the int isn't a `Single`."""
async def wrap_output_process(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: Any,
handler: Callable[[Any], Awaitable[Any]],
) -> Any:
return await handler(99)
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([combine, Single]),
capabilities=[SwapToInt()],
)
# No matching inner found → semantic returned unmodified.
result = agent.run_sync('hello')
assert result.output == 99
def test_union_on_output_validate_error_fires(self):
"""on_output_validate_error fires for union output when validation fails."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='not json')])
return ModelResponse(parts=[TextPart(content='{"result": {"kind": "MyOutput", "data": {"value": 1}}}')])
error_log: list[str] = []
@dataclass
class ErrorLogCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
error_log.append('validate_error')
raise error
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([MyOutput, MyOutput]),
capabilities=[ErrorLogCap()],
)
result = agent.run_sync('hello')
assert isinstance(result.output, MyOutput)
assert call_count == 2
assert 'validate_error' in error_log
async def test_union_error_hook_recovery(self):
"""on_output_validate_error can recover for union types without crashing."""
class TypeA(BaseModel):
a_val: int
class TypeB(BaseModel):
b_val: str
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Return invalid union JSON — missing 'result' envelope
return ModelResponse(parts=[TextPart(content='{"bad": "data"}')])
@dataclass
class RecoverUnionCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
# Recover with a pre-built result
return TypeA(a_val=42)
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([TypeA, TypeB]),
capabilities=[RecoverUnionCap()],
)
result = await agent.run('hello')
assert result.output == TypeA(a_val=42)
async def test_union_error_hook_recovery_second_type(self):
"""Error recovery matching the second union type exercises the isinstance loop."""
class TypeA(BaseModel):
a_val: int
class TypeB(BaseModel):
b_val: str
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"bad": "data"}')])
@dataclass
class RecoverUnionCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
# Recover with TypeB — the second union member — so isinstance(output, TypeA)
# fails first, then isinstance(output, TypeB) succeeds
return TypeB(b_val='recovered')
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([TypeA, TypeB]),
capabilities=[RecoverUnionCap()],
)
result = await agent.run('hello')
assert result.output == TypeB(b_val='recovered')
async def test_union_error_hook_recovery_with_primitive(self):
"""Union mixing a BaseModel with a primitive (`Foo | bool | None`).
`bool` gets an `outer_typed_dict_key='response'` wrapper; recovery must rewrap the
primitive into the inner processor's dict shape before calling.
"""
class Foo(BaseModel):
x: int
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"bad": "data"}')])
@dataclass
class RecoverPrimitiveCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
return True # recover with a bool, matching the second union member
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([Foo, bool]),
capabilities=[RecoverPrimitiveCap()],
)
result = await agent.run('hello')
assert result.output is True
async def test_union_error_hook_recovery_with_generic(self):
"""Union mixing a BaseModel with a generic (`Foo | list[Bar]`).
`isinstance(x, list[Bar])` raises `TypeError`; resolution must fall back to the
generic origin (`list`) so the recovered list-valued output still maps to its
inner processor.
"""
class Foo(BaseModel):
x: int
class Bar(BaseModel):
y: int
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"bad": "data"}')])
@dataclass
class RecoverListCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
return [Bar(y=1), Bar(y=2)]
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([Foo, list[Bar]]),
capabilities=[RecoverListCap()],
)
result = await agent.run('hello')
assert result.output == [Bar(y=1), Bar(y=2)]
async def test_union_after_validate_hook_swaps_union_member(self):
"""`after_output_validate` can return a value of a different union member.
If the validated kind was `Foo` but a hook returned a `Bar`, `hook_execute` must
fall through to type-based resolution instead of passing a `Bar` to `Foo`'s inner
processor.
"""
class Foo(BaseModel):
kind: str = 'Foo'
x: int
class Bar(BaseModel):
kind: str = 'Bar'
y: int
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"result": {"kind": "Foo", "data": {"x": 1}}}')])
@dataclass
class SwapUnionCap(AbstractCapability[Any]):
async def after_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
# Model said "Foo", hook swaps to "Bar" — execute must route to Bar's processor.
return Bar(y=42)
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([Foo, Bar]),
capabilities=[SwapUnionCap()],
)
result = await agent.run('hello')
assert result.output == Bar(y=42)
async def test_union_hook_returns_unknown_type_passes_through(self):
"""If a hook returns a value matching NO union member, `hook_execute` passes it through.
The output function (if any) doesn't run, and the value reaches the user as-is —
better than silently dropping to `None`.
"""
class Foo(BaseModel):
x: int
class Bar(BaseModel):
y: int
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"bad": "data"}')])
@dataclass
class RecoverUnknownCap(AbstractCapability[Any]):
async def on_output_validate_error(
self,
ctx: RunContext[Any],
*,
output_context: OutputContext,
output: str | dict[str, Any],
error: ValidationError | ModelRetry,
) -> Any:
return 'not in union' # str isn't Foo or Bar
agent = Agent(
FunctionModel(model_fn),
output_type=PromptedOutput([Foo, Bar]),
capabilities=[RecoverUnknownCap()],
)
result = await agent.run('hello')
assert result.output == 'not in union'
class TestTextFunctionOutputCallHook:
"""Tests that TextFunctionOutputProcessor.call() is exercised through execute hooks."""
def test_text_function_execute_hook_wraps_call(self):
"""Execute hooks wrap the text function call (processor.call)."""
def uppercase(text: str) -> str:
return text.upper()
log: list[str] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='hello world')])
@dataclass
class ExecLogCap(AbstractCapability[Any]):
async def wrap_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any, handler: Any
) -> Any:
log.append(f'input: {output}')
result = await handler(output)
log.append(f'output: {result}')
return result
agent = Agent(
FunctionModel(model_fn),
output_type=TextOutput(uppercase),
capabilities=[ExecLogCap()],
)
result = agent.run_sync('hello')
assert result.output == 'HELLO WORLD'
assert log == ['input: hello world', 'output: HELLO WORLD']
class TestNativeOutputWithHooks:
"""Output hooks fire for native structured output mode."""
async def test_hooks_fire_for_native_output(self):
"""Output hooks fire with mode='native' for NativeOutput."""
log: list[tuple[str, str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 7}')])
@dataclass
class LogCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append(('before_validate', output_context.mode))
return output
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append(('after_execute', output_context.mode))
return output
agent = Agent(FunctionModel(model_fn), output_type=NativeOutput(MyOutput), capabilities=[LogCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=7)
assert log == [('before_validate', 'native'), ('after_execute', 'native')]
async def test_before_validate_transforms_native_output(self):
"""before_output_validate can transform raw text before native output parsing."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": "bad"}')])
@dataclass
class FixCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
if isinstance(output, str):
return output.replace('"bad"', '42')
return output # pragma: no cover
agent = Agent(FunctionModel(model_fn), output_type=NativeOutput(MyOutput), capabilities=[FixCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=42)
async def test_model_retry_from_native_output_hook(self):
"""ModelRetry from output hooks triggers retry for native output."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": -1}')])
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
@dataclass
class RejectCap(AbstractCapability[Any]):
async def after_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, MyOutput) and output.value < 0:
raise ModelRetry('Value must be non-negative')
return output
agent = Agent(FunctionModel(model_fn), output_type=NativeOutput(MyOutput), capabilities=[RejectCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=5)
assert call_count == 2
class TestImageOutputWithHooks:
"""Image output fires process hooks (not validate hooks, since there's no parsing)."""
async def test_process_hooks_fire_for_image_output(self):
"""Process hooks fire for image output; validate hooks are skipped."""
log: list[str] = []
def return_image(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'test-png', media_type='image/png'))])
@dataclass
class LogCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append('validate') # pragma: no cover — should NOT fire for images
return output # pragma: no cover
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append(f'process:{output_context.mode}')
return output
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append('after_process')
assert isinstance(output, BinaryImage)
return output
image_profile = ModelProfile(supports_image_output=True)
agent = Agent(
FunctionModel(return_image, profile=image_profile), output_type=BinaryImage, capabilities=[LogCap()]
)
result = await agent.run('hello')
assert isinstance(result.output, BinaryImage)
assert result.output.data == b'test-png'
# Process hooks fire; validate hooks do NOT (no parsing for images)
assert log == ['process:image', 'after_process']
async def test_image_process_hook_can_transform(self):
"""Process hooks can transform image output."""
def return_image(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[FilePart(content=BinaryImage(data=b'original', media_type='image/png'))])
@dataclass
class TransformCap(AbstractCapability[Any]):
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, BinaryImage):
return BinaryImage(data=b'transformed', media_type=output.media_type)
return output # pragma: no cover
image_profile = ModelProfile(supports_image_output=True)
agent = Agent(
FunctionModel(return_image, profile=image_profile), output_type=BinaryImage, capabilities=[TransformCap()]
)
result = await agent.run('hello')
assert isinstance(result.output, BinaryImage)
assert result.output.data == b'transformed'
class TestAutoModeOutputWithHooks:
"""Output hooks fire for auto mode (which delegates to tool or text based on model)."""
async def test_hooks_fire_for_auto_mode_tool_path(self):
"""Auto mode that resolves to tool output fires output hooks."""
log: list[tuple[str, str]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Auto mode with default tool profile — model uses output tools
if info.output_tools:
tool = info.output_tools[0]
return ModelResponse(
parts=[ToolCallPart(tool_name=tool.name, args='{"value": 99}', tool_call_id='call-1')]
)
return ModelResponse(parts=[TextPart(content='{"value": 99}')]) # pragma: no cover
@dataclass
class LogCap(AbstractCapability[Any]):
async def before_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: str | dict[str, Any]
) -> str | dict[str, Any]:
log.append(('before_validate', output_context.mode))
return output
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append(('after_execute', output_context.mode))
return output
# Default auto mode — FunctionModel defaults to tool mode
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[LogCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=99)
assert log == [('before_validate', 'tool'), ('after_execute', 'tool')]
class TestHookSemanticValue:
"""Output hooks see the **semantic value** (what the model was asked to produce), not the
internal dict-wrapped form used by the validator pipeline.
This is intentionally different from *tool* call hooks, which always see `dict[str, Any]`
(matching the tool schema the model satisfies). For outputs, users think of
`Agent(output_type=T)` as "the model produces a T", so hooks should see T.
"""
async def _run_and_capture(
self,
*,
output_type: Any,
model_fn: Any,
) -> tuple[Any, list[tuple[str, Any]]]:
log: list[tuple[str, Any]] = []
@dataclass
class CaptureCap(AbstractCapability[Any]):
async def after_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append(('after_validate', output))
return output
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
log.append(('before_process', output))
return output
agent = Agent(FunctionModel(model_fn), output_type=output_type, capabilities=[CaptureCap()])
result = await agent.run('hello')
return result.output, log
async def test_case_a_bare_basemodel_tool_output(self):
"""Case A: `Agent(output_type=MyOutput)` — hooks see the BaseModel instance."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"value": 42}')])
output, log = await self._run_and_capture(output_type=MyOutput, model_fn=model_fn)
assert output == MyOutput(value=42)
assert log == [('after_validate', MyOutput(value=42)), ('before_process', MyOutput(value=42))]
async def test_case_b_bare_int_tool_output(self):
"""Case B: `Agent(output_type=int)` — hooks see `42`, not `{'response': 42}`."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"response": 42}')])
output, log = await self._run_and_capture(output_type=int, model_fn=model_fn)
assert output == 42
assert log == [('after_validate', 42), ('before_process', 42)]
async def test_case_c_function_basemodel_arg(self):
"""Case C: `def f(data: MyOutput) -> int` — hooks see `MyOutput(...)`, not `{'data': MyOutput(...)}`."""
def double(data: MyOutput) -> int:
return data.value * 2
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"value": 21}')])
output, log = await self._run_and_capture(output_type=double, model_fn=model_fn)
assert output == 42
assert log == [('after_validate', MyOutput(value=21)), ('before_process', MyOutput(value=21))]
async def test_case_d_function_primitive_arg(self):
"""Case D: `def f(data: int) -> str` — hooks see `42`, not `{'data': 42}`."""
def stringify(data: int) -> str:
return f'got {data}'
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"data": 42}')])
output, log = await self._run_and_capture(output_type=stringify, model_fn=model_fn)
assert output == 'got 42'
assert log == [('after_validate', 42), ('before_process', 42)]
async def test_case_e_function_multiple_args(self):
"""Case E: multi-arg function — hooks see the dict (genuine multi-value input)."""
def combine(data: MyOutput, other: str) -> str:
return f'{data.value}:{other}'
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"data": {"value": 7}, "other": "x"}')])
output, log = await self._run_and_capture(output_type=combine, model_fn=model_fn)
assert output == '7:x'
# Multi-arg: hooks see the dict
assert log == [
('after_validate', {'data': MyOutput(value=7), 'other': 'x'}),
('before_process', {'data': MyOutput(value=7), 'other': 'x'}),
]
async def test_native_output_unwraps_primitive(self):
"""NativeOutput(int) — hooks see `42`, not `{'response': 42}`."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"response": 42}')])
output, log = await self._run_and_capture(output_type=NativeOutput(int), model_fn=model_fn)
assert output == 42
assert log == [('after_validate', 42), ('before_process', 42)]
async def test_native_output_unwraps_function_basemodel(self):
"""NativeOutput(func-with-basemodel-arg) — hooks see the BaseModel, not the wrap dict."""
def double(data: MyOutput) -> int:
return data.value * 2
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"value": 21}')])
output, log = await self._run_and_capture(output_type=NativeOutput(double), model_fn=model_fn)
assert output == 42
assert log == [('after_validate', MyOutput(value=21)), ('before_process', MyOutput(value=21))]
async def test_prompted_output_unwraps_primitive(self):
"""PromptedOutput(int) — hooks see `42`, not `{'response': 42}`."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"response": 42}')])
output, log = await self._run_and_capture(output_type=PromptedOutput(int), model_fn=model_fn)
assert output == 42
assert log == [('after_validate', 42), ('before_process', 42)]
async def test_prompted_output_unwraps_function_primitive(self):
"""PromptedOutput(func-with-primitive-arg) — hooks see the primitive value."""
def stringify(data: int) -> str:
return f'got {data}'
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[TextPart(content='{"data": 42}')])
output, log = await self._run_and_capture(output_type=PromptedOutput(stringify), model_fn=model_fn)
assert output == 'got 42'
assert log == [('after_validate', 42), ('before_process', 42)]
async def test_output_validator_sees_final_processed_value(self):
"""Output validators see the final value (after function call), not the wrapped form."""
def double(data: MyOutput) -> int:
return data.value * 2
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"value": 21}')])
seen: list[Any] = []
agent = Agent(FunctionModel(model_fn), output_type=double)
@agent.output_validator
def validate(v: int) -> int:
seen.append(v)
return v
result = await agent.run('hello')
assert result.output == 42
# Validator sees the post-process value (function's return), an int
assert seen == [42]
async def test_hook_transform_at_semantic_boundary(self):
"""A hook can transform the semantic value and the transformed value flows through correctly."""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
return ModelResponse(parts=[ToolCallPart(tool.name, '{"response": 10}')])
@dataclass
class DoubleCap(AbstractCapability[Any]):
async def after_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
return output * 2 # transform the semantic int value
agent = Agent(FunctionModel(model_fn), output_type=int, capabilities=[DoubleCap()])
result = await agent.run('hello')
assert result.output == 20
async def test_dict_output_type_contains_unwrap_key(self):
"""Regression: `output_type=dict[str, Any]` where the dict contains the unwrap key
('response') must not be mistaken for an already-wrapped value during re-wrap.
"""
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
assert info.output_tools is not None
tool = info.output_tools[0]
# The dict itself contains a 'response' key — the same key used as the outer wrapper
return ModelResponse(parts=[ToolCallPart(tool.name, '{"response": {"response": "yes", "other": "stuff"}}')])
output, log = await self._run_and_capture(output_type=dict[str, Any], model_fn=model_fn)
# Hook sees the inner dict (unwrapped)
assert log == [
('after_validate', {'response': 'yes', 'other': 'stuff'}),
('before_process', {'response': 'yes', 'other': 'stuff'}),
]
# Final output is the full inner dict — NOT just "yes" (which would happen if re-wrap
# was skipped due to the buggy "already wrapped" check)
assert output == {'response': 'yes', 'other': 'stuff'}
class TestHookExceptionHandling:
"""ValidationError/ModelRetry raised from before_* and after_* hooks should trigger retry,
matching the behavior when raised from wrap_output_validate/wrap_output_process.
"""
async def test_validation_error_from_after_output_validate_triggers_retry(self):
"""ValidationError from after_output_validate should be caught and trigger model retry."""
from pydantic import TypeAdapter
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": -1}')])
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
@dataclass
class StricterCap(AbstractCapability[Any]):
async def after_output_validate(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
# Additional Pydantic validation: reject negative values
if isinstance(output, MyOutput) and output.value < 0:
# Simulate Pydantic validation failing
TypeAdapter(int).validate_python('not_an_int')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[StricterCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=5)
assert call_count == 2 # retry happened
async def test_validation_error_from_after_output_process_triggers_retry(self):
"""ValidationError from after_output_process should be caught and trigger model retry."""
from pydantic import TypeAdapter
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": -1}')])
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
@dataclass
class StricterCap(AbstractCapability[Any]):
async def after_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, MyOutput) and output.value < 0:
TypeAdapter(int).validate_python('not_an_int')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[StricterCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=5)
assert call_count == 2
async def test_model_retry_from_before_output_process_triggers_retry(self):
"""ModelRetry from before_output_process should trigger model retry."""
call_count = 0
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[TextPart(content='{"value": -1}')])
return ModelResponse(parts=[TextPart(content='{"value": 5}')])
@dataclass
class RejectCap(AbstractCapability[Any]):
async def before_output_process(
self, ctx: RunContext[Any], *, output_context: OutputContext, output: Any
) -> Any:
if isinstance(output, MyOutput) and output.value < 0:
raise ModelRetry('Value must be non-negative')
return output
agent = Agent(FunctionModel(model_fn), output_type=PromptedOutput(MyOutput), capabilities=[RejectCap()])
result = await agent.run('hello')
assert result.output == MyOutput(value=5)
assert call_count == 2
# region HandleDeferredToolCalls
async def test_deferred_tool_handler_approve():
"""HandleDeferredToolCalls capability auto-approves a requires_approval tool inline."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {'x': 5}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Done!')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x * 10
result = await agent.run('Hello')
assert result.output == 'Done!'
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='my_tool', args={'x': 5}, tool_call_id='call1')],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='my_tool',
content=50,
tool_call_id='call1',
timestamp=IsDatetime(),
)
],
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='Done!')],
usage=RequestUsage(input_tokens=52, output_tokens=5),
model_name='function:llm:',
timestamp=IsDatetime(),
run_id=IsStr(),
conversation_id=IsStr(),
),
]
)
async def test_deferred_tool_handler_deny():
"""HandleDeferredToolCalls capability denies a requires_approval tool inline, producing a `ToolReturnPart(outcome='denied')`."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {'x': 5}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Understood, denied.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
approvals={call.tool_call_id: ToolDenied('Not allowed.') for call in requests.approvals}
)
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x * 10 # pragma: no cover
result = await agent.run('Hello')
assert result.output == 'Understood, denied.'
# The denial must surface in message history as outcome='denied', not a successful return.
tool_returns = [p for m in result.all_messages() for p in m.parts if isinstance(p, ToolReturnPart)]
assert len(tool_returns) == 1
assert tool_returns[0].tool_call_id == 'call1'
assert tool_returns[0].outcome == 'denied'
assert tool_returns[0].content == 'Not allowed.'
async def test_deferred_tool_handler_no_output_type_needed():
"""When handler resolves all deferred calls, DeferredToolRequests is not needed in output type."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {'x': 3}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Result received.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
# Note: output_type is just str, no DeferredToolRequests
agent = Agent(
FunctionModel(llm),
output_type=str,
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x * 100
result = await agent.run('Hello')
assert result.output == 'Result received.'
async def test_deferred_tool_handler_none_fallback():
"""When no handler is present, deferred tools bubble up as DeferredToolRequests output."""
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests])
@agent.tool_plain
def my_tool(x: int) -> int:
raise ApprovalRequired
result = await agent.run('Hello')
assert isinstance(result.output, DeferredToolRequests)
assert len(result.output.approvals) == 1
async def test_deferred_tool_handler_partial_resolution():
"""Handler resolves some calls, remaining bubble up as DeferredToolRequests output."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart('tool_a', {}, tool_call_id='a1'),
ToolCallPart('tool_b', {}, tool_call_id='b1'),
]
)
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
# Only approve tool_a, leave tool_b unresolved
results = DeferredToolResults()
for call in requests.approvals:
if call.tool_name == 'tool_a':
results.approvals[call.tool_call_id] = True
return results
agent = Agent(
FunctionModel(llm),
output_type=[str, DeferredToolRequests],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def tool_a(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'a done'
@agent.tool
def tool_b(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'b done' # pragma: no cover
result = await agent.run('Hello')
assert isinstance(result.output, DeferredToolRequests)
assert len(result.output.approvals) == 1
assert result.output.approvals[0].tool_name == 'tool_b'
async def test_deferred_tool_handler_sync_handler():
"""HandleDeferredToolCalls works with a sync handler function."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('OK')])
def handle_deferred_sync(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred_sync)],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done'
result = await agent.run('Hello')
assert result.output == 'OK'
async def test_deferred_tool_handler_accumulation():
"""Two capabilities each resolve different deferred calls."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart('tool_a', {}, tool_call_id='a1'),
ToolCallPart('tool_b', {}, tool_call_id='b1'),
]
)
return ModelResponse(parts=[TextPart('Both done.')])
def handler_a(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
results = DeferredToolResults()
for call in requests.approvals:
if call.tool_name == 'tool_a':
results.approvals[call.tool_call_id] = True
return results
def handler_b(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
# handler_a resolved tool_a, so we only see tool_b
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
capabilities=[
HandleDeferredToolCalls(handler=handler_a),
HandleDeferredToolCalls(handler=handler_b),
],
)
@agent.tool
def tool_a(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'a result'
@agent.tool
def tool_b(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'b result'
result = await agent.run('Hello')
assert result.output == 'Both done.'
async def test_deferred_tool_handler_unresolved_no_output_type_error():
"""Unresolved deferred calls without DeferredToolRequests in output type raises UserError."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
# Handler returns None → does not resolve
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults() # Empty results → nothing resolved
agent = Agent(
FunctionModel(llm),
output_type=str,
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done' # pragma: no cover
with pytest.raises(UserError, match='DeferredToolRequests'):
await agent.run('Hello')
async def test_deferred_tool_handler_external_call():
"""HandleDeferredToolCalls capability resolves an externally-executed tool."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {'x': 3}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Got it.')])
from pydantic_ai.exceptions import CallDeferred
from pydantic_ai.messages import ToolReturn
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
# Simulate external execution: return a ToolReturn with metadata
return DeferredToolResults(
calls={
call.tool_call_id: ToolReturn(return_value='external result', metadata={'source': 'ext'})
for call in requests.calls
}
)
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool_plain
def my_tool(x: int) -> str:
raise CallDeferred
result = await agent.run('Hello')
assert result.output == 'Got it.'
async def test_deferred_tool_handler_via_handle_call():
"""handle_call(resolve_deferred=True) resolves deferred tools inline via ToolManager."""
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('outer_tool', {}, tool_call_id='outer1')])
return ModelResponse(parts=[TextPart('All done.')])
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
async def outer_tool(ctx: RunContext) -> str:
"""A tool that internally calls another tool via ToolManager.handle_call."""
assert ctx.tool_manager is not None
inner_call = ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner1')
result = await ctx.tool_manager.handle_call(inner_call)
return f'inner returned: {result}'
@agent.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'approved inner result'
result = await agent.run('Hello')
assert result.output == 'All done.'
async def test_deferred_tool_handler_via_handle_call_wrap_validation_errors_false():
"""`wrap_validation_errors=False` propagates through deferred-tool resolution.
Regression for the case where a sandboxed caller (`handle_call(wrap_validation_errors=False)`)
invokes a tool that requires approval: after the handler approves, the re-execution must
keep the raw-error contract — `ModelRetry` from the approved tool body should propagate
as-is, not wrapped as `ToolRetryError`.
"""
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('outer_tool', {}, tool_call_id='outer1')])
return ModelResponse(parts=[TextPart('Done.')])
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
async def outer_tool(ctx: RunContext) -> str:
assert ctx.tool_manager is not None
inner_call = ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner1')
try:
await ctx.tool_manager.handle_call(inner_call, wrap_validation_errors=False)
except ModelRetry as e:
return f'raw ModelRetry: {e}'
return 'no error' # pragma: no cover
@agent.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
raise ModelRetry('post-approval retry')
result = await agent.run('Hello')
assert result.output == 'Done.'
# outer_tool caught the raw ModelRetry from the approved inner_tool body and surfaced it
# in its return value; if wrap_validation_errors hadn't been forwarded through
# _resolve_single_deferred, outer_tool would have seen a ToolRetryError instead.
inner_message = next(
msg
for msg in result.all_messages()
if isinstance(msg, ModelRequest)
and any(isinstance(part, ToolReturnPart) and part.tool_name == 'outer_tool' for part in msg.parts)
)
outer_return = next(
part for part in inner_message.parts if isinstance(part, ToolReturnPart) and part.tool_name == 'outer_tool'
)
assert outer_return.content == 'raw ModelRetry: post-approval retry'
async def test_deferred_tool_handler_via_handle_call_no_handler():
"""handle_call(resolve_deferred=True) re-raises when no handler is available."""
from pydantic_ai.toolsets import FunctionToolset
# inner_tool is only available via ToolManager, not as a top-level agent tool
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'approved inner result' # pragma: no cover
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('outer_tool', {}, tool_call_id='outer1')])
return ModelResponse(parts=[TextPart('OK')])
agent = Agent(FunctionModel(llm), toolsets=[inner_toolset])
@agent.tool
async def outer_tool(ctx: RunContext) -> str:
"""A tool that internally calls another tool via ToolManager.handle_call."""
assert ctx.tool_manager is not None
inner_call = ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner1')
try:
result = await ctx.tool_manager.handle_call(inner_call)
return f'inner returned: {result}' # pragma: no cover
except ApprovalRequired:
return 'inner needs approval'
result = await agent.run('Hello')
assert result.output == 'OK'
async def test_deferred_tool_handler_build_results_helper():
"""DeferredToolRequests.build_results() creates a DeferredToolResults."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Done.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return requests.build_results(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done'
result = await agent.run('Hello')
assert result.output == 'Done.'
def test_deferred_tool_requests_build_results_validates_ids():
"""build_results rejects result IDs that don't match a pending request of the right kind."""
requests = DeferredToolRequests(
approvals=[ToolCallPart('a', {}, tool_call_id='approval_1')],
calls=[ToolCallPart('b', {}, tool_call_id='call_1')],
)
# Mis-routed ID: tool-result provided for something in the approvals list.
with pytest.raises(ValueError, match=r'calls.*not in.*DeferredToolRequests.calls'):
requests.build_results(calls={'approval_1': 'oops'})
# Unknown ID entirely.
with pytest.raises(ValueError, match=r'approvals.*not in.*DeferredToolRequests.approvals'):
requests.build_results(approvals={'unknown_id': True})
# Happy path still works.
results = requests.build_results(approvals={'approval_1': True}, calls={'call_1': 'result'})
assert results.approvals == {'approval_1': True}
assert results.calls == {'call_1': 'result'}
def test_deferred_tool_requests_build_results_approve_all():
"""approve_all=True approves every pending approval not explicitly specified."""
requests = DeferredToolRequests(
approvals=[
ToolCallPart('a', {}, tool_call_id='approval_1'),
ToolCallPart('b', {}, tool_call_id='approval_2'),
ToolCallPart('c', {}, tool_call_id='approval_3'),
],
)
# Explicit deny wins; the other two get auto-approved.
results = requests.build_results(
approvals={'approval_1': False},
approve_all=True,
)
assert results.approvals['approval_1'] is False
assert isinstance(results.approvals['approval_2'], ToolApproved)
assert isinstance(results.approvals['approval_3'], ToolApproved)
async def test_deferred_tool_handler_wrapper_capability():
"""HandleDeferredToolCalls works through WrapperCapability delegation."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Done.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
# PrefixTools wraps HandleDeferredToolCalls — tests WrapperCapability delegation
inner = HandleDeferredToolCalls(handler=handle_deferred)
agent = Agent(
FunctionModel(llm),
capabilities=[inner.prefix_tools('ns')],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done'
result = await agent.run('Hello')
assert result.output == 'Done.'
async def test_deferred_tool_handler_external_call_plain_value():
"""HandleDeferredToolCalls resolves an external call with a plain value (not ToolReturn)."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Got it.')])
from pydantic_ai.exceptions import CallDeferred
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(calls={call.tool_call_id: 'plain string result' for call in requests.calls})
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool_plain
def my_tool() -> str:
raise CallDeferred
result = await agent.run('Hello')
assert result.output == 'Got it.'
async def test_deferred_tool_handler_re_deferred_with_metadata():
"""When an approved tool re-raises ApprovalRequired, it stays unresolved with metadata."""
call_count = 0
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
output_type=[str, DeferredToolRequests],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
nonlocal call_count
call_count += 1
# Always requires approval — even when approved, raises again with metadata
raise ApprovalRequired(metadata={'attempt': call_count})
result = await agent.run('Hello')
# Tool re-raised after approval → goes to remaining → becomes output
assert isinstance(result.output, DeferredToolRequests)
assert len(result.output.approvals) == 1
assert result.output.metadata.get('call1') == {'attempt': 2}
async def test_deferred_tool_handler_denied_via_batch():
"""Batch path deny via handler produces a `ToolReturnPart(outcome='denied')` in message history."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Understood.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
approvals={call.tool_call_id: ToolDenied('Policy denied.') for call in requests.approvals}
)
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done' # pragma: no cover
result = await agent.run('Hello')
assert result.output == 'Understood.'
tool_returns = [p for m in result.all_messages() for p in m.parts if isinstance(p, ToolReturnPart)]
assert len(tool_returns) == 1
assert tool_returns[0].outcome == 'denied'
assert tool_returns[0].content == 'Policy denied.'
async def test_deferred_tool_handler_batch_deny_via_bool_and_default():
"""Batch path: covers `approvals[id] = False` AND default `ToolDenied()` as separate calls."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart('needs_approval', {'x': 1}, tool_call_id='bool_false'),
ToolCallPart('needs_approval', {'x': 2}, tool_call_id='default_denied'),
]
)
return ModelResponse(parts=[TextPart('ok')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
approvals={
'bool_false': False,
'default_denied': ToolDenied(), # no custom message
}
)
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def needs_approval(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x # pragma: no cover
result = await agent.run('go')
assert result.output == 'ok'
tool_returns = {p.tool_call_id: p for m in result.all_messages() for p in m.parts if isinstance(p, ToolReturnPart)}
assert tool_returns['bool_false'].outcome == 'denied'
assert tool_returns['bool_false'].content == ToolDenied().message
assert tool_returns['default_denied'].outcome == 'denied'
assert tool_returns['default_denied'].content == ToolDenied().message
async def test_deferred_tool_handler_batch_approve_via_tool_approved_default():
"""Batch path: covers `approvals[id] = ToolApproved()` (default, no override_args)."""
from pydantic_ai.tools import ToolApproved
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('needs_approval', {'x': 7}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('done')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: ToolApproved() for call in requests.approvals})
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def needs_approval(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x * 2
result = await agent.run('go')
assert result.output == 'done'
tool_returns = [p for m in result.all_messages() for p in m.parts if isinstance(p, ToolReturnPart)]
assert len(tool_returns) == 1
assert tool_returns[0].outcome != 'denied'
assert tool_returns[0].content == 14
async def test_deferred_tool_handler_batch_external_tool_return_metadata():
"""Batch path: handler-supplied external `ToolReturn(value, metadata)` lands on the return part."""
from pydantic_ai.messages import ToolReturn as _ToolReturn
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('external_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('done')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
calls={
call.tool_call_id: _ToolReturn(
return_value='computed', metadata={'source': 'external'}, content='user extra'
)
for call in requests.calls
}
)
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def external_tool(ctx: RunContext) -> str:
raise CallDeferred
result = await agent.run('go')
assert result.output == 'done'
messages = result.all_messages()
tool_returns = [p for m in messages for p in m.parts if isinstance(p, ToolReturnPart)]
assert len(tool_returns) == 1
assert tool_returns[0].content == 'computed'
assert tool_returns[0].metadata == {'source': 'external'}
# The `content` field on ToolReturn becomes a UserPromptPart.
from pydantic_ai.messages import UserPromptPart
user_extras = [p for m in messages for p in m.parts if isinstance(p, UserPromptPart) and p.content == 'user extra']
assert len(user_extras) == 1
async def test_deferred_tool_handler_batch_external_model_retry():
"""Batch path: handler-supplied `ModelRetry` in `calls` surfaces as a `RetryPromptPart`, not a tool return."""
call_count = 0
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart('external_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('retried')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(calls={call.tool_call_id: ModelRetry('try again') for call in requests.calls})
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def external_tool(ctx: RunContext) -> str:
raise CallDeferred
result = await agent.run('go')
assert result.output == 'retried'
messages = result.all_messages()
retry_parts = [p for m in messages for p in m.parts if isinstance(p, RetryPromptPart)]
assert len(retry_parts) == 1
assert retry_parts[0].tool_call_id == 'c1'
assert retry_parts[0].content == 'try again'
tool_returns = [p for m in messages for p in m.parts if isinstance(p, ToolReturnPart) and p.tool_call_id == 'c1']
assert tool_returns == []
async def test_deferred_tool_handler_batch_external_retry_prompt_part():
"""Batch path: handler-supplied `RetryPromptPart` in `calls` surfaces as a retry (names stamped from the deferred call)."""
call_count = 0
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal call_count
call_count += 1
if call_count == 1:
return ModelResponse(parts=[ToolCallPart('external_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('retried')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
calls={
call.tool_call_id: RetryPromptPart(content='retry via part', tool_name='', tool_call_id='')
for call in requests.calls
}
)
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def external_tool(ctx: RunContext) -> str:
raise CallDeferred
result = await agent.run('go')
assert result.output == 'retried'
retry_parts = [p for m in result.all_messages() for p in m.parts if isinstance(p, RetryPromptPart)]
assert len(retry_parts) == 1
assert retry_parts[0].tool_call_id == 'c1'
assert retry_parts[0].tool_name == 'external_tool'
assert retry_parts[0].content == 'retry via part'
async def test_deferred_tool_handler_via_handle_call_external_tool_return():
"""Per-call path: handler-supplied external `ToolReturn(value, metadata)` is returned verbatim from handle_call."""
from pydantic_ai.exceptions import CallDeferred
from pydantic_ai.messages import ToolReturn as _ToolReturn
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool_plain
def inner_tool() -> str:
raise CallDeferred
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
calls={call.tool_call_id: _ToolReturn(return_value='ext', metadata={'k': 'v'}) for call in requests.calls}
)
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
captured_result: Any = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal captured_result
assert ctx.tool_manager is not None
captured_result = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'done'
await agent.run('go')
# Per-call path returns whatever the handler supplied verbatim — full ToolReturn wrapper preserved.
assert isinstance(captured_result, _ToolReturn)
assert captured_result.return_value == 'ext'
assert captured_result.metadata == {'k': 'v'}
def test_deferred_tool_handler_serialization_name():
"""HandleDeferredToolCalls is not spec-constructible."""
assert HandleDeferredToolCalls.get_serialization_name() is None
async def test_deferred_tool_handler_via_handle_call_with_resolve():
"""handle_call(resolve_deferred=True) goes through _resolve_single_deferred happy path.
This exercises the per-call resolution path used by CodeMode-style callers.
"""
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'approved result'
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
assert ctx.tool_manager is not None
# Call inner_tool via handle_call — exercises _resolve_single_deferred
result = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
# _resolve_single_deferred returns result_part.content
assert result == 'approved result'
return f'got: {result}'
result = await agent.run('go')
assert result.output == 'final'
# Verify the inner tool was called (tool return visible in messages)
tool_returns = [
p
for m in result.all_messages()
for p in m.parts
if isinstance(p, ToolReturnPart) and p.tool_name == 'caller_tool'
]
assert len(tool_returns) == 1
assert tool_returns[0].content == 'got: approved result'
async def test_deferred_tool_handler_approved_tool_returns_tool_return():
"""Approved tool returning a ToolReturn preserves metadata and user content."""
from pydantic_ai.messages import ToolReturn as _ToolReturn, UserPromptPart
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Done.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def my_tool(ctx: RunContext):
if not ctx.tool_call_approved:
raise ApprovalRequired
return _ToolReturn(return_value='result', metadata={'source': 'tool'}, content='user prompt extra')
result = await agent.run('Hello')
assert result.output == 'Done.'
# Verify ToolReturn.metadata preserved
tool_returns = [
p for m in result.all_messages() for p in m.parts if isinstance(p, ToolReturnPart) and p.tool_name == 'my_tool'
]
assert len(tool_returns) == 1
assert tool_returns[0].metadata == {'source': 'tool'}
# Verify ToolReturn.content appears as UserPromptPart
user_parts = [
p
for m in result.all_messages()
for p in m.parts
if isinstance(p, UserPromptPart) and p.content == 'user prompt extra'
]
assert len(user_parts) == 1
async def test_deferred_tool_handler_approved_tool_raises_model_retry():
"""Approved tool that raises ModelRetry produces a RetryPromptPart."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Retried and done.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
raise ModelRetry('try again')
result = await agent.run('Hello')
assert result.output == 'Retried and done.'
# Verify the retry happened
retry_parts = [
p for m in result.all_messages() for p in m.parts if isinstance(p, RetryPromptPart) and p.tool_name == 'my_tool'
]
assert len(retry_parts) == 1
async def test_deferred_tool_handler_approved_tool_override_args():
"""Approved tool with ToolApproved(override_args=...) uses the override."""
from pydantic_ai.tools import ToolApproved
received_x = None
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {'x': 5}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Done.')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
# Override the args: replace x=5 with x=42
return DeferredToolResults(
approvals={call.tool_call_id: ToolApproved(override_args={'x': 42}) for call in requests.approvals}
)
agent = Agent(FunctionModel(llm), capabilities=[HandleDeferredToolCalls(handler=handle_deferred)])
@agent.tool
def my_tool(ctx: RunContext, x: int) -> int:
nonlocal received_x
if not ctx.tool_call_approved:
raise ApprovalRequired
received_x = x
return x * 10
result = await agent.run('Hello')
assert result.output == 'Done.'
assert received_x == 42 # Override was applied
async def test_deferred_tool_handler_via_handle_call_retry():
"""handle_call path: approved tool raising ModelRetry propagates ToolRetryError."""
from pydantic_ai.exceptions import ToolRetryError
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
retry_count = 0
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
nonlocal retry_count
if not ctx.tool_call_approved:
raise ApprovalRequired
retry_count += 1
raise ModelRetry('try again')
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
assert ctx.tool_manager is not None
try:
await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'no retry' # pragma: no cover
except ToolRetryError:
return 'got retry'
result = await agent.run('go')
assert result.output == 'final'
assert retry_count == 1
async def test_deferred_tool_handler_re_deferred_without_metadata():
"""Approved tool that re-raises without metadata — no metadata added to remaining."""
call_count = 0
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
output_type=[str, DeferredToolRequests],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
nonlocal call_count
call_count += 1
# No metadata
raise ApprovalRequired
result = await agent.run('Hello')
assert isinstance(result.output, DeferredToolRequests)
assert len(result.output.approvals) == 1
# No metadata set (tool raised without metadata both times)
assert 'call1' not in result.output.metadata
async def test_deferred_tool_handler_mixed_unresolved_and_re_deferred():
"""Handler resolves some, another call is re-deferred — both end up in remaining."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart('re_raising_tool', {}, tool_call_id='re1'),
ToolCallPart('unhandled_tool', {}, tool_call_id='un1'),
]
)
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
# Only approve the re-raising one; leave unhandled_tool unresolved
return DeferredToolResults(
approvals={call.tool_call_id: True for call in requests.approvals if call.tool_name == 're_raising_tool'}
)
agent = Agent(
FunctionModel(llm),
output_type=[str, DeferredToolRequests],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def re_raising_tool(ctx: RunContext) -> str:
# Always raises — even after approval
raise ApprovalRequired
@agent.tool
def unhandled_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done' # pragma: no cover
result = await agent.run('Hello')
assert isinstance(result.output, DeferredToolRequests)
# Both calls in remaining: unhandled_tool (never resolved) + re_raising_tool (re-deferred after approval)
approval_ids = {call.tool_call_id for call in result.output.approvals}
assert 're1' in approval_ids
assert 'un1' in approval_ids
async def test_deferred_tool_handler_re_deferred_as_call_deferred():
"""Approved tool that re-raises CallDeferred (not ApprovalRequired) stays in remaining.calls."""
from pydantic_ai.exceptions import CallDeferred
call_count = 0
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(
FunctionModel(llm),
output_type=[str, DeferredToolRequests],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
nonlocal call_count
call_count += 1
if call_count == 1:
raise ApprovalRequired
# After approval, raise CallDeferred (external execution needed)
raise CallDeferred(metadata={'reason': 'external'})
result = await agent.run('Hello')
assert isinstance(result.output, DeferredToolRequests)
# Should be in calls (external), not approvals
assert len(result.output.calls) == 1
assert len(result.output.approvals) == 0
assert result.output.metadata == {'call1': {'reason': 'external'}}
async def test_deferred_tool_handler_via_handle_call_preserves_tool_return():
"""handle_call(resolve_deferred=True) preserves `ToolReturn` wrapper (metadata, user content).
The non-deferred `handle_call` path returns whatever the tool returned verbatim.
The deferred path should do the same — critical for CodeMode-style callers that
check `isinstance(result, ToolReturn)` to preserve metadata on nested return parts.
"""
from pydantic_ai.messages import ToolReturn as _ToolReturn
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext):
if not ctx.tool_call_approved:
raise ApprovalRequired
return _ToolReturn(return_value='actual result', metadata={'source': 'inner'}, content='user extra')
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
captured_result: Any = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal captured_result
assert ctx.tool_manager is not None
result = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
captured_result = result
return 'done'
await agent.run('go')
# handle_call returned the ToolReturn wrapper verbatim, not the unwrapped content
assert isinstance(captured_result, _ToolReturn)
assert captured_result.return_value == 'actual result'
assert captured_result.metadata == {'source': 'inner'}
assert captured_result.content == 'user extra'
async def test_deferred_tool_handler_via_handle_call_denied_via_bool():
"""When a handler denies via `approvals[id] = False`, handle_call returns `ToolDenied()` with the default denial message."""
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'never' # pragma: no cover
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: False for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
captured: Any = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal captured
assert ctx.tool_manager is not None
captured = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'caught' if isinstance(captured, ToolDenied) else 'no denial'
await agent.run('go')
assert isinstance(captured, ToolDenied)
assert captured == ToolDenied()
async def test_deferred_tool_handler_via_handle_call_override_args():
"""When a handler approves with override_args, handle_call executes the tool with those args."""
from pydantic_ai.tools import ToolApproved
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext, x: int) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return f'x={x}'
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
approvals={call.tool_call_id: ToolApproved(override_args={'x': 42}) for call in requests.approvals}
)
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
captured_result: Any = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal captured_result
assert ctx.tool_manager is not None
captured_result = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={'x': 1}, tool_call_id='inner_1'),
)
return 'done'
await agent.run('go')
assert captured_result == 'x=42'
async def test_deferred_tool_handler_via_handle_call_external_plain_value():
"""When a handler supplies an external-call plain value, handle_call returns it verbatim."""
from pydantic_ai.exceptions import CallDeferred
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool_plain
def inner_tool() -> str:
raise CallDeferred
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(calls={call.tool_call_id: 'external value' for call in requests.calls})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
captured_result: Any = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal captured_result
assert ctx.tool_manager is not None
captured_result = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'done'
await agent.run('go')
assert captured_result == 'external value'
async def test_deferred_tool_handler_via_handle_call_external_model_retry():
"""When a handler supplies a `ModelRetry` external-call result, handle_call raises `ToolRetryError`."""
from pydantic_ai.exceptions import CallDeferred, ModelRetry, ToolRetryError
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool_plain
def inner_tool() -> str:
raise CallDeferred
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(calls={call.tool_call_id: ModelRetry('retry please') for call in requests.calls})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
caught: ToolRetryError | None = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal caught
assert ctx.tool_manager is not None
try:
await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'no raise' # pragma: no cover
except ToolRetryError as e:
caught = e
return 'caught'
await agent.run('go')
assert caught is not None
assert caught.tool_retry.content == 'retry please'
assert caught.tool_retry.tool_name == 'inner_tool'
assert caught.tool_retry.tool_call_id == 'inner_1'
async def test_deferred_tool_handler_via_handle_call_external_retry_prompt_part():
"""When a handler supplies a `RetryPromptPart` external-call result, handle_call raises `ToolRetryError` with the part."""
from pydantic_ai.exceptions import CallDeferred, ToolRetryError
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool_plain
def inner_tool() -> str:
raise CallDeferred
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
calls={
call.tool_call_id: RetryPromptPart(content='retry via part', tool_name='', tool_call_id='')
for call in requests.calls
}
)
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
caught: ToolRetryError | None = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal caught
assert ctx.tool_manager is not None
try:
await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'no raise' # pragma: no cover
except ToolRetryError as e:
caught = e
return 'caught'
await agent.run('go')
assert caught is not None
assert caught.tool_retry.content == 'retry via part'
# The helper stamps the real tool name / id onto the prompt part.
assert caught.tool_retry.tool_name == 'inner_tool'
assert caught.tool_retry.tool_call_id == 'inner_1'
async def test_deferred_tool_handler_via_handle_call_denied_returns_message():
"""When a handler denies a deferred call, handle_call returns the custom `ToolDenied` value verbatim."""
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'never' # pragma: no cover
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(
approvals={call.tool_call_id: ToolDenied(message='not today') for call in requests.approvals}
)
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
captured: Any = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal captured
assert ctx.tool_manager is not None
captured = await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'caught' if isinstance(captured, ToolDenied) else 'no denial'
await agent.run('go')
assert isinstance(captured, ToolDenied)
assert captured == ToolDenied(message='not today')
async def test_deferred_tool_handler_via_handle_call_re_raises_new_exception():
"""After approval, if tool re-raises CallDeferred (not ApprovalRequired), the new exception type is propagated."""
from pydantic_ai.exceptions import CallDeferred
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
call_count = 0
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
nonlocal call_count
call_count += 1
if call_count == 1:
raise ApprovalRequired
# After approval, raise a *different* deferral type with new metadata
raise CallDeferred(metadata={'reason': 'external-after-approval'})
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
caught_exc_type: type | None = None
caught_metadata: dict[str, Any] | None = None
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
nonlocal caught_exc_type, caught_metadata
assert ctx.tool_manager is not None
try:
await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'no raise' # pragma: no cover
except (CallDeferred, ApprovalRequired) as e:
caught_exc_type = type(e)
caught_metadata = e.metadata
return 'caught'
result = await agent.run('go')
assert result.output == 'final'
# The new CallDeferred exception should surface, not the original ApprovalRequired
assert caught_exc_type is CallDeferred
assert caught_metadata == {'reason': 'external-after-approval'}
async def test_deferred_tool_handler_via_handle_call_handler_resolves_wrong_id():
"""handle_call path: handler returns results for wrong ID → remaining non-empty → raises original exc."""
from pydantic_ai.toolsets import FunctionToolset
inner_toolset = FunctionToolset()
@inner_toolset.tool
def inner_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done' # pragma: no cover
async def handle_deferred(ctx: RunContext, requests: DeferredToolRequests) -> DeferredToolResults:
# Resolve a non-existent ID — our tool's ID stays in remaining
return DeferredToolResults(approvals={'wrong_id': True})
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('caller_tool', {}, tool_call_id='c1')])
return ModelResponse(parts=[TextPart('final')])
agent = Agent(
FunctionModel(llm),
toolsets=[inner_toolset],
capabilities=[HandleDeferredToolCalls(handler=handle_deferred)],
)
@agent.tool
async def caller_tool(ctx: RunContext) -> str:
assert ctx.tool_manager is not None
try:
await ctx.tool_manager.handle_call(
ToolCallPart(tool_name='inner_tool', args={}, tool_call_id='inner_1'),
)
return 'no raise' # pragma: no cover
except ApprovalRequired:
return 'caught'
result = await agent.run('go')
assert result.output == 'final'
async def test_deferred_tool_handler_via_hooks_decorator():
"""`@hooks.on.deferred_tool_calls` resolves deferred calls inline."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('my_tool', {'x': 5}, tool_call_id='call1')])
return ModelResponse(parts=[TextPart('Done!')])
hooks = Hooks()
@hooks.on.deferred_tool_calls
async def handler(ctx: RunContext, *, requests: DeferredToolRequests) -> DeferredToolResults:
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
agent = Agent(FunctionModel(llm), capabilities=[hooks])
@agent.tool
def my_tool(ctx: RunContext, x: int) -> int:
if not ctx.tool_call_approved:
raise ApprovalRequired
return x * 10
result = await agent.run('Hello')
assert result.output == 'Done!'
async def test_deferred_tool_handler_via_hooks_constructor_kwarg_and_accumulation():
"""`Hooks(deferred_tool_calls=...)` accumulates results across registered handlers."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart('tool_a', {}, tool_call_id='a1'),
ToolCallPart('tool_b', {}, tool_call_id='b1'),
ToolCallPart('tool_c', {}, tool_call_id='c1'),
]
)
return ModelResponse(parts=[TextPart('All done.')])
def handle_a(ctx: RunContext, *, requests: DeferredToolRequests) -> DeferredToolResults | None:
results = DeferredToolResults()
for call in requests.approvals:
if call.tool_name == 'tool_a':
results.approvals[call.tool_call_id] = True
return results
hooks = Hooks(deferred_tool_calls=handle_a)
@hooks.on.deferred_tool_calls
async def handle_rest(ctx: RunContext, *, requests: DeferredToolRequests) -> DeferredToolResults | None:
# tool_a was already resolved by handle_a; this handler sees only tool_b and tool_c
return DeferredToolResults(approvals={call.tool_call_id: True for call in requests.approvals})
@hooks.on.deferred_tool_calls
async def never_called( # pragma: no cover
ctx: RunContext, *, requests: DeferredToolRequests
) -> DeferredToolResults | None:
# All calls should already be resolved by the previous handler — this is the early-break path
raise AssertionError('Should not be called: all requests already resolved')
agent = Agent(FunctionModel(llm), capabilities=[hooks])
@agent.tool
def tool_a(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'a'
@agent.tool
def tool_b(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'b'
@agent.tool
def tool_c(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'c'
result = await agent.run('Hello')
assert result.output == 'All done.'
async def test_deferred_tool_handler_via_hooks_returns_none_when_unhandled():
"""`Hooks` returns None from the deferred-tool-calls hook when no registered handler resolves anything."""
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
return ModelResponse(parts=[ToolCallPart('my_tool', {}, tool_call_id='call1')])
hooks = Hooks()
@hooks.on.deferred_tool_calls
async def declines(ctx: RunContext, *, requests: DeferredToolRequests) -> DeferredToolResults | None:
return None
@hooks.on.deferred_tool_calls
async def empty(ctx: RunContext, *, requests: DeferredToolRequests) -> DeferredToolResults | None:
# Empty results count as "didn't handle"
return DeferredToolResults()
agent = Agent(
FunctionModel(llm),
output_type=[str, DeferredToolRequests],
capabilities=[hooks],
)
@agent.tool
def my_tool(ctx: RunContext) -> str:
if not ctx.tool_call_approved:
raise ApprovalRequired
return 'done' # pragma: no cover
result = await agent.run('Hello')
# Falls through to bubble-up since no handler resolved anything
assert isinstance(result.output, DeferredToolRequests)
assert len(result.output.approvals) == 1
# --- Dynamic capabilities ---
@dataclass
class _RecordingCapability(AbstractCapability[Any]):
"""Test capability that records every hook firing and contributes instructions."""
label: str
fired: list[str] = field(default_factory=list[str])
def get_instructions(self) -> str:
return f'Label is {self.label}.'
async def before_run(self, ctx: RunContext[Any]) -> None:
self.fired.append(f'{self.label}:before_run')
async def before_model_request(
self, ctx: RunContext[Any], request_context: ModelRequestContext
) -> ModelRequestContext:
self.fired.append(f'{self.label}:before_model_request')
return request_context
async def test_dynamic_capability_factory_called_with_run_context() -> None:
"""The factory receives the run's RunContext (with deps) once per run."""
seen: list[Any] = []
def factory(ctx: RunContext[str]) -> AbstractCapability[Any] | None:
seen.append(ctx.deps)
return _RecordingCapability(label=ctx.deps)
agent = Agent(TestModel(), deps_type=str, capabilities=[factory])
await agent.run('hi', deps='admin')
await agent.run('hi', deps='guest')
assert seen == ['admin', 'guest']
async def test_dynamic_capability_async_factory() -> None:
"""Async factories are awaited."""
calls = 0
async def factory(ctx: RunContext) -> AbstractCapability[Any]:
nonlocal calls
calls += 1
return _RecordingCapability(label='async')
agent = Agent(TestModel(), capabilities=[factory])
await agent.run('hi')
assert calls == 1
async def test_dynamic_capability_returning_none_contributes_nothing() -> None:
"""A factory returning None is a no-op for the run."""
def factory(ctx: RunContext) -> AbstractCapability[Any] | None:
return None
agent = Agent(TestModel(), capabilities=[factory])
result = await agent.run('hi')
request = next(m for m in result.all_messages() if isinstance(m, ModelRequest))
assert request.instructions is None
async def test_dynamic_capability_contributes_instructions_per_run() -> None:
"""Resolved capability's instructions flow through to the model request."""
def factory(ctx: RunContext[str]) -> AbstractCapability[Any] | None:
if ctx.deps == 'admin':
return _RecordingCapability(label='admin')
return None
agent = Agent(TestModel(), deps_type=str, capabilities=[factory])
admin_result = await agent.run('hi', deps='admin')
admin_request = next(m for m in admin_result.all_messages() if isinstance(m, ModelRequest))
assert admin_request.instructions == 'Label is admin.'
guest_result = await agent.run('hi', deps='guest')
guest_request = next(m for m in guest_result.all_messages() if isinstance(m, ModelRequest))
assert guest_request.instructions is None
async def test_dynamic_capability_contributes_toolset() -> None:
"""Resolved capability's toolset is exposed to the model and its tools execute."""
toolset = FunctionToolset()
@toolset.tool_plain
def special() -> str:
return 'used'
@dataclass
class ToolCap(AbstractCapability):
def get_toolset(self):
return toolset
def factory(ctx: RunContext[bool]) -> AbstractCapability[Any] | None:
return ToolCap() if ctx.deps else None
seen_tools: list[str] = []
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_tools.append(','.join(sorted(t.name for t in info.function_tools)))
# On the first request call the tool if it's available; on the follow-up
# request after the tool return, finish.
already_called = any(
isinstance(p, ToolReturnPart) for m in messages if isinstance(m, ModelRequest) for p in m.parts
)
if not already_called and any(t.name == 'special' for t in info.function_tools):
return ModelResponse(parts=[ToolCallPart('special')])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(respond), deps_type=bool, capabilities=[factory])
with_tool = await agent.run('hi', deps=True)
tool_returns = [
p.content
for m in with_tool.all_messages()
if isinstance(m, ModelRequest)
for p in m.parts
if isinstance(p, ToolReturnPart)
]
assert tool_returns == ['used']
await agent.run('hi', deps=False)
assert seen_tools == ['special', 'special', '']
async def test_dynamic_capability_hooks_fire() -> None:
"""Hooks contributed by the resolved capability fire during the run."""
cap = _RecordingCapability(label='dyn')
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return cap
agent = Agent(TestModel(), capabilities=[factory])
await agent.run('hi')
assert 'dyn:before_run' in cap.fired
assert 'dyn:before_model_request' in cap.fired
async def test_dynamic_capability_factory_called_once_per_run_not_per_step() -> None:
"""The factory is called once at for_run, not on every model request."""
calls = 0
def factory(ctx: RunContext) -> AbstractCapability[Any]:
nonlocal calls
calls += 1
return _RecordingCapability(label='once')
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
# Two-step run: first a tool call, then a final text response.
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart('echo', {'text': 'hi'})])
return ModelResponse(parts=[TextPart('done')])
toolset = FunctionToolset()
@toolset.tool_plain
def echo(text: str) -> str:
return text
agent = Agent(FunctionModel(respond), toolsets=[toolset], capabilities=[factory])
await agent.run('hi')
assert calls == 1
async def test_dynamic_capability_returning_combined() -> None:
"""A factory may return a CombinedCapability; all child contributions flow through."""
fired: list[str] = []
@dataclass
class A(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
fired.append('A')
@dataclass
class B(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
fired.append('B')
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return CombinedCapability([A(), B()])
agent = Agent(TestModel(), capabilities=[factory])
await agent.run('hi')
assert fired == ['A', 'B']
async def test_dynamic_deferred_capability_returned_from_custom_init_requires_stable_id() -> None:
"""Deferred capability validation also catches custom init objects returned at run time."""
@dataclass(init=False)
class CustomInitDeferredCap(AbstractCapability):
def __init__(self) -> None:
self.defer_loading = True
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return CustomInitDeferredCap()
agent = Agent(FunctionModel(lambda _messages, _info: make_text_response('done')), capabilities=[factory])
with pytest.raises(UserError, match='stable explicit `id` values'):
await agent.run('hi')
async def test_dynamic_deferred_capability_uses_resolved_capability_for_loaded_tools() -> None:
"""A loaded dynamic deferred capability exposes tools from the resolved capability."""
toolset = FunctionToolset()
@toolset.tool_plain
def lookup_refund_policy(order_id: str) -> str:
"""Look up the refund policy for an order."""
return f'{order_id}: refund allowed'
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return Capability[object](
id='dynamic-refunds',
description='Refund policy tools.',
toolsets=[toolset],
defer_loading=True,
)
seen_tool_state: list[list[tuple[str, bool]]] = []
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_tool_state.append([(t.name, bool(t.defer_loading)) for t in info.function_tools])
tool_returns = [
part
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
if isinstance(part, ToolReturnPart)
]
if not any(
isinstance(part, LoadCapabilityReturnPart)
for message in messages
if isinstance(message, ModelRequest)
for part in message.parts
):
return ModelResponse(
parts=[
ToolCallPart(
tool_name=LOAD_CAPABILITY_TOOL_NAME,
args={'id': 'dynamic-refunds'},
tool_call_id='load-dynamic-refunds',
)
]
)
if not any(part.tool_name == 'lookup_refund_policy' for part in tool_returns):
return ModelResponse(
parts=[
ToolCallPart(
tool_name='lookup_refund_policy',
args={'order_id': 'order-123'},
tool_call_id='lookup-refund',
)
]
)
refund_result = next(part.content for part in tool_returns if part.tool_name == 'lookup_refund_policy')
return make_text_response(f'done: {refund_result}')
agent = Agent(FunctionModel(model_fn), capabilities=[factory])
result = await agent.run('Can I get a refund?')
assert result.output == 'done: order-123: refund allowed'
assert seen_tool_state == snapshot(
[
[('load_capability', False), ('lookup_refund_policy', True), ('search_tools', False)],
[('load_capability', False), ('lookup_refund_policy', False), ('search_tools', False)],
[('load_capability', False), ('lookup_refund_policy', False), ('search_tools', False)],
]
)
async def test_dynamic_capability_in_run_call() -> None:
"""`agent.run(capabilities=[factory])` accepts callables as well."""
calls = 0
def factory(ctx: RunContext) -> AbstractCapability[Any]:
nonlocal calls
calls += 1
return _RecordingCapability(label='run-time')
agent = Agent(TestModel())
result = await agent.run('hi', capabilities=[factory])
request = next(m for m in result.all_messages() if isinstance(m, ModelRequest))
assert request.instructions == 'Label is run-time.'
assert calls == 1
async def test_dynamic_capability_composes_with_static() -> None:
"""Static and dynamic capabilities both contribute, in order."""
fired: list[str] = []
@dataclass
class Static(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
fired.append('static')
@dataclass
class Dynamic(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
fired.append('dynamic')
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return Dynamic()
agent = Agent(TestModel(), capabilities=[Static(), factory])
await agent.run('hi')
assert fired == ['static', 'dynamic']
async def test_dynamic_capability_per_run_isolation() -> None:
"""Concurrent runs see independent factory calls and resolved capabilities."""
seen_deps: list[str] = []
def factory(ctx: RunContext[str]) -> AbstractCapability[Any]:
seen_deps.append(ctx.deps)
return _RecordingCapability(label=ctx.deps)
agent = Agent(TestModel(), deps_type=str, capabilities=[factory])
results = await asyncio.gather(*(agent.run('hi', deps=f'user-{i}') for i in range(5)))
assert sorted(seen_deps) == ['user-0', 'user-1', 'user-2', 'user-3', 'user-4']
for i, result in enumerate(results):
request = next(m for m in result.all_messages() if isinstance(m, ModelRequest))
assert request.instructions == f'Label is user-{i}.'
async def test_dynamic_capability_wraps_func_in_constructor() -> None:
"""Constructor wraps a bare function into a `DynamicCapability`, and the factory runs at run time."""
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return _RecordingCapability(label='x')
agent = Agent(TestModel(), capabilities=[factory])
result = await agent.run('hi')
request = next(m for m in result.all_messages() if isinstance(m, ModelRequest))
assert request.instructions == 'Label is x.'
def test_dynamic_capability_rejects_wrapper_fields() -> None:
"""`defer_loading` on the wrapper would otherwise be silently ignored — reject at construction."""
from pydantic_ai.capabilities import DynamicCapability
def factory(ctx: RunContext) -> AbstractCapability[Any]:
return _RecordingCapability(label='x') # pragma: no cover
with pytest.raises(UserError, match='not supported on `DynamicCapability`'):
DynamicCapability(capability_func=factory, defer_loading=True)
# endregion