# 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. 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)` → `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 (the `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.

### Phase 1 — Analysis
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`).

### 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-20 steps
- hard maximum: 20 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 (`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.

---

## 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 |
| `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) |

---

## 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`](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
```

### 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`). 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`](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.
