Newer
Older
navi-1 / docs / tasks.md

Background tasks (agent-level parallelism)

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.

The background parameter

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

TaskManager

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.

Isolation guarantees

  • The detached job gets a rebuilt ToolContext and its own ContextVar overrides: its events stream into its own ring (never into a dead foreground sink), and current_stop_event points at the job's own event — pressing Stop on the session does not cancel background tasks; only tasks cancel does.
  • Background jobs never write session history. The only mutation is the "task started" tool message written by the foreground loop.

Delivery of results — two paths

  1. 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.
  2. Completion note — recorded in the KV store (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).

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

Background sub-agents (spawn_agent)

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.

Parallel tool-call batch (Ф3)

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;
  • live events from all tools merge through one tagged queue;
  • results and tool messages are appended in call order after the batch settles, with a single session save;
  • Stop mid-batch cancels every unfinished tool and synthesises "stopped by the user" results for each;
  • one tool's exception is recorded as its own failed result and does not kill its neighbours (backend-level LLM errors still abort the turn).

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.

Message queue

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.