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#
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.