Files
pydantic-ai/tests/test_run_cancellation.py

2326 lines
87 KiB
Python

"""Tests for the run-cancellation contract.
External cancellation: a step the run awaits — a Temporal activity under
`WAIT_CANCELLATION_COMPLETED`, an `event_stream_handler`, a capability hook — can absorb the
`CancelledError` injected by `task.cancel()` and return normally, which used to let a
cancelled run complete as if it was never cancelled. A level-triggered backstop
(`Task.cancelling()` re-checked at step boundaries) re-asserts the pending cancellation after
the completed step's messages have been recorded.
First-party cancellation: `AgentRun.cancel()` / `RunContext.cancel()` cancel the task
driving the run (reusing the external-cancellation teardown) and surface as `RunCancelled`,
never touching external semantics: an external `CancelledError` is never translated, and wins
when both race.
These are unit-style tests rather than VCR tests because the behavior under test is pure
control flow around injected `asyncio` cancellation, which no recorded provider response can
trigger.
"""
from __future__ import annotations as _annotations
import asyncio
import pickle
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator
from contextlib import contextmanager
from datetime import UTC
from typing import Any, Literal
import anyio
import pytest
from anyio import to_thread
from anyio.from_thread import start_blocking_portal
from inline_snapshot import snapshot
from pydantic_ai import (
Agent,
AgentRunEvents,
AgentRunResultEvent,
CancellationToken,
RunCancelled,
UserError,
capture_run_messages,
)
from pydantic_ai._cancel import RunCancellation
from pydantic_ai._utils import BaseExceptionGroup, get_event_loop
from pydantic_ai.capabilities import AbstractCapability
from pydantic_ai.messages import (
AgentStreamEvent,
FunctionToolResultEvent,
ModelMessage,
ModelMessagesTypeAdapter,
ModelRequest,
ModelResponse,
TextPart,
ToolCallPart,
ToolReturnPart,
UserPromptPart,
)
from pydantic_ai.models.function import AgentInfo, DeltaToolCall, DeltaToolCalls, FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.run import AgentRun, AgentRunResult
from pydantic_ai.tools import RunContext
from pydantic_ai.toolsets import AbstractToolset, FunctionToolset, WrapperToolset
from pydantic_ai.usage import RequestUsage, RunUsage
from .conftest import IsNow, IsStr
READINESS_WAIT_TIMEOUT = 5
def _parallel_sibling_tools_agent(*, raise_first: bool) -> tuple[Agent[None, str], asyncio.Event]:
"""An agent whose first-emitted tool waits until its sibling task has completed."""
fast_finished = asyncio.Event()
first_blocked = asyncio.Event()
def call_tools(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
return ModelResponse(
parts=[
ToolCallPart('first_tool', {}, tool_call_id='call_first'),
ToolCallPart('fast_tool', {}, tool_call_id='call_fast'),
]
)
async def stream_call_tools(_messages: list[ModelMessage], _info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
yield {
0: DeltaToolCall(name='first_tool', json_args='{}', tool_call_id='call_first'),
1: DeltaToolCall(name='fast_tool', json_args='{}', tool_call_id='call_fast'),
}
agent = Agent(FunctionModel(call_tools, stream_function=stream_call_tools))
@agent.tool_plain
async def first_tool() -> str:
await fast_finished.wait()
if raise_first:
raise RuntimeError('first tool failed')
first_blocked.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
@agent.tool_plain
async def fast_tool() -> str:
# Release `first_tool` only once this tool's task is done, so the completed return is
# deterministically sitting in a finished task when the sibling raises or blocks.
task = asyncio.current_task()
assert task is not None
task.add_done_callback(lambda _: fast_finished.set())
return 'fast result'
return agent, first_blocked
def _assert_interrupted_fast_result(message: ModelMessage) -> None:
assert message == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='fast_tool',
content='fast result',
tool_call_id='call_fast',
timestamp=IsNow(tz=UTC),
)
],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
)
)
@pytest.mark.parametrize('mode', ['parallel', 'parallel_ordered_events'])
async def test_sibling_exception_keeps_completed_tool_result(
mode: Literal['parallel', 'parallel_ordered_events'],
) -> None:
"""A completed tool's return survives in the interrupted request when an earlier-emitted sibling raises."""
agent, _ = _parallel_sibling_tools_agent(raise_first=True)
with capture_run_messages() as messages, agent.parallel_tool_call_execution_mode(mode):
with pytest.raises(RuntimeError, match='first tool failed'):
await agent.run('go')
_assert_interrupted_fast_result(messages[-1])
@pytest.mark.parametrize('mode', ['parallel', 'parallel_ordered_events'])
async def test_external_cancellation_keeps_completed_tool_result(
mode: Literal['parallel', 'parallel_ordered_events'],
) -> None:
"""A completed tool's return survives in the interrupted request when the run is cancelled mid-segment."""
agent, first_blocked = _parallel_sibling_tools_agent(raise_first=False)
with capture_run_messages() as messages, agent.parallel_tool_call_execution_mode(mode):
run_task = asyncio.create_task(agent.run('go'))
await asyncio.wait_for(first_blocked.wait(), timeout=READINESS_WAIT_TIMEOUT)
run_task.cancel()
with pytest.raises(asyncio.CancelledError):
await run_task
_assert_interrupted_fast_result(messages[-1])
async def test_parallel_ordered_sibling_exception_does_not_emit_later_result_event() -> None:
"""Keeping the completed return doesn't add a result event: ordered settlement stops at the first exception."""
agent, _ = _parallel_sibling_tools_agent(raise_first=True)
seen_events: list[object] = []
with agent.parallel_tool_call_execution_mode('parallel_ordered_events'):
with pytest.raises(RuntimeError, match='first tool failed'):
async with agent.run_stream_events('go') as events:
async for event in events:
seen_events.append(event)
assert seen_events
assert not any(isinstance(event, FunctionToolResultEvent) for event in seen_events)
def _task_cancelling(task: asyncio.Task[Any]) -> int:
return task.cancelling()
def _task_uncancel(task: asyncio.Task[Any]) -> None:
task.uncancel()
async def test_swallowing_event_stream_handler_run_still_cancels():
"""An `event_stream_handler` that catches `CancelledError` must not absorb the run's cancellation.
The handler is awaited on the run task itself, so its swallow used to let the whole run
complete normally. The backstop re-asserts the pending cancellation at the next step
boundary — after the partial response has been recorded.
"""
in_flight = asyncio.Event()
async def handler(ctx: RunContext, events: AsyncIterable[AgentStreamEvent]) -> None:
try:
async for _event in events: # pragma: no branch
in_flight.set()
await asyncio.Event().wait() # a slow consumer; cancel lands here
except asyncio.CancelledError:
pass # "clean up the UI" — must not swallow the run's cancellation
agent = Agent(TestModel())
with capture_run_messages() as messages:
task = asyncio.create_task(agent.run('hello', event_stream_handler=handler))
await asyncio.wait_for(in_flight.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
# Cancellation never discards completed work: the interrupted turn is recorded.
assert [type(m).__name__ for m in messages] == ['ModelRequest', 'ModelResponse']
async def test_after_run_hook_cannot_convert_external_cancel_to_success():
"""An `after_run` hook that absorbs the cancellation must not let the run finalize as a
success: the backstop fires before the result is stored."""
in_flight = asyncio.Event()
class SwallowInAfterRun(AbstractCapability):
async def after_run(self, ctx: RunContext, *, result: AgentRunResult) -> AgentRunResult:
in_flight.set()
try:
await asyncio.Event().wait() # the in-flight "durable step"
except asyncio.CancelledError:
pass # step completed successfully; cancellation consumed
return result
agent = Agent(TestModel(), capabilities=[SwallowInAfterRun()])
task = asyncio.create_task(agent.run('hello'))
await asyncio.wait_for(in_flight.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
async def test_consumed_cancellation_is_not_a_false_positive():
"""A timeout scope inside a tool consumes its own cancellation via `Task.uncancel()` (as
`asyncio.timeout()` and AnyIO scopes do), so the backstop must not misread it as a pending
external cancellation."""
agent = Agent(TestModel())
@agent.tool_plain
async def slow_lookup() -> str:
with anyio.move_on_after(0.01):
await asyncio.sleep(10)
return 'timed out, moved on'
result = await agent.run('hello')
assert result.output == '{"slow_lookup":"timed out, moved on"}'
# --- First-party cancellation: `AgentRun.cancel()` / `RunContext.cancel()` ---
async def test_cancellation_token_from_sibling_task():
started = asyncio.Event()
async def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
started.set()
await asyncio.Event().wait()
raise AssertionError # pragma: no cover
token = CancellationToken()
task = asyncio.create_task(Agent(FunctionModel(model_function)).run('hello', cancellation_token=token))
await started.wait()
token.cancel()
with pytest.raises(RunCancelled) as exc_info:
await task
assert [type(message).__name__ for message in exc_info.value.all_messages()] == ['ModelRequest']
assert token.cancelled
async def test_pre_cancelled_token_does_not_start_run():
called = False
async def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: # pragma: no cover
nonlocal called
called = True
raise AssertionError
token = CancellationToken()
token.cancel()
with pytest.raises(RunCancelled) as exc_info:
await Agent(FunctionModel(model_function)).run('hello', cancellation_token=token)
assert exc_info.value.all_messages() == []
assert not called
async def test_one_token_cancels_two_runs():
started = 0
both_started = asyncio.Event()
async def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal started
started += 1
if started == 2:
both_started.set()
await asyncio.Event().wait()
raise AssertionError # pragma: no cover
token = CancellationToken()
agent = Agent(FunctionModel(model_function))
tasks = [asyncio.create_task(agent.run(str(index), cancellation_token=token)) for index in range(2)]
await both_started.wait()
token.cancel()
results = await asyncio.gather(*tasks, return_exceptions=True)
assert all(isinstance(result, RunCancelled) for result in results)
async def test_late_token_cancel_does_not_affect_finished_task():
token = CancellationToken()
result = await Agent(TestModel()).run('hello', cancellation_token=token)
assert result.output
token.cancel()
await asyncio.sleep(0)
assert await asyncio.sleep(0, result='unrelated') == 'unrelated'
async def test_token_accepted_by_iter_and_stream_surfaces():
for run_method in ('iter', 'run_stream', 'run_stream_events'):
token = CancellationToken()
token.cancel()
agent = Agent(TestModel())
context = getattr(agent, run_method)('hello', cancellation_token=token)
with pytest.raises(RunCancelled):
async with context as value:
if run_method == 'run_stream_events':
async for _ in value:
pass
async def test_token_and_agent_run_cancel_are_idempotent():
token = CancellationToken()
agent = Agent(TestModel())
with pytest.raises(RunCancelled):
async with agent.iter('hello', cancellation_token=token) as agent_run:
agent_run.cancel()
token.cancel()
token.cancel()
async for _ in agent_run:
pass
async def test_sync_tool_can_cancel_from_worker_thread():
agent = Agent(TestModel(call_tools=['stop']))
@agent.tool
def stop(ctx: RunContext) -> str:
ctx.cancel()
return 'stopped'
with pytest.raises(RunCancelled):
await agent.run('hello')
async def test_run_sync_token_cancel_from_another_thread():
started = threading.Event()
async def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
started.set()
await asyncio.Event().wait()
raise AssertionError # pragma: no cover
token = CancellationToken()
run_thread = asyncio.create_task(
asyncio.to_thread(Agent(FunctionModel(model_function)).run_sync, 'hello', cancellation_token=token)
)
await asyncio.to_thread(started.wait)
token.cancel()
with pytest.raises(RunCancelled):
await run_thread
def test_run_cancelled_result_surface():
"""`RunCancelled` exposes the result accessors while keeping a detached history snapshot."""
messages = [
ModelRequest(parts=[UserPromptPart('previous')]),
ModelResponse(parts=[TextPart('cancelled')]),
]
usage = RunUsage(requests=1)
error = RunCancelled(
'cancelled',
messages=messages,
new_message_index=1,
usage=usage,
metadata={'customer': '123'},
run_id='run-123',
conversation_id='conversation-123',
)
assert error.all_messages() == messages
assert error.all_messages() is not messages
assert error.all_messages_json() == ModelMessagesTypeAdapter.dump_json(messages)
assert error.new_messages() == messages[1:]
assert error.new_messages_json() == ModelMessagesTypeAdapter.dump_json(messages[1:])
assert error.response is messages[-1]
assert error.timestamp == messages[-1].timestamp
assert error.usage is usage
assert error.metadata == {'customer': '123'}
assert error.run_id == 'run-123'
assert error.conversation_id == 'conversation-123'
def test_run_cancelled_defaults_before_model_response():
"""A cancellation before the first response has zero usage and no response-derived timestamp."""
error = RunCancelled('cancelled')
assert error.all_messages() == []
assert error.new_messages() == []
assert error.usage == RunUsage()
assert error.metadata is None
assert error.run_id is None
assert error.conversation_id is None
with pytest.raises(ValueError, match='No response found in the message history'):
_ = error.response
with pytest.raises(ValueError, match='No response found in the message history'):
_ = error.timestamp
def test_run_cancelled_pickle_round_trip():
"""Pickling preserves the detached history, new-message boundary, usage, metadata, and IDs."""
messages = [
ModelRequest(parts=[UserPromptPart('previous')]),
ModelResponse(parts=[TextPart('cancelled')]),
]
error = RunCancelled(
'cancelled',
messages=messages,
new_message_index=1,
usage=RunUsage(requests=1),
metadata={'customer': '123'},
run_id='run-123',
conversation_id='conversation-123',
)
restored = pickle.loads(pickle.dumps(error))
assert restored.all_messages() == messages
assert restored.new_messages() == messages[1:]
assert restored.usage == RunUsage(requests=1)
assert restored.metadata == {'customer': '123'}
assert restored.run_id == 'run-123'
assert restored.conversation_id == 'conversation-123'
def _parallel_tools_agent() -> tuple[Agent, list[list[ModelMessage]]]:
"""An agent whose first response calls a fast tool and a slow self-cancelling tool.
Returns the agent and a list capturing the raw messages each model request receives, so
tests can assert exactly what a resumed run sends to the model.
"""
seen_by_model: list[list[ModelMessage]] = []
def model_func(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_by_model.append(list(messages))
if len(messages) == 1:
return ModelResponse(
parts=[
ToolCallPart(tool_name='fast_tool', args={}, tool_call_id='call_fast'),
ToolCallPart(tool_name='cancelling_tool', args={}, tool_call_id='call_slow'),
]
)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(model_func))
@agent.tool_plain
async def fast_tool() -> str:
return 'fast result'
@agent.tool
async def cancelling_tool(ctx: RunContext) -> str:
await asyncio.sleep(0.05) # let the sibling finish first
ctx.cancel()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'never reached' # pragma: no cover
return agent, seen_by_model
async def test_tool_cancels_run_and_history_is_resumable():
"""`ctx.cancel()` from a tool raises `RunCancelled` from `agent.run()`.
The completed sibling tool's real result is preserved in an interrupted request on
`RunCancelled.all_messages()`. The snapshot survives a `ModelMessagesTypeAdapter` JSON
round-trip, and resuming from the restored copy plus a new prompt sends the model a
provider-valid transcript: the real return, exactly one synthesized `'interrupted'` return
for the cancelled call, and the new prompt.
"""
agent, seen_by_model = _parallel_tools_agent()
with capture_run_messages() as live_messages, pytest.raises(RunCancelled) as exc_info:
await agent.run('go', metadata={'customer': '123'})
error = exc_info.value
messages = error.all_messages()
assert messages is not live_messages
assert messages == live_messages
assert error.new_messages() == messages
assert error.response is messages[1]
assert error.usage.requests == 1
assert error.metadata == {'customer': '123'}
assert error.run_id is not None
assert error.conversation_id is not None
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='go', timestamp=IsNow(tz=UTC))],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[
ToolCallPart(tool_name='fast_tool', args={}, tool_call_id='call_fast'),
ToolCallPart(tool_name='cancelling_tool', args={}, tool_call_id='call_slow'),
],
usage=RequestUsage(input_tokens=51, output_tokens=4),
model_name='function:model_func:',
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='fast_tool',
content='fast result',
tool_call_id='call_fast',
timestamp=IsNow(tz=UTC),
)
],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
restored = ModelMessagesTypeAdapter.validate_json(error.all_messages_json())
assert restored == messages
result = await agent.run('never mind, wrap up', message_history=restored)
assert result.output == 'done'
resumed_request = seen_by_model[-1][-1]
assert resumed_request == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='fast_tool',
content='fast result',
tool_call_id='call_fast',
timestamp=IsNow(tz=UTC),
),
ToolReturnPart(
tool_name='cancelling_tool',
content='The tool call was interrupted before a result was produced.',
tool_call_id='call_slow',
metadata={'pydantic_ai_synthesized_tool_return': True},
timestamp=IsNow(tz=UTC),
outcome='interrupted',
),
UserPromptPart(content='never mind, wrap up', timestamp=IsNow(tz=UTC)),
],
timestamp=IsNow(tz=UTC),
)
)
def _single_tool_agent() -> tuple[Agent, list[list[ModelMessage]], asyncio.Event]:
"""An agent whose first response calls a single tool that never returns.
Returns the agent, a list capturing the raw messages each model request receives, and an
event set once the tool is in flight.
"""
seen_by_model: list[list[ModelMessage]] = []
tool_started = asyncio.Event()
def model_func(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
seen_by_model.append(list(messages))
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart(tool_name='in_flight_tool', args={}, tool_call_id='call_only')])
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(model_func))
@agent.tool_plain
async def in_flight_tool() -> str:
tool_started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'never reached' # pragma: no cover
return agent, seen_by_model, tool_started
async def test_cancel_during_only_tool_call_is_resumable():
"""Cancelling while the response's only tool call is still in flight stays resumable.
No tool produced a result, so the interrupted request that closes out the turn is empty —
but it's still recorded, because it's what tells a resumed run that the response's calls
will never be answered. Without it the history ends in a response with an unanswered tool
call, and a new prompt is refused.
"""
agent, seen_by_model, tool_started = _single_tool_agent()
token = CancellationToken()
task = asyncio.create_task(agent.run('go', cancellation_token=token))
await asyncio.wait_for(tool_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
token.cancel()
with pytest.raises(RunCancelled) as exc_info:
await task
messages = exc_info.value.all_messages()
assert messages == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='go', timestamp=IsNow(tz=UTC))],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='in_flight_tool', args={}, tool_call_id='call_only')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='function:model_func:',
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
result = await agent.run('never mind, wrap up', message_history=messages)
assert result.output == 'done'
assert seen_by_model[-1][-1] == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='in_flight_tool',
content='The tool call was interrupted before a result was produced.',
tool_call_id='call_only',
metadata={'pydantic_ai_synthesized_tool_return': True},
timestamp=IsNow(tz=UTC),
outcome='interrupted',
),
UserPromptPart(content='never mind, wrap up', timestamp=IsNow(tz=UTC)),
],
timestamp=IsNow(tz=UTC),
)
)
async def test_cancel_during_only_tool_call_is_resumable_without_a_new_prompt():
"""The same history also resumes with no new prompt: the synthesized return is sent on its own."""
agent, seen_by_model, tool_started = _single_tool_agent()
token = CancellationToken()
task = asyncio.create_task(agent.run('go', cancellation_token=token))
await asyncio.wait_for(tool_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
token.cancel()
with pytest.raises(RunCancelled) as exc_info:
await task
result = await agent.run(message_history=exc_info.value.all_messages())
assert result.output == 'done'
assert seen_by_model[-1][-1] == snapshot(
ModelRequest(
parts=[
ToolReturnPart(
tool_name='in_flight_tool',
content='The tool call was interrupted before a result was produced.',
tool_call_id='call_only',
metadata={'pydantic_ai_synthesized_tool_return': True},
timestamp=IsNow(tz=UTC),
outcome='interrupted',
)
],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
)
)
async def test_agent_run_cancel_from_another_task():
"""`AgentRun.cancel()` is safe to call from a sibling task (a TUI's Esc handler) and
surfaces as `RunCancelled` from whatever is driving the run."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
runs: list[AgentRun[None, str]] = []
async def drive():
async with agent.iter('go') as agent_run:
runs.append(agent_run)
async for _node in agent_run:
pass
task = asyncio.create_task(drive())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
runs[0].cancel()
with pytest.raises(RunCancelled):
await asyncio.wait_for(task, timeout=READINESS_WAIT_TIMEOUT)
async def test_iter_cancellation_is_typed_only_after_context_exit():
"""The run sees `CancelledError`; the same cancellation becomes `RunCancelled` after teardown."""
agent = Agent(TestModel())
seen_inside = False
with pytest.raises(RunCancelled):
async with agent.iter('go') as agent_run:
agent_run.cancel()
try:
await anext(agent_run)
except asyncio.CancelledError:
seen_inside = True
raise
assert seen_inside
async def test_iter_swallowed_cancellation_is_quiet_abandonment():
"""Leaving `agent.iter()` normally after swallowing its cancellation cleans task state."""
agent = Agent(TestModel())
async with agent.iter('go') as agent_run:
agent_run.cancel()
try:
await anext(agent_run)
except asyncio.CancelledError:
pass
assert agent_run.result is None
task = asyncio.current_task()
assert task is not None
assert _task_cancelling(task) == 0
async def test_cancel_followed_by_other_error_releases_cancellation():
"""A run that ends with a non-cancellation error after `cancel()` was issued must release the
issued cancellation: leaking it would spuriously cancel unrelated later work on the task."""
agent = Agent(TestModel())
with pytest.raises(RuntimeError, match='overtaking error'):
async with agent.iter('go') as agent_run:
agent_run.cancel()
try:
await anext(agent_run)
except asyncio.CancelledError:
raise RuntimeError('overtaking error') from None
task = asyncio.current_task()
assert task is not None
assert _task_cancelling(task) == 0
async def test_iter_reasserts_swallowed_cancellation_before_next_node():
"""A swallowed first-party cancellation stops iteration before another model call."""
model_calls: list[None] = []
def model_function(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
model_calls.append(None)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(model_function))
with pytest.raises(RunCancelled):
async with agent.iter('go') as agent_run:
async for _node in agent_run:
if len(model_calls) == 1:
agent_run.cancel()
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
pass
assert len(model_calls) == 1
async def test_external_cancel_uncancelled_by_caller_completes_run():
"""A caller that catches an external cancellation inside the `async for` body and calls
`Task.uncancel()` — asyncio's sanctioned suppression — gets a completed run, without the
already-completed step re-executing (no duplicate model call, no duplicated history)."""
model_calls: list[None] = []
def model_function(_messages: list[ModelMessage], _info: AgentInfo) -> ModelResponse:
model_calls.append(None)
return ModelResponse(parts=[TextPart('done')])
agent = Agent(FunctionModel(model_function))
task = asyncio.current_task()
assert task is not None
cancelled_once = False
async with agent.iter('go') as agent_run:
async for _node in agent_run:
if len(model_calls) == 1 and not cancelled_once:
cancelled_once = True
task.cancel()
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
_task_uncancel(task)
assert agent_run.result is not None
assert agent_run.result.output == 'done'
assert model_calls == [None]
async def test_run_cancellation_tracks_issuances_per_task():
"""A controller unit test pins the task-rebind window that the public API cannot trigger
deterministically."""
cancellation = RunCancellation()
a_bound = asyncio.Event()
a_cancelled = asyncio.Event()
resolve_a = asyncio.Event()
a_resolved = asyncio.Event()
external_cancel_a = asyncio.Event()
a_state: list[tuple[bool, int]] = []
async def drive_a() -> None:
task = asyncio.current_task()
assert task is not None
cancellation.bind(task)
a_bound.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
a_cancelled.set()
await resolve_a.wait()
a_state.append((cancellation.resolve(), _task_cancelling(task)))
a_resolved.set()
try:
await external_cancel_a.wait()
except asyncio.CancelledError:
a_state.append((cancellation.resolve(), _task_cancelling(task)))
_task_uncancel(task)
task_a = asyncio.create_task(drive_a())
await a_bound.wait()
cancellation.cancel()
cancellation.cancel() # idempotent while a request is already pending
await a_cancelled.wait()
b_cancelled = asyncio.Event()
finish_b = asyncio.Event()
b_state: list[int] = []
async def drive_b() -> None:
task = asyncio.current_task()
assert task is not None
try:
cancellation.bind(task)
await asyncio.Event().wait()
except asyncio.CancelledError:
b_cancelled.set()
await finish_b.wait()
b_state.append(_task_cancelling(task))
task_b = asyncio.create_task(drive_b())
await b_cancelled.wait()
resolve_a.set()
await a_resolved.wait()
assert a_state == [(True, 0)]
cancellation.release_issued()
finish_b.set()
await task_b
assert b_state == [0]
task_a.cancel()
await task_a
assert a_state == [(True, 0), (False, 1)]
async def test_threadsafe_cancel_delivery_after_finish_is_noop():
"""A `cancel()` marshalled from another thread whose queued delivery lands after `finish()`
must not cancel the (former) owner task."""
task = asyncio.current_task()
assert task is not None
baseline = _task_cancelling(task)
controller = RunCancellation()
controller.bind()
thread = threading.Thread(target=controller.cancel)
thread.start()
thread.join()
controller.finish() # the run ends before the queued `_deliver` callback runs
await asyncio.sleep(0) # drain the `call_soon_threadsafe` delivery
assert _task_cancelling(task) == baseline
assert controller.cancel_requested # the request itself was recorded, just never delivered
async def test_release_issued_on_finished_task_is_noop():
"""Releasing issued cancellations for a task that already finished, and releasing an already-empty
controller, are no-ops — a controller unit test because the public API can't trigger it deterministically."""
done_cancellation = RunCancellation()
done_bound = asyncio.Event()
async def finish_with_issued_cancellation() -> None:
task = asyncio.current_task()
assert task is not None
done_cancellation.bind(task)
done_bound.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
pass
done_task = asyncio.create_task(finish_with_issued_cancellation())
await done_bound.wait()
done_cancellation.cancel()
await done_task
done_cancellation.release_issued()
done_cancellation.release_issued() # clearing an already-empty controller is a no-op
async def test_cancel_before_bind_delivers_on_bind():
"""A cancellation requested before any task is bound is delivered as soon as one binds — a controller
unit test because the public API can't trigger it deterministically."""
unbound_cancellation = RunCancellation()
unbound_cancellation.cancel()
rebound_cancelled = asyncio.Event()
async def bind_after_request() -> None:
task = asyncio.current_task()
assert task is not None
try:
unbound_cancellation.bind(task)
await asyncio.Event().wait()
except asyncio.CancelledError:
rebound_cancelled.set()
assert unbound_cancellation.resolve()
rebound_task = asyncio.create_task(bind_after_request())
await rebound_cancelled.wait()
await rebound_task
async def test_swallowed_and_uncancelled_request_redelivers_on_rebind():
"""A request whose cancellation was swallowed and `uncancel()`ed is redelivered when the task rebinds — a
controller unit test because the public API can't trigger it deterministically."""
uncancelled_cancellation = RunCancellation()
uncancelled_bound = asyncio.Event()
keep_uncancelled_task_live = asyncio.Event()
uncancelled_redelivered = asyncio.Event()
async def swallow_and_uncancel() -> None:
task = asyncio.current_task()
assert task is not None
uncancelled_cancellation.bind(task)
uncancelled_bound.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
_task_uncancel(task)
try:
await keep_uncancelled_task_live.wait()
except asyncio.CancelledError:
uncancelled_redelivered.set()
assert uncancelled_cancellation.resolve()
uncancelled_task = asyncio.create_task(swallow_and_uncancel())
await uncancelled_bound.wait()
uncancelled_cancellation.cancel()
await asyncio.sleep(0)
uncancelled_cancellation.bind(uncancelled_task)
await uncancelled_redelivered.wait()
keep_uncancelled_task_live.set()
await uncancelled_task
async def test_event_stream_handler_cancels_run():
"""`ctx.cancel()` from an `event_stream_handler` (the TUI Esc gesture) cancels the run;
the partial response streamed so far is preserved by `RunCancelled.all_messages()`."""
async def handler(ctx: RunContext, events: AsyncIterable[AgentStreamEvent]) -> None:
async for _event in events: # pragma: no branch
ctx.cancel()
agent = Agent(TestModel(custom_output_text='a few words of output'))
with pytest.raises(RunCancelled) as exc_info:
await agent.run('go', event_stream_handler=handler)
response = exc_info.value.all_messages()[-1]
assert isinstance(response, ModelResponse)
assert response.state == 'interrupted'
async def test_external_cancellation_is_never_translated():
"""Externally cancelling the task running the agent keeps raising `CancelledError`, not
`RunCancelled` — it's an infrastructure signal, not an application outcome."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
task = asyncio.create_task(agent.run('go'))
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
async def test_task_cancel_of_run_carries_run_cancelled():
"""The attached `CancelledError` instance carries run state across `await task`."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
task = asyncio.create_task(agent.run('go'))
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError) as exc_info:
await task
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert cancelled.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='go', timestamp=IsNow(tz=UTC))],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='slow_tool', args={}, tool_call_id='pyd_ai_tool_call_id__slow_tool')],
usage=RequestUsage(input_tokens=51, output_tokens=2),
model_name='test',
timestamp=IsNow(tz=UTC),
provider_name='test',
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
assert cancelled.usage.requests == 1
assert cancelled.run_id is not None
assert task.cancelled()
async def test_direct_await_cancellation_carries_run_cancelled_on_all_versions():
started = asyncio.Event()
agent = Agent(TestModel())
recorded: list[RunCancelled | None] = []
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
async def run_and_record() -> None:
try:
await agent.run('go')
except asyncio.CancelledError as exc:
recorded.append(RunCancelled.from_cancellation(exc))
raise
task = asyncio.create_task(run_and_record())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
(cancelled,) = recorded
assert cancelled is not None
assert [type(message) for message in cancelled.all_messages()] == [ModelRequest, ModelResponse, ModelRequest]
assert cancelled.usage.requests == 1
assert cancelled.run_id is not None
@contextmanager
def _interrupt_sync_run_once(started: asyncio.Event) -> Generator[None]:
"""Simulate Ctrl-C reaching the caller of a sync method once `started` is set.
The `loop.run_until_complete()` call that is driving the loop when `started` is set raises
`KeyboardInterrupt`, as Python's default `SIGINT` handler would; earlier calls (e.g. entering a
sync stream) complete normally, and later calls (the interrupt cleanup) run normally. Patching
the loop keeps this in-process: a real signal can't be timed reliably from a test.
"""
loop = get_event_loop()
real_run_until_complete = loop.run_until_complete
started_waiter = loop.create_task(started.wait())
def interrupt_once(future: Any) -> Any:
if started_waiter.done():
return real_run_until_complete(future)
future = asyncio.ensure_future(future, loop=loop)
real_run_until_complete(
asyncio.wait([future, started_waiter], timeout=READINESS_WAIT_TIMEOUT, return_when=asyncio.FIRST_COMPLETED)
)
if started_waiter.done():
raise KeyboardInterrupt
assert future.done(), 'the run neither finished nor set `started` in time'
return future.result()
try:
with pytest.MonkeyPatch.context() as mp:
mp.setattr(loop, 'run_until_complete', interrupt_once)
yield
finally:
started_waiter.cancel()
def _fast_then_slow_tool_agent(started: asyncio.Event) -> Agent[None, str]:
"""An agent that completes `fast_tool` and then suspends in `slow_tool`, setting `started`."""
def call_tools(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
tool_returns = sum(isinstance(part, ToolReturnPart) for message in messages for part in message.parts)
return ModelResponse(parts=[ToolCallPart(['fast_tool', 'slow_tool'][tool_returns])])
async def stream_tool_calls(messages: list[ModelMessage], info: AgentInfo) -> AsyncIterator[DeltaToolCalls]:
(part,) = call_tools(messages, info).parts
assert isinstance(part, ToolCallPart)
yield {0: DeltaToolCall(name=part.tool_name)}
agent = Agent(FunctionModel(call_tools, stream_function=stream_tool_calls))
@agent.tool_plain
def fast_tool() -> str:
return 'fast done'
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.Event().wait()
raise AssertionError # pragma: no cover
return agent
def _context_chain(exc: BaseException) -> list[BaseException]:
"""The exceptions reached by following `__context__` from `exc`, stopping at a repeat."""
chain: list[BaseException] = []
node = exc.__context__
while node is not None and all(node is not seen for seen in chain):
chain.append(node)
node = node.__context__
return chain
def _tool_returns(cancelled: RunCancelled) -> list[Any]:
return [
part.content
for message in cancelled.all_messages()
for part in message.parts
if isinstance(part, ToolReturnPart)
]
def test_run_sync_keyboard_interrupt_carries_run_state():
"""Ctrl-C during `run_sync()` carries the run state, like `asyncio.run(agent.run())` does.
The interrupt cancels the run's task, whose `CancelledError` carries the state; it is chained
to the re-raised `KeyboardInterrupt` as a suppressed `__context__`, so the traceback is unchanged.
"""
started = asyncio.Event()
agent = _fast_then_slow_tool_agent(started)
with _interrupt_sync_run_once(started), pytest.raises(KeyboardInterrupt) as exc_info:
agent.run_sync('go')
assert exc_info.value.__suppress_context__
assert all(node is not exc_info.value for node in _context_chain(exc_info.value)) # no cycle back to it
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert _tool_returns(cancelled) == ['fast done']
def test_run_sync_keyboard_interrupt_preserves_nested_cleanup_errors():
"""Ctrl-C keeps nested event-loop cleanup errors without cycling back to the interrupt."""
started = asyncio.Event()
cleanup_error = ValueError('event loop cleanup failed')
final_error = RuntimeError('cleanup recovery failed')
agent = _fast_then_slow_tool_agent(started)
loop = get_event_loop()
tasks_before = asyncio.all_tasks(loop)
with _interrupt_sync_run_once(started), pytest.MonkeyPatch.context() as mp:
run_until_complete = loop.run_until_complete
def failing_cleanup(future: Any) -> Any:
try:
return run_until_complete(future)
except asyncio.CancelledError:
try:
raise cleanup_error
except ValueError:
raise final_error
mp.setattr(loop, 'run_until_complete', failing_cleanup)
with pytest.raises(KeyboardInterrupt) as exc_info:
agent.run_sync('go')
chain = _context_chain(exc_info.value)
assert chain[:2] == [final_error, cleanup_error]
assert len(chain) == 3
assert isinstance(chain[2], asyncio.CancelledError)
assert chain[2].__context__ is None
assert exc_info.value.__suppress_context__
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert _tool_returns(cancelled) == ['fast done']
assert asyncio.all_tasks(loop) == tasks_before
def test_run_stream_sync_keyboard_interrupt_before_final_result_carries_run_state():
"""Ctrl-C while `run_stream_sync()` runs tools before finding the final result carries the run state."""
started = asyncio.Event()
agent = _fast_then_slow_tool_agent(started)
with _interrupt_sync_run_once(started), pytest.raises(KeyboardInterrupt) as exc_info:
with agent.run_stream_sync('go') as result:
result.get_output() # pragma: no cover
assert exc_info.value.__suppress_context__
assert all(node is not exc_info.value for node in _context_chain(exc_info.value)) # no cycle back to it
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert _tool_returns(cancelled) == ['fast done']
def test_run_stream_sync_keyboard_interrupt_while_streaming_output_carries_run_state():
"""Ctrl-C while `run_stream_sync()` streams the final output carries the run state.
The interrupt tears the stream down by passing the `KeyboardInterrupt` itself into the run's
`__aexit__`, so the run attaches its state to that exception rather than to a `CancelledError`.
"""
started = asyncio.Event()
async def stream_text_after_tool(
messages: list[ModelMessage], info: AgentInfo
) -> AsyncIterator[str | DeltaToolCalls]:
if not any(isinstance(part, ToolReturnPart) for message in messages for part in message.parts):
yield {0: DeltaToolCall(name='fast_tool')}
return
yield 'partial '
started.set()
await asyncio.Event().wait()
raise AssertionError # pragma: no cover
agent = Agent(FunctionModel(stream_function=stream_text_after_tool))
@agent.tool_plain
def fast_tool() -> str:
return 'fast done'
with _interrupt_sync_run_once(started), pytest.raises(KeyboardInterrupt) as exc_info:
with agent.run_stream_sync('go') as result:
result.get_output()
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert cancelled.all_messages() == snapshot(
[
ModelRequest(
parts=[UserPromptPart(content='go', timestamp=IsNow(tz=UTC))],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[ToolCallPart(tool_name='fast_tool', tool_call_id=IsStr())],
usage=RequestUsage(input_tokens=50),
model_name='function::stream_text_after_tool',
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelRequest(
parts=[
ToolReturnPart(
tool_name='fast_tool',
content='fast done',
tool_call_id=IsStr(),
timestamp=IsNow(tz=UTC),
)
],
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
),
ModelResponse(
parts=[TextPart(content='partial ')],
usage=RequestUsage(input_tokens=50, output_tokens=1),
model_name='function::stream_text_after_tool',
timestamp=IsNow(tz=UTC),
run_id=IsStr(),
conversation_id=IsStr(),
state='interrupted',
),
]
)
async def test_from_cancellation_through_asyncio_timeout():
started = asyncio.Event()
agent = Agent(TestModel())
timeout_scope: list[asyncio.Timeout] = []
@agent.tool_plain
async def slow_tool() -> str:
started.set()
# Expire the enclosing timeout only now, so the model response is deterministically
# recorded before the cancellation lands (a fixed small timeout raced run progress in CI).
timeout_scope[0].reschedule(asyncio.get_running_loop().time())
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
with pytest.raises(TimeoutError) as exc_info:
async with asyncio.timeout(READINESS_WAIT_TIMEOUT) as scope:
timeout_scope.append(scope)
await agent.run('go')
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert [type(message) for message in cancelled.all_messages()] == [ModelRequest, ModelResponse, ModelRequest]
assert started.is_set()
async def test_first_party_cancel_inside_asyncio_timeout_leaves_scope_intact():
"""A first-party cancellation consumes only its own cancellation: an enclosing
`asyncio.timeout()` neither trips into `TimeoutError` nor inherits a stray
`Task.cancelling()` count."""
agent = Agent(TestModel())
@agent.tool
async def cancelling_tool(ctx: RunContext) -> str:
ctx.cancel()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'never reached' # pragma: no cover
task = asyncio.current_task()
assert task is not None
# Baseline-relative: the shared anyio runner task can carry a stray count from an earlier
# test hitting CPython 3.11/3.12's `TaskGroup.__aexit__` uncancel leak (fixed in 3.13).
baseline = _task_cancelling(task)
with pytest.raises(RunCancelled):
async with asyncio.timeout(READINESS_WAIT_TIMEOUT):
await agent.run('go')
assert _task_cancelling(task) == baseline
async def test_first_party_cancel_inside_task_group_is_application_error():
"""Inside a `TaskGroup`, a first-party cancellation surfaces as an ordinary application error
(`RunCancelled` inside the group's `ExceptionGroup`), not as a cleanly-cancelled child.
The no-stray-count half of the isolation contract is pinned by the `asyncio.timeout()`
sibling test: on CPython 3.11/3.12 `TaskGroup.__aexit__` itself leaks its own uncancel when
a child errors while the parent waits in `__aexit__` (fixed in 3.13), so the host task's
count cannot be asserted here — the leak is drained below instead so it can't poison later
tests on the shared runner task."""
agent = Agent(TestModel())
@agent.tool
async def cancelling_tool(ctx: RunContext) -> str:
ctx.cancel()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'never reached' # pragma: no cover
with pytest.raises(BaseExceptionGroup) as exc_info:
async with asyncio.TaskGroup() as tg:
tg.create_task(agent.run('go'))
assert [type(exc) for exc in exc_info.value.exceptions] == [RunCancelled]
task = asyncio.current_task()
assert task is not None
while _task_cancelling(task) > 0: # pragma: lax no cover
_task_uncancel(task)
def test_from_cancellation_identity_and_none():
cancelled = RunCancelled('x')
caused = ValueError('wrapper')
caused.__cause__ = cancelled
assert RunCancelled.from_cancellation(cancelled) is cancelled
assert RunCancelled.from_cancellation(caused) is cancelled
assert RunCancelled.from_cancellation(asyncio.CancelledError()) is None
assert RunCancelled.from_cancellation(ValueError()) is None
def test_from_cancellation_cycle_safe():
first = ValueError('first')
second = RuntimeError('second')
first.__context__ = second
second.__context__ = first
assert RunCancelled.from_cancellation(first) is None
async def test_iter_external_cancel_carries_run_cancelled():
"""As `test_task_cancel_of_run_carries_run_cancelled`, on the `agent.iter()` driving path."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
async def drive() -> None:
async with agent.iter('go') as agent_run:
async for _node in agent_run:
pass
task = asyncio.create_task(drive())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError) as exc_info:
await task
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert [type(message) for message in cancelled.all_messages()] == [ModelRequest, ModelResponse, ModelRequest]
assert cancelled.usage.requests == 1
async def test_external_cancellation_wins_race_with_first_party_cancel():
"""When `cancel()` and an external `task.cancel()` race, the external cancellation wins and
propagates as `CancelledError`."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
runs: list[AgentRun[None, str]] = []
async def drive():
async with agent.iter('go') as agent_run:
runs.append(agent_run)
async for _node in agent_run:
pass
task = asyncio.create_task(drive())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
runs[0].cancel()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
async def test_external_cancellation_wins_when_it_arrives_first():
"""An external cancellation delivered before `cancel()` still wins the race."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
runs: list[AgentRun[None, str]] = []
async def drive():
async with agent.iter('go') as agent_run:
runs.append(agent_run)
async for _node in agent_run:
pass
task = asyncio.create_task(drive())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
runs[0].cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
assert _task_cancelling(task) == 1
async def test_cancel_under_run_stream_events():
"""With `run_stream_events()` the run is driven by a background task; `ctx.cancel()`
from a tool must cancel *that* task and surface `RunCancelled` to the event consumer."""
agent = Agent(TestModel())
@agent.tool
async def cancelling_tool(ctx: RunContext) -> str:
ctx.cancel()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'never reached' # pragma: no cover
events: list[str] = []
with pytest.raises(RunCancelled):
async with agent.run_stream_events('go') as stream:
async for event in stream:
events.append(type(event).__name__)
# events streamed before the cancellation are delivered
assert events == snapshot(['PartStartEvent', 'PartEndEvent', 'FunctionToolCallEvent'])
async def test_run_stream_events_cancel_mid_iteration():
"""The public event handle cancels its run while preserving events and live history."""
agent = Agent(TestModel(custom_output_text='several words of output'))
received: list[AgentStreamEvent | AgentRunResultEvent[str]] = []
async with agent.run_stream_events('go') as events:
with pytest.raises(RunCancelled):
async for event in events:
received.append(event)
events.cancel()
assert received
assert events.all_messages()
assert events.result is None
async def test_run_stream_events_cancel_from_sibling_task():
"""A sibling task can cancel while the consumer is blocked waiting for its next event."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
async with agent.run_stream_events('go') as events:
consumer = asyncio.create_task(_consume_events(events))
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
events.cancel()
with pytest.raises(RunCancelled):
await asyncio.wait_for(consumer, timeout=READINESS_WAIT_TIMEOUT)
async def test_run_stream_events_external_cancel_of_consumer():
"""Externally cancelling the consumer task keeps standard `CancelledError` semantics, but the
run state rides along for `from_cancellation()` and the handle stays accessible post-cancel."""
started = asyncio.Event()
agent = Agent(TestModel())
holder: list[AgentRunEvents[str]] = []
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
async def consume() -> None:
async with agent.run_stream_events('go') as events:
holder.append(events)
await _consume_events(events)
task = asyncio.create_task(consume())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError) as exc_info:
await task
assert task.cancelled()
cancelled = RunCancelled.from_cancellation(exc_info.value)
assert cancelled is not None
assert [type(message) for message in cancelled.all_messages()] == [ModelRequest, ModelResponse, ModelRequest]
assert cancelled.usage.requests == 1
# The handle itself also remains usable after teardown.
(events,) = holder
assert [type(message) for message in events.all_messages()] == [ModelRequest, ModelResponse, ModelRequest]
assert events.result is None
async def test_run_stream_events_external_cancel_caught_in_task():
"""The consumer's own `except CancelledError` sees the attached state on all Python versions:
the state is attached by the context manager's exit in the consumer's task, so no `await task`
boundary is crossed before the catch."""
started = asyncio.Event()
agent = Agent(TestModel())
recorded: list[RunCancelled | None] = []
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
async def consume() -> None:
try:
async with agent.run_stream_events('go') as events:
await _consume_events(events)
except asyncio.CancelledError as exc:
recorded.append(RunCancelled.from_cancellation(exc))
raise
task = asyncio.create_task(consume())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
(cancelled,) = recorded
assert cancelled is not None
assert [type(message) for message in cancelled.all_messages()] == [ModelRequest, ModelResponse, ModelRequest]
async def test_run_stream_events_external_cancel_before_iteration_attaches_nothing():
"""Cancelling a consumer that entered the context manager but never iterated has no run state
to attach: the exception propagates bare."""
agent = Agent(TestModel())
recorded: list[RunCancelled | None] = []
async def consume() -> None:
try:
async with agent.run_stream_events('go'):
await asyncio.Event().wait()
except asyncio.CancelledError as exc:
recorded.append(RunCancelled.from_cancellation(exc))
raise
task = asyncio.create_task(consume())
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert recorded == [None]
async def test_concurrent_run_stream_events_handles_do_not_cross_consume():
"""Concurrent lazy runs created in the same context retain their own bindings."""
started = {'first': asyncio.Event(), 'second': asyncio.Event()}
finish_second = asyncio.Event()
agents = {'first': Agent(TestModel()), 'second': Agent(TestModel())}
@agents['first'].tool_plain
async def block_first() -> str:
started['first'].set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
@agents['second'].tool_plain
async def block_second() -> str:
started['second'].set()
await finish_second.wait()
return 'second complete'
handles: dict[str, AgentRunEvents[str]] = {}
async def consume(name: str) -> AgentRunResultEvent[str]:
async with agents[name].run_stream_events(name) as events:
handles[name] = events
result_event: AgentRunResultEvent[str] | None = None
async for event in events:
if isinstance(event, AgentRunResultEvent):
result_event = event
assert result_event is not None
return result_event
first_consumer = asyncio.create_task(consume('first'))
second_consumer = asyncio.create_task(consume('second'))
await asyncio.wait_for(
asyncio.gather(started['first'].wait(), started['second'].wait()), timeout=READINESS_WAIT_TIMEOUT
)
handles['first'].cancel()
finish_second.set()
with pytest.raises(RunCancelled):
await asyncio.wait_for(first_consumer, timeout=READINESS_WAIT_TIMEOUT)
second_result_event = await asyncio.wait_for(second_consumer, timeout=READINESS_WAIT_TIMEOUT)
second_events = handles['second']
assert second_result_event.result.output == '{"block_second":"second complete"}'
assert second_events.result is second_result_event.result
assert any(
isinstance(part, ToolReturnPart) and part.content == 'second complete'
for message in second_events.all_messages()
for part in message.parts
)
async def test_run_stream_events_cancel_from_worker_thread():
"""`cancel()` is thread-safe: a direct call from a worker thread cancels the run."""
started = asyncio.Event()
agent = Agent(TestModel())
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
async with agent.run_stream_events('go') as events:
consumer = asyncio.create_task(_consume_events(events))
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
# Call `cancel()` directly from the thread — the controller marshals onto the run's loop.
worker = threading.Thread(target=events.cancel)
worker.start()
await to_thread.run_sync(worker.join)
with pytest.raises(RunCancelled) as exc_info:
await asyncio.wait_for(consumer, timeout=READINESS_WAIT_TIMEOUT)
assert exc_info.value.all_messages()
async def _consume_events(events: AsyncIterable[AgentStreamEvent | AgentRunResultEvent[Any]]) -> None:
async for _event in events:
pass
async def test_run_stream_events_cancel_before_iteration():
"""A pre-start cancellation prevents the lazy run from starting."""
model_calls = 0
def model_func(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse:
nonlocal model_calls
model_calls += 1 # pragma: no cover - the pre-start cancel must prevent any model call
return ModelResponse(parts=[TextPart('done')]) # pragma: no cover
agent = Agent(FunctionModel(model_func))
received: list[AgentStreamEvent | AgentRunResultEvent[str]] = []
async with agent.run_stream_events('go') as events:
with pytest.raises(RunCancelled) as exc_info:
events.cancel()
async for event in events:
received.append(event) # pragma: no cover - no events may be delivered
assert received == []
assert model_calls == 0
assert exc_info.value.all_messages() == []
assert events.result is None
with pytest.raises(UserError, match='run has not started; iterate the events first'):
events.all_messages()
async def test_run_stream_events_cancel_without_iteration():
"""Cancelling and closing an unstarted handle remains quiet and does not create run state."""
agent = Agent(TestModel())
async with agent.run_stream_events('go') as events:
events.cancel()
assert events.result is None
with pytest.raises(UserError, match='run has not started; iterate the events first'):
events.all_messages()
async def test_run_stream_events_state_before_and_after_completion():
"""State access rejects an unstarted run and exposes the successful final result."""
agent = Agent(TestModel())
result_event: AgentRunResultEvent[str] | None = None
async with agent.run_stream_events('go') as events:
with pytest.raises(UserError, match='run has not started; iterate the events first'):
events.all_messages()
with pytest.raises(UserError, match='run has not started; iterate the events first'):
_ = events.usage
async for event in events:
if isinstance(event, AgentRunResultEvent):
result_event = event
assert result_event is not None
assert events.all_messages()
assert events.new_messages()
assert events.usage.requests == 1
assert events.result is not None
assert events.result.output == result_event.result.output
async def test_run_stream_events_cancel_after_completion_is_noop():
"""Cancelling a completed event handle cannot disturb later work in the consumer task."""
agent = Agent(TestModel())
async with agent.run_stream_events('go') as events:
async for _event in events:
pass
result = events.result
events.cancel()
await asyncio.sleep(0)
assert events.result is result
async def test_run_stream_events_early_break_has_no_result():
"""Leaving after an early break quietly tears down the background run without a result."""
agent = Agent(TestModel())
async with agent.run_stream_events('go') as events:
await anext(events)
assert events.result is None
async def test_run_stream_events_binding_does_not_leak_to_nested_run():
"""A plain nested run completes independently before cancellation targets the outer run."""
inner_completed = asyncio.Event()
outer_blocked = asyncio.Event()
inner_agent = Agent(TestModel(custom_output_text='inner complete'))
outer_agent = Agent(TestModel())
@outer_agent.tool_plain
async def nested_run() -> str:
result = await inner_agent.run('inner')
inner_completed.set()
outer_blocked.set()
await asyncio.Event().wait()
return result.output # pragma: no cover
async with outer_agent.run_stream_events('outer') as events:
consumer = asyncio.create_task(_consume_events(events))
await asyncio.wait_for(outer_blocked.wait(), timeout=READINESS_WAIT_TIMEOUT)
events.cancel()
with pytest.raises(RunCancelled):
await asyncio.wait_for(consumer, timeout=READINESS_WAIT_TIMEOUT)
assert inner_completed.is_set()
async def test_run_stream_events_binding_across_blocking_portal():
"""Bindings reach a portal loop, including cancellation marshalled from the outer thread."""
def run_portal_cases() -> None:
with start_blocking_portal() as portal:
completed_agent = Agent(TestModel(custom_output_text='portal complete'))
async def consume_completed() -> AgentRunResultEvent[str]:
async with completed_agent.run_stream_events('go') as events:
result_event: AgentRunResultEvent[str] | None = None
async for event in events:
if isinstance(event, AgentRunResultEvent):
result_event = event
assert events.result is not None
assert events.all_messages()
assert result_event is not None
return result_event
result_event = portal.call(consume_completed)
assert result_event.result.output == 'portal complete'
started = threading.Event()
handles: list[AgentRunEvents[str]] = []
cancelled_agent = Agent(TestModel())
@cancelled_agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
async def consume_cancelled() -> None:
async with cancelled_agent.run_stream_events('go') as events:
handles.append(events)
await _consume_events(events)
future = portal.start_task_soon(consume_cancelled)
assert started.wait(timeout=READINESS_WAIT_TIMEOUT)
portal.call(handles[0].cancel)
with pytest.raises(RunCancelled) as exc_info:
future.result(timeout=READINESS_WAIT_TIMEOUT)
assert exc_info.value.all_messages()
await to_thread.run_sync(run_portal_cases)
async def test_nested_run_stream_events_binding_isolated_under_outer_cancellation():
"""Cancelling an outer handle remains external cancellation to a nested handle."""
inner_started = asyncio.Event()
inner_outcome: list[str] = []
inner_agent = Agent(TestModel())
outer_agent = Agent(TestModel())
@inner_agent.tool_plain
async def inner_slow_tool() -> str:
inner_started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
@outer_agent.tool_plain
async def nested_run() -> str:
try:
async with inner_agent.run_stream_events('inner') as inner_events:
await _consume_events(inner_events)
except asyncio.CancelledError:
inner_outcome.append('tool cancelled')
raise
except RunCancelled: # pragma: no cover - the inner handle must not claim the outer cancel
inner_outcome.append('inner run cancelled')
raise
# Never executed (the inner run blocks until cancelled), but coverage's exception-table
# attribution counts this exit arc as covered, so it must not carry a `no cover` pragma.
return 'never reached'
async with outer_agent.run_stream_events('outer') as outer_events:
consumer = asyncio.create_task(_consume_events(outer_events))
await asyncio.wait_for(inner_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
outer_events.cancel()
with pytest.raises(RunCancelled) as exc_info:
await asyncio.wait_for(consumer, timeout=READINESS_WAIT_TIMEOUT)
assert inner_outcome == ['tool cancelled']
assert exc_info.value.all_messages()
async def test_nested_run_in_for_run_hook_does_not_steal_binding():
"""A nested agent run started from a capability `for_run()` hook must not consume the
outer `AgentRunEvents` handle's binding: the binding is taken before any lifecycle hook runs."""
inner_agent = Agent(TestModel(custom_output_text='inner done'))
outer_started = asyncio.Event()
inner_outputs: list[str] = []
class RunsAgentInForRun(AbstractCapability):
async def for_run(self, ctx: RunContext) -> RunsAgentInForRun:
result = await inner_agent.run('inner')
inner_outputs.append(result.output)
return self
outer_agent = Agent(TestModel(), capabilities=[RunsAgentInForRun()])
@outer_agent.tool_plain
async def outer_slow_tool() -> str:
outer_started.set()
await asyncio.Event().wait()
return 'never reached' # pragma: no cover
async with outer_agent.run_stream_events('outer') as events:
consumer = asyncio.create_task(_consume_events(events))
await asyncio.wait_for(outer_started.wait(), timeout=READINESS_WAIT_TIMEOUT)
events.cancel()
with pytest.raises(RunCancelled) as exc_info:
await asyncio.wait_for(consumer, timeout=READINESS_WAIT_TIMEOUT)
assert inner_outputs == ['inner done']
assert exc_info.value.all_messages()
async def test_capability_for_run_cancel_ends_run_before_model_request():
"""`RunContext.cancel()` from a capability `for_run()` hook records the request — the run's
controller exists before any setup hook runs — and the run ends with `RunCancelled` before
the first model request. Recording is not interruption: the hook itself and the remaining
setup hooks (here the toolset's `for_run()`) still run to completion."""
hooks_ran: list[str] = []
class CancelsInForRun(AbstractCapability):
async def for_run(self, ctx: RunContext) -> CancelsInForRun:
ctx.cancel()
hooks_ran.append('capability')
return self
class RecordsForRun(WrapperToolset[Any]):
async def for_run(self, ctx: RunContext[Any]) -> AbstractToolset[Any]:
hooks_ran.append('toolset')
return await super().for_run(ctx)
async def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: # pragma: no cover
raise AssertionError('the model must not be called after a setup-phase cancellation')
agent = Agent(
FunctionModel(model_function),
capabilities=[CancelsInForRun()],
toolsets=[RecordsForRun(FunctionToolset())],
)
with pytest.raises(RunCancelled) as exc_info:
await agent.run('hello')
assert exc_info.value.all_messages() == [] # cancelled before any model request
assert hooks_ran == ['capability', 'toolset']
async def test_toolset_for_run_cancel_ends_run_before_model_request():
"""`RunContext.cancel()` from a toolset `for_run()` hook is likewise recorded, not a `UserError`."""
class CancelsInForRun(WrapperToolset[Any]):
async def for_run(self, ctx: RunContext[Any]) -> AbstractToolset[Any]:
ctx.cancel()
return await super().for_run(ctx)
async def model_function(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: # pragma: no cover
raise AssertionError('the model must not be called after a setup-phase cancellation')
agent = Agent(FunctionModel(model_function), toolsets=[CancelsInForRun(FunctionToolset())])
with pytest.raises(RunCancelled) as exc_info:
await agent.run('hello')
assert exc_info.value.all_messages() == []
async def test_cancel_during_blocked_before_run_is_delivered():
"""`events.cancel()` while a `before_run` hook is blocked must interrupt it promptly —
the controller is bound before `wrap_run`/`before_run` start, and the translation funnel
covers all of setup."""
hook_blocked = asyncio.Event()
class BlockingBeforeRun(AbstractCapability):
async def before_run(self, ctx: RunContext) -> None:
hook_blocked.set()
await asyncio.Event().wait() # blocks until cancelled
agent = Agent(TestModel(), capabilities=[BlockingBeforeRun()])
async with agent.run_stream_events('go') as events:
consumer = asyncio.create_task(_consume_events(events))
await asyncio.wait_for(hook_blocked.wait(), timeout=READINESS_WAIT_TIMEOUT)
events.cancel()
with pytest.raises(RunCancelled) as exc_info:
await asyncio.wait_for(consumer, timeout=READINESS_WAIT_TIMEOUT)
assert exc_info.value.all_messages() == [] # cancelled before any model request
async def test_first_party_cancel_swallowed_by_after_run_is_typed():
"""A first-party cancellation absorbed by `after_run` is typed at the outer funnel."""
class CancelInAfterRun(AbstractCapability):
async def after_run(self, ctx: RunContext, *, result: AgentRunResult) -> AgentRunResult:
ctx.cancel()
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
pass
return result
agent = Agent(TestModel(), capabilities=[CancelInAfterRun()])
with pytest.raises(RunCancelled):
await agent.run('go')
async def test_first_party_cancel_uncancelled_by_after_run_is_typed():
"""A first-party request remains terminal after a hook clears the owning task's cancellation count."""
task = asyncio.current_task()
assert task is not None
class UncancelInAfterRun(AbstractCapability):
async def after_run(self, ctx: RunContext, *, result: AgentRunResult) -> AgentRunResult:
ctx.cancel()
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
task.uncancel()
return result
agent = Agent(TestModel(), capabilities=[UncancelInAfterRun()])
with pytest.raises(RunCancelled):
await agent.run('go')
async def test_uncancelled_request_does_not_leak_after_hook_error():
"""A hook error after clearing cancellation leaves the caller task's counter unchanged."""
task = asyncio.current_task()
assert task is not None
baseline = task.cancelling()
class FailAfterUncancel(AbstractCapability):
async def after_run(self, ctx: RunContext, *, result: AgentRunResult) -> AgentRunResult:
ctx.cancel()
try:
await asyncio.sleep(0)
except asyncio.CancelledError:
task.uncancel()
raise ValueError('hook failed')
agent = Agent(TestModel(), capabilities=[FailAfterUncancel()])
with pytest.raises(ValueError, match='hook failed'):
await agent.run('go')
assert task.cancelling() == baseline
@pytest.mark.parametrize('first_party', [True, False])
async def test_run_capabilities_cannot_recover_cancellation(first_party: bool):
"""`on_run_error` and `wrap_run` both observe cancellation, but neither can recover it."""
started = asyncio.Event()
observed: list[str] = []
class RecoverCancellation(AbstractCapability):
async def wrap_run(self, ctx: RunContext, *, handler: Any) -> AgentRunResult:
try:
return await handler()
except asyncio.CancelledError:
observed.append('wrap_run')
return AgentRunResult(output='recovered')
async def on_run_error(self, ctx: RunContext, *, error: BaseException) -> AgentRunResult:
assert isinstance(error, asyncio.CancelledError)
observed.append('on_run_error')
return AgentRunResult(output='recovered')
agent = Agent(TestModel(), capabilities=[RecoverCancellation()])
@agent.tool_plain
async def slow_tool() -> str:
started.set()
await asyncio.sleep(READINESS_WAIT_TIMEOUT)
return 'slow' # pragma: no cover
runs: list[AgentRun[None, str]] = []
async def drive() -> AgentRunResult:
async with agent.iter('go') as agent_run:
runs.append(agent_run)
async for _node in agent_run:
pass
assert agent_run.result is not None # pragma: lax no cover
return agent_run.result # pragma: no cover
task = asyncio.create_task(drive())
await asyncio.wait_for(started.wait(), timeout=READINESS_WAIT_TIMEOUT)
if first_party:
runs[0].cancel()
expected_exception = RunCancelled
else:
task.cancel()
expected_exception = asyncio.CancelledError
with pytest.raises(expected_exception):
await asyncio.wait_for(asyncio.shield(task), timeout=READINESS_WAIT_TIMEOUT)
assert observed == ['on_run_error', 'wrap_run']
async def test_cancel_after_completion_is_a_noop():
"""`cancel()` after the run finished must never cancel unrelated work still running on the
task that drove the run."""
agent = Agent(TestModel())
async with agent.iter('go') as agent_run:
async for _node in agent_run:
pass
agent_run.cancel()
agent_run.cancel() # repeated calls are no-ops too
await asyncio.sleep(0) # a cancellation would be delivered here
assert agent_run.result is not None
assert agent_run.result.output == 'success (no tool calls)'
async def test_cancel_after_end_within_context_stops_iteration_cleanly():
"""Once the run has yielded `End`, a trailing `__anext__` stops iteration cleanly with
`StopAsyncIteration` rather than `CancelledError`, even when `cancel()` was requested as the
run finished. (The requested cancellation is still surfaced when the `iter()` context exits,
since a run counts as finished for `cancel()` only on context exit.)"""
agent = Agent(TestModel())
with pytest.raises(RunCancelled):
async with agent.iter('go') as agent_run:
async for _node in agent_run:
pass
agent_run.cancel()
with pytest.raises(StopAsyncIteration):
await agent_run.__anext__()
assert agent_run.result is not None
assert agent_run.result.output == 'success (no tool calls)'
async def test_token_cancels_run_queued_behind_concurrency_limiter():
"""A run queued for a concurrency slot is still cancellable via its token: the token is
registered before the (blocking) concurrency limiter is entered, so a pre-cancelled token
stops the queued run instead of leaving it stuck until a slot frees."""
agent = Agent(TestModel(), max_concurrency=1)
occupied = asyncio.Event()
release = asyncio.Event()
@agent.tool
async def hold(ctx: RunContext) -> str:
occupied.set()
await release.wait()
return 'done'
# Occupy the single concurrency slot with a run blocked in its tool.
first = asyncio.create_task(agent.run('first'))
await asyncio.wait_for(occupied.wait(), timeout=READINESS_WAIT_TIMEOUT)
# A second run with an already-cancelled token must not wait for the slot to free.
token = CancellationToken()
token.cancel()
with pytest.raises(RunCancelled):
await asyncio.wait_for(agent.run('second', cancellation_token=token), timeout=READINESS_WAIT_TIMEOUT)
assert not first.done() # the second run never took the slot the first still holds
release.set()
assert (await first).output == snapshot('{"hold":"done"}')
def _self_cancelling_agent(name: str) -> Agent[None, str]:
"""A sub-agent whose tool cancels its own run via `cancel()`."""
agent = Agent(TestModel(), name=name)
@agent.tool
async def stop(ctx: RunContext) -> str:
ctx.cancel()
return 'discarded'
return agent
def _user_prompts(messages: list[ModelMessage]) -> list[Any]:
return [part.content for message in messages for part in message.parts if isinstance(part, UserPromptPart)]
async def test_sub_agent_self_cancel_is_isolated_as_tool_failure():
"""`cancel()` cancels the run it belongs to, so a sub-agent cancelling itself does NOT
tear down the parent: the delegate tool reports a failed tool return the parent can react to,
and the parent run completes normally."""
inner_agent = _self_cancelling_agent('inner')
outer_agent = Agent(TestModel(), name='outer')
@outer_agent.tool
async def delegate(ctx: RunContext) -> str:
result = await inner_agent.run('inner prompt')
return result.output # pragma: no cover — the sub-agent cancels before returning
result = await outer_agent.run('outer prompt')
failed = [
part
for message in result.all_messages()
for part in message.parts
if isinstance(part, ToolReturnPart) and part.outcome == 'failed'
]
assert [(p.tool_name, p.content) for p in failed] == snapshot(
[('delegate', 'The sub-agent run was cancelled: The agent run was cancelled.')]
)
async def test_sub_agent_cancel_can_be_propagated_by_delegate():
"""A delegate tool that *wants* a sub-agent's cancellation to cancel the parent too opts in by
catching `RunCancelled` and calling `ctx.cancel()` — then the parent ends with `RunCancelled`
carrying the parent's history."""
inner_agent = _self_cancelling_agent('inner')
outer_agent = Agent(TestModel(), name='outer')
@outer_agent.tool
async def delegate(ctx: RunContext) -> str:
try:
result = await inner_agent.run('inner prompt')
except RunCancelled:
ctx.cancel()
return 'discarded'
return result.output # pragma: no cover
with pytest.raises(RunCancelled) as exc_info:
await outer_agent.run('outer prompt')
assert _user_prompts(exc_info.value.all_messages()) == ['outer prompt']
async def test_sub_agent_cancel_from_non_tool_site_reports_parent_history():
"""A sub-agent cancellation that escapes from a non-tool site (here an output validator) can't be
isolated as a tool failure, so it terminates the parent — but the re-stamp at the run's outer
edge still makes the escaping `RunCancelled` carry the parent's history, not the sub-agent's."""
inner_agent = _self_cancelling_agent('inner')
outer_agent = Agent(TestModel(), name='outer')
@outer_agent.output_validator
async def validate(ctx: RunContext, output: str) -> str:
await inner_agent.run('inner prompt')
return output # pragma: no cover — the sub-agent cancels before returning
with pytest.raises(RunCancelled) as exc_info:
await outer_agent.run('outer prompt')
exc = exc_info.value
assert _user_prompts(exc.all_messages()) == ['outer prompt'] # never the sub-agent's 'inner prompt'
assert isinstance(exc.__cause__, RunCancelled) # the sub-agent's own cancellation, preserved
async def test_cancel_outside_a_run_raises_user_error():
"""A synthetic `RunContext` not backed by a running agent has no run to cancel."""
ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
with pytest.raises(UserError, match='`cancel` is only available during an agent run'):
ctx.cancel()