Cancel and reuse an agent#

Close an interrupted stream, clean up extension state, and complete a new request.

Run it#

From the repository root:

uv run python docs/_examples/cancellation.py

The output includes:

Cancelled request; extension state cleared

This program uses deterministic offline models and temporary storage where needed. It requires no API key and is executed by the documentation test suite.

Close the stream when you leave it#

Use aclosing around a stream you might stop early. The example breaks on the first text delta, exits the context manager, and then reuses the client for a complete request:

async def main() -> None:
    tracker = WorkTracker()
    client = await create_agent(StreamingModel(), extensions=[tracker])
    # stream() is annotated AsyncIterator but is an async generator, which aclosing needs.
    async with aclosing(client.stream("Start")) as events:  # type: ignore[type-var]
        async for event in events:
            if event.type == AgentEventType.TEXT_DELTA:
                break
    assert client.agent.state.phase == AgentPhase.CANCELLED and not tracker.active
    print("Cancelled request; extension state cleared")
    print((await client.run("Try again")).content)
    assert client.agent.state.phase == AgentPhase.COMPLETED and not tracker.active

If an extension retains request-local state, clean it on cancellation as well as success and error. WorkTracker demonstrates all three paths. Closing a stream does not roll back a tool action that already completed; design side-effecting tools with that distinction in mind.

Complete source#

Download cancellation.py.

 1"""Close a streaming request and then safely reuse the same Agent."""
 2
 3import asyncio
 4from collections.abc import AsyncIterator
 5from contextlib import aclosing
 6
 7from zett_agent.agent import AgentRunContext
 8from zett_agent.client import create_agent
 9from zett_agent.events import (
10    AgentEventType,
11    AgentPhase,
12)
13from zett_agent.extensions.base import AgentExtension
14from zett_agent.extensions.events import (
15    ExtensionEvent,
16    RunCancelledEvent,
17)
18from zett_agent.messages import AssistantMessage
19from zett_agent.model import (
20    ModelEvent,
21    ModelRequest,
22    ModelResponse,
23    RetryOptions,
24)
25
26
27class WorkTracker(AgentExtension):
28    def __init__(self) -> None:
29        self.active: set[AgentRunContext] = set()
30
31    async def before_run(self, context: AgentRunContext) -> None:
32        self.active.add(context)
33
34    async def on_event(self, context: AgentRunContext, event: ExtensionEvent) -> None:
35        if isinstance(event, RunCancelledEvent):
36            self.active.discard(context)
37
38    async def on_error(self, context: AgentRunContext, error: Exception) -> None:
39        self.active.discard(context)
40
41    async def on_success(self, context: AgentRunContext, result: AssistantMessage) -> None:
42        self.active.discard(context)
43
44
45class StreamingModel:
46    retry = RetryOptions(max_retries=0)
47
48    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
49        yield ModelEvent.text("Partial")
50        yield ModelEvent.text(" answer")
51        yield ModelEvent.completed(ModelResponse(AssistantMessage(content="Partial answer")))
52
53
54async def main() -> None:
55    tracker = WorkTracker()
56    client = await create_agent(StreamingModel(), extensions=[tracker])
57    # stream() is annotated AsyncIterator but is an async generator, which aclosing needs.
58    async with aclosing(client.stream("Start")) as events:  # type: ignore[type-var]
59        async for event in events:
60            if event.type == AgentEventType.TEXT_DELTA:
61                break
62    assert client.agent.state.phase == AgentPhase.CANCELLED and not tracker.active
63    print("Cancelled request; extension state cleared")
64    print((await client.run("Try again")).content)
65    assert client.agent.state.phase == AgentPhase.COMPLETED and not tracker.active
66
67
68if __name__ == "__main__":
69    asyncio.run(main())

Next: Stream to a terminal or UI for consumer patterns, or State, concurrency, and cleanup for extension cleanup.