mirror of
https://github.com/pydantic/pydantic-ai.git
synced 2026-10-12 02:21:31 +00:00
5162 lines
206 KiB
Python
5162 lines
206 KiB
Python
"""Tests for capability lifecycle, error, execution, and recovery hooks.
|
|
|
|
Split out of `test_capabilities.py` per #7304.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
import threading
|
|
import warnings
|
|
from collections.abc import AsyncIterable, AsyncIterator, Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field, replace
|
|
from types import NoneType
|
|
from typing import Any, cast
|
|
|
|
import anyio
|
|
import pytest
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from pydantic_ai._run_context import RunContext
|
|
from pydantic_ai.agent import Agent
|
|
from pydantic_ai.capabilities import (
|
|
CombinedCapability,
|
|
ToolSearch,
|
|
UseThreadExecutor,
|
|
)
|
|
from pydantic_ai.capabilities.abstract import AbstractCapability
|
|
from pydantic_ai.capabilities.hooks import Hooks
|
|
from pydantic_ai.exceptions import (
|
|
ApprovalRequired,
|
|
CallDeferred,
|
|
ModelRetry,
|
|
SkipModelRequest,
|
|
SkipToolExecution,
|
|
SkipToolValidation,
|
|
ToolFailed,
|
|
UnexpectedModelBehavior,
|
|
UsageLimitExceeded,
|
|
UserError,
|
|
)
|
|
from pydantic_ai.messages import (
|
|
AgentStreamEvent,
|
|
FinalResultEvent,
|
|
FunctionToolCallEvent,
|
|
ModelMessage,
|
|
ModelRequest,
|
|
ModelResponse,
|
|
PartEndEvent,
|
|
PartStartEvent,
|
|
RetryPromptPart,
|
|
TextPart,
|
|
ToolCallPart,
|
|
ToolReturn,
|
|
ToolReturnPart,
|
|
UserPromptPart,
|
|
)
|
|
from pydantic_ai.models import (
|
|
KnownModelName,
|
|
Model,
|
|
ModelRequestContext,
|
|
ModelResolutionContext,
|
|
)
|
|
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, DeltaToolCalls, FunctionModel
|
|
from pydantic_ai.models.test import TestModel
|
|
from pydantic_ai.output import ToolOutput
|
|
from pydantic_ai.result import FinalResult
|
|
from pydantic_ai.run import AgentRunResult, AgentRunResultEvent
|
|
from pydantic_ai.tool_manager import ToolManager
|
|
from pydantic_ai.tools import DeferredToolRequests, ToolDefinition
|
|
from pydantic_ai.toolsets import FunctionToolset
|
|
from pydantic_ai.usage import RequestUsage, RunUsage, UsageLimits
|
|
from pydantic_graph import End
|
|
|
|
from ._inline_snapshot import snapshot
|
|
from .capability_models import (
|
|
LoggingCapability,
|
|
MyOutput,
|
|
build_run_context as _build_run_context,
|
|
make_text_response,
|
|
simple_model_function,
|
|
simple_stream_function,
|
|
tool_calling_model,
|
|
tool_calling_stream_function,
|
|
)
|
|
from .conftest import IsDatetime, IsStr
|
|
from .continuation_utils import ScriptedContinuationModel, scripted_response
|
|
|
|
_SEARCH_TOOLS_NAME = ToolSearch.function_tool_name
|
|
|
|
READINESS_WAIT_TIMEOUT = 10
|
|
|
|
pytestmark = []
|
|
|
|
|
|
# --- Hooks test helpers ---
|
|
|
|
|
|
# Defined at module scope so pydantic-ai can resolve the annotation under `from __future__ import annotations`.
|
|
class SingleBaseModelArg(BaseModel):
|
|
label: str = 'default'
|
|
|
|
|
|
# --- Logging capability for testing ---
|
|
|
|
|
|
# --- Tests ---
|
|
|
|
|
|
class TestRunHooks:
|
|
async def test_before_run(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'before_run' in cap.log
|
|
|
|
@pytest.mark.parametrize('subclass', [False, True], ids=['plain', 'subclass'])
|
|
async def test_context_copies(self, subclass: bool) -> None:
|
|
"""Context isolation and subclass initialization do not depend on model I/O."""
|
|
|
|
@dataclass
|
|
class CheckedContext(RunContext[list[str]]):
|
|
active_at_init: bool | None = field(init=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
self.active_at_init = self.capability_active
|
|
|
|
ctx_type = CheckedContext if subclass else RunContext[list[str]]
|
|
ctx = ctx_type(deps=[], model=TestModel(), usage=RunUsage())
|
|
contexts: list[RunContext[list[str]]] = []
|
|
hooks = Hooks[list[str]]()
|
|
|
|
@hooks.on.before_run
|
|
async def record(cap_ctx: RunContext[list[str]]) -> None:
|
|
assert cap_ctx is not ctx
|
|
assert type(cap_ctx) is type(ctx)
|
|
assert cap_ctx.capability_active is True
|
|
assert cap_ctx.retry == 0
|
|
assert cap_ctx.deps is ctx.deps and cap_ctx.usage is ctx.usage
|
|
cap_ctx.retry = 1
|
|
contexts.append(cap_ctx)
|
|
if isinstance(cap_ctx, CheckedContext):
|
|
assert cap_ctx.active_at_init is True
|
|
|
|
await CombinedCapability([hooks, hooks]).before_run(ctx)
|
|
assert contexts[0] is not contexts[1]
|
|
assert ctx.retry == 0
|
|
assert ctx.capability_active is None
|
|
if isinstance(ctx, CheckedContext):
|
|
assert ctx.active_at_init is None
|
|
|
|
async def test_after_run(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'after_run' in cap.log
|
|
|
|
async def test_wrap_run(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'wrap_run:before' in cap.log
|
|
assert 'wrap_run:after' in cap.log
|
|
|
|
async def test_run_hook_order(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
# The wrapper encloses the full run lifecycle.
|
|
assert cap.log.index('wrap_run:before') < cap.log.index('before_run')
|
|
assert cap.log.index('before_run') < cap.log.index('after_run')
|
|
assert cap.log.index('after_run') < cap.log.index('wrap_run:after')
|
|
|
|
async def test_after_run_can_modify_result(self):
|
|
@dataclass
|
|
class ModifyResultCap(AbstractCapability[Any]):
|
|
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
|
|
return AgentRunResult(output='modified output')
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[ModifyResultCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'modified output'
|
|
|
|
async def test_wrap_run_can_short_circuit(self):
|
|
@dataclass
|
|
class ShortCircuitRunCap(AbstractCapability[Any]):
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
# Don't call handler - short-circuit the run
|
|
return AgentRunResult(output='short-circuited')
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[ShortCircuitRunCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'short-circuited'
|
|
|
|
async def test_wrap_run_can_recover_from_error(self):
|
|
"""wrap_run can catch errors from handler() and return a recovery result."""
|
|
|
|
@dataclass
|
|
class ErrorRecoveryCap(AbstractCapability[Any]):
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
try:
|
|
return await handler()
|
|
except RuntimeError:
|
|
return AgentRunResult(output='recovered from error')
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[ErrorRecoveryCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'recovered from error'
|
|
|
|
async def test_wrap_run_error_propagates_without_recovery(self):
|
|
"""Without recovery in wrap_run, errors propagate normally."""
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model))
|
|
with pytest.raises(RuntimeError, match='model exploded'):
|
|
await agent.run('hello')
|
|
|
|
async def test_wrap_run_recovery_via_iter(self):
|
|
"""wrap_run error recovery works when using agent.iter() too."""
|
|
|
|
@dataclass
|
|
class ErrorRecoveryCap(AbstractCapability[Any]):
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
try:
|
|
return await handler()
|
|
except RuntimeError:
|
|
return AgentRunResult(output='recovered via iter')
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[ErrorRecoveryCap()])
|
|
async with agent.iter('hello') as agent_run:
|
|
async for _node in agent_run:
|
|
pass
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'recovered via iter'
|
|
|
|
|
|
class TestModelRequestHooks:
|
|
@pytest.mark.parametrize('streaming', [False, True])
|
|
async def test_wrap_model_request_handler_can_only_be_called_once(self, streaming: bool):
|
|
class CallTwice(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
await handler(request_context)
|
|
return await handler(request_context)
|
|
|
|
async def consume_events(_ctx: RunContext[Any], events: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in events:
|
|
pass
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function), capabilities=[CallTwice()]
|
|
)
|
|
with pytest.raises(UserError, match='may call its handler only once'):
|
|
await agent.run('hello', event_stream_handler=consume_events if streaming else None)
|
|
|
|
async def test_before_model_request(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'before_model_request' in cap.log
|
|
|
|
@pytest.mark.parametrize(
|
|
('mode', 'streaming'),
|
|
[('run', False), ('run_stream', True), ('event_stream_handler', True)],
|
|
)
|
|
async def test_before_model_request_sees_selection_context(self, mode: str, streaming: bool):
|
|
"""`before_model_request` sees the selected model ID and effective streaming mode."""
|
|
contexts: list[ModelRequestContext] = []
|
|
|
|
@dataclass
|
|
class CaptureContext(AbstractCapability[None]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[None],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
contexts.append(request_context)
|
|
return request_context
|
|
|
|
agent = Agent('test', deps_type=type(None), capabilities=[CaptureContext()], defer_model_check=True)
|
|
if mode == 'run_stream':
|
|
async with agent.run_stream('hello') as result:
|
|
await result.get_output()
|
|
elif mode == 'event_stream_handler':
|
|
|
|
async def handle_events(ctx: RunContext[None], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
await agent.run('hello', event_stream_handler=handle_events)
|
|
else:
|
|
await agent.run('hello')
|
|
|
|
assert [(context.model_id, context.streaming) for context in contexts] == [('test', streaming)]
|
|
|
|
@pytest.mark.parametrize(
|
|
('mode', 'streaming'),
|
|
[('run', False), ('run_stream', True)],
|
|
)
|
|
async def test_before_model_request_copying_its_context_keeps_the_selection_context(
|
|
self, mode: str, streaming: bool
|
|
):
|
|
"""A hook returning `replace(request_context, ...)` must not lose `model_id` or `streaming`.
|
|
|
|
Returning a modified context is the documented way to change a request, and `dataclasses.replace`
|
|
is how you do it — but it re-initializes `init=False` fields to their defaults, so declaring these
|
|
two that way silently zeroed both for every hook that copied its context. A lost `streaming` flag
|
|
misreports the request mode; a lost `model_id` costs a durable-execution worker the selection
|
|
token it re-resolves an aliased model from.
|
|
"""
|
|
contexts: list[ModelRequestContext] = []
|
|
|
|
@dataclass
|
|
class CopyContext(AbstractCapability[None]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[None],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
copied = replace(request_context, messages=request_context.messages)
|
|
contexts.append(copied)
|
|
return copied
|
|
|
|
agent = Agent('test', deps_type=type(None), capabilities=[CopyContext()], defer_model_check=True)
|
|
if mode == 'run_stream':
|
|
async with agent.run_stream('hello') as result:
|
|
await result.get_output()
|
|
else:
|
|
await agent.run('hello')
|
|
|
|
assert [(context.model_id, context.streaming) for context in contexts] == [('test', streaming)]
|
|
|
|
async def test_withdrawn_bootstrap_model_id_does_not_leak_to_default(self):
|
|
"""A bootstrap model contribution withdrawn by `for_run` must not leak its selection string as provenance."""
|
|
model_ids: list[str | None] = []
|
|
|
|
@dataclass
|
|
class BootstrapModel(AbstractCapability[None]):
|
|
def get_model(self) -> str:
|
|
return 'bootstrap-alias'
|
|
|
|
async def for_run(self, ctx: RunContext[None]) -> AbstractCapability[None]:
|
|
return AbstractCapability()
|
|
|
|
async def resolve_model_id(
|
|
self, ctx: ModelResolutionContext[None], *, model_id: KnownModelName | str
|
|
) -> Model | None:
|
|
return TestModel() if model_id == 'bootstrap-alias' else None
|
|
|
|
@dataclass
|
|
class CaptureModelId(AbstractCapability[None]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[None],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
model_ids.append(request_context.model_id)
|
|
return request_context
|
|
|
|
agent = Agent(TestModel(), deps_type=NoneType, capabilities=[BootstrapModel(), CaptureModelId()])
|
|
await agent.run('hello')
|
|
|
|
assert model_ids == [None]
|
|
|
|
async def test_after_model_request(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'after_model_request' in cap.log
|
|
|
|
async def test_wrap_model_request(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'wrap_model_request:before' in cap.log
|
|
assert 'wrap_model_request:after' in cap.log
|
|
|
|
async def test_model_request_hook_order(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert cap.log.index('wrap_model_request:before') < cap.log.index('before_model_request')
|
|
assert cap.log.index('before_model_request') < cap.log.index('after_model_request')
|
|
assert cap.log.index('after_model_request') < cap.log.index('wrap_model_request:after')
|
|
|
|
async def test_after_model_request_can_modify_response(self):
|
|
@dataclass
|
|
class ModifyResponseCap(AbstractCapability[Any]):
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
return ModelResponse(parts=[TextPart(content='modified by after hook')])
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[ModifyResponseCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'modified by after hook'
|
|
|
|
async def test_wrapper_recovery_keeps_provider_usage(self):
|
|
class RecoverAfterRejection(AbstractCapability[Any]):
|
|
async def wrap_model_request(self, ctx: RunContext[Any], *, request_context: Any, handler: Any) -> Any:
|
|
try:
|
|
return await handler(request_context)
|
|
except RuntimeError:
|
|
return ModelResponse(parts=[TextPart('recovered')])
|
|
|
|
class RejectResponse(AbstractCapability[Any]):
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
raise RuntimeError('reject')
|
|
|
|
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(
|
|
parts=[TextPart('provider response')],
|
|
usage=RequestUsage(input_tokens=10, output_tokens=5),
|
|
)
|
|
|
|
result = await Agent(
|
|
FunctionModel(model_function), capabilities=[RecoverAfterRejection(), RejectResponse()]
|
|
).run('hello')
|
|
|
|
assert result.output == 'recovered'
|
|
assert result.usage.requests == 1
|
|
assert result.usage.input_tokens == 10
|
|
assert result.usage.output_tokens == 5
|
|
assert [part.content for part in result.all_messages()[-1].parts if isinstance(part, TextPart)] == ['recovered']
|
|
|
|
@pytest.mark.parametrize('streaming', [False, True])
|
|
async def test_failed_model_request_is_not_counted_as_a_request(self, streaming: bool):
|
|
"""`RunUsage.requests` counts responses the agent acted on, so an unrecovered failure counts in neither mode."""
|
|
|
|
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('provider failed')
|
|
|
|
async def stream_function(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
|
|
raise RuntimeError('provider failed')
|
|
yield # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(model_function, stream_function=stream_function))
|
|
usage = RunUsage()
|
|
with pytest.raises(RuntimeError, match='provider failed'):
|
|
if streaming:
|
|
async with agent.run_stream('hello', usage=usage) as stream:
|
|
await stream.get_output() # pragma: lax no cover
|
|
else:
|
|
await agent.run('hello', usage=usage)
|
|
|
|
assert usage.requests == 0
|
|
|
|
@pytest.mark.parametrize('streaming', [False, True])
|
|
async def test_skipped_model_request_is_counted_as_a_request(self, streaming: bool):
|
|
"""The agent acts on a `SkipModelRequest` response, so the step counts like any other."""
|
|
|
|
class SkipCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
|
) -> ModelRequestContext:
|
|
raise SkipModelRequest(ModelResponse(parts=[TextPart('skipped model')]))
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function), capabilities=[SkipCap()]
|
|
)
|
|
if streaming:
|
|
async with agent.run_stream('hello') as stream:
|
|
assert await stream.get_output() == 'skipped model'
|
|
usage = stream.usage
|
|
else:
|
|
result = await agent.run('hello')
|
|
assert result.output == 'skipped model'
|
|
usage = result.usage
|
|
assert usage.requests == 1
|
|
|
|
@pytest.mark.parametrize('streaming', [False, True])
|
|
async def test_request_limit_bounds_responses_a_wrapper_returns_without_the_model(self, streaming: bool):
|
|
"""A step answered from a cache counts and is checked, so `request_limit` still stops a loop."""
|
|
|
|
class CachedToolCall(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
|
|
) -> ModelResponse:
|
|
return ModelResponse(parts=[ToolCallPart('noop', {})])
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[CachedToolCall()],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def noop() -> str:
|
|
return 'ok'
|
|
|
|
usage = RunUsage()
|
|
with pytest.raises(UsageLimitExceeded, match='The next request would exceed the request_limit of 3'):
|
|
if streaming:
|
|
async with agent.run_stream('hello', usage=usage, usage_limits=UsageLimits(request_limit=3)) as stream:
|
|
await stream.get_output() # pragma: lax no cover
|
|
else:
|
|
await agent.run('hello', usage=usage, usage_limits=UsageLimits(request_limit=3))
|
|
|
|
assert usage.requests == 3
|
|
|
|
async def test_usage_ledger_is_shared_with_replaced_request_context(self):
|
|
observed_responses: list[ModelResponse] = []
|
|
|
|
class ObserveUsage(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
response = await handler(request_context)
|
|
observed_responses.extend(request_context._usage_responses) # pyright: ignore[reportPrivateUsage]
|
|
return response
|
|
|
|
class CopyContext(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
return await handler(replace(request_context, messages=list(request_context.messages)))
|
|
|
|
result = await Agent(FunctionModel(simple_model_function), capabilities=[ObserveUsage(), CopyContext()]).run(
|
|
'hello'
|
|
)
|
|
|
|
assert len(observed_responses) == 1
|
|
assert observed_responses[0] is result.all_messages()[-1]
|
|
|
|
async def test_wrapper_recovery_keeps_continuation_usage(self):
|
|
class RecoverAfterRejection(AbstractCapability[Any]):
|
|
async def wrap_model_request(self, ctx: RunContext[Any], *, request_context: Any, handler: Any) -> Any:
|
|
try:
|
|
return await handler(request_context)
|
|
except RuntimeError:
|
|
return ModelResponse(parts=[TextPart('recovered')])
|
|
|
|
class RejectResponse(AbstractCapability[Any]):
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
raise RuntimeError('reject')
|
|
|
|
model = ScriptedContinuationModel(
|
|
responses=[
|
|
scripted_response(
|
|
texts=['working'],
|
|
state='suspended',
|
|
provider_response_id='first',
|
|
input_tokens=10,
|
|
output_tokens=1,
|
|
),
|
|
scripted_response(texts=['done'], provider_response_id='second', input_tokens=3, output_tokens=2),
|
|
]
|
|
)
|
|
result = await Agent(model, capabilities=[RecoverAfterRejection(), RejectResponse()]).run('hello')
|
|
|
|
assert result.output == 'recovered'
|
|
assert result.usage.requests == 1
|
|
assert result.usage.input_tokens == 13
|
|
assert result.usage.output_tokens == 3
|
|
assert model.request_calls == 2
|
|
|
|
async def test_wrapper_recovery_keeps_streaming_provider_usage(self):
|
|
class RecoverAfterRejection(AbstractCapability[Any]):
|
|
async def wrap_model_request(self, ctx: RunContext[Any], *, request_context: Any, handler: Any) -> Any:
|
|
try:
|
|
return await handler(request_context)
|
|
except RuntimeError:
|
|
return ModelResponse(parts=[TextPart('recovered')])
|
|
|
|
class RejectResponse(AbstractCapability[Any]):
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
raise RuntimeError('reject')
|
|
|
|
async def consume_events(_ctx: RunContext[Any], events: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in events:
|
|
pass
|
|
|
|
result = await Agent(TestModel(call_tools=[]), capabilities=[RecoverAfterRejection(), RejectResponse()]).run(
|
|
'hello', event_stream_handler=consume_events
|
|
)
|
|
|
|
assert result.output == 'recovered'
|
|
assert result.usage.requests == 1
|
|
assert result.usage.input_tokens == 51
|
|
assert result.usage.output_tokens == 4
|
|
|
|
async def test_wrap_model_request_can_modify_response(self):
|
|
@dataclass
|
|
class WrapModifyCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
|
|
) -> ModelResponse:
|
|
response = await handler(request_context)
|
|
return ModelResponse(parts=[TextPart(content='wrapped: ' + response.parts[0].content)])
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[WrapModifyCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'wrapped: response from model'
|
|
|
|
async def test_skip_model_request(self):
|
|
@dataclass
|
|
class SkipCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='skipped model')]))
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[SkipCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'skipped model'
|
|
|
|
async def test_before_model_request_swaps_model(self):
|
|
call_log: list[str] = []
|
|
|
|
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
call_log.append('swap_model')
|
|
return make_text_response('from swap model')
|
|
|
|
swap_target = FunctionModel(swap_model_fn)
|
|
|
|
@dataclass
|
|
class SwapModelCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
|
) -> ModelRequestContext:
|
|
request_context.model = swap_target
|
|
return request_context
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[SwapModelCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from swap model'
|
|
assert call_log == ['swap_model']
|
|
|
|
async def test_wrap_model_request_swaps_model(self):
|
|
call_log: list[str] = []
|
|
|
|
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
call_log.append('swap_model')
|
|
return make_text_response('from swap model')
|
|
|
|
swap_target = FunctionModel(swap_model_fn)
|
|
|
|
@dataclass
|
|
class SwapInWrapCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
|
|
) -> ModelResponse:
|
|
request_context.model = swap_target
|
|
return await handler(request_context)
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[SwapInWrapCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from swap model'
|
|
assert call_log == ['swap_model']
|
|
|
|
async def test_before_model_request_swaps_model_streaming(self):
|
|
call_log: list[str] = []
|
|
|
|
async def swap_stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
|
|
call_log.append('swap_stream')
|
|
yield 'from swap stream'
|
|
|
|
swap_target = FunctionModel(stream_function=swap_stream_fn)
|
|
|
|
@dataclass
|
|
class SwapModelCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
|
) -> ModelRequestContext:
|
|
request_context.model = swap_target
|
|
return request_context
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[SwapModelCap()],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
output = await stream.get_output()
|
|
assert output == 'from swap stream'
|
|
assert call_log == ['swap_stream']
|
|
|
|
async def test_run_context_model_unchanged_after_swap(self):
|
|
observed_models: list[Any] = []
|
|
|
|
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return make_text_response('from swap model')
|
|
|
|
original_model = FunctionModel(simple_model_function)
|
|
swap_target = FunctionModel(swap_model_fn)
|
|
|
|
@dataclass
|
|
class SwapAndObserveCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self, ctx: RunContext[Any], request_context: ModelRequestContext
|
|
) -> ModelRequestContext:
|
|
observed_models.append(ctx.model)
|
|
request_context.model = swap_target
|
|
return request_context
|
|
|
|
agent = Agent(original_model, capabilities=[SwapAndObserveCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from swap model'
|
|
assert observed_models[0] is original_model
|
|
|
|
async def test_hooks_before_model_request_swaps_model(self):
|
|
call_log: list[str] = []
|
|
hooks = Hooks()
|
|
|
|
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
call_log.append('swap_model')
|
|
return make_text_response('from swap model')
|
|
|
|
swap_target = FunctionModel(swap_model_fn)
|
|
|
|
@hooks.on.before_model_request
|
|
async def _(ctx: RunContext[Any], request_context: ModelRequestContext) -> ModelRequestContext:
|
|
request_context.model = swap_target
|
|
return request_context
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[hooks])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from swap model'
|
|
assert call_log == ['swap_model']
|
|
|
|
async def test_after_model_request_sees_wrap_swap(self):
|
|
"""after_model_request sees the model swapped during wrap_model_request."""
|
|
after_models: list[Any] = []
|
|
|
|
def swap_model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return make_text_response('from swap model')
|
|
|
|
swap_target = FunctionModel(swap_model_fn)
|
|
|
|
@dataclass
|
|
class SwapInWrapAndObserveCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
|
|
) -> ModelResponse:
|
|
request_context.model = swap_target
|
|
return await handler(request_context)
|
|
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
after_models.append(request_context.model)
|
|
return response
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[SwapInWrapAndObserveCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'from swap model'
|
|
assert after_models[0] is swap_target
|
|
|
|
|
|
class TestToolValidateHooks:
|
|
async def test_tool_validate_hooks_fire(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
await agent.run('call the tool')
|
|
assert 'before_tool_validate:my_tool' in cap.log
|
|
assert 'after_tool_validate:my_tool' in cap.log
|
|
assert 'wrap_tool_validate:my_tool:before' in cap.log
|
|
assert 'wrap_tool_validate:my_tool:after' in cap.log
|
|
|
|
async def test_before_tool_validate_can_modify_args(self):
|
|
@dataclass
|
|
class ModifyArgsCap(AbstractCapability[Any]):
|
|
async def before_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
|
|
) -> str | dict[str, Any]:
|
|
# Inject an argument
|
|
if isinstance(args, dict):
|
|
return {**args, 'name': 'injected'} # pragma: no cover
|
|
return {'name': 'injected'}
|
|
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[ModifyArgsCap()])
|
|
|
|
received_name = None
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str:
|
|
nonlocal received_name
|
|
received_name = name
|
|
return f'hello {name}'
|
|
|
|
await agent.run('greet someone')
|
|
assert received_name == 'injected'
|
|
|
|
async def test_skip_tool_validation(self):
|
|
@dataclass
|
|
class SkipValidateCap(AbstractCapability[Any]):
|
|
async def before_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
|
|
) -> str | dict[str, Any]:
|
|
raise SkipToolValidation({'name': 'skip-validated'})
|
|
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[SkipValidateCap()])
|
|
|
|
received_name = None
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str:
|
|
nonlocal received_name
|
|
received_name = name
|
|
return f'hello {name}'
|
|
|
|
await agent.run('greet someone')
|
|
assert received_name == 'skip-validated'
|
|
|
|
async def test_tool_def_matches_called_tool(self):
|
|
"""Verify tool_def is the correct ToolDefinition for the tool being called."""
|
|
received_tool_defs: list[ToolDefinition] = []
|
|
|
|
@dataclass
|
|
class CaptureCap(AbstractCapability[Any]):
|
|
async def before_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
|
|
) -> str | dict[str, Any]:
|
|
received_tool_defs.append(tool_def)
|
|
return args
|
|
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[CaptureCap()])
|
|
|
|
@agent.tool_plain(description='Say hello')
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
await agent.run('call the tool')
|
|
assert len(received_tool_defs) == 1
|
|
td = received_tool_defs[0]
|
|
assert td.name == 'my_tool'
|
|
assert td.description == 'Say hello'
|
|
assert td.kind == 'function'
|
|
|
|
|
|
class TestToolExecuteHooks:
|
|
async def test_tool_execute_hooks_fire(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
await agent.run('call the tool')
|
|
assert 'before_tool_execute:my_tool' in cap.log
|
|
assert 'after_tool_execute:my_tool' in cap.log
|
|
assert 'wrap_tool_execute:my_tool:before' in cap.log
|
|
assert 'wrap_tool_execute:my_tool:after' in cap.log
|
|
|
|
async def test_after_tool_execute_can_modify_result(self):
|
|
@dataclass
|
|
class ModifyResultCap(AbstractCapability[Any]):
|
|
async def after_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
result: Any,
|
|
) -> Any:
|
|
return f'modified: {result}'
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[ModifyResultCap()])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'original'
|
|
|
|
result = await agent.run('call tool')
|
|
assert 'modified: original' in result.output
|
|
|
|
async def test_skip_tool_execution(self):
|
|
@dataclass
|
|
class SkipExecCap(AbstractCapability[Any]):
|
|
async def before_tool_execute(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
raise SkipToolExecution('denied')
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[SkipExecCap()])
|
|
|
|
tool_was_called = False
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
nonlocal tool_was_called
|
|
tool_was_called = True # pragma: no cover
|
|
return 'should not be called' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
assert not tool_was_called
|
|
assert 'denied' in result.output
|
|
|
|
async def test_wrap_tool_execute_with_error_handling(self):
|
|
@dataclass
|
|
class ErrorHandlingCap(AbstractCapability[Any]):
|
|
caught_error: str | None = None
|
|
|
|
async def wrap_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
handler: Any,
|
|
) -> Any:
|
|
try:
|
|
return await handler(args)
|
|
except Exception as e:
|
|
self.caught_error = str(e)
|
|
return 'recovered from error'
|
|
|
|
cap = ErrorHandlingCap()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
raise ValueError('tool failed')
|
|
|
|
await agent.run('call tool')
|
|
assert cap.caught_error == 'tool failed'
|
|
|
|
async def test_hooks_receive_dict_args_for_single_base_model_tool(self):
|
|
"""Validate and execute hooks receive dict-shaped args when the tool has a single BaseModel parameter.
|
|
|
|
The JSON schema sent to the model unwraps the BaseModel, so the model generates its fields at the
|
|
top level. Pydantic's validator returns a BaseModel instance directly, but the framework wraps it
|
|
as `{param_name: model}` so hooks and `call_tool` always see a dict.
|
|
"""
|
|
captured_args: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
@dataclass
|
|
class CapturingCap(AbstractCapability[Any]):
|
|
async def after_tool_validate(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
captured_args.append(('validate', args))
|
|
return args
|
|
|
|
async def wrap_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
handler: Any,
|
|
) -> Any:
|
|
captured_args.append(('execute', args))
|
|
return await handler(args)
|
|
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[CapturingCap()])
|
|
|
|
@agent.tool_plain
|
|
def my_tool(payload: SingleBaseModelArg) -> str:
|
|
return f'got {payload.label}'
|
|
|
|
await agent.run('call the tool')
|
|
assert captured_args == [
|
|
('validate', {'payload': SingleBaseModelArg()}),
|
|
('execute', {'payload': SingleBaseModelArg()}),
|
|
]
|
|
|
|
async def test_tool_hooks_skip_output_tools(self):
|
|
"""Tool hooks don't fire for internal output tools (#5111).
|
|
|
|
Output tools deliver structured output to the user via `result.output`; they're not
|
|
user-facing tool calls. Firing hooks on them lets e.g. `after_tool_execute` return a
|
|
`ToolReturn` that leaks through to `result.output` instead of the typed value.
|
|
"""
|
|
|
|
class MyOutput(BaseModel):
|
|
answer: str
|
|
|
|
hooks = Hooks()
|
|
|
|
@hooks.on.after_tool_execute
|
|
async def wrap_result(
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
result: Any,
|
|
) -> ToolReturn:
|
|
return ToolReturn(return_value=result, content='extra context')
|
|
|
|
cap = LoggingCapability()
|
|
agent = Agent(
|
|
TestModel(custom_output_args={'answer': 'hi'}),
|
|
output_type=MyOutput,
|
|
capabilities=[cap, hooks],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
result = await agent.run('call tool and answer')
|
|
|
|
# Function tool still fires every tool hook.
|
|
assert 'before_tool_validate:my_tool' in cap.log
|
|
assert 'after_tool_validate:my_tool' in cap.log
|
|
assert 'wrap_tool_validate:my_tool:before' in cap.log
|
|
assert 'wrap_tool_validate:my_tool:after' in cap.log
|
|
assert 'before_tool_execute:my_tool' in cap.log
|
|
assert 'after_tool_execute:my_tool' in cap.log
|
|
assert 'wrap_tool_execute:my_tool:before' in cap.log
|
|
assert 'wrap_tool_execute:my_tool:after' in cap.log
|
|
# Output tool does not appear in any hook log entry.
|
|
assert all('final_result' not in entry for entry in cap.log)
|
|
# Regression for #5111: the ToolReturn from `after_tool_execute` would have corrupted
|
|
# `result.output` if output tool hooks still fired.
|
|
assert result.output == MyOutput(answer='hi')
|
|
|
|
|
|
class TestCompositionOrder:
|
|
async def test_multiple_capabilities_model_request_order(self):
|
|
"""Test that multiple capabilities compose in the correct order."""
|
|
log: list[str] = []
|
|
|
|
@dataclass
|
|
class Cap1(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
log.append('cap1:before')
|
|
return request_context
|
|
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
log.append('cap1:after')
|
|
return response
|
|
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
|
|
) -> ModelResponse:
|
|
log.append('cap1:wrap:before')
|
|
response = await handler(request_context)
|
|
log.append('cap1:wrap:after')
|
|
return response
|
|
|
|
@dataclass
|
|
class Cap2(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
log.append('cap2:before')
|
|
return request_context
|
|
|
|
async def after_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, response: ModelResponse
|
|
) -> ModelResponse:
|
|
log.append('cap2:after')
|
|
return response
|
|
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
|
|
) -> ModelResponse:
|
|
log.append('cap2:wrap:before')
|
|
response = await handler(request_context)
|
|
log.append('cap2:wrap:after')
|
|
return response
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[Cap1(), Cap2()])
|
|
await agent.run('hello')
|
|
|
|
# before hooks: forward order (cap1 then cap2)
|
|
assert log.index('cap1:before') < log.index('cap2:before')
|
|
# wrap hooks: cap1 outermost, cap2 innermost
|
|
assert log.index('cap1:wrap:before') < log.index('cap2:wrap:before')
|
|
assert log.index('cap2:wrap:after') < log.index('cap1:wrap:after')
|
|
# after hooks: reverse order (cap2 then cap1)
|
|
assert log.index('cap2:after') < log.index('cap1:after')
|
|
|
|
async def test_multiple_capabilities_run_hooks_order(self):
|
|
log: list[str] = []
|
|
|
|
@dataclass
|
|
class Cap1(AbstractCapability[Any]):
|
|
async def before_run(self, ctx: RunContext[Any]) -> None:
|
|
log.append('cap1:before_run')
|
|
|
|
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
|
|
log.append('cap1:after_run')
|
|
return result
|
|
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
log.append('cap1:wrap_run:before')
|
|
result = await handler()
|
|
log.append('cap1:wrap_run:after')
|
|
return result
|
|
|
|
@dataclass
|
|
class Cap2(AbstractCapability[Any]):
|
|
async def before_run(self, ctx: RunContext[Any]) -> None:
|
|
log.append('cap2:before_run')
|
|
|
|
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
|
|
log.append('cap2:after_run')
|
|
return result
|
|
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
log.append('cap2:wrap_run:before')
|
|
result = await handler()
|
|
log.append('cap2:wrap_run:after')
|
|
return result
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[Cap1(), Cap2()])
|
|
await agent.run('hello')
|
|
|
|
# before_run: forward order
|
|
assert log.index('cap1:before_run') < log.index('cap2:before_run')
|
|
# wrap_run: cap1 outermost
|
|
assert log.index('cap1:wrap_run:before') < log.index('cap2:wrap_run:before')
|
|
assert log.index('cap2:wrap_run:after') < log.index('cap1:wrap_run:after')
|
|
# after_run: reverse order
|
|
assert log.index('cap2:after_run') < log.index('cap1:after_run')
|
|
|
|
|
|
class TestCombinedBeforeWrapAfter:
|
|
async def test_all_hook_types_on_same_capability(self):
|
|
"""Test before + wrap + after all fire correctly on a single capability."""
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'result'
|
|
|
|
await agent.run('call tool')
|
|
|
|
# Check run hooks
|
|
assert 'before_run' in cap.log
|
|
assert 'wrap_run:before' in cap.log
|
|
assert 'wrap_run:after' in cap.log
|
|
assert 'after_run' in cap.log
|
|
|
|
# Check model request hooks (should fire twice: once for tool call, once for final)
|
|
model_request_before_count = cap.log.count('before_model_request')
|
|
assert model_request_before_count == 2
|
|
|
|
# Check tool hooks
|
|
assert 'before_tool_validate:my_tool' in cap.log
|
|
assert 'wrap_tool_validate:my_tool:before' in cap.log
|
|
assert 'wrap_tool_validate:my_tool:after' in cap.log
|
|
assert 'after_tool_validate:my_tool' in cap.log
|
|
assert 'before_tool_execute:my_tool' in cap.log
|
|
assert 'wrap_tool_execute:my_tool:before' in cap.log
|
|
assert 'wrap_tool_execute:my_tool:after' in cap.log
|
|
assert 'after_tool_execute:my_tool' in cap.log
|
|
|
|
|
|
class TestRunHooksRunStream:
|
|
"""Test that wrap_run and after_run fire for run_stream()."""
|
|
|
|
async def test_wrap_run_fires_for_run_stream(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
assert 'wrap_run:before' in cap.log
|
|
assert 'wrap_run:after' in cap.log
|
|
|
|
async def test_after_run_fires_for_run_stream(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
assert 'after_run' in cap.log
|
|
|
|
async def test_wrap_run_fires_for_iter(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
async with agent.iter('hello') as agent_run:
|
|
async for _node in agent_run:
|
|
pass
|
|
assert 'wrap_run:before' in cap.log
|
|
assert 'wrap_run:after' in cap.log
|
|
assert 'after_run' in cap.log
|
|
|
|
async def test_after_run_can_modify_result_via_iter(self):
|
|
@dataclass
|
|
class ModifyResultCap(AbstractCapability[Any]):
|
|
async def after_run(self, ctx: RunContext[Any], *, result: AgentRunResult[Any]) -> AgentRunResult[Any]:
|
|
return AgentRunResult(output='modified by after_run')
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[ModifyResultCap()])
|
|
async with agent.iter('hello') as agent_run:
|
|
async for _node in agent_run:
|
|
pass
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'modified by after_run'
|
|
|
|
async def test_run_hook_order_via_run_stream(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
assert cap.log.index('wrap_run:before') < cap.log.index('before_run')
|
|
assert cap.log.index('before_run') < cap.log.index('after_run')
|
|
assert cap.log.index('after_run') < cap.log.index('wrap_run:after')
|
|
|
|
|
|
class TestStreamingHooks:
|
|
"""Test that SkipModelRequest and wrap_model_request work in streaming paths."""
|
|
|
|
async def test_before_model_request_context_reaches_later_tool_hooks(self):
|
|
request_state: ContextVar[str | None] = ContextVar('request_state', default=None)
|
|
tool_states: list[str | None] = []
|
|
|
|
@dataclass
|
|
class ContextCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
request_state.set('prepared')
|
|
return request_context
|
|
|
|
async def before_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
tool_states.append(request_state.get())
|
|
return args
|
|
|
|
agent = Agent(
|
|
FunctionModel(tool_calling_model, stream_function=tool_calling_stream_function),
|
|
capabilities=[ContextCap()],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
async with agent.run_stream('call tool') as stream:
|
|
assert await stream.get_output() == 'final response'
|
|
|
|
assert tool_states == ['prepared']
|
|
|
|
async def test_skip_model_request_streaming(self):
|
|
@dataclass
|
|
class SkipCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='skipped in stream')]))
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[SkipCap()],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
output = await stream.get_output()
|
|
assert output == 'skipped in stream'
|
|
|
|
async def test_skip_model_request_from_wrap_model_request(self):
|
|
"""SkipModelRequest raised inside wrap_model_request is handled in non-streaming."""
|
|
|
|
@dataclass
|
|
class WrapSkipCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='wrap-skipped')]))
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[WrapSkipCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'wrap-skipped'
|
|
|
|
async def test_skip_model_request_from_wrap_model_request_streaming(self):
|
|
"""SkipModelRequest raised inside wrap_model_request during streaming is handled."""
|
|
|
|
@dataclass
|
|
class WrapSkipCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='wrap-skipped in stream')]))
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[WrapSkipCap()],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
output = await stream.get_output()
|
|
assert output == 'wrap-skipped in stream'
|
|
|
|
async def test_wrap_model_request_streaming(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
assert 'wrap_model_request:before' in cap.log
|
|
assert 'wrap_model_request:after' in cap.log
|
|
|
|
async def test_wrap_model_request_modifies_result_via_run_with_streaming(self):
|
|
"""wrap_model_request modification affects the final result when using run() with streaming."""
|
|
|
|
@dataclass
|
|
class WrapModifyCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
|
|
) -> ModelResponse:
|
|
response = await handler(request_context)
|
|
return ModelResponse(parts=[TextPart(content='wrapped: ' + response.parts[0].content)])
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[WrapModifyCap()],
|
|
)
|
|
|
|
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
result = await agent.run('hello', event_stream_handler=handler)
|
|
assert result.output == 'wrapped: streamed response'
|
|
|
|
async def test_after_model_request_fires_streaming(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
assert 'after_model_request' in cap.log
|
|
|
|
|
|
class TestWrapRunEventStream:
|
|
"""Tests for the wrap_run_event_stream hook."""
|
|
|
|
async def test_wrap_run_event_stream_observes(self):
|
|
"""Hook sees events from model streaming."""
|
|
observed_events: list[AgentStreamEvent] = []
|
|
|
|
@dataclass
|
|
class ObserverCap(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
async for event in stream:
|
|
observed_events.append(event)
|
|
yield event
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[ObserverCap()],
|
|
)
|
|
|
|
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
await agent.run('hello', event_stream_handler=handler)
|
|
assert len(observed_events) > 0
|
|
|
|
async def test_wrap_run_event_stream_transforms(self):
|
|
"""Modifications by the hook are visible to event_stream_handler."""
|
|
handler_events: list[AgentStreamEvent] = []
|
|
|
|
@dataclass
|
|
class TransformCap(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
async for event in stream:
|
|
yield event
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[TransformCap()],
|
|
)
|
|
|
|
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for event in stream:
|
|
handler_events.append(event)
|
|
|
|
await agent.run('hello', event_stream_handler=handler)
|
|
assert len(handler_events) > 0
|
|
|
|
async def test_wrap_run_event_stream_composition(self):
|
|
"""Multiple capabilities compose in correct order (first = outermost)."""
|
|
log: list[str] = []
|
|
|
|
@dataclass
|
|
class Cap1(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
log.append('cap1:enter')
|
|
async for event in stream:
|
|
yield event
|
|
log.append('cap1:exit')
|
|
|
|
@dataclass
|
|
class Cap2(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
log.append('cap2:enter')
|
|
async for event in stream:
|
|
yield event
|
|
log.append('cap2:exit')
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[Cap1(), Cap2()],
|
|
)
|
|
|
|
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
await agent.run('hello', event_stream_handler=handler)
|
|
|
|
# Cap1 is outermost, so enters first and exits last
|
|
assert log.index('cap1:enter') < log.index('cap2:enter')
|
|
assert log.index('cap2:exit') < log.index('cap1:exit')
|
|
|
|
async def test_wrap_run_event_stream_tool_events(self):
|
|
"""HandleResponseEvents from CallToolsNode flow through the hook."""
|
|
observed_events: list[AgentStreamEvent] = []
|
|
|
|
@dataclass
|
|
class ObserverCap(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
async for event in stream:
|
|
observed_events.append(event)
|
|
yield event
|
|
|
|
agent = Agent(
|
|
FunctionModel(tool_calling_model, stream_function=tool_calling_stream_function),
|
|
capabilities=[ObserverCap()],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
await agent.run('call tool', event_stream_handler=handler)
|
|
# Should have observed events from both ModelRequestNode and CallToolsNode streams
|
|
assert len(observed_events) > 0
|
|
|
|
async def test_wrap_run_event_stream_fires_in_run_stream_without_handler(self):
|
|
"""wrap_run_event_stream fires in run_stream() even without an event_stream_handler."""
|
|
observed_events: list[AgentStreamEvent] = []
|
|
|
|
@dataclass
|
|
class ObserverCap(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
async for event in stream:
|
|
observed_events.append(event)
|
|
yield event
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[ObserverCap()],
|
|
)
|
|
|
|
# No event_stream_handler — hook should still fire
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
assert len(observed_events) > 0
|
|
|
|
async def test_wrap_run_event_stream_fires_in_run_without_handler(self):
|
|
"""wrap_run_event_stream fires in run() even without an event_stream_handler."""
|
|
observed_events: list[AgentStreamEvent] = []
|
|
|
|
@dataclass
|
|
class ObserverCap(AbstractCapability[Any]):
|
|
async def wrap_run_event_stream(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
stream: AsyncIterable[AgentStreamEvent],
|
|
) -> AsyncIterable[AgentStreamEvent]:
|
|
async for event in stream:
|
|
observed_events.append(event)
|
|
yield event
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[ObserverCap()],
|
|
)
|
|
|
|
# No event_stream_handler — hook should still fire via forced streaming
|
|
result = await agent.run('hello')
|
|
assert result.output is not None
|
|
assert any(isinstance(e, PartStartEvent) for e in observed_events)
|
|
|
|
|
|
class TestWrapRunShortCircuit:
|
|
"""Test short-circuiting wrap_run via iter() and run_stream()."""
|
|
|
|
async def test_wrap_run_short_circuit_via_iter(self):
|
|
@dataclass
|
|
class ShortCircuitRunCap(AbstractCapability[Any]):
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
return AgentRunResult(output='short-circuited')
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[ShortCircuitRunCap()])
|
|
async with agent.iter('hello') as agent_run:
|
|
nodes: list[Any] = []
|
|
async for node in agent_run:
|
|
nodes.append(node) # pragma: no cover
|
|
# Iteration should stop immediately (no graph nodes executed)
|
|
assert nodes == []
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'short-circuited'
|
|
|
|
async def test_wrap_run_short_circuit_via_run_stream(self):
|
|
@dataclass
|
|
class ShortCircuitRunCap(AbstractCapability[Any]):
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
return AgentRunResult(output='short-circuited')
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[ShortCircuitRunCap()],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
output = await stream.get_output()
|
|
assert output == 'short-circuited'
|
|
|
|
|
|
class TestSkipModelRequestInteraction:
|
|
"""Test SkipModelRequest interaction with the wrapped lifecycle."""
|
|
|
|
async def test_skip_model_request_from_before_skips_after_model_request(self):
|
|
log: list[str] = []
|
|
|
|
@dataclass
|
|
class SkipAndLogCap(AbstractCapability[Any]):
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
log.append('before_model_request')
|
|
raise SkipModelRequest(ModelResponse(parts=[TextPart(content='skipped')]))
|
|
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
log.append('after_model_request') # pragma: no cover
|
|
return response # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[SkipAndLogCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'skipped'
|
|
assert log == ['before_model_request']
|
|
|
|
async def test_wrap_model_request_short_circuit_streaming(self):
|
|
"""wrap_model_request can return without calling handler in streaming path."""
|
|
|
|
@dataclass
|
|
class ShortCircuitModelCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: Any, handler: Any
|
|
) -> ModelResponse:
|
|
# Don't call handler — return a response directly
|
|
return ModelResponse(parts=[TextPart(content='model short-circuited')])
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[ShortCircuitModelCap()],
|
|
)
|
|
async with agent.run_stream('hello') as stream:
|
|
output = await stream.get_output()
|
|
assert output == 'model short-circuited'
|
|
|
|
@pytest.mark.parametrize('skip', [False, True])
|
|
async def test_wrap_model_request_stopping_its_handler_mid_stream(self, skip: bool):
|
|
"""Stopping the handler mid-stream closes the model stream and streams the wrap's response instead.
|
|
|
|
Returning a response and raising `SkipModelRequest` end the same way. The model stalls after
|
|
its first chunk, so a stream that is not closed hangs until `READINESS_WAIT_TIMEOUT`.
|
|
"""
|
|
first_chunk_sent = asyncio.Event()
|
|
model_stream_closed = asyncio.Event()
|
|
|
|
async def stream_function(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
|
|
try:
|
|
yield 'model '
|
|
first_chunk_sent.set()
|
|
await asyncio.Event().wait()
|
|
yield 'never' # pragma: no cover
|
|
finally:
|
|
model_stream_closed.set()
|
|
|
|
@dataclass
|
|
class StopHandlerCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
|
|
) -> ModelResponse:
|
|
handler_task = asyncio.create_task(handler(request_context))
|
|
await first_chunk_sent.wait()
|
|
handler_task.cancel()
|
|
await asyncio.gather(handler_task, return_exceptions=True)
|
|
response = ModelResponse(parts=[TextPart(content='replaced')])
|
|
if skip:
|
|
raise SkipModelRequest(response)
|
|
return response
|
|
|
|
agent = Agent(FunctionModel(stream_function=stream_function), capabilities=[StopHandlerCap()])
|
|
with anyio.fail_after(READINESS_WAIT_TIMEOUT):
|
|
async with agent.run_stream_events('hello') as stream:
|
|
events = [event async for event in stream]
|
|
|
|
assert events[:-1] == snapshot(
|
|
[
|
|
PartStartEvent(index=0, part=TextPart(content='model ')),
|
|
FinalResultEvent(tool_name=None, tool_call_id=None),
|
|
PartStartEvent(index=0, part=TextPart(content='replaced')),
|
|
FinalResultEvent(tool_name=None, tool_call_id=None),
|
|
PartEndEvent(index=0, part=TextPart(content='replaced')),
|
|
]
|
|
)
|
|
assert isinstance(events[-1], AgentRunResultEvent)
|
|
assert events[-1].result.output == 'replaced'
|
|
assert model_stream_closed.is_set()
|
|
|
|
async def test_wrap_model_request_retrying_after_stopping_its_handler_mid_stream(self):
|
|
"""`ModelRetry` after stopping the handler mid-stream ends the stream and retries the request."""
|
|
first_chunk_sent = asyncio.Event()
|
|
|
|
async def stream_function(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
|
|
if first_chunk_sent.is_set():
|
|
yield 'retried'
|
|
return
|
|
yield 'model '
|
|
first_chunk_sent.set()
|
|
await asyncio.Event().wait()
|
|
yield 'never' # pragma: no cover
|
|
|
|
@dataclass
|
|
class RetryCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, handler: Any
|
|
) -> ModelResponse:
|
|
if first_chunk_sent.is_set():
|
|
return await handler(request_context)
|
|
handler_task = asyncio.create_task(handler(request_context))
|
|
await first_chunk_sent.wait()
|
|
handler_task.cancel()
|
|
await asyncio.gather(handler_task, return_exceptions=True)
|
|
raise ModelRetry('try again')
|
|
|
|
agent = Agent(FunctionModel(stream_function=stream_function), capabilities=[RetryCap()])
|
|
with anyio.fail_after(READINESS_WAIT_TIMEOUT):
|
|
async with agent.run_stream_events('hello') as stream:
|
|
events = [event async for event in stream]
|
|
|
|
assert [event for event in events if isinstance(event, PartStartEvent)] == snapshot(
|
|
[
|
|
PartStartEvent(index=0, part=TextPart(content='model ')),
|
|
PartStartEvent(index=0, part=TextPart(content='retried')),
|
|
]
|
|
)
|
|
assert isinstance(events[-1], AgentRunResultEvent)
|
|
assert events[-1].result.output == 'retried'
|
|
|
|
|
|
class TestPrepareToolsHook:
|
|
async def test_filter_function_tools(self):
|
|
"""Capability can filter out function tools by name."""
|
|
|
|
@dataclass
|
|
class HideToolCap(AbstractCapability[Any]):
|
|
async def prepare_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
return [td for td in tool_defs if td.name != 'hidden_tool']
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
tool_names = [t.name for t in info.function_tools]
|
|
return make_text_response(f'tools: {sorted(tool_names)}')
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[HideToolCap()])
|
|
|
|
@agent.tool_plain
|
|
def hidden_tool() -> str:
|
|
return 'hidden' # pragma: no cover
|
|
|
|
@agent.tool_plain
|
|
def visible_tool() -> str:
|
|
return 'visible' # pragma: no cover
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == "tools: ['visible_tool']"
|
|
|
|
async def test_receives_function_tools_only(self):
|
|
"""`prepare_tools` receives **function** tools only. Output tools route to
|
|
`prepare_output_tools` (with `ctx.max_retries` reflecting the output retry budget)."""
|
|
|
|
@dataclass
|
|
class CountKindsCap(AbstractCapability[Any]):
|
|
seen_kinds: list[str] = field(default_factory=list[str])
|
|
|
|
async def prepare_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
self.seen_kinds = sorted({td.kind for td in tool_defs})
|
|
return tool_defs
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
|
|
)
|
|
|
|
cap = CountKindsCap()
|
|
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'result' # pragma: no cover
|
|
|
|
await agent.run('hello')
|
|
assert cap.seen_kinds == ['function']
|
|
|
|
async def test_modify_tool_description(self):
|
|
"""Capability can modify tool descriptions."""
|
|
from dataclasses import replace as dc_replace
|
|
|
|
@dataclass
|
|
class PrefixDescriptionCap(AbstractCapability[Any]):
|
|
async def prepare_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
return [dc_replace(td, description=f'[PREFIXED] {td.description}') for td in tool_defs]
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
descs = [t.description for t in info.function_tools]
|
|
return make_text_response(f'descriptions: {descs}')
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[PrefixDescriptionCap()])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
"""Original description."""
|
|
return 'result' # pragma: no cover
|
|
|
|
result = await agent.run('hello')
|
|
assert '[PREFIXED] Original description.' in result.output
|
|
|
|
async def test_chaining_order(self):
|
|
"""Multiple capabilities chain prepare_tools in forward order."""
|
|
|
|
@dataclass
|
|
class AddSuffixCap(AbstractCapability[Any]):
|
|
suffix: str
|
|
|
|
async def prepare_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
from dataclasses import replace as dc_replace
|
|
|
|
return [dc_replace(td, description=f'{td.description}{self.suffix}') for td in tool_defs]
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
descs = [t.description for t in info.function_tools]
|
|
return make_text_response(f'{descs}')
|
|
|
|
agent = Agent(
|
|
FunctionModel(model_fn),
|
|
capabilities=[AddSuffixCap(suffix='_A'), AddSuffixCap(suffix='_B')],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def tool() -> str:
|
|
"""desc"""
|
|
return 'r' # pragma: no cover
|
|
|
|
result = await agent.run('hello')
|
|
# A runs first, then B, so suffix order is _A_B
|
|
assert 'desc_A_B' in result.output
|
|
|
|
|
|
class TestPrepareOutputToolsHook:
|
|
async def test_only_receives_output_tools(self):
|
|
"""`prepare_output_tools` receives only output tools — function tools route to
|
|
`prepare_tools`."""
|
|
|
|
@dataclass
|
|
class CountKindsCap(AbstractCapability[Any]):
|
|
seen_kinds: list[str] = field(default_factory=list[str])
|
|
|
|
async def prepare_output_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
self.seen_kinds = [td.kind for td in tool_defs]
|
|
return tool_defs
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
|
|
)
|
|
|
|
cap = CountKindsCap()
|
|
agent = Agent(FunctionModel(model_fn), output_type=MyOutput, capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'result' # pragma: no cover
|
|
|
|
await agent.run('hello')
|
|
assert cap.seen_kinds == ['output'], f'expected only output tools, got {cap.seen_kinds}'
|
|
|
|
async def test_filter_output_tools(self):
|
|
"""Capability can hide output tools from the model."""
|
|
|
|
class Out(BaseModel):
|
|
value: str
|
|
|
|
@dataclass
|
|
class HideCap(AbstractCapability[Any]):
|
|
async def prepare_output_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
return [] # hide all output tools
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return make_text_response(f'output_tools: {len(info.output_tools)}')
|
|
|
|
agent = Agent(
|
|
FunctionModel(model_fn),
|
|
output_type=[str, ToolOutput(Out, name='out')],
|
|
capabilities=[HideCap()],
|
|
)
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'output_tools: 0'
|
|
|
|
async def test_run_context_carries_output_max_retries(self):
|
|
"""`prepare_output_tools` ctx.max_retries reflects the agent-level output retry budget,
|
|
matching the contract of output hooks (and unlike `prepare_tools` which doesn't have
|
|
a tool-specific retry budget at preparation time)."""
|
|
seen: list[tuple[int, int]] = []
|
|
|
|
@dataclass
|
|
class CaptureCtxCap(AbstractCapability[Any]):
|
|
async def prepare_output_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
seen.append((ctx.retry, ctx.max_retries))
|
|
return tool_defs
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 7}', tool_call_id='c1')]
|
|
)
|
|
|
|
agent = Agent(
|
|
FunctionModel(model_fn),
|
|
output_type=MyOutput,
|
|
retries={'tools': 4, 'output': 4},
|
|
capabilities=[CaptureCtxCap()],
|
|
)
|
|
await agent.run('hello')
|
|
assert seen == [(0, 4)]
|
|
|
|
async def test_chaining_order(self):
|
|
"""Multiple capabilities chain `prepare_output_tools` in forward order."""
|
|
from dataclasses import replace as dc_replace
|
|
|
|
@dataclass
|
|
class AddSuffixCap(AbstractCapability[Any]):
|
|
suffix: str
|
|
|
|
async def prepare_output_tools(
|
|
self, ctx: RunContext[Any], tool_defs: list[ToolDefinition]
|
|
) -> list[ToolDefinition]:
|
|
return [dc_replace(td, description=f'{td.description or ""}{self.suffix}') for td in tool_defs]
|
|
|
|
descs: list[str | None] = []
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
descs.extend(t.description for t in info.output_tools)
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.output_tools[0].name, args='{"value": 1}', tool_call_id='c1')]
|
|
)
|
|
|
|
agent = Agent(
|
|
FunctionModel(model_fn),
|
|
output_type=MyOutput,
|
|
capabilities=[AddSuffixCap(suffix='_A'), AddSuffixCap(suffix='_B')],
|
|
)
|
|
await agent.run('hello')
|
|
assert descs and descs[0] is not None and descs[0].endswith('_A_B')
|
|
|
|
|
|
class TestWrapNodeRunHook:
|
|
async def test_observe_nodes(self):
|
|
"""wrap_node_run can observe all nodes in the agent run."""
|
|
|
|
@dataclass
|
|
class NodeObserverCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return await handler(node)
|
|
|
|
cap = NodeObserverCap()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
|
|
|
|
async def test_observe_nodes_with_tools(self):
|
|
"""wrap_node_run fires for each node including tool call round-trips."""
|
|
|
|
@dataclass
|
|
class NodeObserverCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return await handler(node)
|
|
|
|
cap = NodeObserverCap()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
await agent.run('hello')
|
|
# UserPrompt -> ModelRequest (calls tool) -> CallTools (executes tool) ->
|
|
# ModelRequest (gets final response) -> CallTools (produces End)
|
|
assert cap.nodes == [
|
|
'UserPromptNode',
|
|
'ModelRequestNode',
|
|
'CallToolsNode',
|
|
'ModelRequestNode',
|
|
'CallToolsNode',
|
|
]
|
|
|
|
async def test_works_with_iter_next(self):
|
|
"""wrap_node_run fires when driving iter() with next()."""
|
|
from pydantic_graph import End
|
|
|
|
@dataclass
|
|
class NodeObserverCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return await handler(node)
|
|
|
|
cap = NodeObserverCap()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
|
|
async with agent.iter('hello') as agent_run:
|
|
node = agent_run.next_node
|
|
while not isinstance(node, End):
|
|
node = await agent_run.next(node)
|
|
|
|
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
|
|
|
|
async def test_bare_async_for_mixed_with_next_does_not_double_run_nodes(self):
|
|
"""Advancing inside the loop body doesn't make bare iteration re-run the same node.
|
|
|
|
`__anext__` advances the node it last yielded, so a loop body that calls `next()` itself would
|
|
otherwise run that node — and every one of its hooks — a second time. It checks where the graph
|
|
actually is instead, which makes mixing the two drive styles safe rather than silently doubling
|
|
side effects.
|
|
"""
|
|
|
|
@dataclass
|
|
class NodeObserverCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return node
|
|
|
|
cap = NodeObserverCap()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
|
|
async with agent.iter('hello') as agent_run:
|
|
async for node in agent_run:
|
|
if not isinstance(node, End):
|
|
await agent_run.next(node)
|
|
|
|
assert cap.nodes == snapshot(['UserPromptNode', 'ModelRequestNode', 'CallToolsNode'])
|
|
|
|
async def test_bare_async_for_mixed_with_next_after_wrap_node_run_short_circuit(self):
|
|
"""A wrapper short-circuit advances the graph so bare iteration does not run the node again."""
|
|
|
|
@dataclass
|
|
class ShortCircuitCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def before_node_run(self, ctx: RunContext[Any], *, node: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return node
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
if Agent.is_model_request_node(node):
|
|
return End(FinalResult(output='short-circuited'))
|
|
return await handler(node)
|
|
|
|
cap = ShortCircuitCap()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
|
|
async with agent.iter('hello') as agent_run:
|
|
async for node in agent_run:
|
|
if not isinstance(node, End):
|
|
await agent_run.next(node)
|
|
|
|
assert cap.nodes == snapshot(['UserPromptNode'])
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'short-circuited'
|
|
|
|
async def test_bare_async_for_mixed_with_next_after_wrap_node_run_recovers_error(self):
|
|
"""A wrapper that handles the model error advances the graph past its ErrorMarker."""
|
|
|
|
@dataclass
|
|
class RecoverErrorCap(AbstractCapability[Any]):
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
try:
|
|
return await handler(node)
|
|
except RuntimeError:
|
|
return End(FinalResult(output='recovered'))
|
|
|
|
def model_error(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(model_error), capabilities=[RecoverErrorCap()])
|
|
|
|
async with agent.iter('hello') as agent_run:
|
|
async for node in agent_run:
|
|
if not isinstance(node, End):
|
|
await agent_run.next(node)
|
|
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'recovered'
|
|
|
|
async def test_bare_async_for_after_wrap_node_run_retries_a_failed_node(self):
|
|
"""A wrapper that recovers from an error by returning a node re-runs it, rather than re-raising."""
|
|
|
|
@dataclass
|
|
class RetryOnErrorCap(AbstractCapability[Any]):
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
try:
|
|
return await handler(node)
|
|
except RuntimeError:
|
|
# The graph is holding an `ErrorMarker`; hand back the node to run again.
|
|
return node
|
|
|
|
attempts = 0
|
|
|
|
def model_error_once(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
|
|
nonlocal attempts
|
|
attempts += 1
|
|
if attempts == 1:
|
|
raise RuntimeError('model exploded')
|
|
return ModelResponse(parts=[TextPart(content='second time lucky')])
|
|
|
|
agent = Agent(FunctionModel(model_error_once), capabilities=[RetryOnErrorCap()])
|
|
|
|
nodes: list[str] = []
|
|
async with agent.iter('hello') as agent_run:
|
|
async for node in agent_run:
|
|
nodes.append(type(node).__name__)
|
|
|
|
assert nodes == snapshot(['UserPromptNode', 'ModelRequestNode', 'ModelRequestNode', 'CallToolsNode', 'End'])
|
|
assert attempts == 2
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'second time lucky'
|
|
|
|
async def test_wrap_node_run_replacing_the_handler_result_ends_the_run(self):
|
|
"""A wrapper that runs the handler and then overrides its result ends the run there."""
|
|
|
|
@dataclass
|
|
class OverrideResultCap(AbstractCapability[Any]):
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
result = await handler(node)
|
|
if Agent.is_model_request_node(node):
|
|
# The handler advanced the graph to `CallToolsNode`; end the run instead.
|
|
return End(FinalResult(output='overridden'))
|
|
return result
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[OverrideResultCap()])
|
|
|
|
nodes: list[str] = []
|
|
async with agent.iter('hello') as agent_run:
|
|
async for node in agent_run:
|
|
nodes.append(type(node).__name__)
|
|
|
|
assert nodes == snapshot(['UserPromptNode', 'ModelRequestNode', 'End'])
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'overridden'
|
|
assert agent_run.next_node == End(FinalResult(output='overridden'))
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'overridden'
|
|
|
|
async def test_bare_async_for_fires_wrap_node_run(self):
|
|
"""Bare `async for` fires `wrap_node_run`, matching `next()` driving and `agent.run()`."""
|
|
|
|
@dataclass
|
|
class NodeObserverCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return await handler(node)
|
|
|
|
cap = NodeObserverCap()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
|
|
with warnings.catch_warnings(record=True) as w:
|
|
warnings.simplefilter('always')
|
|
async with agent.iter('hello') as agent_run:
|
|
async for _node in agent_run:
|
|
pass
|
|
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
|
|
assert w == []
|
|
|
|
async def test_works_with_manual_next(self):
|
|
"""wrap_node_run fires when using manual next() driving."""
|
|
from pydantic_graph import End
|
|
|
|
@dataclass
|
|
class NodeObserverCap(AbstractCapability[Any]):
|
|
nodes: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
self.nodes.append(type(node).__name__)
|
|
return await handler(node)
|
|
|
|
cap = NodeObserverCap()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
|
|
async with agent.iter('hello') as agent_run:
|
|
node = agent_run.next_node
|
|
while not isinstance(node, End):
|
|
node = await agent_run.next(node)
|
|
|
|
assert cap.nodes == ['UserPromptNode', 'ModelRequestNode', 'CallToolsNode']
|
|
|
|
async def test_chaining_nests_correctly(self):
|
|
"""Multiple capabilities compose wrap_node_run as nested middleware."""
|
|
log: list[str] = []
|
|
|
|
@dataclass
|
|
class OrderedCap(AbstractCapability[Any]):
|
|
name: str
|
|
|
|
async def wrap_node_run(self, ctx: RunContext[Any], *, node: Any, handler: Any) -> Any:
|
|
log.append(f'{self.name}:before:{type(node).__name__}')
|
|
result = await handler(node)
|
|
log.append(f'{self.name}:after:{type(result).__name__}')
|
|
return result
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function),
|
|
capabilities=[OrderedCap(name='outer'), OrderedCap(name='inner')],
|
|
)
|
|
await agent.run('hello')
|
|
# For UserPromptNode: outer wraps inner
|
|
assert log[0] == 'outer:before:UserPromptNode'
|
|
assert log[1] == 'inner:before:UserPromptNode'
|
|
assert log[2] == 'inner:after:ModelRequestNode'
|
|
assert log[3] == 'outer:after:ModelRequestNode'
|
|
|
|
|
|
# --- Node run lifecycle hook tests ---
|
|
|
|
|
|
class TestNodeRunHooks:
|
|
async def test_before_node_run_fires(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'before_node_run:UserPromptNode' in cap.log
|
|
assert 'before_node_run:ModelRequestNode' in cap.log
|
|
assert 'before_node_run:CallToolsNode' in cap.log
|
|
|
|
async def test_after_node_run_fires(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'after_node_run:UserPromptNode' in cap.log
|
|
assert 'after_node_run:ModelRequestNode' in cap.log
|
|
assert 'after_node_run:CallToolsNode' in cap.log
|
|
|
|
async def test_node_hook_order(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
# For each node, before fires before after
|
|
for node_name in ('UserPromptNode', 'ModelRequestNode', 'CallToolsNode'):
|
|
before_idx = cap.log.index(f'before_node_run:{node_name}')
|
|
after_idx = cap.log.index(f'after_node_run:{node_name}')
|
|
assert before_idx < after_idx
|
|
|
|
|
|
# --- Run error hook tests ---
|
|
|
|
|
|
class TestRunErrorHooks:
|
|
async def test_on_run_error_fires_on_failure(self):
|
|
cap = LoggingCapability()
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
|
|
with pytest.raises(RuntimeError, match='model exploded'):
|
|
await agent.run('hello')
|
|
assert 'on_run_error' in cap.log
|
|
|
|
async def test_on_run_error_not_called_on_success(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'on_run_error' not in cap.log
|
|
|
|
async def test_on_run_error_can_transform_error(self):
|
|
@dataclass
|
|
class TransformErrorCap(AbstractCapability[Any]):
|
|
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
|
|
raise ValueError('transformed error')
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[TransformErrorCap()])
|
|
with pytest.raises(ValueError, match='transformed error'):
|
|
await agent.run('hello')
|
|
|
|
async def test_on_run_error_can_recover(self):
|
|
@dataclass
|
|
class RecoverRunCap(AbstractCapability[Any]):
|
|
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
|
|
return AgentRunResult(output='recovered')
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[RecoverRunCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'recovered'
|
|
|
|
async def test_on_run_error_runs_before_wrap_run_recovers(self):
|
|
@dataclass
|
|
class WrapRecoveryCap(AbstractCapability[Any]):
|
|
log: list[str] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_run(self, ctx: RunContext[Any], *, handler: Any) -> AgentRunResult[Any]:
|
|
try:
|
|
return await handler()
|
|
except RuntimeError:
|
|
self.log.append('wrap_run:caught')
|
|
return AgentRunResult(output='wrap_recovered')
|
|
|
|
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
|
|
self.log.append('on_run_error')
|
|
raise error
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
cap = WrapRecoveryCap()
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'wrap_recovered'
|
|
assert 'wrap_run:caught' in cap.log
|
|
assert cap.log == ['on_run_error', 'wrap_run:caught']
|
|
|
|
async def test_on_run_error_fires_via_iter(self):
|
|
from pydantic_graph import End
|
|
|
|
@dataclass
|
|
class RecoverRunCap(AbstractCapability[Any]):
|
|
called: bool = False
|
|
|
|
async def on_run_error(self, ctx: RunContext[Any], *, error: BaseException) -> AgentRunResult[Any]:
|
|
self.called = True
|
|
return AgentRunResult(output='recovered via iter')
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
cap = RecoverRunCap()
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
|
|
async with agent.iter('hello') as agent_run:
|
|
node = agent_run.next_node
|
|
while not isinstance(node, End): # pragma: no branch
|
|
node = await agent_run.next(node)
|
|
assert cap.called
|
|
assert agent_run.result is not None
|
|
assert agent_run.result.output == 'recovered via iter'
|
|
|
|
|
|
# --- Node run error hook tests ---
|
|
|
|
|
|
class TestNodeRunErrorHooks:
|
|
async def test_node_error_propagates_without_an_error_hook(self):
|
|
class FailAfterNodeStep(AbstractCapability[Any]):
|
|
async def wrap_node_run(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
node: Any,
|
|
handler: Any,
|
|
) -> Any:
|
|
result = await handler(node)
|
|
if Agent.is_model_request_node(node):
|
|
raise RuntimeError('post-stream error')
|
|
return result
|
|
|
|
agent = Agent(TestModel(call_tools='all'), capabilities=[FailAfterNodeStep()])
|
|
|
|
@agent.tool_plain
|
|
def tool() -> str:
|
|
return 'done' # pragma: no cover
|
|
|
|
with pytest.raises(RuntimeError, match='post-stream error'):
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output() # pragma: no cover
|
|
|
|
async def test_on_node_run_error_fires(self):
|
|
cap = LoggingCapability()
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
|
|
with pytest.raises(RuntimeError, match='model exploded'):
|
|
await agent.run('hello')
|
|
assert 'on_node_run_error:ModelRequestNode' in cap.log
|
|
|
|
async def test_on_node_run_error_can_recover_with_end(self):
|
|
from pydantic_ai.result import FinalResult
|
|
from pydantic_graph import End
|
|
|
|
@dataclass
|
|
class RecoverNodeCap(AbstractCapability[Any]):
|
|
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: BaseException) -> Any:
|
|
return End(FinalResult(output='recovered'))
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
cap = RecoverNodeCap()
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
|
|
async with agent.iter('hello') as agent_run:
|
|
node = agent_run.next_node
|
|
while not isinstance(node, End):
|
|
node = await agent_run.next(node)
|
|
assert isinstance(node, End)
|
|
assert node.data.output == 'recovered'
|
|
|
|
async def test_on_node_run_error_not_called_on_success(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert all('on_node_run_error' not in entry for entry in cap.log)
|
|
|
|
|
|
# --- Model request error hook tests ---
|
|
|
|
|
|
class TestModelRequestErrorHooks:
|
|
async def test_model_retry_from_model_is_not_sent_to_the_error_hook(self):
|
|
def retrying_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise ModelRetry('retry')
|
|
|
|
with pytest.raises(UnexpectedModelBehavior, match='maximum output retries'):
|
|
await Agent(FunctionModel(retrying_model), retries=0).run('hello')
|
|
|
|
async def test_on_model_request_error_fires(self):
|
|
cap = LoggingCapability()
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[cap])
|
|
with pytest.raises(RuntimeError, match='model exploded'):
|
|
await agent.run('hello')
|
|
assert 'on_model_request_error' in cap.log
|
|
|
|
async def test_on_model_request_error_can_recover(self):
|
|
@dataclass
|
|
class RecoverModelCap(AbstractCapability[Any]):
|
|
async def on_model_request_error(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
|
|
) -> ModelResponse:
|
|
return ModelResponse(parts=[TextPart(content='recovered response')])
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[RecoverModelCap()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'recovered response'
|
|
|
|
async def test_on_model_request_error_not_called_on_success(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(simple_model_function), capabilities=[cap])
|
|
await agent.run('hello')
|
|
assert 'on_model_request_error' not in cap.log
|
|
|
|
async def test_default_on_model_request_error_reraises(self):
|
|
"""Default on_model_request_error re-raises, exercised with a minimal capability."""
|
|
|
|
@dataclass
|
|
class MinimalCap(AbstractCapability[Any]):
|
|
def get_instructions(self):
|
|
return 'Be helpful.'
|
|
|
|
def failing_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise RuntimeError('model exploded')
|
|
|
|
agent = Agent(FunctionModel(failing_model), capabilities=[MinimalCap()])
|
|
with pytest.raises(RuntimeError, match='model exploded'):
|
|
await agent.run('hello')
|
|
|
|
async def test_default_on_model_request_error_reraises_streaming(self):
|
|
"""Default on_model_request_error re-raises in streaming path (wrap_task error after stream consumed)."""
|
|
|
|
@dataclass
|
|
class PostProcessFailCap(AbstractCapability[Any]):
|
|
"""wrap_model_request that fails AFTER handler returns (post-processing error)."""
|
|
|
|
def get_instructions(self):
|
|
return 'Be helpful.'
|
|
|
|
async def wrap_model_request(self, ctx: RunContext[Any], *, request_context: Any, handler: Any) -> Any:
|
|
await handler(request_context)
|
|
raise RuntimeError('post-processing exploded')
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[PostProcessFailCap()],
|
|
)
|
|
with pytest.raises(RuntimeError, match='post-processing exploded'):
|
|
async with agent.run_stream('hello') as stream:
|
|
await stream.get_output()
|
|
|
|
async def test_on_model_request_error_does_not_recover_post_stream_wrap_error(self):
|
|
@dataclass
|
|
class RecoverPostStreamErrorCap(AbstractCapability[Any]):
|
|
errors: list[Exception] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
await handler(request_context)
|
|
raise RuntimeError('post-stream error')
|
|
|
|
async def on_model_request_error(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
|
|
) -> ModelResponse:
|
|
self.errors.append(error) # pragma: no cover
|
|
return ModelResponse(parts=[TextPart(content='recovered after stream')]) # pragma: no cover
|
|
|
|
cap = RecoverPostStreamErrorCap()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
|
|
async def handler(_ctx: RunContext[Any], stream: AsyncIterable[AgentStreamEvent]) -> None:
|
|
async for _ in stream:
|
|
pass
|
|
|
|
with pytest.raises(RuntimeError, match='post-stream error'):
|
|
await agent.run('hello', event_stream_handler=handler)
|
|
|
|
assert cap.errors == []
|
|
|
|
async def test_on_model_request_error_does_not_recover_streaming_wrap_short_circuit_error(self):
|
|
@dataclass
|
|
class RecoverShortCircuitErrorCap(AbstractCapability[Any]):
|
|
errors: list[Exception] = field(default_factory=lambda: [])
|
|
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
raise RuntimeError('short-circuit error')
|
|
|
|
async def on_model_request_error(
|
|
self, ctx: RunContext[Any], *, request_context: ModelRequestContext, error: Exception
|
|
) -> ModelResponse:
|
|
self.errors.append(error) # pragma: no cover
|
|
return ModelResponse(parts=[TextPart(content='recovered short circuit')]) # pragma: no cover
|
|
|
|
cap = RecoverShortCircuitErrorCap()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=simple_stream_function),
|
|
capabilities=[cap],
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match='short-circuit error'):
|
|
async with agent.run_stream('hello'):
|
|
pass
|
|
|
|
assert cap.errors == []
|
|
|
|
|
|
# --- Tool validate error hook tests ---
|
|
|
|
|
|
class TestToolValidateErrorHooks:
|
|
async def test_on_tool_validate_error_fires_on_validation_failure(self):
|
|
cap = LoggingCapability()
|
|
|
|
call_count = 0
|
|
|
|
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
if info.function_tools:
|
|
tool = info.function_tools[0]
|
|
if call_count <= 1:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
|
|
)
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=tool.name, args='{"name": "correct"}', tool_call_id='call-2')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(bad_args_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str:
|
|
return f'hello {name}'
|
|
|
|
await agent.run('greet someone')
|
|
assert 'on_tool_validate_error:greet' in cap.log
|
|
|
|
async def test_on_tool_validate_error_not_called_on_success(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
await agent.run('call the tool')
|
|
assert all('on_tool_validate_error' not in entry for entry in cap.log)
|
|
|
|
async def test_on_tool_validate_error_can_recover(self):
|
|
@dataclass
|
|
class RecoverValidateCap(AbstractCapability[Any]):
|
|
async def on_tool_validate_error(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
|
|
) -> dict[str, Any]:
|
|
return {'name': 'recovered-name'}
|
|
|
|
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
if info.function_tools:
|
|
tool = info.function_tools[0]
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(bad_args_model), capabilities=[RecoverValidateCap()])
|
|
|
|
received_name = None
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str:
|
|
nonlocal received_name
|
|
received_name = name
|
|
return f'hello {name}'
|
|
|
|
result = await agent.run('greet someone')
|
|
assert received_name == 'recovered-name'
|
|
assert 'hello recovered-name' in result.output
|
|
|
|
async def test_default_on_tool_validate_error_reraises(self):
|
|
"""The default on_tool_validate_error re-raises, exercised with a minimal capability."""
|
|
|
|
@dataclass
|
|
class MinimalCap(AbstractCapability[Any]):
|
|
def get_instructions(self):
|
|
return 'Be helpful.'
|
|
|
|
call_count = 0
|
|
|
|
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
if info.function_tools:
|
|
tool = info.function_tools[0]
|
|
if call_count <= 1:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=tool.name, args='{"wrong": 1}', tool_call_id='call-1')]
|
|
)
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=tool.name, args='{"name": "correct"}', tool_call_id='call-2')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(bad_args_model), capabilities=[MinimalCap()])
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str:
|
|
return f'hello {name}'
|
|
|
|
result = await agent.run('greet someone')
|
|
assert 'hello correct' in result.output
|
|
|
|
async def test_args_validator_deferral_is_not_a_validate_error(self):
|
|
"""A deferral raised by an `args_validator` passes through the validate hooks as control flow.
|
|
|
|
Like an execute-stage deferral, it's not an error, so `on_tool_validate_error` doesn't fire
|
|
and the tool is never executed. `after_tool_validate` still runs: it guards validated
|
|
arguments, and a deferred call is queued with exactly those.
|
|
"""
|
|
cap = LoggingCapability()
|
|
|
|
def my_validator(ctx: RunContext[Any], x: int) -> None:
|
|
raise ApprovalRequired()
|
|
|
|
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests], capabilities=[cap])
|
|
|
|
@agent.tool_plain(args_validator=my_validator)
|
|
def my_tool(x: int) -> int: # pragma: no cover
|
|
return x
|
|
|
|
result = await agent.run('call the tool')
|
|
assert isinstance(result.output, DeferredToolRequests)
|
|
assert [entry for entry in cap.log if 'tool_validate' in entry or 'tool_execute' in entry] == snapshot(
|
|
['wrap_tool_validate:my_tool:before', 'before_tool_validate:my_tool', 'after_tool_validate:my_tool']
|
|
)
|
|
|
|
|
|
# --- `after_tool_validate` as a policy gate on deferred calls ---
|
|
|
|
|
|
@dataclass
|
|
class ArgsGateCap(AbstractCapability[Any]):
|
|
"""An `after_tool_validate` policy gate: records the args it sees, and can reject or rewrite them."""
|
|
|
|
reject_first: bool = False
|
|
rewrite: dict[str, Any] | None = None
|
|
seen: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]])
|
|
|
|
async def after_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
self.seen.append(dict(args))
|
|
if self.reject_first and len(self.seen) == 1:
|
|
raise ModelRetry('policy says no')
|
|
return self.rewrite if self.rewrite is not None else args
|
|
|
|
|
|
class TestAfterToolValidateOnDeferral:
|
|
"""`after_tool_validate` guards validated arguments, so a deferral must not bypass it.
|
|
|
|
Before this was fixed, an `args_validator` deferral escaped `wrap_tool_validate` and skipped the
|
|
hook entirely, so a deployment using it as an authorization gate had a privileged call queued for
|
|
approval without ever passing the gate.
|
|
"""
|
|
|
|
async def test_runs_when_args_validator_defers(self):
|
|
"""The gate sees the validated args, and the call is still deferred once it passes."""
|
|
cap = ArgsGateCap()
|
|
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests], capabilities=[cap])
|
|
|
|
def my_validator(ctx: RunContext[Any], x: int) -> None:
|
|
raise ApprovalRequired(metadata={'from': 'args_validator'})
|
|
|
|
# `retries=0` pins that the deferral still doesn't consume the retry budget.
|
|
@agent.tool(args_validator=my_validator, retries=0)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int: # pragma: no cover
|
|
return x
|
|
|
|
events: list[AgentStreamEvent | AgentRunResultEvent[Any]] = []
|
|
async with agent.run_stream_events('call the tool') as stream:
|
|
async for event in stream:
|
|
events.append(event)
|
|
|
|
result = events[-1]
|
|
assert isinstance(result, AgentRunResultEvent)
|
|
assert result.result.output == snapshot(
|
|
DeferredToolRequests(
|
|
approvals=[
|
|
ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_tool')
|
|
],
|
|
metadata={'pyd_ai_tool_call_id__my_tool': {'from': 'args_validator'}},
|
|
)
|
|
)
|
|
assert cap.seen == snapshot([{'x': 0}])
|
|
assert [e.args_valid for e in events if isinstance(e, FunctionToolCallEvent)] == [True]
|
|
|
|
async def test_rejection_wins_over_the_deferral(self):
|
|
"""A gate that rejects is honored: the model gets the retry, not a queued approval request."""
|
|
cap = ArgsGateCap(reject_first=True)
|
|
agent = Agent(TestModel(), output_type=[str, DeferredToolRequests], capabilities=[cap])
|
|
|
|
def my_validator(ctx: RunContext[Any], x: int) -> None:
|
|
raise ApprovalRequired()
|
|
|
|
@agent.tool(args_validator=my_validator, retries=1)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int: # pragma: no cover
|
|
return x
|
|
|
|
result = await agent.run('call the tool')
|
|
retries = [
|
|
part.content
|
|
for msg in result.all_messages()
|
|
if isinstance(msg, ModelRequest)
|
|
for part in msg.parts
|
|
if isinstance(part, RetryPromptPart)
|
|
]
|
|
assert retries == snapshot(['policy says no'])
|
|
# The second attempt passes the gate, so that one defers.
|
|
assert isinstance(result.output, DeferredToolRequests)
|
|
assert cap.seen == snapshot([{'x': 0}, {'x': 0}])
|
|
|
|
async def test_still_runs_after_a_recovered_validation_failure(self):
|
|
"""Regression pin: the failure path already ran the gate, via `on_tool_validate_error`."""
|
|
|
|
@dataclass
|
|
class RecoverCap(AbstractCapability[Any]):
|
|
async def on_tool_validate_error(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
|
|
) -> dict[str, Any]:
|
|
return {'name': 'recovered'}
|
|
|
|
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
return ModelResponse(parts=[ToolCallPart(tool_name='greet', args='{"wrong": 1}', tool_call_id='call-1')])
|
|
|
|
gate = ArgsGateCap()
|
|
agent = Agent(FunctionModel(bad_args_model), capabilities=[RecoverCap(), gate])
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str:
|
|
return f'hello {name}'
|
|
|
|
result = await agent.run('greet someone')
|
|
assert result.output == snapshot('got: hello recovered')
|
|
assert gate.seen == snapshot([{'name': 'recovered'}])
|
|
|
|
async def test_deferral_carries_the_hooks_args(self):
|
|
"""A deferred call carries `after_tool_validate`'s output — what a non-deferred call would use.
|
|
|
|
`ValidatedToolCall.validated_args` has no public observable on the deferral path (the request
|
|
holds the model's original `ToolCallPart`, and resuming re-validates from it), so the
|
|
contract is pinned directly on the tool manager.
|
|
"""
|
|
toolset = FunctionToolset[None]()
|
|
|
|
def my_validator(ctx: RunContext[None], x: int) -> None:
|
|
raise ApprovalRequired()
|
|
|
|
@toolset.tool(args_validator=my_validator)
|
|
def my_tool(ctx: RunContext[None], x: int) -> int: # pragma: no cover
|
|
return x
|
|
|
|
cap = ArgsGateCap(rewrite={'x': 99})
|
|
manager = await ToolManager[None](toolset=toolset, root_capability=cap).for_run_step(_build_run_context())
|
|
|
|
validated = await manager.validate_tool_call(ToolCallPart('my_tool', {'x': 0}, tool_call_id='call-1'))
|
|
assert validated.args_valid is True
|
|
assert isinstance(validated.deferral, ApprovalRequired)
|
|
assert validated.validated_args == snapshot({'x': 99})
|
|
assert cap.seen == snapshot([{'x': 0}])
|
|
|
|
|
|
# --- Deferrals raised from tool hooks ---
|
|
|
|
|
|
@dataclass
|
|
class DeferringHookCap(AbstractCapability[Any]):
|
|
"""Raises a deferral from the single hook position named by `where` (none, if `where` is empty)."""
|
|
|
|
where: str
|
|
exc_type: type[ApprovalRequired] | type[CallDeferred] = ApprovalRequired
|
|
|
|
def _maybe(self, position: str) -> None:
|
|
if self.where == position:
|
|
raise self.exc_type(metadata={'from': position})
|
|
|
|
async def before_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any
|
|
) -> Any:
|
|
self._maybe('before_tool_validate')
|
|
return args
|
|
|
|
async def wrap_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, handler: Any
|
|
) -> Any:
|
|
self._maybe('wrap_tool_validate_before')
|
|
result = await handler(args)
|
|
self._maybe('wrap_tool_validate_after')
|
|
return result
|
|
|
|
async def after_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any
|
|
) -> Any:
|
|
self._maybe('after_tool_validate')
|
|
return args
|
|
|
|
async def before_tool_execute(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any
|
|
) -> Any:
|
|
self._maybe('before_tool_execute')
|
|
return args
|
|
|
|
async def wrap_tool_execute(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, handler: Any
|
|
) -> Any:
|
|
self._maybe('wrap_tool_execute_before')
|
|
result = await handler(args)
|
|
self._maybe('wrap_tool_execute_after')
|
|
return result
|
|
|
|
async def after_tool_execute(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, result: Any
|
|
) -> Any:
|
|
self._maybe('after_tool_execute')
|
|
return result
|
|
|
|
|
|
class TestToolHookDeferrals:
|
|
"""A tool call may only be deferred once its arguments are known to be valid.
|
|
|
|
That admits the tool's `args_validator` (covered by `tests/test_tools.py`), the validation hooks
|
|
that run after validation, and every execution hook; the validation hooks that run before it get
|
|
a `UserError` instead of the bare exception aborting the run.
|
|
"""
|
|
|
|
@pytest.mark.parametrize('exc_type', [ApprovalRequired, CallDeferred])
|
|
@pytest.mark.parametrize('where', ['after_tool_validate', 'wrap_tool_validate_after'])
|
|
async def test_validate_hook_defers_once_args_are_valid(
|
|
self, where: str, exc_type: type[ApprovalRequired] | type[CallDeferred]
|
|
):
|
|
"""Hooks holding validated args defer the call, exactly like an `args_validator` does."""
|
|
executed: list[int] = []
|
|
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringHookCap(where=where, exc_type=exc_type)],
|
|
)
|
|
|
|
# `retries=0` pins that deferring doesn't consume the retry budget: charging it would raise
|
|
# `UnexpectedModelBehavior` here rather than deferring.
|
|
@agent.tool(retries=0)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int: # pragma: no cover
|
|
executed.append(x)
|
|
return x
|
|
|
|
events: list[AgentStreamEvent | AgentRunResultEvent[Any]] = []
|
|
async with agent.run_stream_events('call the tool') as stream:
|
|
async for event in stream:
|
|
events.append(event)
|
|
|
|
result = events[-1]
|
|
assert isinstance(result, AgentRunResultEvent)
|
|
requests = result.result.output
|
|
assert isinstance(requests, DeferredToolRequests)
|
|
deferred = requests.approvals if exc_type is ApprovalRequired else requests.calls
|
|
assert [call.tool_name for call in deferred] == ['my_tool']
|
|
assert requests.metadata == {deferred[0].tool_call_id: {'from': where}}
|
|
assert [e.args_valid for e in events if isinstance(e, FunctionToolCallEvent)] == [True]
|
|
assert executed == []
|
|
|
|
@pytest.mark.parametrize('exc_type', [ApprovalRequired, CallDeferred])
|
|
@pytest.mark.parametrize('where', ['before_tool_validate', 'wrap_tool_validate_before'])
|
|
async def test_validate_hook_cannot_defer_before_args_are_valid(
|
|
self, where: str, exc_type: type[ApprovalRequired] | type[CallDeferred]
|
|
):
|
|
"""Deferring before validation has run is a usage error, not a bare exception aborting the run."""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringHookCap(where=where, exc_type=exc_type)],
|
|
)
|
|
|
|
@agent.tool
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int: # pragma: no cover
|
|
return x
|
|
|
|
# The full wording is pinned once by `test_on_tool_validate_error_cannot_defer`; here we pin
|
|
# that each rejected position is named, so a user can find the hook that raised.
|
|
hook_name = 'wrap_tool_validate' if where.startswith('wrap') else where
|
|
with pytest.raises(UserError, match=re.escape(f'`{hook_name}` raised `{exc_type.__name__}`')):
|
|
await agent.run('call the tool')
|
|
|
|
async def test_on_tool_validate_error_cannot_defer(self):
|
|
"""The error hook only runs when validation failed, so it has no valid arguments to defer."""
|
|
|
|
def bad_args_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(parts=[ToolCallPart(tool_name='greet', args='{"wrong": 1}', tool_call_id='call-1')])
|
|
|
|
@dataclass
|
|
class DeferringValidateErrorCap(AbstractCapability[Any]):
|
|
async def on_tool_validate_error(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Any
|
|
) -> Any:
|
|
raise ApprovalRequired()
|
|
|
|
agent = Agent(
|
|
FunctionModel(bad_args_model),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringValidateErrorCap()],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def greet(name: str) -> str: # pragma: no cover
|
|
return f'hello {name}'
|
|
|
|
with pytest.raises(UserError) as exc_info:
|
|
await agent.run('greet someone')
|
|
assert str(exc_info.value) == snapshot(
|
|
"`on_tool_validate_error` raised `ApprovalRequired`, but a tool call can only be deferred once its arguments have been validated. Raise it from `after_tool_validate`, from the tool's `args_validator`, or from `before_tool_execute` instead."
|
|
)
|
|
|
|
async def test_hook_deferral_replaces_an_args_validator_deferral(self):
|
|
"""When both defer, the hook wins: it runs later and is the policy layer.
|
|
|
|
The `args_validator` asks for approval; `after_tool_validate` — which runs even for an
|
|
already-deferred call — defers for external execution instead, and that's what the run
|
|
surfaces, with the hook's metadata.
|
|
"""
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringHookCap(where='after_tool_validate', exc_type=CallDeferred)],
|
|
)
|
|
|
|
def my_validator(ctx: RunContext[Any], x: int) -> None:
|
|
raise ApprovalRequired(metadata={'from': 'args_validator'})
|
|
|
|
@agent.tool(args_validator=my_validator, retries=0)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int: # pragma: no cover
|
|
return x
|
|
|
|
result = await agent.run('call the tool')
|
|
assert result.output == snapshot(
|
|
DeferredToolRequests(
|
|
calls=[ToolCallPart(tool_name='my_tool', args={'x': 0}, tool_call_id='pyd_ai_tool_call_id__my_tool')],
|
|
metadata={'pyd_ai_tool_call_id__my_tool': {'from': 'after_tool_validate'}},
|
|
)
|
|
)
|
|
|
|
@pytest.mark.parametrize('exc_type', [ApprovalRequired, CallDeferred])
|
|
@pytest.mark.parametrize(
|
|
'where',
|
|
['before_tool_execute', 'wrap_tool_execute_before', 'wrap_tool_execute_after', 'after_tool_execute'],
|
|
)
|
|
async def test_execute_hook_defers(self, where: str, exc_type: type[ApprovalRequired] | type[CallDeferred]):
|
|
"""Every execution hook can defer: by then the arguments are validated.
|
|
|
|
The two positions that run after the tool body defer a call whose side effects already
|
|
happened and whose result is discarded — documented, not fixed here.
|
|
"""
|
|
executed: list[int] = []
|
|
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringHookCap(where=where, exc_type=exc_type)],
|
|
)
|
|
|
|
@agent.tool(retries=0)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int:
|
|
executed.append(x)
|
|
return x
|
|
|
|
events: list[AgentStreamEvent | AgentRunResultEvent[Any]] = []
|
|
async with agent.run_stream_events('call the tool') as stream:
|
|
async for event in stream:
|
|
events.append(event)
|
|
|
|
result = events[-1]
|
|
assert isinstance(result, AgentRunResultEvent)
|
|
requests = result.result.output
|
|
assert isinstance(requests, DeferredToolRequests)
|
|
deferred = requests.approvals if exc_type is ApprovalRequired else requests.calls
|
|
assert [call.tool_name for call in deferred] == ['my_tool']
|
|
assert requests.metadata == {deferred[0].tool_call_id: {'from': where}}
|
|
assert [e.args_valid for e in events if isinstance(e, FunctionToolCallEvent)] == [True]
|
|
assert executed == ([0] if where.endswith('_after') or where == 'after_tool_execute' else [])
|
|
|
|
@pytest.mark.parametrize('exc_type', [ApprovalRequired, CallDeferred])
|
|
async def test_execute_error_hook_defers(self, exc_type: type[ApprovalRequired] | type[CallDeferred]):
|
|
"""The execution error hook can replace a tool failure with a deferral."""
|
|
|
|
@dataclass
|
|
class DeferringExecuteErrorCap(AbstractCapability[Any]):
|
|
async def on_tool_execute_error(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: Any, error: Exception
|
|
) -> Any:
|
|
raise exc_type(metadata={'from': 'on_tool_execute_error'})
|
|
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringExecuteErrorCap()],
|
|
)
|
|
|
|
@agent.tool(retries=0)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int:
|
|
raise RuntimeError('tool failed')
|
|
|
|
result = await agent.run('call the tool')
|
|
requests = result.output
|
|
assert isinstance(requests, DeferredToolRequests)
|
|
deferred = requests.approvals if exc_type is ApprovalRequired else requests.calls
|
|
assert [call.tool_name for call in deferred] == ['my_tool']
|
|
assert requests.metadata == {deferred[0].tool_call_id: {'from': 'on_tool_execute_error'}}
|
|
|
|
async def test_hooks_that_defer_nowhere_leave_the_call_alone(self):
|
|
"""Control case: the same capability without a deferral runs the tool and returns its result.
|
|
|
|
Pins that it's the deferral, not the hooks themselves, that changes any of the above.
|
|
"""
|
|
executed: list[int] = []
|
|
|
|
agent = Agent(
|
|
TestModel(),
|
|
output_type=[str, DeferredToolRequests],
|
|
capabilities=[DeferringHookCap(where='')],
|
|
)
|
|
|
|
@agent.tool(retries=0)
|
|
def my_tool(ctx: RunContext[Any], x: int) -> int:
|
|
executed.append(x)
|
|
return x
|
|
|
|
result = await agent.run('call the tool')
|
|
assert result.output == snapshot('{"my_tool":0}')
|
|
assert executed == [0]
|
|
|
|
|
|
# --- Tool execute error hook tests ---
|
|
|
|
|
|
class TestToolExecuteErrorHooks:
|
|
async def test_on_tool_execute_error_fires(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
raise ValueError('tool failed')
|
|
|
|
with pytest.raises(ValueError, match='tool failed'):
|
|
await agent.run('call the tool')
|
|
assert 'on_tool_execute_error:my_tool' in cap.log
|
|
|
|
async def test_on_tool_execute_error_not_called_on_success(self):
|
|
cap = LoggingCapability()
|
|
agent = Agent(FunctionModel(tool_calling_model), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
await agent.run('call the tool')
|
|
assert all('on_tool_execute_error' not in entry for entry in cap.log)
|
|
|
|
async def test_on_tool_execute_error_can_recover(self):
|
|
@dataclass
|
|
class RecoverExecCap(AbstractCapability[Any]):
|
|
async def on_tool_execute_error(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
error: Exception,
|
|
) -> Any:
|
|
return 'fallback result'
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[RecoverExecCap()])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
raise ValueError('tool failed')
|
|
|
|
result = await agent.run('call tool')
|
|
assert 'fallback result' in result.output
|
|
|
|
|
|
# --- ToolFailed and ModelRetry from hooks tests ---
|
|
|
|
|
|
class _BeforeToolFailedCap(AbstractCapability[Any]):
|
|
async def before_tool_execute(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
raise ToolFailed('failed before execution')
|
|
|
|
|
|
class _WrapToolFailedCap(AbstractCapability[Any]):
|
|
async def wrap_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
handler: Any,
|
|
) -> Any:
|
|
try:
|
|
return await handler(args)
|
|
except RuntimeError as e:
|
|
raise ToolFailed('failed during wrapper') from e
|
|
|
|
|
|
class _AfterToolFailedCap(AbstractCapability[Any]):
|
|
async def after_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
result: Any,
|
|
) -> Any:
|
|
raise ToolFailed('failed after execution')
|
|
|
|
|
|
class _OnToolExecuteErrorFailedCap(AbstractCapability[Any]):
|
|
async def on_tool_execute_error(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
error: Exception,
|
|
) -> Any:
|
|
raise ToolFailed('failed while handling error')
|
|
|
|
|
|
class _BeforeToolValidateFailedCap(AbstractCapability[Any]):
|
|
async def before_tool_validate(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: str | dict[str, Any]
|
|
) -> str | dict[str, Any]:
|
|
raise ToolFailed('failed before validation')
|
|
|
|
|
|
class _OnToolValidateErrorFailedCap(AbstractCapability[Any]):
|
|
async def on_tool_validate_error(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: str | dict[str, Any],
|
|
error: ValidationError | ModelRetry,
|
|
) -> dict[str, Any]:
|
|
raise ToolFailed('failed while handling validation error')
|
|
|
|
|
|
class _WrapToolValidateFailedCap(AbstractCapability[Any]):
|
|
async def wrap_tool_validate(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: str | dict[str, Any],
|
|
handler: Any,
|
|
) -> dict[str, Any]:
|
|
raise ToolFailed('failed during validate wrapper')
|
|
|
|
|
|
def _tool_failed_roundtrip_model(tool_args: str) -> Callable[[list[ModelMessage], AgentInfo], ModelResponse]:
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.outcome}:{part.content}')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args=tool_args, tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
return model_fn
|
|
|
|
|
|
def _assert_failed_tool_result(result: AgentRunResult[Any], expected_message: str) -> None:
|
|
assert result.output == f'got: failed:{expected_message}'
|
|
|
|
parts = [part for msg in result.all_messages() for part in msg.parts]
|
|
tool_return = next(part for part in parts if isinstance(part, ToolReturnPart))
|
|
assert tool_return.outcome == 'failed'
|
|
assert tool_return.content == expected_message
|
|
assert not any(isinstance(part, RetryPromptPart) for part in parts)
|
|
|
|
|
|
class TestToolFailedFromHooks:
|
|
"""Tests for raising ToolFailed from capability tool hooks."""
|
|
|
|
@pytest.mark.parametrize('hook_name', ['before', 'wrap', 'after', 'on_error'])
|
|
async def test_tool_execute_hook_tool_failed(self, hook_name: str):
|
|
tool_call_count = 0
|
|
cap_type, expected_message, tool_should_run = {
|
|
'before': (_BeforeToolFailedCap, 'failed before execution', False),
|
|
'wrap': (_WrapToolFailedCap, 'failed during wrapper', True),
|
|
'after': (_AfterToolFailedCap, 'failed after execution', True),
|
|
'on_error': (_OnToolExecuteErrorFailedCap, 'failed while handling error', True),
|
|
}[hook_name]
|
|
|
|
agent = Agent(FunctionModel(_tool_failed_roundtrip_model('{}')), capabilities=[cap_type()])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1
|
|
if hook_name in {'wrap', 'on_error'}:
|
|
raise RuntimeError('tool failed')
|
|
return 'tool result'
|
|
|
|
result = await agent.run('call tool')
|
|
|
|
_assert_failed_tool_result(result, expected_message)
|
|
assert tool_call_count == int(tool_should_run)
|
|
|
|
async def test_deferred_tool_validate_hook_tool_failed(self):
|
|
"""Deferred tool validation can return a failed tool result instead of a deferred request."""
|
|
tool_call_count = 0
|
|
|
|
agent = Agent(
|
|
FunctionModel(_tool_failed_roundtrip_model('{}')),
|
|
capabilities=[_BeforeToolValidateFailedCap()],
|
|
output_type=[str, DeferredToolRequests],
|
|
retries={'tools': 0, 'output': 2},
|
|
)
|
|
|
|
@agent.tool_plain(requires_approval=True)
|
|
def my_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1 # pragma: no cover
|
|
return 'tool result' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
|
|
_assert_failed_tool_result(result, 'failed before validation')
|
|
assert tool_call_count == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
('capability', 'tool_args', 'expected_message'),
|
|
[
|
|
pytest.param(
|
|
_BeforeToolValidateFailedCap(), '{"x":1}', 'failed before validation', id='before_tool_validate'
|
|
),
|
|
pytest.param(
|
|
_OnToolValidateErrorFailedCap(),
|
|
'{"x":"bad"}',
|
|
'failed while handling validation error',
|
|
id='on_tool_validate_error',
|
|
),
|
|
pytest.param(
|
|
_WrapToolValidateFailedCap(), '{"x":1}', 'failed during validate wrapper', id='wrap_tool_validate'
|
|
),
|
|
],
|
|
)
|
|
async def test_tool_validate_hook_tool_failed(
|
|
self, capability: AbstractCapability[Any], tool_args: str, expected_message: str
|
|
):
|
|
"""Non-deferred tool validation hooks can report a failed tool result instead of retrying."""
|
|
tool_call_count = 0
|
|
|
|
agent = Agent(
|
|
FunctionModel(_tool_failed_roundtrip_model(tool_args)),
|
|
capabilities=[capability],
|
|
retries={'tools': 0, 'output': 2},
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool(x: int) -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1 # pragma: no cover
|
|
return f'tool result: {x}' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
|
|
_assert_failed_tool_result(result, expected_message)
|
|
assert tool_call_count == 0
|
|
|
|
async def test_args_validator_tool_failed(self):
|
|
"""An `args_validator` raising `ToolFailed` reports a failed tool result instead of retrying."""
|
|
tool_call_count = 0
|
|
expected_message = 'failed in args validator'
|
|
|
|
def validate_args(ctx: RunContext[Any]) -> None:
|
|
raise ToolFailed(expected_message)
|
|
|
|
agent = Agent(
|
|
FunctionModel(_tool_failed_roundtrip_model('{}')),
|
|
retries={'tools': 0, 'output': 2},
|
|
)
|
|
|
|
@agent.tool_plain(args_validator=validate_args)
|
|
def my_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1 # pragma: no cover
|
|
return 'tool result' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
|
|
_assert_failed_tool_result(result, expected_message)
|
|
assert tool_call_count == 0
|
|
|
|
|
|
class TestModelRetryFromHooks:
|
|
"""Tests for raising ModelRetry from capability hooks."""
|
|
|
|
async def test_after_model_request_model_retry(self):
|
|
"""after_model_request raises ModelRetry — model is called again with retry prompt."""
|
|
call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return make_text_response('bad response')
|
|
return make_text_response('good response')
|
|
|
|
@dataclass
|
|
class RetryCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
if not self.retried:
|
|
self.retried = True
|
|
raise ModelRetry('Response was bad, please try again')
|
|
return response
|
|
|
|
cap = RetryCap()
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'good response'
|
|
assert call_count == 2
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='bad response')],
|
|
usage=RequestUsage(input_tokens=51, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Response was bad, please try again',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='good response')],
|
|
usage=RequestUsage(input_tokens=66, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_after_model_request_model_retry_max_retries(self):
|
|
"""after_model_request raises ModelRetry repeatedly — hits output_retries."""
|
|
|
|
@dataclass
|
|
class AlwaysRetryCap(AbstractCapability[Any]):
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
raise ModelRetry('always bad')
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function),
|
|
capabilities=[AlwaysRetryCap()],
|
|
retries={'output': 2},
|
|
)
|
|
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries'):
|
|
await agent.run('hello')
|
|
|
|
async def test_after_model_request_model_retry_streaming(self):
|
|
"""after_model_request raises ModelRetry during streaming with tool calls — model is called again."""
|
|
call_count = 0
|
|
|
|
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
# First call: return a tool call that after_model_request will reject
|
|
yield {0: DeltaToolCall(name='my_tool', json_args='{}', tool_call_id='call-1')}
|
|
elif call_count == 2:
|
|
# Second call (after retry): return text
|
|
yield 'good response'
|
|
else:
|
|
yield 'unexpected' # pragma: no cover
|
|
|
|
@dataclass
|
|
class RetryCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def after_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
response: ModelResponse,
|
|
) -> ModelResponse:
|
|
if not self.retried:
|
|
self.retried = True
|
|
raise ModelRetry('Response was bad, please try again')
|
|
return response
|
|
|
|
cap = RetryCap()
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function, stream_function=stream_fn),
|
|
capabilities=[cap],
|
|
)
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result' # pragma: no cover
|
|
|
|
async with agent.run_stream('hello') as streamed:
|
|
result = await streamed.get_output()
|
|
assert result == 'good response'
|
|
assert call_count == 2
|
|
assert streamed.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=50, output_tokens=1),
|
|
model_name='function:simple_model_function:stream_fn',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Response was bad, please try again',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='good response')],
|
|
usage=RequestUsage(input_tokens=50, output_tokens=2),
|
|
model_name='function:simple_model_function:stream_fn',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_wrap_model_request_model_retry_streaming_short_circuit(self):
|
|
"""wrap_model_request raises ModelRetry without calling handler during streaming."""
|
|
|
|
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str]:
|
|
yield 'good response'
|
|
|
|
@dataclass
|
|
class ShortCircuitRetryCap(AbstractCapability[Any]):
|
|
call_count: int = 0
|
|
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
self.call_count += 1
|
|
if self.call_count == 1:
|
|
# Short-circuit: don't call handler, raise ModelRetry
|
|
raise ModelRetry('Short-circuit retry')
|
|
return await handler(request_context)
|
|
|
|
cap = ShortCircuitRetryCap()
|
|
agent = Agent(FunctionModel(simple_model_function, stream_function=stream_fn), capabilities=[cap])
|
|
async with agent.run_stream('hello') as streamed:
|
|
result = await streamed.get_output()
|
|
assert result == 'good response'
|
|
assert cap.call_count == 2
|
|
assert streamed.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Short-circuit retry',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='good response')],
|
|
usage=RequestUsage(input_tokens=50, output_tokens=2),
|
|
model_name='function:simple_model_function:stream_fn',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_wrap_model_request_model_retry_streaming_after_handler(self):
|
|
"""wrap_model_request raises ModelRetry after calling handler during streaming (tool call scenario)."""
|
|
call_count = 0
|
|
|
|
async def stream_fn(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[str | DeltaToolCalls]:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
# First call: tool call that wrap hook will reject
|
|
yield {0: DeltaToolCall(name='my_tool', json_args='{}', tool_call_id='call-1')}
|
|
else:
|
|
yield 'good response'
|
|
|
|
@dataclass
|
|
class AfterHandlerRetryCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
response = await handler(request_context)
|
|
if not self.retried:
|
|
self.retried = True
|
|
raise ModelRetry('Post-handler retry')
|
|
return response
|
|
|
|
cap = AfterHandlerRetryCap()
|
|
agent = Agent(FunctionModel(simple_model_function, stream_function=stream_fn), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result' # pragma: no cover
|
|
|
|
async with agent.run_stream('hello') as streamed:
|
|
result = await streamed.get_output()
|
|
assert result == 'good response'
|
|
assert call_count == 2
|
|
assert streamed.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=50, output_tokens=1),
|
|
model_name='function:simple_model_function:stream_fn',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Post-handler retry',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='good response')],
|
|
usage=RequestUsage(input_tokens=50, output_tokens=2),
|
|
model_name='function:simple_model_function:stream_fn',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_wrap_model_request_model_retry(self):
|
|
"""wrap_model_request raises ModelRetry after calling handler — triggers retry."""
|
|
call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return make_text_response('first attempt')
|
|
return make_text_response('second attempt')
|
|
|
|
@dataclass
|
|
class WrapRetryCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
response = await handler(request_context)
|
|
if not self.retried:
|
|
self.retried = True
|
|
raise ModelRetry('Wrap says retry')
|
|
return response
|
|
|
|
cap = WrapRetryCap()
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'second attempt'
|
|
assert call_count == 2
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='first attempt')],
|
|
usage=RequestUsage(input_tokens=51, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Wrap says retry',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='second attempt')],
|
|
usage=RequestUsage(input_tokens=63, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_wrap_model_request_model_retry_skips_on_error(self):
|
|
"""wrap_model_request raising ModelRetry should NOT call on_model_request_error."""
|
|
on_error_called = False
|
|
|
|
@dataclass
|
|
class WrapRetrySkipErrorCap(AbstractCapability[Any]):
|
|
async def wrap_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
handler: Any,
|
|
) -> ModelResponse:
|
|
raise ModelRetry('retry please')
|
|
|
|
# The uncovered body is the assertion: this hook must not be called.
|
|
async def on_model_request_error( # pragma: no cover
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
error: Exception,
|
|
) -> ModelResponse:
|
|
nonlocal on_error_called
|
|
on_error_called = True
|
|
raise error
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function), capabilities=[WrapRetrySkipErrorCap()], retries={'output': 1}
|
|
)
|
|
with pytest.raises(UnexpectedModelBehavior, match='Exceeded maximum output retries'):
|
|
await agent.run('hello')
|
|
assert not on_error_called
|
|
|
|
async def test_on_model_request_error_model_retry(self):
|
|
"""on_model_request_error raises ModelRetry to recover via retry."""
|
|
call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise RuntimeError('model failed')
|
|
return make_text_response('recovered response')
|
|
|
|
@dataclass
|
|
class ErrorRetryCap(AbstractCapability[Any]):
|
|
async def on_model_request_error(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
request_context: ModelRequestContext,
|
|
error: Exception,
|
|
) -> ModelResponse:
|
|
raise ModelRetry('Model failed, please try again')
|
|
|
|
cap = ErrorRetryCap()
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'recovered response'
|
|
assert call_count == 2
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='hello', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Model failed, please try again',
|
|
tool_call_id=IsStr(),
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='recovered response')],
|
|
usage=RequestUsage(input_tokens=65, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_after_tool_execute_model_retry(self):
|
|
"""after_tool_execute raises ModelRetry — tool retry prompt sent to model, tool retried on success."""
|
|
tool_call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
# Always call the tool — after retry, the hook won't raise again
|
|
if info.function_tools:
|
|
# Check if we already got a tool return (second call succeeded)
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class AfterExecRetryCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def after_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
result: Any,
|
|
) -> Any:
|
|
if not self.retried:
|
|
self.retried = True
|
|
raise ModelRetry('Tool result is bad, try again')
|
|
return result
|
|
|
|
cap = AfterExecRetryCap()
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1
|
|
return 'tool result'
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got: tool result'
|
|
assert tool_call_count == 2 # Tool called twice: first rejected by hook, second succeeds
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Tool result is bad, try again',
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=65, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got: tool result')],
|
|
usage=RequestUsage(input_tokens=67, output_tokens=7),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_before_tool_execute_model_retry(self):
|
|
"""before_tool_execute raises ModelRetry — tool execution is skipped, then succeeds on retry."""
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
# Always call the tool — after retry, the hook won't raise again
|
|
if info.function_tools:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
hooks = Hooks[Any]()
|
|
hook_called = False
|
|
|
|
@hooks.on.before_tool_execute
|
|
async def reject_first(
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
nonlocal hook_called
|
|
if not hook_called:
|
|
hook_called = True
|
|
raise ModelRetry('Not ready to execute, try again')
|
|
return args
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[hooks], retries={'tools': 2, 'output': 2})
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result'
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got: tool result'
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Not ready to execute, try again',
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=65, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got: tool result')],
|
|
usage=RequestUsage(input_tokens=67, output_tokens=7),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_after_tool_execute_validation_error(self):
|
|
"""after_tool_execute raises ValidationError — converted to ToolRetryError for retry."""
|
|
from pydantic import TypeAdapter
|
|
|
|
tool_call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
if info.function_tools:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class ValErrCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def after_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
result: Any,
|
|
) -> Any:
|
|
if not self.retried:
|
|
self.retried = True
|
|
# Simulate a user hook doing additional Pydantic validation
|
|
TypeAdapter(int).validate_python('not_an_int')
|
|
return result
|
|
|
|
cap = ValErrCap()
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1
|
|
return 'tool result'
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got: tool result'
|
|
assert tool_call_count == 2 # Retried after ValidationError
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content=[
|
|
{
|
|
'type': 'int_parsing',
|
|
'loc': (),
|
|
'msg': 'Input should be a valid integer, unable to parse string as an integer',
|
|
'input': 'not_an_int',
|
|
}
|
|
],
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=88, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got: tool result')],
|
|
usage=RequestUsage(input_tokens=90, output_tokens=7),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_before_tool_execute_validation_error(self):
|
|
"""before_tool_execute raises ValidationError — converted to ToolRetryError for retry."""
|
|
from pydantic import TypeAdapter
|
|
|
|
tool_call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
if info.function_tools:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, ToolReturnPart):
|
|
return make_text_response(f'got: {part.content}')
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class ValErrCap(AbstractCapability[Any]):
|
|
retried: bool = False
|
|
|
|
async def before_tool_execute(
|
|
self, ctx: RunContext[Any], *, call: ToolCallPart, tool_def: ToolDefinition, args: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
if not self.retried:
|
|
self.retried = True
|
|
TypeAdapter(int).validate_python('not_an_int')
|
|
return args
|
|
|
|
cap = ValErrCap()
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[cap])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1
|
|
return 'tool result'
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got: tool result'
|
|
# Tool only called once — before_tool_execute ValidationError prevented first call
|
|
assert tool_call_count == 1
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content=[
|
|
{
|
|
'type': 'int_parsing',
|
|
'loc': (),
|
|
'msg': 'Input should be a valid integer, unable to parse string as an integer',
|
|
'input': 'not_an_int',
|
|
}
|
|
],
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=88, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
ToolReturnPart(
|
|
tool_name='my_tool', content='tool result', tool_call_id='call-1', timestamp=IsDatetime()
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got: tool result')],
|
|
usage=RequestUsage(input_tokens=90, output_tokens=7),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_wrap_tool_execute_model_retry_skips_on_error(self):
|
|
"""wrap_tool_execute raising ModelRetry should NOT call on_tool_execute_error."""
|
|
on_error_called = False
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, RetryPromptPart):
|
|
return make_text_response('got retry')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class WrapExecRetryCap(AbstractCapability[Any]):
|
|
async def wrap_tool_execute(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
handler: Any,
|
|
) -> Any:
|
|
raise ModelRetry('Wrap says retry tool')
|
|
|
|
# The uncovered body is the assertion: this hook must not be called.
|
|
async def on_tool_execute_error( # pragma: no cover
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
error: Exception,
|
|
) -> Any:
|
|
nonlocal on_error_called
|
|
on_error_called = True
|
|
raise error
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[WrapExecRetryCap()], retries={'tools': 2, 'output': 2})
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got retry'
|
|
assert not on_error_called
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Wrap says retry tool',
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got retry')],
|
|
usage=RequestUsage(input_tokens=63, output_tokens=4),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_on_tool_execute_error_model_retry(self):
|
|
"""on_tool_execute_error raises ModelRetry to recover via retry."""
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, RetryPromptPart):
|
|
return make_text_response('got retry after error')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class ErrorRetryCap(AbstractCapability[Any]):
|
|
async def on_tool_execute_error(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
error: Exception,
|
|
) -> Any:
|
|
raise ModelRetry('Tool errored, please retry')
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[ErrorRetryCap()], retries={'tools': 2, 'output': 2})
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
raise ValueError('tool failed')
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got retry after error'
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Tool errored, please retry',
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got retry after error')],
|
|
usage=RequestUsage(input_tokens=63, output_tokens=6),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_after_tool_validate_model_retry(self):
|
|
"""after_tool_validate raises ModelRetry — validation retry sent to model."""
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, RetryPromptPart):
|
|
return make_text_response('got validation retry')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class AfterValRetryCap(AbstractCapability[Any]):
|
|
async def after_tool_validate(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
raise ModelRetry('Validated args are bad')
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[AfterValRetryCap()], retries={'tools': 2, 'output': 2})
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got validation retry'
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Validated args are bad',
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got validation retry')],
|
|
usage=RequestUsage(input_tokens=63, output_tokens=5),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
async def test_before_tool_validate_model_retry(self):
|
|
"""before_tool_validate raises ModelRetry — validation retry sent to model."""
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
for msg in messages:
|
|
for part in msg.parts:
|
|
if isinstance(part, RetryPromptPart):
|
|
return make_text_response('got pre-validation retry')
|
|
if info.function_tools:
|
|
return ModelResponse(
|
|
parts=[ToolCallPart(tool_name=info.function_tools[0].name, args='{}', tool_call_id='call-1')]
|
|
)
|
|
return make_text_response('no tools') # pragma: no cover
|
|
|
|
@dataclass
|
|
class BeforeValRetryCap(AbstractCapability[Any]):
|
|
async def before_tool_validate(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
*,
|
|
call: ToolCallPart,
|
|
tool_def: ToolDefinition,
|
|
args: str | dict[str, Any],
|
|
) -> str | dict[str, Any]:
|
|
raise ModelRetry('Args look bad before validation')
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[BeforeValRetryCap()], retries={'tools': 2, 'output': 2})
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result' # pragma: no cover
|
|
|
|
result = await agent.run('call tool')
|
|
assert result.output == 'got pre-validation retry'
|
|
assert result.all_messages() == snapshot(
|
|
[
|
|
ModelRequest(
|
|
parts=[UserPromptPart(content='call tool', timestamp=IsDatetime())],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[ToolCallPart(tool_name='my_tool', args='{}', tool_call_id='call-1')],
|
|
usage=RequestUsage(input_tokens=52, output_tokens=2),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelRequest(
|
|
parts=[
|
|
RetryPromptPart(
|
|
content='Args look bad before validation',
|
|
tool_name='my_tool',
|
|
tool_call_id='call-1',
|
|
timestamp=IsDatetime(),
|
|
)
|
|
],
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
ModelResponse(
|
|
parts=[TextPart(content='got pre-validation retry')],
|
|
usage=RequestUsage(input_tokens=64, output_tokens=5),
|
|
model_name='function:model_fn:',
|
|
timestamp=IsDatetime(),
|
|
run_id=IsStr(),
|
|
conversation_id=IsStr(),
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
class TestCtxAgentInCapability:
|
|
"""Test that ctx.agent is available in capability hooks."""
|
|
|
|
async def test_ctx_agent_in_hooks(self):
|
|
hook_agent_names: list[str | None] = []
|
|
|
|
@dataclass
|
|
class AgentTrackingCap(AbstractCapability[Any]):
|
|
async def before_run(self, ctx: RunContext[Any]) -> None:
|
|
assert ctx.agent is not None
|
|
hook_agent_names.append(ctx.agent.name)
|
|
|
|
async def before_model_request(
|
|
self,
|
|
ctx: RunContext[Any],
|
|
request_context: ModelRequestContext,
|
|
) -> ModelRequestContext:
|
|
assert ctx.agent is not None
|
|
hook_agent_names.append(ctx.agent.name)
|
|
return request_context
|
|
|
|
agent = Agent(FunctionModel(simple_model_function), name='hook_test_agent', capabilities=[AgentTrackingCap()])
|
|
await agent.run('hello')
|
|
assert hook_agent_names == ['hook_test_agent', 'hook_test_agent']
|
|
|
|
|
|
# region --- Compaction capability tests ---
|
|
|
|
|
|
class TestCompaction:
|
|
def test_compaction_part_serialization(self):
|
|
"""CompactionPart round-trips through Pydantic serialization."""
|
|
from pydantic_ai.messages import CompactionPart, ModelMessagesTypeAdapter, ModelResponse
|
|
|
|
# Anthropic-style (text content)
|
|
anthropic_part = CompactionPart(content='Summary of conversation', provider_name='anthropic')
|
|
assert anthropic_part.has_content()
|
|
assert anthropic_part.part_kind == 'compaction'
|
|
|
|
# OpenAI-style (encrypted, no content)
|
|
openai_part = CompactionPart(
|
|
content=None,
|
|
id='cmp_123',
|
|
provider_name='openai',
|
|
provider_details={'encrypted_content': 'abc123', 'type': 'compaction'},
|
|
)
|
|
assert not openai_part.has_content()
|
|
assert openai_part.part_kind == 'compaction'
|
|
|
|
# Round-trip through serialization
|
|
response = ModelResponse(parts=[anthropic_part, openai_part])
|
|
messages: list[ModelMessage] = [response]
|
|
serialized = ModelMessagesTypeAdapter.dump_json(messages)
|
|
deserialized = ModelMessagesTypeAdapter.validate_json(serialized)
|
|
assert len(deserialized) == 1
|
|
assert isinstance(deserialized[0], ModelResponse)
|
|
parts = deserialized[0].parts
|
|
assert len(parts) == 2
|
|
assert isinstance(parts[0], CompactionPart)
|
|
assert parts[0].content == 'Summary of conversation'
|
|
assert parts[0].provider_name == 'anthropic'
|
|
assert isinstance(parts[1], CompactionPart)
|
|
assert parts[1].content is None
|
|
assert parts[1].id == 'cmp_123'
|
|
assert parts[1].provider_details == {'encrypted_content': 'abc123', 'type': 'compaction'}
|
|
|
|
async def test_openai_compaction_with_wrong_model(self):
|
|
"""OpenAICompaction raises UserError when used with a non-OpenAI model."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
agent = Agent(
|
|
FunctionModel(simple_model_function),
|
|
capabilities=[OpenAICompaction(message_count_threshold=0)],
|
|
)
|
|
with pytest.raises(UserError, match='OpenAICompaction requires OpenAIResponsesModel'):
|
|
await agent.run('hello')
|
|
|
|
async def test_openai_compaction_with_wrapped_wrong_model(self):
|
|
"""OpenAICompaction unwraps WrapperModel and raises for non-OpenAI model."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
from pydantic_ai.models.wrapper import WrapperModel
|
|
|
|
wrapped = WrapperModel(FunctionModel(simple_model_function))
|
|
agent = Agent(
|
|
wrapped,
|
|
capabilities=[OpenAICompaction(message_count_threshold=0)],
|
|
)
|
|
with pytest.raises(UserError, match='OpenAICompaction requires OpenAIResponsesModel'):
|
|
await agent.run('hello')
|
|
|
|
def test_openai_compaction_should_compact_with_trigger(self):
|
|
"""OpenAICompaction._should_compact delegates to custom trigger."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
cap = OpenAICompaction(trigger=lambda msgs: len(msgs) > 2)
|
|
assert not cap._should_compact([ModelRequest(parts=[UserPromptPart(content='hi')])]) # pyright: ignore[reportPrivateUsage]
|
|
assert cap._should_compact( # pyright: ignore[reportPrivateUsage]
|
|
[
|
|
ModelRequest(parts=[UserPromptPart(content='1')]),
|
|
ModelResponse(parts=[TextPart(content='r1')]),
|
|
ModelRequest(parts=[UserPromptPart(content='2')]),
|
|
]
|
|
)
|
|
|
|
def test_openai_compaction_should_compact_no_config(self):
|
|
"""Bare `OpenAICompaction()` is stateful mode and never triggers the before_model_request hook."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
cap = OpenAICompaction()
|
|
assert cap.stateless is False
|
|
assert not cap._should_compact([ModelRequest(parts=[UserPromptPart(content='hi')])]) # pyright: ignore[reportPrivateUsage]
|
|
|
|
def test_openai_compaction_mode_inference(self):
|
|
"""`stateless` is inferred from which mode-specific fields are passed."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
assert OpenAICompaction().stateless is False
|
|
assert OpenAICompaction(token_threshold=1000).stateless is False
|
|
assert OpenAICompaction(message_count_threshold=5).stateless is True
|
|
assert OpenAICompaction(trigger=lambda _msgs: True).stateless is True
|
|
|
|
def test_openai_compaction_stateful_model_settings(self):
|
|
"""Stateful mode returns `openai_context_management` via get_model_settings."""
|
|
pytest.importorskip('openai')
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
def _resolve(cap: OpenAICompaction, model_settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
resolver = cap.get_model_settings()
|
|
assert resolver is not None
|
|
ctx = SimpleNamespace(model_settings=model_settings)
|
|
return cast(dict[str, Any], resolver(cast(Any, ctx)))
|
|
|
|
assert _resolve(OpenAICompaction()) == {'openai_context_management': [{'type': 'compaction'}]}
|
|
assert _resolve(OpenAICompaction(token_threshold=50_000)) == {
|
|
'openai_context_management': [{'type': 'compaction', 'compact_threshold': 50_000}]
|
|
}
|
|
# If the user already configured `openai_context_management` directly, we defer
|
|
# to them entirely and don't append our own entry. OpenAI's context_management
|
|
# list only meaningfully supports one `compaction` entry, so mixing the capability
|
|
# with manual config would produce ambiguous/conflicting state.
|
|
assert (
|
|
_resolve(
|
|
OpenAICompaction(token_threshold=50_000),
|
|
model_settings={'openai_context_management': [{'type': 'compaction', 'compact_threshold': 200_000}]},
|
|
)
|
|
== {}
|
|
)
|
|
# When user has other model settings but no `openai_context_management`,
|
|
# the capability's compaction entry is injected normally.
|
|
assert _resolve(
|
|
OpenAICompaction(token_threshold=50_000),
|
|
model_settings={'temperature': 0.5},
|
|
) == {'openai_context_management': [{'type': 'compaction', 'compact_threshold': 50_000}]}
|
|
# Stateless mode does not inject model settings
|
|
assert OpenAICompaction(message_count_threshold=5).get_model_settings() is None
|
|
|
|
def test_openai_compaction_rejects_mixed_fields(self):
|
|
"""Mixing stateful-only and stateless-only fields raises UserError."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
with pytest.raises(UserError, match='`token_threshold` is only valid for stateful compaction'):
|
|
OpenAICompaction(stateless=True, token_threshold=1000, message_count_threshold=5)
|
|
|
|
with pytest.raises(UserError, match='only valid for stateless compaction'):
|
|
OpenAICompaction(stateless=False, message_count_threshold=5)
|
|
|
|
with pytest.raises(UserError, match='only valid for stateless compaction'):
|
|
OpenAICompaction(stateless=False, trigger=lambda _msgs: True)
|
|
|
|
def test_openai_compaction_stateless_requires_trigger(self):
|
|
"""`stateless=True` without message_count_threshold or trigger raises UserError."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
with pytest.raises(UserError, match='requires `message_count_threshold` or `trigger`'):
|
|
OpenAICompaction(stateless=True)
|
|
|
|
def test_openai_compaction_serialization_name(self):
|
|
"""OpenAICompaction has the correct serialization name."""
|
|
pytest.importorskip('openai')
|
|
from pydantic_ai.models.openai import OpenAICompaction
|
|
|
|
assert OpenAICompaction.get_serialization_name() == 'OpenAICompaction'
|
|
|
|
def test_anthropic_compaction_serialization_name(self):
|
|
"""AnthropicCompaction has the correct serialization name."""
|
|
pytest.importorskip('anthropic')
|
|
from pydantic_ai.models.anthropic import AnthropicCompaction
|
|
|
|
assert AnthropicCompaction.get_serialization_name() == 'AnthropicCompaction'
|
|
|
|
async def test_compaction_part_in_function_model_history(self):
|
|
"""FunctionModel handles message history containing CompactionPart."""
|
|
from pydantic_ai.messages import CompactionPart
|
|
|
|
compaction_response = ModelResponse(
|
|
parts=[CompactionPart(content='Summary: user greeted.', provider_name='anthropic')],
|
|
provider_name='anthropic',
|
|
)
|
|
history: list[ModelMessage] = [
|
|
ModelRequest(parts=[UserPromptPart(content='Hello!')]),
|
|
compaction_response,
|
|
ModelRequest(parts=[UserPromptPart(content='How are you?')]),
|
|
]
|
|
|
|
agent = Agent(FunctionModel(simple_model_function))
|
|
result = await agent.run('Follow up', message_history=history)
|
|
assert result.output == 'response from model'
|
|
|
|
async def test_compaction_part_without_content_in_response(self):
|
|
"""CompactionPart with content=None (OpenAI-style) is handled alongside text."""
|
|
from pydantic_ai.messages import CompactionPart
|
|
|
|
def model_with_compaction(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
return ModelResponse(
|
|
parts=[
|
|
CompactionPart(content=None, id='cmp_123', provider_name='openai'),
|
|
TextPart(content='actual response'),
|
|
]
|
|
)
|
|
|
|
agent = Agent(FunctionModel(model_with_compaction))
|
|
result = await agent.run('hello')
|
|
assert result.output == 'actual response'
|
|
|
|
|
|
# endregion
|
|
|
|
|
|
def test_thread_executor_not_serializable() -> None:
|
|
assert UseThreadExecutor.get_serialization_name() is None
|
|
|
|
|
|
def test_thread_executor_deprecated_alias() -> None:
|
|
from pydantic_ai.exceptions import PydanticAIDeprecationWarning
|
|
|
|
with pytest.warns(PydanticAIDeprecationWarning, match='renamed to `UseThreadExecutor`'):
|
|
from pydantic_ai.capabilities import ThreadExecutor
|
|
assert ThreadExecutor is UseThreadExecutor
|
|
|
|
# The defining module resolves the old name too, so unpickling keeps working.
|
|
with pytest.warns(PydanticAIDeprecationWarning, match='renamed to `UseThreadExecutor`'):
|
|
from pydantic_ai.capabilities.thread_executor import ThreadExecutor as submodule_thread_executor
|
|
assert submodule_thread_executor is UseThreadExecutor
|
|
|
|
|
|
async def test_thread_executor_capability() -> None:
|
|
tool_threads: list[str] = []
|
|
|
|
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts):
|
|
return ModelResponse(parts=[TextPart(content='done')])
|
|
return ModelResponse(parts=[ToolCallPart(tool_name='check_thread', args='{}')])
|
|
|
|
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='cap-pool')
|
|
try:
|
|
agent = Agent(FunctionModel(model_function), capabilities=[UseThreadExecutor(executor)])
|
|
|
|
@agent.tool_plain
|
|
def check_thread() -> str:
|
|
tool_threads.append(threading.current_thread().name)
|
|
return 'ok'
|
|
|
|
result = await agent.run('test')
|
|
assert result.output == 'done'
|
|
assert len(tool_threads) == 1
|
|
assert tool_threads[0].startswith('cap-pool')
|
|
finally:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
async def test_thread_executor_static_method() -> None:
|
|
tool_threads: list[str] = []
|
|
|
|
def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
if any(isinstance(p, ToolReturnPart) for m in messages for p in m.parts):
|
|
return ModelResponse(parts=[TextPart(content='done')])
|
|
return ModelResponse(parts=[ToolCallPart(tool_name='check_thread', args='{}')])
|
|
|
|
agent = Agent(FunctionModel(model_function))
|
|
|
|
@agent.tool_plain
|
|
def check_thread() -> str:
|
|
tool_threads.append(threading.current_thread().name)
|
|
return 'ok'
|
|
|
|
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix='static-pool')
|
|
try:
|
|
with Agent.using_thread_executor(executor):
|
|
result = await agent.run('test')
|
|
assert result.output == 'done'
|
|
assert len(tool_threads) == 1
|
|
assert tool_threads[0].startswith('static-pool')
|
|
finally:
|
|
executor.shutdown(wait=True)
|
|
|
|
|
|
# --- Hook recovery tests (after_node_run End→node, ErrorMarker in next_node) ---
|
|
|
|
|
|
async def test_after_node_run_end_to_node_override():
|
|
"""after_node_run can convert an End result back to a node, continuing execution."""
|
|
from pydantic_ai import ModelRequestNode
|
|
|
|
call_count = 0
|
|
|
|
def llm(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return ModelResponse(parts=[TextPart('first answer')])
|
|
return ModelResponse(parts=[TextPart('second answer')])
|
|
|
|
redirected = False
|
|
|
|
@dataclass
|
|
class RedirectOnFirstEnd(AbstractCapability[Any]):
|
|
"""Redirects the first End back to a ModelRequestNode to force a second model call."""
|
|
|
|
_redirected: bool = field(default=False, init=False)
|
|
|
|
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
|
|
nonlocal redirected
|
|
if isinstance(result, End) and not self._redirected:
|
|
self._redirected = True
|
|
redirected = True
|
|
return ModelRequestNode(ModelRequest(parts=[UserPromptPart(content='try again')])) # pyright: ignore[reportUnknownVariableType]
|
|
return result # pyright: ignore[reportUnknownVariableType]
|
|
|
|
agent = Agent(FunctionModel(llm), capabilities=[RedirectOnFirstEnd()])
|
|
result = await agent.run('hello')
|
|
|
|
assert redirected
|
|
assert call_count == 2
|
|
assert result.output == 'second answer'
|
|
|
|
|
|
async def test_next_node_raises_on_error_marker():
|
|
"""Accessing next_node after a node error re-raises the original exception."""
|
|
call_count = 0
|
|
|
|
def failing_then_ok_model(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
raise ValueError('model failure')
|
|
|
|
agent = Agent(FunctionModel(failing_then_ok_model))
|
|
async with agent.iter('hello') as agent_run:
|
|
node = agent_run.next_node
|
|
node = cast(Any, await agent_run.next(cast(Any, node)))
|
|
with pytest.raises(ValueError, match='model failure'):
|
|
await agent_run.next(node)
|
|
# After an unrecovered error, next_node should re-raise
|
|
with pytest.raises(ValueError, match='model failure'):
|
|
_ = agent_run.next_node
|
|
|
|
|
|
async def test_on_node_run_error_returns_end():
|
|
"""on_node_run_error can recover from an exception by returning End, completing the run."""
|
|
from pydantic_ai.result import FinalResult
|
|
|
|
def always_fails(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
raise ValueError('model exploded')
|
|
|
|
@dataclass
|
|
class RecoverWithEnd(AbstractCapability[Any]):
|
|
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
|
|
return End(FinalResult('recovered output'))
|
|
|
|
agent = Agent(FunctionModel(always_fails), capabilities=[RecoverWithEnd()])
|
|
result = await agent.run('hello')
|
|
assert result.output == 'recovered output'
|
|
|
|
|
|
async def test_on_node_run_error_returns_node():
|
|
"""on_node_run_error can recover by returning a retry node, continuing execution."""
|
|
from pydantic_ai import ModelRequestNode
|
|
|
|
call_count = 0
|
|
|
|
def fails_then_succeeds(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise ValueError('transient failure')
|
|
return ModelResponse(parts=[TextPart('recovered')])
|
|
|
|
@dataclass
|
|
class RetryOnError(AbstractCapability[Any]):
|
|
async def on_node_run_error(self, ctx: RunContext[Any], *, node: Any, error: Exception) -> Any:
|
|
# Retry by returning a new ModelRequestNode with the same request
|
|
return ModelRequestNode(request=node.request) # pyright: ignore[reportUnknownVariableType]
|
|
|
|
agent = Agent(FunctionModel(fails_then_succeeds), capabilities=[RetryOnError()])
|
|
result = await agent.run('hello')
|
|
assert call_count == 2
|
|
assert result.output == 'recovered'
|
|
|
|
|
|
async def test_after_node_run_node_to_end():
|
|
"""after_node_run can short-circuit a run by converting a continuation node to End."""
|
|
from pydantic_ai.result import FinalResult
|
|
|
|
model_call_count = 0
|
|
|
|
def model_fn(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
|
|
nonlocal model_call_count
|
|
model_call_count += 1
|
|
# Always request a tool call, producing a CallToolsNode (not End)
|
|
return ModelResponse(parts=[ToolCallPart(tool_name='my_tool', args='{}')])
|
|
|
|
@dataclass
|
|
class ShortCircuitAfterModelRequest(AbstractCapability[Any]):
|
|
"""Short-circuit after the first model request node by converting the continuation to End."""
|
|
|
|
async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
|
|
from pydantic_ai import ModelRequestNode
|
|
|
|
# The ModelRequestNode produces a CallToolsNode (not End); convert it to End.
|
|
if isinstance(node, ModelRequestNode) and not isinstance(result, End):
|
|
return End(FinalResult('short-circuited'))
|
|
return result # pyright: ignore[reportUnknownVariableType]
|
|
|
|
agent = Agent(FunctionModel(model_fn), capabilities=[ShortCircuitAfterModelRequest()])
|
|
|
|
@agent.tool_plain
|
|
def my_tool() -> str:
|
|
return 'tool result' # pragma: no cover
|
|
|
|
result = await agent.run('hello')
|
|
assert result.output == 'short-circuited'
|
|
assert model_call_count == 1
|