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
Thebackground_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 settingrun_in_background=Trueon the tool input
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: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:
role="user" (provider wire formats require it mid-history) but is tagged:
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.
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
Setbackground_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
Setbackground_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.
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 envTIMBAL_MAX_CONCURRENT_BACKGROUND(0/none/unlimited= no cap). - Depth — max nesting of background spawns (default unlimited).
max_background_depth=1means only top-level code may detach; a background child cannot spawn further background work. Env:TIMBAL_MAX_BACKGROUND_DEPTH.
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 sameafter 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).
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, usewait_for_background. It returns the same snapshot as get_background_task and does not ack completion notices.
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 HTTPJobStore). 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:
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.
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
finally instead of being abandoned mid-flight.
Session Scope
The task store is bound to theRunContext 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
Agentobject — no sharedparent_id— get isolated stores. Neither sees the other’s tasks, andget_background_taskwith a foreigntask_idreturnsnot_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 anasyncio.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.