How the agent keeps working while a long tool call runs detached. Files: navi/core/tasks.py (TaskManager), navi/core/task_notes.py (completion notes), navi/core/tool_executor.py (interception), navi/tools/tasks.py (the tasks tool), navi/api/websocket.py (message queue).
The contract follows the pattern used by other harnesses (start → continue → collect): the agent launches a long call with background: true, immediately receives a task_id, keeps doing useful work, and collects the result either by calling the tasks tool or automatically as a completion note next turn.
background parameterTool calls whose arguments carry "background": true are detached — the ToolExecutor submits the whole tool.execute() to the TaskManager and returns a synthetic result right away:
{"task_id": "bt-ab12cd34", "tool": "terminal", "status": "running",
"args_summary": "{\"action\": \"run\", \"command\": \"make test\"}",
"hint": "The call is running in the background — continue with other work. ..."}
The background flag is stripped before the tool runs. Tools that accept it: terminal (action run), ssh_exec, peer (action ask), spawn_agent, code_exec — the list is settings.backgroundable_tools. Any tool outside the list runs inline even with the flag set.
Exception: terminal with action: "open" keeps its native meaning of background (a persistent detached process) and is never hijacked.
The interception lives in ToolExecutor._execute_one, so it works identically from the top-level agent loop and from sub-agents.
navi/core/tasks.py, process-wide singleton (get_task_manager()). Each TaskJob carries:
task_id — bt- + 8 hex chars;status — running → completed | failed | cancelled;result — the tool's ToolResult; error for failures;ring — a bounded (drop-oldest) event queue with the job's live events;done — an asyncio event set when the job reaches a terminal state;stop_event — the job's own stop signal, independent of the session's.Caps (per session unless noted, configurable in docs/config.md): tasks_max_per_session=5 running, tasks_max_global=20, tasks_max_spawn=2 background sub-agents, and a rate limit of tasks_rate_limit=10 submissions per 5 minutes. A rejected submit returns the reason as a string; the executor turns it into a normal failed tool result ("Cannot run in background: …") so the agent falls back to running the tool inline.
Finished jobs are reaped by a lazy sweeper after tasks_ttl_sec (1 h), with at most 50 finished jobs kept per session.
current_stop_event points at the job's own event — pressing Stop on the session does not cancel background tasks; only tasks cancel does.task_update wire event — published via the orchestrator to all connected clients the moment the job reaches a terminal state (running on submit, final state on finish). Out-of-band: not part of the run's replay buffer; on reconnect the client re-syncs via tasks list instead.task_notes scope) and drained by the next run_stream turn into the session context as a system message (persisted, invisible in the UI):
[Background task results] - bt-ab12 (terminal) completed: PASS 42/42... ... (Use these results to continue your work. Do not start new background tasks in response to this note unless the user asked.)Up to
task_notes_per_turn=5 notes per turn, coalesced; overflow says to call tasks check. At most task_notes_max_pending=20 notes per session (oldest dropped). Notes survive a server restart; the running tasks themselves do not (they die with the process).tasks tool{action: list | check | wait | cancel, task_id?, timeout?} — session-scoped.
list — this session's jobs with status and age.check — status + result (≤2000 chars) + the last ring events as live progress (useful while a background sub-agent is still working). For spawn_agent jobs the check output includes the sub-agent's token count.wait — blocks the turn until the job finishes or timeout (capped at 120 s) expires; wait_timeout is a normal failed result telling the agent to continue other work.cancel — cooperative cancel: sets the job's stop event and cancels its asyncio task.Manual for the agent: manuals/tasks.md.
spawn_agent is synchronous by default (blocks until the sub-agent completes, ≤300 s). With background: true the same interception detaches it; the sub-agent's event stream (thinking, tool cards, planning) flows into the job's ring, so tasks check shows live progress. Deeper ring (200 events) for spawn jobs.
Caveat: tokens consumed by a background sub-agent are reported in task_update.subagent_tokens and the completion note — they no longer appear in the parent turn's token count.
When the profile enables parallel_tool_calls (profile override, else settings.parallel_tool_calls, default off), a model response carrying several tool calls executes them concurrently:
tool_started frames for the whole batch are emitted up-front, in call order;The gate lives in Agent._execute_tools_with_sink; the batch is used only for 2+ calls. A crash mid-batch can leave assistant tool_calls without their role=tool pair — repaired on session load by pg_session_store.repair_dangling_tool_calls (display history only).
Agent discipline: batch only independent calls; never two actions over the same terminal in one batch.
A user message arriving while a run is active is queued (see docs/websocket.md, message_queued) instead of erroring. Queue state is in-memory on SessionState (pending_user_messages, maxlen message_queue_max=5, oldest dropped with dropped_total counter). Drained in submit order by the socket after the run finishes, or headlessly when the client disconnected.