Run subagents in the background#

Start a child agent without waiting for it, keep the parent working, and read the report when it is ready. Tool restrictions on a child are not an operating-system sandbox.

Run it#

From the repository root:

uv run python docs/_examples/async_subagent.py

The output includes:

Background task finished; child session is separate

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

How the model manages a task#

AsyncSubAgentExtension registers five tools:

Tool

Purpose

task_start

Start a task from a configured profile; returns its task_id at once.

task_list

Show every task of the session with its status.

task_status

Show one task without its report; never waits.

task_output

Return the report, waiting up to wait_seconds when asked.

task_cancel

Stop a running task.

A finished task is announced before the parent’s next model call, or when the session’s next request starts, as an ordinary agent message. A task whose result the model already read is not announced again.

async def main() -> None:
    definition = SubAgentDefinition(
        name="audit",
        description="audit a code area and report what it finds.",
        system_prompt="You audit code and return a short report.",
        model=ChildModel(),
    )
    extension = AsyncSubAgentExtension([definition])
    client = await create_agent(
        ParentModel(),
        config=AgentRunConfig(session_id="parent"),
        extensions=[extension, ToolGuidelinesExtension()],
    )
    try:
        print((await client.run("Audit the login code")).content)
        [task] = extension.tasks("parent")
        assert task.status is SubAgentTaskStatus.COMPLETED and task.session_id != "parent"
        print("Background task finished; child session is separate")
    finally:
        await extension.close()

Customize it by subclassing#

Override only what you need instead of copying the extension:

class TerseNotices(AsyncSubAgentExtension):
    def build_tools(self, context, subagents):
        # Drop cancellation for this deployment.
        return tuple(t for t in super().build_tools(context, subagents) if t.name != "task_cancel")

    def completion_notice(self, task):
        return f"{task.task_id} is {task.status.value}."

    async def task_finished(self, task):
        metrics.record(task.subagent_type, task.elapsed)

task_info shapes what each tool returns, task_started and task_finished observe the lifecycle (their errors are logged, never raised into the task), and _create_child changes how each child agent is built. Tasks live in memory for the lifetime of the extension object and are visible only to the session that started them. Call await extension.close() when the application shuts down.

Complete source#

Download async_subagent.py.

 1"""Start a subagent in the background, keep working, and read its report later."""
 2
 3import asyncio
 4import json
 5from collections.abc import AsyncIterator
 6
 7from zett_agent.agent import AgentRunConfig
 8from zett_agent.client import create_agent
 9from zett_agent.extensions.async_subagent import AsyncSubAgentExtension, SubAgentTaskStatus
10from zett_agent.extensions.subagent import SubAgentDefinition
11from zett_agent.extensions.tool_guidelines import ToolGuidelinesExtension
12from zett_agent.messages import AssistantMessage, ToolCall, ToolMessage
13from zett_agent.model import ModelEvent, ModelRequest, ModelResponse, RetryOptions
14
15
16class ParentModel:
17    """Start a task, do other work, then wait for the report."""
18
19    retry = RetryOptions(max_retries=0)
20
21    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
22        results = [message for message in request.messages if isinstance(message, ToolMessage)]
23        if not results:
24            call = ToolCall(
25                "start-1",
26                "task_start",
27                {"description": "Audit auth", "prompt": "List the auth entry points", "subagent_type": "audit"},
28            )
29            message = AssistantMessage(tool_calls=(call,))
30        elif len(results) == 1:
31            # The task is running; ask for its report and wait up to ten seconds.
32            task_id = json.loads(results[0].text)["task_id"]
33            call = ToolCall("output-1", "task_output", {"task_id": task_id, "wait_seconds": 10})
34            message = AssistantMessage(tool_calls=(call,))
35        else:
36            report = json.loads(results[-1].text)["content"]
37            message = AssistantMessage(content=f"Background report: {report}")
38        yield ModelEvent.completed(ModelResponse(message))
39
40
41class ChildModel:
42    retry = RetryOptions(max_retries=0)
43
44    async def stream(self, request: ModelRequest) -> AsyncIterator[ModelEvent]:
45        await asyncio.sleep(0.05)  # stands in for real work the parent does not wait for
46        yield ModelEvent.completed(ModelResponse(AssistantMessage(content="login, logout, refresh")))
47
48
49async def main() -> None:
50    definition = SubAgentDefinition(
51        name="audit",
52        description="audit a code area and report what it finds.",
53        system_prompt="You audit code and return a short report.",
54        model=ChildModel(),
55    )
56    extension = AsyncSubAgentExtension([definition])
57    client = await create_agent(
58        ParentModel(),
59        config=AgentRunConfig(session_id="parent"),
60        extensions=[extension, ToolGuidelinesExtension()],
61    )
62    try:
63        print((await client.run("Audit the login code")).content)
64        [task] = extension.tasks("parent")
65        assert task.status is SubAgentTaskStatus.COMPLETED and task.session_id != "parent"
66        print("Background task finished; child session is separate")
67    finally:
68        await extension.close()
69
70
71if __name__ == "__main__":
72    asyncio.run(main())

Next: Run an isolated child agent for the blocking variant, or Compose built-in extensions to compare it with the other capabilities.