Skip to main content
Background tasks let runnables execute asynchronously while the agent continues. When a tool runs in the background, it immediately returns a task_id and status: "running" instead of blocking until completion. Tasks belong to the session that started them: the agent stays talkable, can run several children at once, and can answer questions about any of them on a later turn.

Configuring Background Mode

The background_mode parameter controls when a runnable executes asynchronously:
  • "never" (default) — always runs synchronously
  • "always" — always runs in the background
  • "auto" — the LLM decides per call by setting run_in_background=True on the tool input
The built-in Bash tool uses background_mode="auto" by default. The model can run a shell command in the background by passing run_in_background=True.

Checking Task Status

Background tools return immediately with a task handle:
Once this session has at least one background task, the agent automatically gains three tools — get_background_task, list_background_tasks, and cancel_background_task — so it can brief the user or stop a child on a follow-up turn without you wiring anything. Set background_transcript_tool=True on the Agent to also expose read_background_transcript for paged raw event replay (off by default). get_background_task peeks; it does not drain. Calling it twice returns the same thing, so the agent polling a child never steals events from a frontend streaming the same child.

Completion notifications

When a background child reaches a terminal status (completed, error, cancelled, timed_out, or stalled), the session store queues a one-shot notice. At the start of the next parent turn — not mid-turn while the parent is still talking — the agent drains that inbox and injects a synthetic user message into memory so the LLM learns without polling:
The notice stays role="user" (provider wire formats require it mid-history) but is tagged:
Transcript UIs should filter metadata.source == "runtime" (or Message.is_runtime()) — do not sniff the XML tag. The model still sees the message; humans should not. Notices are lean (short summary / result preview, error string if any) — not full transcripts. After drain they are not re-injected; get_background_task still peeks the full snapshot. Concurrent parent runs without a shared parent_id keep isolated inboxes. If the model already polls via the auto-registered get_background_task / list_background_tasks tools and sees a terminal status, that peek acks the notice — the next turn will not fire a duplicate <background_task_completed> for a completion the agent already handled. App/Python peeks (timbal.state.get_background_task) do not ack by default, so UIs can inspect without stealing the LLM inbox.
The summary is deliberately bounded — it’s for briefing the user, not for dumping a long build into the context window. When the task finishes, the snapshot also carries result (or error on failure / timeout). Durable child ids seen on the child’s events, such as its run_id, are lifted onto the snapshot as they arrive.

Structured progress

Handlers that yield progress dicts (e.g. {"stage": "processing", "progress": 50}) surface as summary.phase and summary.pct. Agent children also expose summary.last_tool and summary.tools_in_flight (open tool calls inferred from the event log — a tool drops off when its OutputEvent arrives). While running with no explicit stage, phase falls back to streaming, tool:{name}, or running; on terminal success phase becomes completed and pct is 100.

Timeouts

Set background_timeout (seconds) on a tool to hard-cap how long a detached child may run. When the deadline hits, the child is cancelled like a user cancel (Task cancel + handler aclose + on_background_cancel) but reports status timed_out so the next-turn completion notice is unambiguous.
None (default) or a non-positive value means no deadline.

Stall detection

Set background_stall_timeout (seconds) to cancel a child that stops emitting log events — distinct from wall-clock background_timeout. The idle timer resets on every event (streaming chunks, tool calls, progress dicts), so a long but chatty build stays alive while a hung subprocess does not.
Terminal status is stalled. While still running, snapshots expose summary.seconds_since_event and stall_timeout for UI warnings before the watchdog fires.

Concurrency and depth caps

Each session bag bounds fan-out so one turn cannot detach unbounded work:
  • Concurrent — max children whose asyncio.Task is still running (default 20). Override per Agent with max_background_concurrent=... or env TIMBAL_MAX_CONCURRENT_BACKGROUND (0 / none / unlimited = no cap).
  • Depth — max nesting of background spawns (default unlimited). max_background_depth=1 means only top-level code may detach; a background child cannot spawn further background work. Env: TIMBAL_MAX_BACKGROUND_DEPTH.
Hitting a cap raises BackgroundLimitError (surfaced as a tool error to the LLM) instead of spawning.

Reading the Raw Transcript

For the full event stream, read from a logical cursor. This does not drain either — the same after returns the same events while they remain in the ring, so it’s safe to call from more than one place. Python / app code — always available via timbal.state.read_background_transcript. LLM tool — opt in with background_transcript_tool=True on the Agent (registered alongside the other session background tools once a child exists).
When the ring has dropped older events, gapped is true and after is behind forgotten_through — treat that like an expired reconnect, not a silent skip to the new head.

Waiting for completion

For app code or frontends that want to block instead of polling the agent loop, use wait_for_background. It returns the same snapshot as get_background_task and does not ack completion notices.
Without after, the wait is tied to the child’s asyncio task finishing. With after, it wakes when the append-only log advances, the log closes on completion, or the timeout hits — useful for streaming UIs that already have a transcript_cursor.

Log retention

Each child’s event log is a ring buffer (defaults: 50k events / 32 MiB — same idea as HTTP JobStore). Oldest events drop when over cap; forgotten_through is the logical floor. Finished task records are removed from the session bag after 300 seconds by default so long-lived workers do not leak metadata. Override per Agent:
Env: TIMBAL_BG_LOG_MAX_EVENTS, TIMBAL_BG_LOG_MAX_BYTES, TIMBAL_BG_TASK_RETENTION_SECS (0 / none = unlimited / keep forever). To follow a child live instead of polling, subscribe to its log. subscribe replays from after and then yields events as they arrive, so you cannot miss one that lands mid-replay. If after is behind forgotten_through (ring eviction), the iterator yields BACKGROUND_LOG_GAPPED once — same signal as gapped=True on read/peek — then replays from the ring head and continues with live events.
A streaming handler can yield either form and both reach the log. Raw delta items are wrapped into DeltaEvents under the parent’s run. Already-formed Timbal events (StartEvent, DeltaEvent, OutputEvent) are logged as they are — which is how a child that runs its own agent, such as a coding harness or a remote worker, keeps its own run_id and metadata on the task record. Those are the ids on_background_cancel and resume need.

Listing and Cancelling

Cancelling stops the child’s in-flight work: the task is cancelled and its handler is closed, so a handler holding a subprocess or a socket runs its finally instead of being abandoned mid-flight.

Session Scope

The task store is bound to the RunContext and inherited across sequential turns via parent_id. In practice:
  • A finished turn can still list, peek, and cancel the children it started, because the next turn passes the previous run as parent_id.
  • Two concurrent runs of the same Agent object — no shared parent_id — get isolated stores. Neither sees the other’s tasks, and get_background_task with a foreign task_id returns not_found.
  • The store is process-local, so it does not survive a restart. Use a durable job store if a child must outlive the process.

Stopping External Work

Cancelling an asyncio.Task can only reach work the event loop owns. If a child drives something outside the process, use on_background_cancel to tear it down. The hook receives the task record, whose metadata carries the child’s ids once its events have arrived.