mirror of
https://github.com/pydantic/pydantic-ai.git
synced 2026-10-12 02:21:31 +00:00
2686 lines
114 KiB
Python
2686 lines
114 KiB
Python
"""Tests for native-or-local capability tools.
|
|
|
|
Split out of `test_capabilities.py` per #7304.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
from dataclasses import dataclass, replace
|
|
from datetime import datetime
|
|
from importlib.util import find_spec
|
|
from types import ModuleType, NoneType
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from pydantic_ai._run_context import RunContext
|
|
from pydantic_ai._spec import NamedSpec
|
|
from pydantic_ai._warnings import PydanticAIDeprecationWarning
|
|
from pydantic_ai.agent import Agent
|
|
from pydantic_ai.agent.abstract import AbstractAgent
|
|
from pydantic_ai.capabilities import (
|
|
CAPABILITY_TYPES,
|
|
MCP,
|
|
PrepareTools,
|
|
ResolveModelId,
|
|
SelectModel,
|
|
ToolSearch,
|
|
WebFetch,
|
|
WebSearch,
|
|
WrapperCapability,
|
|
XSearch,
|
|
)
|
|
from pydantic_ai.capabilities.abstract import AbstractCapability
|
|
from pydantic_ai.capabilities.combined import CombinedCapability
|
|
from pydantic_ai.exceptions import (
|
|
ModelRetry,
|
|
UnexpectedModelBehavior,
|
|
UserError,
|
|
)
|
|
from pydantic_ai.messages import (
|
|
ModelMessage,
|
|
ModelMessagesTypeAdapter,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
RetryPromptPart,
|
|
SystemPromptPart,
|
|
TextPart,
|
|
ToolCallPart,
|
|
ToolReturnPart,
|
|
UserContent,
|
|
UserPromptPart,
|
|
)
|
|
from pydantic_ai.models import (
|
|
KnownModelName,
|
|
Model,
|
|
ModelRequestContext,
|
|
ModelResolutionContext,
|
|
ModelSelectionContext,
|
|
)
|
|
from pydantic_ai.models.fallback import FallbackModel
|
|
from pydantic_ai.models.function import AgentInfo, FunctionModel
|
|
from pydantic_ai.models.test import TestModel
|
|
from pydantic_ai.native_tools import (
|
|
MCPServerTool,
|
|
WebFetchTool,
|
|
WebSearchTool,
|
|
XSearchTool,
|
|
)
|
|
from pydantic_ai.output import ToolOutput
|
|
from pydantic_ai.profiles import ModelProfile
|
|
from pydantic_ai.tools import DeferredToolResults, ToolDefinition
|
|
from pydantic_ai.toolsets import AbstractToolset
|
|
from pydantic_ai.usage import RequestUsage
|
|
|
|
from ._inline_snapshot import snapshot
|
|
from .capability_models import (
|
|
MyOutput,
|
|
build_run_context as _build_run_context,
|
|
make_text_response,
|
|
)
|
|
from .conftest import IsDatetime, IsStr, iter_message_parts
|
|
|
|
_SEARCH_TOOLS_NAME = ToolSearch.function_tool_name
|
|
|
|
pytestmark = []
|
|
|
|
|
|
# --- 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())) # pyright: ignore[reportArgumentType]
|
|
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())) # pyright: ignore[reportArgumentType]
|
|
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())) # pyright: ignore[reportArgumentType]
|
|
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_subagent_model is None
|
|
assert cap.get_toolset() is None
|
|
|
|
def test_xsearch_native_false_without_local_raises(self):
|
|
"""XSearch(native=False) without a local tool or `fallback_subagent_model` → UserError at construction.
|
|
|
|
It raises at construction, before any request, so this is not a VCR test.
|
|
"""
|
|
with pytest.raises(UserError, match='requires an explicit local tool'):
|
|
XSearch(native=False)
|
|
|
|
def test_xsearch_with_subagent_model(self):
|
|
"""XSearch(fallback_subagent_model=...) → native XSearchTool, local subagent fallback."""
|
|
cap = XSearch(fallback_subagent_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'])])
|
|
|
|
async def test_xsearch_native_false_local_callable(self):
|
|
"""XSearch(native=False, local=some_function) → the user's function is the only tool offered.
|
|
|
|
It is decided at construction and toolset build, before any request, so this is not a VCR test.
|
|
"""
|
|
|
|
def my_search(query: str) -> str:
|
|
return f'results for {query}' # pragma: no cover
|
|
|
|
cap = XSearch(native=False, local=my_search)
|
|
|
|
assert cap.get_native_tools() == []
|
|
toolset = cap.get_toolset()
|
|
assert toolset is not None
|
|
tools = await toolset.get_tools(_build_run_context())
|
|
assert list(tools) == ['my_search']
|
|
|
|
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_subagent_model → UserError."""
|
|
with pytest.raises(UserError, match='constraint fields require the native tool'):
|
|
XSearch(native=False, allowed_x_handles=['handle1'])
|
|
|
|
def test_xsearch_subagent_model_and_local_conflict(self):
|
|
"""XSearch(fallback_subagent_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_subagent_model` and `local`'):
|
|
XSearch(fallback_subagent_model='xai:grok-4-1-fast-non-reasoning', local=my_search)
|
|
|
|
def test_xsearch_subagent_model_with_local_false(self):
|
|
"""XSearch(fallback_subagent_model=..., local=False) raises UserError."""
|
|
with pytest.raises(UserError, match='cannot specify both `fallback_subagent_model` and `local`'):
|
|
XSearch(fallback_subagent_model='xai:grok-4-1-fast-non-reasoning', local=False)
|
|
|
|
def test_xsearch_deprecated_fallback_model_argument(self):
|
|
"""`fallback_model=` still configures the subagent, and warns at the caller's own line."""
|
|
with pytest.warns(
|
|
PydanticAIDeprecationWarning, match=r'`fallback_model` is deprecated; use `fallback_subagent_model`'
|
|
) as record:
|
|
cap = XSearch(fallback_model='xai:grok-4-1-fast-non-reasoning')
|
|
assert [warning.filename for warning in record] == [__file__]
|
|
assert cap.fallback_subagent_model == 'xai:grok-4-1-fast-non-reasoning'
|
|
assert cap.get_toolset() is not None
|
|
|
|
def test_xsearch_deprecated_fallback_model_from_a_pydantic_ai_prefixed_package(self):
|
|
"""A caller package whose name merely starts with `pydantic_ai` still gets its own line.
|
|
|
|
`pydantic_ai_harness` is such a package, and the notice is useless to it if the walk out of
|
|
the framework's own frames treats it as one of them.
|
|
"""
|
|
caller_file = '/pydantic_ai_harness/capabilities.py'
|
|
caller = ModuleType('pydantic_ai_harness.capabilities')
|
|
caller.__dict__['XSearch'] = XSearch
|
|
code = compile("cap = XSearch(fallback_model='xai:grok-4-1-fast-non-reasoning')", caller_file, 'exec')
|
|
with pytest.warns(
|
|
PydanticAIDeprecationWarning, match=r'`fallback_model` is deprecated; use `fallback_subagent_model`'
|
|
) as record:
|
|
exec(code, caller.__dict__)
|
|
assert [warning.filename for warning in record] == [caller_file]
|
|
|
|
def test_xsearch_deprecated_fallback_model_attribute(self):
|
|
"""Reading and writing `.fallback_model` proxies `fallback_subagent_model`, and warns."""
|
|
cap = XSearch(fallback_subagent_model='xai:grok-4-1-fast-non-reasoning')
|
|
with pytest.warns(
|
|
PydanticAIDeprecationWarning, match=r'`fallback_model` is deprecated; use `fallback_subagent_model`'
|
|
):
|
|
assert cap.fallback_model == 'xai:grok-4-1-fast-non-reasoning' # pyright: ignore[reportDeprecated]
|
|
with pytest.warns(
|
|
PydanticAIDeprecationWarning, match=r'`fallback_model` is deprecated; use `fallback_subagent_model`'
|
|
):
|
|
cap.fallback_model = 'xai:grok-4.3' # pyright: ignore[reportDeprecated]
|
|
assert cap.fallback_subagent_model == 'xai:grok-4.3'
|
|
|
|
def test_xsearch_rejects_both_model_names(self):
|
|
"""Both spellings at once is a mistake to report, not one to resolve silently."""
|
|
with pytest.raises(UserError, match='cannot specify both `fallback_model` and `fallback_subagent_model`'):
|
|
XSearch(fallback_subagent_model='xai:grok-4.3', fallback_model='xai:grok-4-1-fast-non-reasoning')
|
|
|
|
def test_xsearch_callable_native_with_fallback(self):
|
|
"""Callable native with fallback_subagent_model still provides the subagent tool.
|
|
|
|
The tool is derived when the toolset is requested, so `local` keeps what was declared.
|
|
"""
|
|
cap = XSearch(
|
|
native=lambda ctx: XSearchTool(enable_image_understanding=True),
|
|
fallback_subagent_model='xai:grok-4-1-fast-non-reasoning',
|
|
)
|
|
assert cap.local is None
|
|
assert cap.get_toolset() is not None
|
|
|
|
async def test_xsearch_callable_subagent_model(self, allow_model_requests: None):
|
|
"""XSearch with callable fallback_subagent_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_subagent_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_subagent_model(self, allow_model_requests: None):
|
|
"""XSearch with sync callable fallback_subagent_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_subagent_model=model_factory)])
|
|
result = await agent.run('search X')
|
|
assert result.output == 'done'
|
|
tool_returns = list(iter_message_parts(result.all_messages(), ModelRequest, 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_subagent_model=inner_model)])
|
|
result = await agent.run('search X')
|
|
assert result.output == 'gave up'
|
|
retry_parts = list(iter_message_parts(result.all_messages(), ModelRequest, 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())) # pyright: ignore[reportArgumentType]
|
|
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 = list(iter_message_parts(result.all_messages(), ModelResponse, 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())) # pyright: ignore[reportArgumentType]
|
|
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())) # pyright: ignore[reportArgumentType]
|
|
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 = list(iter_message_parts(result.all_messages(), ModelResponse, 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)
|
|
|
|
|
|
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'
|
|
|
|
def test_mcp_local_toolset_id_derived(self):
|
|
"""MCP stamps a derived id on the local `MCPToolset` so it can be used with durable
|
|
execution. Precedence: explicit `id` → native `MCPServerTool` id → host+slug from the URL,
|
|
else `None` when there's nothing to derive from."""
|
|
# `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
|
|
|
|
# (capability, expected local toolset id)
|
|
cases: list[tuple[MCP[object], str | None]] = [
|
|
# id derived from the URL (host + path slug)
|
|
(MCP[object](url='https://mcp.example.com/api'), 'mcp.example.com-api'),
|
|
# explicit id wins
|
|
(MCP[object](url='https://mcp.example.com/api', id='docs'), 'docs'),
|
|
# native MCPServerTool id is reused for the local fallback
|
|
(
|
|
MCP[object](
|
|
url='https://mcp.example.com/api',
|
|
native=MCPServerTool(id='custom-mcp', url='https://mcp.example.com/api'),
|
|
local=True,
|
|
),
|
|
'custom-mcp',
|
|
),
|
|
# `local='https://…'` override with no `url=`: id derived from the override URL,
|
|
# exercising `_derive_id` deriving from the override URL even when `self.url` is `None`
|
|
(MCP[object](local='https://other.example.com/sse'), 'other.example.com-sse'),
|
|
# non-URL local input (in-process `FastMCP` server) wrapped into an `MCPToolset`,
|
|
# inheriting the explicit id
|
|
(MCP[object](id='local-mcp', local=FastMCP('test-server')), 'local-mcp'),
|
|
# nothing to derive from — no id, no native tool, no URL → stays None
|
|
(MCP[object](local=FastMCP('test-server')), None),
|
|
]
|
|
for cap, expected_id in cases:
|
|
local = cap.local
|
|
assert isinstance(local, MCPToolset)
|
|
assert local.id == expected_id
|
|
|
|
def test_mcp_callable_native_without_url_or_id_errors(self):
|
|
"""A `native=<callable>` factory paired with a local fallback has nothing to derive the
|
|
`unless_native` marker from (no `url=`, no `id=`, non-`MCPServerTool` native), so
|
|
`get_toolset()` raises an actionable `UserError` rather than a bare `AssertionError`."""
|
|
|
|
async def native_factory(ctx: RunContext[object]) -> MCPServerTool:
|
|
return MCPServerTool(id='x', url='https://mcp.example.com/api') # pragma: no cover
|
|
|
|
def local_tool() -> str:
|
|
return 'local' # pragma: no cover
|
|
|
|
cap = MCP[object](native=native_factory, local=local_tool)
|
|
with pytest.raises(UserError, match='needs a stable `id` to tie the two together'):
|
|
cap.get_toolset()
|
|
|
|
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(self):
|
|
"""A run-time spec's tool-retry budget replaces the agent default (3 here, not the agent's 1)."""
|
|
call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(parts=[ToolCallPart('flaky', {})])
|
|
|
|
agent = Agent(FunctionModel(model_fn), retries={'tools': 1})
|
|
|
|
@agent.tool_plain
|
|
def flaky() -> str:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
raise ModelRetry('again')
|
|
|
|
with pytest.raises(UnexpectedModelBehavior, match=r"Tool 'flaky' exceeded max retries count of 3"):
|
|
await agent.run('hello', spec={'retries': {'tools': 3}})
|
|
|
|
# initial call + 3 retries, following the spec budget (3), not the agent default (1)
|
|
assert call_count == 4
|
|
|
|
|
|
@dataclass
|
|
class _ModelCap(AbstractCapability):
|
|
"""Test capability that supplies a model via `get_model()`."""
|
|
|
|
model: Model | KnownModelName | str | None = None
|
|
|
|
def get_model(self) -> Model | KnownModelName | str | None:
|
|
return self.model
|
|
|
|
|
|
def _text_model(text: str) -> FunctionModel:
|
|
"""A `FunctionModel` whose response text identifies which model handled the request."""
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return make_text_response(text)
|
|
|
|
return FunctionModel(model_fn)
|
|
|
|
|
|
class TestGetModelHook:
|
|
"""Capabilities can supply the agent's model via `get_model()`."""
|
|
|
|
async def test_model_less_agent_uses_capability_model(self):
|
|
"""A capability can supply the model for an agent that has none (the headline case)."""
|
|
agent = Agent(None, capabilities=[_ModelCap(model='test')])
|
|
|
|
result = await agent.run('hello')
|
|
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_select_model_uses_first_step_dependencies(self):
|
|
"""The convenience capability's bootstrap selector needs live deps, which a provider cassette cannot prove."""
|
|
small = _text_model('small')
|
|
frontier = _text_model('frontier')
|
|
seen_steps: list[int] = []
|
|
|
|
def select(ctx: ModelSelectionContext[bool]) -> Model:
|
|
seen_steps.append(ctx.run_step)
|
|
assert ctx.model is None
|
|
assert [message.parts for message in ctx.messages] == [[UserPromptPart('hello', timestamp=IsDatetime())]]
|
|
return frontier if ctx.deps else small
|
|
|
|
agent = Agent(None, deps_type=bool, capabilities=[SelectModel(select)])
|
|
|
|
assert SelectModel.get_serialization_name() is None
|
|
assert (await agent.run('hello', deps=False)).output == 'small'
|
|
assert (await agent.run('hello', deps=True)).output == 'frontier'
|
|
assert seen_steps == [1, 1]
|
|
|
|
async def test_model_less_agent_without_capability_model_raises(self):
|
|
"""With no model anywhere (capability returns None), the usual missing-model error is raised."""
|
|
agent = Agent(None, capabilities=[_ModelCap(model=None)])
|
|
|
|
with pytest.raises(UserError, match='`model` must either be set on the agent or included when calling it'):
|
|
await agent.run('hello')
|
|
|
|
async def test_run_model_arg_beats_capability_model(self):
|
|
"""A call-site `run(model=...)` wins over a capability-supplied model."""
|
|
agent = Agent(None, capabilities=[_ModelCap(model='test')])
|
|
|
|
result = await agent.run('hello', model=_text_model('from-run-arg'))
|
|
assert result.output == 'from-run-arg'
|
|
|
|
async def test_run_spec_model_beats_capability_model(self):
|
|
"""A run-level `spec=` model wins over a capability-supplied model."""
|
|
agent = Agent(None, capabilities=[_ModelCap(model=_text_model('from-capability'))])
|
|
|
|
result = await agent.run('hello', spec={'model': 'test'})
|
|
assert result.output == 'success (no tool calls)'
|
|
|
|
async def test_capability_model_beats_agent_constructor(self):
|
|
"""A capability-supplied model wins over the agent constructor's model."""
|
|
agent = Agent(_text_model('from-constructor'), capabilities=[_ModelCap(model=_text_model('from-capability'))])
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from-capability'
|
|
|
|
async def test_callable_model_instance_is_static(self):
|
|
"""A callable `Model` instance is still a model, not a selector function."""
|
|
from unittest.mock import Mock
|
|
|
|
class CallableModel(FunctionModel):
|
|
__call__ = Mock(side_effect=AssertionError('model must not be called as a selector'))
|
|
|
|
selected = CallableModel(lambda messages, info: make_text_response('selected'))
|
|
assert (await Agent(None, capabilities=[_ModelCap(model=selected)]).run('hello')).output == 'selected'
|
|
selected.__call__.assert_not_called()
|
|
|
|
async def test_agent_context_with_dynamic_capability_model(self):
|
|
"""The agent context leaves dynamic capability models to the runs that select them."""
|
|
selected_model = _text_model('from-capability')
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return lambda ctx: selected_model
|
|
|
|
agent = Agent(_text_model('from-constructor'), deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
async with agent:
|
|
assert (await agent.run('hello')).output == 'from-capability'
|
|
|
|
async def test_agent_context_uses_model_override(self):
|
|
"""The agent context enters an override model instead of a capability model."""
|
|
agent = Agent(None, capabilities=[_ModelCap(model=_text_model('from-capability'))])
|
|
|
|
with agent.override(model=_text_model('from-override')):
|
|
async with agent:
|
|
assert (await agent.run('hello')).output == 'from-override'
|
|
|
|
async def test_override_model_beats_capability_model(self):
|
|
"""`agent.override(model=...)` wins over a capability-supplied model, per its docs."""
|
|
agent = Agent(None, capabilities=[_ModelCap(model='test')])
|
|
|
|
with agent.override(model=_text_model('from-override')):
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from-override'
|
|
|
|
async def test_last_non_none_capability_wins(self):
|
|
"""Later capability contributions override earlier ones."""
|
|
agent = Agent(
|
|
None,
|
|
capabilities=[
|
|
_ModelCap(model=None),
|
|
_ModelCap(model=_text_model('from-second')),
|
|
_ModelCap(model=_text_model('from-third')),
|
|
],
|
|
)
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from-third'
|
|
|
|
async def test_callable_selects_model_per_step(self):
|
|
first = FunctionModel(lambda messages, info: ModelResponse(parts=[ToolCallPart('advance', '{}')]))
|
|
|
|
def finish(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
assert info.model_settings == {'max_tokens': 123}
|
|
return make_text_response('done')
|
|
|
|
second = FunctionModel(finish, settings={'max_tokens': 123})
|
|
selected_steps: list[int] = []
|
|
selection_history_lengths: list[int] = []
|
|
|
|
def select(ctx: ModelSelectionContext[int]) -> Model:
|
|
selected_steps.append(ctx.run_step)
|
|
selection_history_lengths.append(len(ctx.messages))
|
|
ctx.messages.clear() # The selection context must not expose mutable graph state.
|
|
assert ctx.deps == 42
|
|
return first if ctx.run_step == 1 else second
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[int]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[int]], Model]:
|
|
return select
|
|
|
|
agent = Agent(None, deps_type=int, capabilities=[AdaptiveModel()])
|
|
|
|
@agent.tool_plain
|
|
def advance() -> str:
|
|
return 'advanced'
|
|
|
|
result = await agent.run('hello', deps=42)
|
|
assert result.output == 'done'
|
|
assert selected_steps == [1, 2]
|
|
assert selection_history_lengths == [1, 3]
|
|
|
|
@pytest.mark.parametrize('case', ['fresh', 'history', 'resume', 'no_prompt', 'continue'])
|
|
async def test_selection_context_matches_run_context(self, case: str):
|
|
"""On every step, the selector sees the `messages` and `prompt` the step's `RunContext` holds.
|
|
|
|
The only difference allowed is what's added to the step's request once the model is selected: its
|
|
instructions and, on a fresh run's first step, its system prompt parts, which are dropped from the
|
|
`RunContext` side only. `resume` is a run with no new prompt whose history ends in a request, which
|
|
is then the request being sent; `no_prompt` and `continue` send a request without a new prompt, on
|
|
a fresh run and after a finished one.
|
|
|
|
Not a VCR test: the claim is about what a selector callback receives, which no provider sees.
|
|
"""
|
|
|
|
def respond(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
if isinstance(messages[-1], ModelRequest) and any(
|
|
isinstance(p, ToolReturnPart) for p in messages[-1].parts
|
|
):
|
|
return make_text_response('done')
|
|
return ModelResponse(parts=[ToolCallPart('advance', '{}')])
|
|
|
|
model = FunctionModel(respond)
|
|
selected: list[tuple[int, Any, list[Any]]] = []
|
|
sent: list[tuple[int, Any, list[Any]]] = []
|
|
|
|
def comparable(messages: list[ModelMessage], *, drop_added_by_model: bool = False) -> list[Any]:
|
|
"""Drop what differs between two builds of a request, and optionally what the model adds to the last one."""
|
|
if drop_added_by_model:
|
|
*earlier, last = messages
|
|
assert isinstance(last, ModelRequest)
|
|
system_prompt_parts = [part for part in last.parts if isinstance(part, SystemPromptPart)]
|
|
messages = [*earlier, replace(last, parts=last.parts[len(system_prompt_parts) :], instructions=None)]
|
|
dumped = ModelMessagesTypeAdapter.dump_python(messages, mode='json')
|
|
for message in dumped:
|
|
for key in ('timestamp', 'run_id', 'conversation_id', 'metadata'):
|
|
message.pop(key, None)
|
|
for part in message['parts']:
|
|
part.pop('timestamp', None)
|
|
return dumped
|
|
|
|
@dataclass
|
|
class Recorder(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
def select(ctx: ModelSelectionContext[None]) -> Model:
|
|
selected.append((ctx.run_step, ctx.prompt, comparable(ctx.messages)))
|
|
return model
|
|
|
|
return select
|
|
|
|
async def before_model_request(
|
|
self, ctx: RunContext[None], request_context: ModelRequestContext
|
|
) -> ModelRequestContext:
|
|
sent.append((ctx.run_step, ctx.prompt, comparable(ctx.messages, drop_added_by_model=True)))
|
|
return request_context
|
|
|
|
agent = Agent(
|
|
None,
|
|
deps_type=NoneType,
|
|
instructions='Be terse.',
|
|
system_prompt='You are terse.',
|
|
capabilities=[Recorder()],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def advance() -> str:
|
|
return 'advanced'
|
|
|
|
earlier = (await Agent(_text_model('earlier'), instructions='Earlier.').run('Earlier question.')).all_messages()
|
|
resumed: list[ModelMessage] = [
|
|
ModelRequest(parts=[UserPromptPart('Resumed'), UserPromptPart('question.')], instructions='Earlier.')
|
|
]
|
|
prompt, history = {
|
|
'fresh': ('New question.', None),
|
|
'history': ('New question.', earlier),
|
|
'resume': (None, resumed),
|
|
'no_prompt': (None, None),
|
|
'continue': (None, earlier),
|
|
}[case]
|
|
result = await agent.run(prompt, message_history=history)
|
|
assert result.output == 'done'
|
|
|
|
run_prompt = ['Resumed', 'question.'] if case == 'resume' else prompt
|
|
assert [(step, prompt) for step, prompt, _ in selected] == [(1, run_prompt), (2, run_prompt)]
|
|
assert selected == sent
|
|
|
|
@pytest.mark.parametrize('deferred', [False, True])
|
|
async def test_selection_context_with_tool_calls_to_run(self, deferred: bool):
|
|
"""A run resuming from tool calls still to run is routed on the history ending in their response.
|
|
|
|
The step's request holds the calls' results, which only exist once the tools have run with the
|
|
selected model.
|
|
"""
|
|
selected: list[tuple[str | Sequence[UserContent] | None, list[ModelMessage]]] = []
|
|
|
|
def select(ctx: ModelSelectionContext[None]) -> Model:
|
|
selected.append((ctx.prompt, ctx.messages))
|
|
return _text_model('done')
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[SelectModel(select)])
|
|
|
|
@agent.tool_plain(requires_approval=deferred)
|
|
def delete_file() -> str:
|
|
return 'deleted'
|
|
|
|
history: list[ModelMessage] = [
|
|
ModelRequest(parts=[UserPromptPart('Clean up.')]),
|
|
ModelResponse(parts=[ToolCallPart('delete_file', {}, tool_call_id='call')]),
|
|
]
|
|
if deferred:
|
|
approvals = DeferredToolResults(approvals={'call': True})
|
|
result = await agent.run('And then?', message_history=history, deferred_tool_results=approvals)
|
|
assert selected[0] == ('And then?', history)
|
|
else:
|
|
result = await agent.run(message_history=history)
|
|
assert selected[0] == (None, history)
|
|
assert result.output == 'done'
|
|
|
|
async def test_explicit_run_model_skips_selector(self):
|
|
from unittest.mock import Mock
|
|
|
|
select = Mock(side_effect=AssertionError('selector should not run'))
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return select
|
|
|
|
capability = AdaptiveModel()
|
|
assert capability.get_model() is select
|
|
select.reset_mock()
|
|
|
|
result = await Agent(None, deps_type=NoneType, capabilities=[capability]).run(
|
|
'hello', model=_text_model('explicit')
|
|
)
|
|
assert result.output == 'explicit'
|
|
select.assert_not_called()
|
|
|
|
async def test_selected_model_id_is_resolved_with_deps(self):
|
|
target = _text_model('resolved')
|
|
|
|
def select(ctx: ModelSelectionContext[str]) -> str:
|
|
return 'alias'
|
|
|
|
def resolve(ctx: ModelResolutionContext[str], model_id: str) -> Model | None:
|
|
assert ctx.deps == 'tenant'
|
|
return target if model_id == 'alias' else None
|
|
|
|
@dataclass
|
|
class SelectAlias(AbstractCapability[str]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[str]], str]:
|
|
return select
|
|
|
|
agent = Agent(None, deps_type=str, capabilities=[SelectAlias(), ResolveModelId(resolve)])
|
|
result = await agent.run('hello', deps='tenant')
|
|
assert result.output == 'resolved'
|
|
|
|
async def test_constructor_model_id_is_resolved_with_deps(self):
|
|
target = _text_model('resolved')
|
|
|
|
def resolve(ctx: ModelResolutionContext[str], model_id: str) -> Model | None:
|
|
assert ctx.deps == 'tenant'
|
|
return target if model_id == 'alias' else None
|
|
|
|
agent = Agent('alias', deps_type=str, capabilities=[ResolveModelId(resolve)])
|
|
assert (await agent.run('hello', deps='tenant')).output == 'resolved'
|
|
|
|
async def test_static_model_id_is_resolved_once_per_run(self):
|
|
requests = 0
|
|
resolutions = 0
|
|
|
|
def request(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal requests
|
|
requests += 1
|
|
if requests == 1:
|
|
return ModelResponse(parts=[ToolCallPart('advance', '{}')])
|
|
return make_text_response('done')
|
|
|
|
selected = FunctionModel(request)
|
|
|
|
def resolve(ctx: ModelResolutionContext[None], model_id: str) -> Model | None:
|
|
nonlocal resolutions
|
|
resolutions += 1
|
|
return selected if model_id == 'alias' else None
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[_ModelCap(model='alias'), ResolveModelId(resolve)])
|
|
|
|
@agent.tool_plain
|
|
def advance() -> str:
|
|
return 'advanced'
|
|
|
|
assert (await agent.run('hello')).output == 'done'
|
|
assert resolutions == 1
|
|
|
|
async def test_dynamic_model_id_is_resolved_once_per_run(self):
|
|
requests = 0
|
|
selections = 0
|
|
resolutions = 0
|
|
|
|
def request(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal requests
|
|
requests += 1
|
|
if requests == 1:
|
|
return ModelResponse(parts=[ToolCallPart('advance', '{}')])
|
|
return make_text_response('done')
|
|
|
|
selected = FunctionModel(request)
|
|
|
|
def select(ctx: ModelSelectionContext[None]) -> str:
|
|
nonlocal selections
|
|
selections += 1
|
|
return 'alias'
|
|
|
|
def resolve(ctx: ModelResolutionContext[None], model_id: str) -> Model | None:
|
|
nonlocal resolutions
|
|
resolutions += 1
|
|
return selected if model_id == 'alias' else None
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[SelectModel(select), ResolveModelId(resolve)])
|
|
|
|
@agent.tool_plain
|
|
def advance() -> str:
|
|
return 'advanced'
|
|
|
|
assert (await agent.run('hello')).output == 'done'
|
|
assert selections == 2
|
|
assert resolutions == 1
|
|
|
|
async def test_unchanged_for_run_selector_is_not_repeated_on_first_step(self):
|
|
selections = 0
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
# Deliberately return a fresh closure on every configuration read.
|
|
def select(ctx: ModelSelectionContext[None]) -> Model:
|
|
nonlocal selections
|
|
selections += 1
|
|
return _text_model('selected')
|
|
|
|
return select
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
assert (await agent.run('hello')).output == 'selected'
|
|
assert selections == 1
|
|
|
|
async def test_replaced_for_run_selector_reselects_first_step(self):
|
|
selections: list[str] = []
|
|
|
|
class LifecycleModel(FunctionModel):
|
|
entered = 0
|
|
exited = 0
|
|
|
|
async def __aenter__(self):
|
|
self.entered += 1
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any):
|
|
self.exited += 1
|
|
|
|
bootstrap_model = LifecycleModel(lambda messages, info: make_text_response('bootstrap'))
|
|
replacement_model = LifecycleModel(lambda messages, info: make_text_response('replacement'))
|
|
|
|
def selector(name: str) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
def select(ctx: ModelSelectionContext[None]) -> Model:
|
|
selections.append(name)
|
|
return bootstrap_model if name == 'bootstrap' else replacement_model
|
|
|
|
return select
|
|
|
|
@dataclass
|
|
class Replacement(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return selector('replacement')
|
|
|
|
@dataclass
|
|
class Bootstrap(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return selector('bootstrap')
|
|
|
|
async def for_run(self, ctx: RunContext[None]) -> AbstractCapability[None]:
|
|
return Replacement()
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[Bootstrap()])
|
|
assert (await agent.run('hello')).output == 'replacement'
|
|
assert selections == ['bootstrap', 'replacement']
|
|
assert (bootstrap_model.entered, bootstrap_model.exited) == (1, 1)
|
|
assert (replacement_model.entered, replacement_model.exited) == (1, 1)
|
|
|
|
async def test_replaced_for_run_static_model_is_authoritative(self):
|
|
@dataclass
|
|
class Replacement(AbstractCapability[None]):
|
|
def get_model(self) -> Model:
|
|
return _text_model('replacement')
|
|
|
|
@dataclass
|
|
class Bootstrap(AbstractCapability[None]):
|
|
def get_model(self) -> Model:
|
|
return _text_model('bootstrap')
|
|
|
|
async def for_run(self, ctx: RunContext[None]) -> AbstractCapability[None]:
|
|
return Replacement()
|
|
|
|
assert (await Agent(None, deps_type=NoneType, capabilities=[Bootstrap()]).run('hello')).output == 'replacement'
|
|
|
|
async def test_for_run_cannot_remove_only_bootstrap_model(self):
|
|
@dataclass
|
|
class Bootstrap(AbstractCapability[None]):
|
|
def get_model(self) -> Model:
|
|
return _text_model('bootstrap')
|
|
|
|
async def for_run(self, ctx: RunContext[None]) -> AbstractCapability[None]:
|
|
return AbstractCapability()
|
|
|
|
with pytest.raises(UserError, match='removed the bootstrap model'):
|
|
await Agent(None, deps_type=NoneType, capabilities=[Bootstrap()]).run('hello')
|
|
|
|
async def test_for_run_can_remove_capability_model_when_constructor_model_exists(self):
|
|
@dataclass
|
|
class Bootstrap(AbstractCapability[None]):
|
|
def get_model(self) -> Model:
|
|
return _text_model('bootstrap')
|
|
|
|
async def for_run(self, ctx: RunContext[None]) -> AbstractCapability[None]:
|
|
return AbstractCapability()
|
|
|
|
agent = Agent(_text_model('constructor'), deps_type=NoneType, capabilities=[Bootstrap()])
|
|
assert (await agent.run('hello')).output == 'constructor'
|
|
|
|
async def test_async_selector_and_repeated_model_lifecycle(self):
|
|
requests = 0
|
|
|
|
class LifecycleModel(FunctionModel):
|
|
entered = 0
|
|
|
|
async def __aenter__(self):
|
|
self.entered += 1
|
|
return self
|
|
|
|
def request(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal requests
|
|
requests += 1
|
|
if requests == 1:
|
|
return ModelResponse(parts=[ToolCallPart('advance', '{}')])
|
|
return make_text_response('done')
|
|
|
|
selected = LifecycleModel(request)
|
|
|
|
async def select(ctx: ModelSelectionContext[None]) -> Model:
|
|
return selected
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Awaitable[Model]]:
|
|
return select
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
|
|
@agent.tool_plain
|
|
def advance() -> str:
|
|
return 'advanced'
|
|
|
|
assert (await agent.run('hello')).output == 'done'
|
|
assert selected.entered == 1
|
|
|
|
async def test_run_spec_capability_can_bootstrap_model_less_agent(self, monkeypatch: pytest.MonkeyPatch):
|
|
@dataclass
|
|
class SpecModel(AbstractCapability[None]):
|
|
@classmethod
|
|
def get_serialization_name(cls) -> str:
|
|
return 'SpecModel'
|
|
|
|
def get_model(self) -> Model:
|
|
return _text_model('from spec capability')
|
|
|
|
monkeypatch.setitem(CAPABILITY_TYPES, 'SpecModel', SpecModel)
|
|
agent = Agent(None)
|
|
assert (await agent.run('hello', spec={'capabilities': ['SpecModel']})).output == 'from spec capability'
|
|
|
|
async def test_first_model_id_resolver_wins(self):
|
|
first = _text_model('first')
|
|
second = _text_model('second')
|
|
agent = Agent(
|
|
'alias',
|
|
capabilities=[
|
|
ResolveModelId(lambda ctx, model_id: first),
|
|
ResolveModelId(lambda ctx, model_id: second),
|
|
],
|
|
)
|
|
assert (await agent.run('hello')).output == 'first'
|
|
|
|
async def test_model_id_resolver_delegates_to_registry_backstop(self):
|
|
calls: list[str] = []
|
|
registered = _text_model('registered')
|
|
|
|
def user_resolver(ctx: ModelResolutionContext[None], model_id: str) -> Model | None:
|
|
calls.append('user')
|
|
return None
|
|
|
|
def registry_resolver(ctx: ModelResolutionContext[None], model_id: str) -> Model | None:
|
|
calls.append('registry')
|
|
return registered if model_id == 'registered-id' else None
|
|
|
|
agent = Agent(
|
|
'registered-id',
|
|
deps_type=NoneType,
|
|
capabilities=[ResolveModelId(user_resolver), ResolveModelId(registry_resolver)],
|
|
)
|
|
assert (await agent.run('hello')).output == 'registered'
|
|
assert calls == ['user', 'registry']
|
|
|
|
async def test_async_model_id_resolver_and_deferred_resolver(self):
|
|
from unittest.mock import AsyncMock
|
|
|
|
calls: list[str] = []
|
|
target = _text_model('resolved')
|
|
|
|
deferred = AsyncMock(side_effect=AssertionError('deferred model resolver must not run'))
|
|
|
|
async def eager(ctx: ModelResolutionContext[None], model_id: str) -> Model | None:
|
|
calls.append(model_id)
|
|
return target
|
|
|
|
capability = CombinedCapability(
|
|
[ResolveModelId(deferred, defer_loading=True, id='deferred-resolver'), ResolveModelId(eager)]
|
|
)
|
|
agent = Agent('alias', deps_type=NoneType, capabilities=[capability])
|
|
assert (await agent.run('hello')).output == 'resolved'
|
|
assert calls == ['alias']
|
|
deferred.assert_not_awaited()
|
|
assert ResolveModelId.get_serialization_name() is None
|
|
|
|
async def test_override_spec_model_uses_spec_model_id_resolver(self, monkeypatch: pytest.MonkeyPatch):
|
|
target = _text_model('resolved by spec')
|
|
bound_agents: list[AbstractAgent[None, Any]] = []
|
|
|
|
@dataclass
|
|
class SpecResolver(AbstractCapability[None]):
|
|
bound: bool = False
|
|
|
|
@classmethod
|
|
def get_serialization_name(cls) -> str:
|
|
return 'SpecResolver'
|
|
|
|
def for_agent(self, agent: AbstractAgent[None, Any]) -> SpecResolver:
|
|
bound_agents.append(agent)
|
|
return replace(self, bound=True)
|
|
|
|
def get_model(self) -> Model | None:
|
|
return target if self.bound else None
|
|
|
|
async def resolve_model_id(
|
|
self, ctx: ModelResolutionContext[None], *, model_id: KnownModelName | str
|
|
) -> Model | None:
|
|
return target if self.bound and model_id == 'custom-id' else None
|
|
|
|
monkeypatch.setitem(CAPABILITY_TYPES, 'SpecResolver', SpecResolver)
|
|
agent = Agent('test')
|
|
|
|
with agent.override(spec={'capabilities': ['SpecResolver']}, model='custom-id'):
|
|
assert (await agent.run('hello')).output == 'resolved by spec'
|
|
|
|
with agent.override(spec={'capabilities': ['SpecResolver']}):
|
|
with agent.override(model='custom-id'):
|
|
assert (await agent.run('hello')).output == 'resolved by spec'
|
|
|
|
with agent.override(spec={'capabilities': ['SpecResolver']}):
|
|
assert (await agent.run('hello')).output == 'resolved by spec'
|
|
|
|
assert bound_agents == [agent, agent, agent]
|
|
|
|
async def test_wrapper_subclass_model_id_resolver_is_detected(self):
|
|
target = _text_model('resolved by wrapper')
|
|
|
|
@dataclass
|
|
class ResolvingWrapper(WrapperCapability[None]):
|
|
async def resolve_model_id(
|
|
self, ctx: ModelResolutionContext[None], *, model_id: KnownModelName | str
|
|
) -> Model | None:
|
|
return target if model_id == 'custom-id' else None
|
|
|
|
agent = Agent('test', deps_type=NoneType, capabilities=[ResolvingWrapper(wrapped=AbstractCapability[None]())])
|
|
|
|
with agent.override(model='custom-id'):
|
|
assert (await agent.run('hello')).output == 'resolved by wrapper'
|
|
|
|
async def test_dynamic_models_are_entered_once_per_run(self):
|
|
class LifecycleModel(FunctionModel):
|
|
entered = 0
|
|
exited = 0
|
|
|
|
async def __aenter__(self):
|
|
self.entered += 1
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any):
|
|
self.exited += 1
|
|
|
|
first = LifecycleModel(lambda messages, info: ModelResponse(parts=[ToolCallPart('advance', '{}')]))
|
|
second = LifecycleModel(lambda messages, info: make_text_response('done'))
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return lambda ctx: first if ctx.run_step == 1 else second
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
|
|
@agent.tool_plain
|
|
def advance() -> str:
|
|
return 'advanced'
|
|
|
|
assert (await agent.run('hello')).output == 'done'
|
|
assert (first.entered, first.exited) == (1, 1)
|
|
assert (second.entered, second.exited) == (1, 1)
|
|
|
|
async def test_selector_can_return_fallback_model(self):
|
|
def fail(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('primary failed')
|
|
|
|
fallback = FallbackModel(FunctionModel(fail), _text_model('fallback'), fallback_on=RuntimeError)
|
|
|
|
@dataclass
|
|
class SelectFallback(AbstractCapability[None]):
|
|
def get_model(self) -> FallbackModel:
|
|
return fallback
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[SelectFallback()])
|
|
assert (await agent.run('hello')).output == 'fallback'
|
|
|
|
async def test_cross_run_suspended_resume_rejects_dynamic_model(self):
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return lambda ctx: _text_model('selected')
|
|
|
|
history = [ModelResponse(parts=[], state='suspended')]
|
|
with pytest.raises(UserError, match='cannot be reconstructed unambiguously'):
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
await agent.run(message_history=history)
|
|
|
|
async def test_cross_run_suspended_resume_rejects_for_run_dynamic_model(self):
|
|
@dataclass
|
|
class DynamicModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return lambda ctx: _text_model('selected')
|
|
|
|
@dataclass
|
|
class BootstrapModel(AbstractCapability[None]):
|
|
def get_model(self) -> Model:
|
|
return _text_model('bootstrap')
|
|
|
|
async def for_run(self, ctx: RunContext[None]) -> AbstractCapability[None]:
|
|
return DynamicModel()
|
|
|
|
history = [ModelResponse(parts=[], state='suspended')]
|
|
with pytest.raises(UserError, match='cannot be reconstructed unambiguously'):
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[BootstrapModel()])
|
|
await agent.run(message_history=history)
|
|
|
|
async def test_system_prompt_parts_uses_selector_when_model_is_omitted(self):
|
|
selected = _text_model('selected')
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[str]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[str]], Model]:
|
|
def select(ctx: ModelSelectionContext[str]) -> Model:
|
|
assert ctx.prompt == 'hello'
|
|
return selected
|
|
|
|
return select
|
|
|
|
agent = Agent(None, deps_type=str, capabilities=[AdaptiveModel()])
|
|
|
|
@agent.system_prompt
|
|
def prompt(ctx: RunContext[str]) -> str:
|
|
assert ctx.model is selected
|
|
assert ctx.deps == 'tenant'
|
|
return 'system prompt'
|
|
|
|
assert await agent.system_prompt_parts(deps='tenant', prompt='hello') == snapshot(
|
|
[SystemPromptPart(content='system prompt', timestamp=IsDatetime())]
|
|
)
|
|
|
|
async def test_callable_model_selection_streaming(self):
|
|
async def stream(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
|
|
yield 'selected'
|
|
|
|
selected = FunctionModel(stream_function=stream)
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return lambda ctx: selected
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
async with agent.run_stream('hello') as result:
|
|
assert await result.get_output() == 'selected'
|
|
|
|
async def test_agent_context_does_not_evaluate_dynamic_selector(self):
|
|
calls = 0
|
|
|
|
def select(ctx: ModelSelectionContext[None]) -> Model:
|
|
nonlocal calls
|
|
calls += 1
|
|
return _text_model('selected')
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return select
|
|
|
|
agent = Agent(None, deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
async with agent:
|
|
assert calls == 0
|
|
|
|
assert (await agent.run('hello')).output == 'selected'
|
|
assert calls == 1
|
|
|
|
async def test_static_capability_model_is_entered_by_agent_context(self):
|
|
class LifecycleModel(FunctionModel):
|
|
entered = 0
|
|
exited = 0
|
|
|
|
async def __aenter__(self):
|
|
self.entered += 1
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any):
|
|
self.exited += 1
|
|
|
|
selected = LifecycleModel(lambda messages, info: make_text_response('selected'))
|
|
agent = Agent(None, capabilities=[_ModelCap(model=selected)])
|
|
async with agent:
|
|
assert selected.entered == 1
|
|
assert (await agent.run('hello')).output == 'selected'
|
|
assert (selected.entered, selected.exited) == (1, 0)
|
|
assert selected.exited == 1
|
|
|
|
async def test_static_capability_model_id_reuses_agent_context_model(self, monkeypatch: pytest.MonkeyPatch):
|
|
class LifecycleModel(FunctionModel):
|
|
entered = 0
|
|
exited = 0
|
|
|
|
async def __aenter__(self):
|
|
self.entered += 1
|
|
return self
|
|
|
|
async def __aexit__(self, *args: Any):
|
|
self.exited += 1
|
|
|
|
inferred_models: list[LifecycleModel] = []
|
|
|
|
def infer_model(model_id: str) -> Model:
|
|
assert model_id == 'custom-model'
|
|
model = LifecycleModel(lambda messages, info: make_text_response('selected'))
|
|
inferred_models.append(model)
|
|
return model
|
|
|
|
monkeypatch.setattr('pydantic_ai.models.infer_model', infer_model)
|
|
agent = Agent(None, capabilities=[_ModelCap(model='custom-model')])
|
|
|
|
async with agent:
|
|
assert (await agent.run('hello')).output == 'selected'
|
|
assert len(inferred_models) == 1
|
|
assert (inferred_models[0].entered, inferred_models[0].exited) == (1, 0)
|
|
assert inferred_models[0].exited == 1
|
|
|
|
async def test_system_prompt_parts_resolves_static_capability_model_id(self, monkeypatch: pytest.MonkeyPatch):
|
|
inferred_models: list[Model] = []
|
|
|
|
def infer_model(model_id: str) -> Model:
|
|
assert model_id == 'custom-model'
|
|
model = _text_model('selected')
|
|
inferred_models.append(model)
|
|
return model
|
|
|
|
monkeypatch.setattr('pydantic_ai.models.infer_model', infer_model)
|
|
agent = Agent(None, capabilities=[_ModelCap(model='custom-model')])
|
|
|
|
assert await agent.system_prompt_parts() == []
|
|
assert len(inferred_models) == 1
|
|
|
|
async with agent:
|
|
assert len(inferred_models) == 2
|
|
assert await agent.system_prompt_parts() == []
|
|
assert len(inferred_models) == 2
|
|
|
|
async def test_system_prompt_parts_requires_a_model(self):
|
|
agent = Agent(None)
|
|
with pytest.raises(UserError, match='supplied by a capability'):
|
|
await agent.system_prompt_parts()
|
|
|
|
def test_mcp_sampling_rejects_dynamic_capability_model(self):
|
|
selected = _text_model('selected')
|
|
Agent(None, capabilities=[_ModelCap(model=selected)]).set_mcp_sampling_model()
|
|
|
|
@dataclass
|
|
class AdaptiveModel(AbstractCapability[None]):
|
|
def get_model(self) -> Callable[[ModelSelectionContext[None]], Model]:
|
|
return lambda ctx: selected
|
|
|
|
agent = Agent(_text_model('constructor'), deps_type=NoneType, capabilities=[AdaptiveModel()])
|
|
with pytest.raises(UserError, match='requires run dependencies'):
|
|
agent.set_mcp_sampling_model()
|
|
|
|
resolving_agent = Agent(
|
|
'alias', capabilities=[ResolveModelId(lambda ctx, model_id: selected if model_id == 'alias' else None)]
|
|
)
|
|
with pytest.raises(UserError, match='requires run dependencies'):
|
|
resolving_agent.set_mcp_sampling_model()
|
|
|
|
async def test_wrapper_capability_delegates(self):
|
|
"""A `WrapperCapability` surfaces its wrapped leaf's model."""
|
|
agent = Agent(None, capabilities=[WrapperCapability(wrapped=_ModelCap(model='test'))])
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'success (no tool calls)'
|
|
|
|
async def test_combined_capability_uses_last_non_none_model(self):
|
|
"""A `CombinedCapability` uses the last non-`None` model contribution."""
|
|
agent = Agent(
|
|
None,
|
|
capabilities=[
|
|
CombinedCapability([_ModelCap(model=_text_model('first')), _ModelCap(model=_text_model('last'))])
|
|
],
|
|
)
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'last'
|
|
|
|
async def test_capability_returning_none_is_noop(self):
|
|
"""A capability whose `get_model()` returns None (the default) leaves the agent model in place."""
|
|
agent = Agent(_text_model('from-agent'), capabilities=[_ModelCap(model=None)])
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from-agent'
|
|
|
|
|
|
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(),
|
|
),
|
|
]
|
|
)
|