Core execution engine. File: navi/core/agent.py.
run_stream(session_id, user_message) → AsyncGenerator[AgentEvent]Streaming. Yields AgentEvent objects in real time. Used by the WebSocket handler. Planning is agent-invoked: the model calls the plan tool when a task needs it (see Planning below); nothing runs automatically before the tool loop.
run(session_id, user_message) → strNon-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.
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.
plan tool)Planning is a tool, not a gate. There is no pre-turn planning pass: the agent itself calls the plan tool for non-trivial multi-step tasks (the system prompts teach when). Sub-agents are the exception — they run the pipeline automatically before their tool loop (gated by profile.subagent_planning_enabled), because they must not ask for confirmation.
PlanRunner wiring. run_stream() constructs a PlanRunner(self._planning, session, profile, llm, mem, tool_schemas) per tool-loop iteration and exposes it via the current_plan_runner ContextVar (reset in finally). The plan tool reports "not available" outside an agent run.
Tool result = follow-up instruction. The tool reads the COMPLEXITY classification from Phase 1 (PlanningEngine.last_complexity) and composes the result: for complex tasks it instructs the agent to present the plan briefly and WAIT for the user's confirmation; otherwise it tells the agent to proceed. The former "Plan is ready. Execute it now" prompt injection only happens for sub-agents (is_subagent=True).
Events. PlanningStatus / PlanReady are put on the current_event_sink queue (the agent loop drains it into the WS stream, same pattern as switch_profile), so the UI shows the planning status line and the plan card mid-turn — after the plan tool's own tool_started. PlanningDebugData is appended to session.planning_logs (capped at 20). The next TodoUpdated yield after the tool batch pushes the auto-populated todo to the UI.
Re-plan. Calling plan with a reason runs the same pipeline with is_replan=True + a packed context (reason + updated_goal + todo + scratchpad findings/errors): Phase 1 frames the run as a revision and the todo is replaced. Without a reason — fresh planning.
LLM (think=False — non-streaming planner calls never request extended reasoning; cloud reasoning models would otherwise leak their chain-of-thought into the structured output) receives the user request with a classification prompt, windowed to the most recent ~20k chars of conversation (the original task statement is pinned). Outputs a structured analysis: TASK / GOAL / UNKNOWNS / RESOURCES / knowledge rules / COMPLEXITY: simple|medium|complex / SUBTASKS / COMMITMENTS. Sub-agents may additionally output DIRECT to skip planning for trivial subtasks (the shortcut is offered only when is_subagent=True).
LLM produces milestones plus a numbered step list. Each step is assigned an executor:
TOOL: tool_name — single tool callAGENT: profile_id — bounded 3+ tool-call subtask delegated to a subagent via spawn_agentSELF — handled inline (synthesis, context-dependent action)Plan depth is adaptive:
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 (set_tasks, scoped by current_todo_session_id so sub-agent plans land in the sub-agent's todo row).
The former Phase 2 (structured review) is retired — it fired in under 10% of production plans and rarely changed the outcome.
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 |
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 |
subagent_planning_enabled |
false |
Subagents run the planning pipeline automatically before their tool loop (Phase 1 + Phase 3, no confirmation) |
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
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
10. Check if profile switched → reload profile + tools
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.
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.
run_stream() wraps the LLM generator with _iter_stream_guarded(), which provides two safety layers:
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.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 |
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). The worker gates on the real context_tokens from the last LLM call, then delegates to compress_and_save_session(reason="postturn") — the same retry / hard-truncate / safety-net / archiving pipeline as the pre- and mid-turn paths (with the mid-turn keep_recent_messages).
Pre-turn compression also runs at the start of run_stream(): it estimates tokens via real_baseline_estimate(session.context, session.context) (real prompt_tokens from the last call + heuristic delta; the chars // 3 heuristic only before the first LLM call of a session) and, when over threshold, is guarded by would_compress() before emitting CompressionStarted. See sessions.md.
_build_context)Every LLM call receives:
persona + "---" + profile.system_prompt (injected fresh, never stored)."## What I remember about the user\n...".session.context messages (system messages stripped to avoid duplication).Profile switches and persona changes take effect immediately.
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.
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.