Newer
Older
navi-1 / docs / agent.md

Agent Loop

Core execution engine. File: navi/core/agent.py.

Entry points

run_stream(session_id, user_message)AsyncGenerator[AgentEvent]

Streaming. Yields AgentEvent objects in real time. Used by the WebSocket handler. Runs the planning phase when (_is_first_message or profile.planning_enabled) and not _is_casual (always on the first user message; never for casual greetings). profile.planning_mandatory forces force_plan on every turn.

run(session_id, user_message)str

Non-streaming. Delegates to run_stream() and returns the final text. Planning and the full tool loop run; events are consumed internally, not yielded.

run_ephemeral(user_message, profile_id, *, max_iterations=40, exclude_tools=(), briefing=None, custom_system_prompt=None, inherit_system_prompt=False, context_transfer=None, parent_session_id=None, timeout_seconds=300.0)tuple[str, bool]

Non-persistent subagent. Temporary in-memory context. Called by SpawnAgentTool.

Returns (result_text, completed_normally). completed_normally is False if the subagent hit the iteration limit or timed out.

spawn_agent.profile_id is optional. If omitted, SpawnAgentTool resolves the parent session's current profile. If provided, the subagent uses the selected profile's model, subagent_system_prompt, planning flags, and tool set. Its tools come from that profile's tools.subagent, falling back to tools.agent when tools.subagent is empty. exclude_tools removes specific tools from that set; briefing/custom_system_prompt/inherit_system_prompt shape the system prompt; context_transfer injects a synthetic user/assistant exchange before the task message.

When spawned from a persistent parent session, session-aware tools run under the parent session id so file tools resolve the user's session directory rather than a subagent_* directory.

run_ephemeral reads the parent session from the DB when parent_session_id is provided, so session-aware tools (filesystem, todo, scratchpad) operate on the parent's data.

compact_stream(session_id)AsyncGenerator[AgentEvent]

Forced context compression triggered by the {"type":"compact"} WebSocket control message (TUI /compact, Ctrl+X C). Bypasses the token threshold and runs the compressor immediately, emitting CompressionStarted + ContextCompressed. Raises NothingToCompactError (surfaced as an error frame) when there is nothing to compress.

ContextVar restoration

run_ephemeral saves the parent's current_session_id, current_model, current_working_directory, current_user_id, current_user_role, and current_user_info before starting and restores them in a finally block. This prevents background tasks or the next parent iteration from inheriting stale subagent IDs. run_stream likewise sets and resets current_working_directory from the message cwd.


Planning phase (_run_planning)

Runs before the tool loop when (_is_first_message or profile.planning_enabled) and not _is_casual. profile.planning_mandatory forces force_plan on every turn (suppresses the DIRECT early-return only, not the observe skip — see profiles.md).

Phase 1 — Analysis

LLM receives the user request with a classification prompt. Outputs:

  • DIRECT → skip planning entirely (simple request).
  • A structured analysis + REFLECT: yes/no → continue to Phase 2 or 3.

Phase 2 — Structured review (conditional)

Runs only when planning_phase2_enabled = True AND Phase 1 outputs REFLECT: yes. One LLM call reviews the Phase 1 analysis and returns four sections:

  • Critic — wrong assumptions, risks, contradictions, facts to verify
  • Pragmatist — simpler path, unnecessary steps, better executor choices
  • Detailer — missing requirements, source files/docs/tools to inspect, validation gaps
  • Plan Adjustments — concrete changes Phase 3 must apply

The review is embedded into the Phase 3 prompt.

Phase 3 — Execution plan

LLM produces milestones plus a numbered step list. Each step is assigned an executor:

  • TOOL: tool_name — single tool call
  • AGENT: profile_id — bounded 3+ tool-call subtask delegated to a subagent via spawn_agent
  • SELF — handled inline (synthesis, context-dependent action)

Plan depth is adaptive:

  • simple: 1-3 steps
  • medium: 5-9 steps
  • complex or autonomous: 8-15 steps
  • hard maximum: 15 steps

Comma test (enforced in prompt): if a step description lists multiple things with "and" or commas, each item must be a separate step.

The plan is injected into session.context as an assistant message and saved to session.messages with is_plan=True for UI rendering. The todo list is auto-populated from the plan steps.


Thinking mechanics

All flags live on AgentProfile and can be set per-profile in config.json.

Flag Default What it does
think_enabled true Passes think=True to LLM on every main-loop call (extended reasoning)
iteration_budget_enabled true Injects remaining iteration count into context so model wraps up in time
planning_phase2_enabled false Enables Phase 2 structured review (one extra LLM call when Phase 1 outputs REFLECT: yes)
goal_anchoring_enabled true Injects goal-reminder system message every N iterations
goal_anchoring_interval 5 N for goal anchoring
anti_stall_enabled true Detects looping without todo progress and injects a warning
anti_stall_threshold 8 Consecutive iterations without progress before warning fires
step_validation_enabled false Blocks marking a todo step done without a validation field
adaptive_replan_enabled false When a step is marked failed, queues a re-plan prompt for the next iteration
subagent_planning_enabled false Subagents run their own planning phase

Tool-calling loop

Runs up to profile.max_iterations times. Tool schemas are built at the start of run_stream() from profile.get_agent_tools() (see profiles.md for the tools.agent / tools.subagent structure).

Each iteration:
  1. Mid-turn compression (iteration > 0): estimate tokens via real_baseline_estimate,
     and if over threshold + would_compress() → emit CompressionStarted, compress, save
  2. Build context: _build_context() injects iteration budget and goal anchor (if due)
  3. Check anti-stall: if stalled, append warning message to context
  4. Inject queued adaptive re-plan message (if a step failed last iteration)
  5. check_context_size(built_ctx) → raise ContextTooLargeError if it won't fit
     (surfaced as a synthesized assistant response + StreamEnd)
  6. llm.stream_complete(context, tool_schemas)
     → ThinkingDelta/ThinkingEnd events during reasoning
     → TextDelta events during text generation
     → ModelInfo event when the resolved model changes mid-turn
  7. Record real prompt_tokens baseline (record_real_baseline) for the next estimate
  8a. No tool calls → save session, yield StreamEnd, run workers, return
  8b. Tool calls → execute each, yield ToolEvent, append results to context
  9. Update anti-stall counters, detect newly-failed todo steps
 10. Check if profile switched → reload profile + tools

Sub-agent event forwarding

When spawn_agent runs a subagent, its events arrive through current_event_sink. The parent drains the queue in real time, yielding subagent events marked with is_subagent=True.

Cooperative stop

Stop is signalled via current_stop_event (an asyncio.Event). Checked before each LLM call, during streaming, and after tool execution. Never use task.cancel() — it corrupts WebSocket state.

Streaming guard wrapper

run_stream() wraps the LLM generator with _iter_stream_guarded(), which provides two safety layers:

  1. Stop-event polling during prefill. Ollama emits no chunks during prefill, so a plain await on the first token can block for minutes. The wrapper polls stop_event every second so the user's Stop button works even during silent prefill.
  2. Hard timeouts. first_chunk_timeout (default 90 s) caps prefill wait time. chunk_timeout (default 60 s) caps gaps between subsequent tokens. On timeout the generator is closed, terminating the HTTP connection to Ollama so GPU load drops to idle.
Env var Default Purpose
LLM_STREAM_FIRST_CHUNK_TIMEOUT 90 Max seconds to wait for the first token
LLM_STREAM_CHUNK_TIMEOUT 60 Max seconds between tokens after the first

Workers

Run sequentially after StreamEnd. Currently: CompressionWorker. Workers receive a WorkerContext carrying the active profile, so CompressionWorker applies per-profile compression overrides (compression_keep_recent, compression_max_tokens, compression_prompt_file).

Pre-turn compression also runs at the start of run_stream(): it estimates tokens via estimate_context_tokens(session.context) (not the stored context_token_count) and, when over threshold, is guarded by would_compress() before emitting CompressionStarted. See sessions.md.


System prompt construction (_build_context)

Every LLM call receives:

  1. System message: persona + "---" + profile.system_prompt (injected fresh, never stored).
  2. Optional memory message: "## What I remember about the user\n...".
  3. session.context messages (system messages stripped to avoid duplication).

Profile switches and persona changes take effect immediately.

System prompt caching

The built system prompt string is cached per profile ID in ContextBuilder to avoid rebuilding on every turn. The cache is invalidated when the profile is reloaded (e.g. after switch_profile or hot-reload). This saves ~1–2 ms per turn for profiles with large system prompts.

Per-message view truncation

ContextBuilder.build() head/tail-truncates any single tool/assistant message whose estimated size exceeds CONTEXT_MESSAGE_TOKEN_BUDGET (default 0 → auto, OLLAMA_NUM_CTX // 6) in the built context only. Stored history is never mutated — a copy with a […truncated ~N tokens…] marker is sent to the LLM. This prevents one huge tool result from alone blowing the window; full context compression still handles the cumulative case.