diff --git a/docs/agent.md b/docs/agent.md index 7626fe2..b959135 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -5,7 +5,7 @@ ## 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. +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. @@ -29,24 +29,48 @@ --- -## Planning phase (`_run_planning`) +## Planning (the `plan` tool) -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`). +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 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. +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: @@ -57,12 +81,15 @@ Plan depth is adaptive: - simple: 1-3 steps - medium: 5-9 steps -- complex or autonomous: 8-15 steps -- hard maximum: 15 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. +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. --- @@ -74,14 +101,12 @@ |---|---|---| | `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 | +| `subagent_planning_enabled` | `false` | Subagents run the planning pipeline automatically before their tool loop (Phase 1 + Phase 3, no confirmation) | --- @@ -95,7 +120,6 @@ 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) @@ -105,7 +129,7 @@ 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 + 9. Update anti-stall counters 10. Check if profile switched → reload profile + tools ``` @@ -130,9 +154,9 @@ ## 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`). +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 `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`](sessions.md). +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). --- diff --git a/docs/api.md b/docs/api.md index 1dafcde..d76ab48 100644 --- a/docs/api.md +++ b/docs/api.md @@ -930,7 +930,7 @@ "summary": "User asked about..." } ``` -Context was automatically compressed (triggers at ≥70% of `OLLAMA_NUM_CTX`, or on demand via `{"type":"compact"}`). `summary` is the produced summary text. Informational. +Context was automatically compressed (triggers at ≥90% of `OLLAMA_NUM_CTX`, or on demand via `{"type":"compact"}`). `summary` is the produced summary text. Informational. --- @@ -1317,10 +1317,7 @@ "top_p": null, "num_thread": null, "max_iterations": 10, - "planning_enabled": false, - "planning_mandatory": false, "planning_phase1_enabled": true, - "planning_phase2_enabled": false, "planning_phase3_enabled": true, "think_enabled": true, "iteration_budget_enabled": true, @@ -1329,7 +1326,6 @@ "anti_stall_enabled": true, "anti_stall_threshold": 8, "step_validation_enabled": false, - "adaptive_replan_enabled": false, "tools": { "agent": { "native": ["todo", "scratchpad", "filesystem"], @@ -1361,7 +1357,7 @@ { "temperature": 0.5, "max_iterations": 20, - "planning_enabled": true + "think_enabled": true } ``` diff --git a/docs/architecture.md b/docs/architecture.md index c9a37c5..ee1b31a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,10 +54,9 @@ 4. `run_stream()`: a. Loads session + profile from store. b. Pre-turn: checks if context needs compression; compresses if threshold exceeded. - c. **Planning phase** (if `profile.planning_enabled`): calls LLM once (non-streaming, no tools) to produce a step plan; injects plan as assistant message. - d. **Tool-calling loop** (up to `max_iterations`): + c. **Tool-calling loop** (up to `max_iterations`): - Calls `llm.stream_complete()` → yields `ThinkingDelta`, `TextDelta`, tool call requests. - - If tool calls: executes each tool via `ToolContext`, yields `ToolStarted` → sub-agent events → `ToolEvent`. + - If tool calls: executes each tool via `ToolContext`, yields `ToolStarted` → sub-agent events → `ToolEvent`. Planning is agent-invoked: when the agent calls the `plan` tool mid-loop, it runs the planning pipeline (Phase 1 analysis + Phase 3 plan) and returns the plan with a follow-up instruction (wait for confirmation on complex tasks, proceed otherwise). - If `finish_reason == stop`: yields `StreamEnd`, runs post-turn workers. e. Saves session to DB. 5. Events are broadcast from `SessionRun` to all subscriber queues. diff --git a/docs/config.md b/docs/config.md index 995ac41..a399c56 100644 --- a/docs/config.md +++ b/docs/config.md @@ -130,10 +130,10 @@ | Variable | Type | Default | Description | |---|---|---|---| | `CONTEXT_COMPRESSION_ENABLED` | bool | `true` | Enable/disable automatic context compression | -| `CONTEXT_COMPRESSION_THRESHOLD` | float | `0.70` | Trigger compression at this fraction of `OLLAMA_NUM_CTX` | +| `CONTEXT_COMPRESSION_THRESHOLD` | float | `0.90` | Trigger compression at this fraction of `OLLAMA_NUM_CTX` | | `CONTEXT_KEEP_RECENT` | int | `8` | Number of recent conversation turns to keep verbatim | | `CONTEXT_SUMMARY_TEMPERATURE` | float | `0.3` | Temperature for the summarization LLM call | -| `CONTEXT_SUMMARY_MAX_TOKENS` | int | `4000` | Max output tokens for the summary LLM call | +| `CONTEXT_SUMMARY_MAX_TOKENS` | int | `6000` | Max output tokens for the summary LLM call | | `OUTPUT_RESERVE_TOKENS` | int | `2048` | Headroom reserved for model response in context size checks | | `CONTEXT_MESSAGE_TOKEN_BUDGET` | int | `0` | Per-message token budget for the LLM context view. A single `tool`/`assistant` message whose estimated size exceeds this is head/tail-truncated in the *built* context only (stored history is never mutated) so one huge tool result cannot alone blow the window. `0` = auto (`OLLAMA_NUM_CTX // 6`). | @@ -210,7 +210,7 @@ # Context compression CONTEXT_COMPRESSION_ENABLED=true -CONTEXT_COMPRESSION_THRESHOLD=0.70 +CONTEXT_COMPRESSION_THRESHOLD=0.90 CONTEXT_KEEP_RECENT=8 # Persona diff --git a/docs/mechanics.md b/docs/mechanics.md index e3a8e46..8e055f9 100644 --- a/docs/mechanics.md +++ b/docs/mechanics.md @@ -22,26 +22,23 @@ | Mechanic | Description | Config / Flags | Files | Docs | |---|---|---|---|---| -| **Streaming entry point** | `run_stream()` — yields `AgentEvent` objects in real time. Loads session, runs planning (if enabled), tool loop, workers. | `profile.max_iterations`, `profile.llm_backend`, `profile.model`, `profile.temperature` | `agent.py` | ✅ | -| **Non-streaming entry point** | `run()` — delegates to `run_stream()` and returns the final text. Planning and the full tool loop run; events are consumed internally, not yielded. | Same as above | `agent.py` | ✅ | +| **Streaming entry point** | `run_stream()` — yields `AgentEvent` objects in real time. Loads session, runs the tool loop, workers. Planning happens only when the agent calls the `plan` tool. | `profile.max_iterations`, `profile.llm_backend`, `profile.model`, `profile.temperature` | `agent.py` | ✅ | +| **Non-streaming entry point** | `run()` — delegates to `run_stream()` and returns the final text. Events are consumed internally, not yielded. | Same as above | `agent.py` | ✅ | | **Streaming guard wrapper** | Wraps `llm.stream_complete()` with two safety layers: (1) polls `stop_event` every second during prefill so the Stop button works even when the model emits no chunks, and (2) hard `first_chunk_timeout`/`chunk_timeout` deadlines that close the HTTP connection to Ollama so GPU load drops. | `LLM_STREAM_FIRST_CHUNK_TIMEOUT`, `LLM_STREAM_CHUNK_TIMEOUT` | `agent.py` | ✅ | | **Subagent thinking stall detector** | Monitors subagent streaming; if only `thinking` output is emitted for 60 s or 12 000 chars without text/tool calls, aborts the subagent to prevent endless internal-token loops on local models. | Hard-coded `_SUBAGENT_THINKING_STALL_SECONDS=60.0`, `_SUBAGENT_THINKING_STALL_CHARS=12000` | `agent.py` | ❌ | | **Cooperative stop** | Checks `current_stop_event` (asyncio.Event) before each LLM call, during streaming, and after tool execution. Uses clean generator close — never `task.cancel()`. | None | `agent.py` | ✅ | -| **Planning entry condition** | Planning runs when `(_is_first_message or profile.planning_enabled) and not _is_casual` — i.e. always on the first user message regardless of the flag, on later turns only when enabled, and never for casual greetings. `profile.planning_mandatory` forces `force_plan` on every turn. | `profile.planning_enabled`, `profile.planning_mandatory` | `agent.py` | ✅ | +| **Agent-invoked planning** | The `plan` tool runs the planning pipeline when the model itself decides a task needs it. PlanRunner is bound per tool-loop iteration via the `current_plan_runner` ContextVar; the tool result carries the follow-up instruction (complex → present and wait for confirmation, else proceed). PlanningStatus/PlanReady reach the UI mid-turn via `current_event_sink`. | None | `agent.py`, `plan.py` | ✅ | | **Profile reload mid-session** | After each tool execution batch, checks DB for profile ID change (e.g. from `switch_profile`). If changed, reloads profile, tools, schemas, and backend for next iteration. | None | `agent.py` | ✅ | -| **Pre-turn context compression** | Before the assistant reply, estimates tokens (`estimate_context_tokens(session.context)`) and compresses when over threshold. Guarded by `would_compress()` so `CompressionStarted` is only emitted when the partition can actually shrink the stored context. | `CONTEXT_COMPRESSION_ENABLED`, `OLLAMA_NUM_CTX`, `CONTEXT_COMPRESSION_THRESHOLD` | `agent.py` | ✅ | +| **Pre-turn context compression** | Before the assistant reply, estimates tokens via `real_baseline_estimate(session.context, session.context)` (real `prompt_tokens` bulk + heuristic delta; chars//3 heuristic only before the first LLM call) and compresses when over threshold. Guarded by `would_compress()` so `CompressionStarted` is only emitted when the partition can actually shrink the stored context. | `CONTEXT_COMPRESSION_ENABLED`, `OLLAMA_NUM_CTX`, `CONTEXT_COMPRESSION_THRESHOLD` | `agent.py` | ✅ | | **Mid-turn context compression** | On iterations > 0, estimates tokens via `real_baseline_estimate(session.context, preflight_ctx)` (real prompt_tokens bulk + heuristic delta) and compresses with `keep_recent_messages=max(12, CONTEXT_KEEP_RECENT*2)`. Guarded by `would_compress()`. For long autonomous loops where the entire conversation is one turn. | Same as above + `CONTEXT_KEEP_RECENT` | `agent.py` | ✅ | | **Forced `/compact`** | `compact_stream()` — bypasses the token threshold and compresses immediately on client demand (`{"type":"compact"}` WS control message). Emits `CompressionStarted` + `ContextCompressed`; raises `NothingToCompactError` when there is nothing to compress. Bound to `Ctrl+X C` in the TUI. | None | `agent.py`, `websocket.py` | ✅ | | **Context size check with output reserve** | Raises `ContextTooLargeError` if estimated input tokens exceed `OLLAMA_NUM_CTX - OUTPUT_RESERVE_TOKENS`. When the caller passes `session_context`, the total uses `real_baseline_estimate()` (real bulk + heuristic delta) instead of the chars//3 estimate. Images counted at 500 tokens each. | `OLLAMA_NUM_CTX`, `OUTPUT_RESERVE_TOKENS` | `compressor.py` | ✅ | | **Real-token baseline estimator** | Records the real `prompt_tokens` returned by each LLM call (`record_real_baseline(len(session.context), …)`) and reuses them as the bulk of the next estimate, heuristicking only the messages appended since. Used by the mid-turn gate and `check_context_size`. Cleared after compression (context shrank). | None | `compressor.py`, `agent.py` | ✅ | | **Local token estimation** | Conservative estimate `chars // 3 + imgs * 500` (`ContextCompressor.estimate_context_tokens`, a staticmethod). Used as the fallback when no real baseline exists and for the per-message delta. | None | `compressor.py` | ✅ | | **Anti-stall detection** | Tracks two signals: (1) consecutive iterations with no todo status change, (2) identical tool call signatures. When either hits threshold, injects a hard warning system message. | `profile.anti_stall_enabled`, `profile.anti_stall_threshold` | `agent.py` | ✅ | -| **Adaptive replan on failure** | Detects newly-failed todo steps after each tool batch and queues a re-planning system message for the next iteration. | `profile.adaptive_replan_enabled` | `agent.py` | ✅ | | **Goal anchoring** | Injects `[Goal anchor]` system message with original request + todo state every N iterations. | `profile.goal_anchoring_enabled`, `profile.goal_anchoring_interval` | `agent.py` | ✅ | | **Bounded autonomy — scope boundary** | Injects a standing `[Scope boundary]` system message keeping the agent within the user's literally requested scope — no expanding to sibling/parent dirs/projects, no executing discovered TODO/roadmap/milestone docs. | `profile.scope_boundary_enabled` | `agent.py`, `context_builder.py` | ✅ | -| **Observe-vs-act planning skip** | When Phase 1 classifies the request as `MODE: observe` (look/read/explain — no changes), skips Phase 2/3 — no execution plan, no auto-todo, no "execute step by step" prompt. `act` requests plan normally. | `profile.observe_skips_plan_enabled` | `planning.py` | ✅ | | **Todo status snapshot** | Captures frozenset of `(task_text, status)` before each iteration so anti-stall can detect progress. | None | `agent.py` | ❌ | -| **Todo failed-steps tracking** | Captures frozenset of `(index, text)` for failed steps, used by adaptive replan. | None | `agent.py` | ❌ | | **Todo progress message injection** | Injects compact system reminder with current todo state and discipline notes at start of every iteration. | None | `agent.py` | ❌ | | **Memory facts deduplication** | Tracks `_injected_fact_ids` across a single `run_stream` call so the same memory fact is not injected twice in one turn. | None | `agent.py` | ❌ | | **Context injection collection (parallel)** | Fires `_collect_context_injections` and `_memory_facts_msg` concurrently before each turn. | `profile.context_providers` | `agent.py` | ❌ | @@ -60,7 +57,7 @@ | **Inherit system prompt** | When `inherit_system_prompt=True`, prepends parent's `profile.system_prompt` as base layer, then subagent specialization on top. | `inherit_system_prompt` param | `agent.py` | ✅ | | **Context transfer priming** | If `context_transfer` provided, injects it as synthetic user/assistant exchange before task message. | `context_transfer` param | `agent.py` | ❌ | | **Wall-clock timeout** | Monitors elapsed time; aborts and returns `[Sub-agent timed out]` if exceeded. | `timeout_seconds` param (default 300.0) | `agent.py` | ✅ | -| **Subagent planning phase** | Optionally runs full 3-phase planning before tool loop for subagents. | `profile.subagent_planning_enabled` | `agent.py` | ✅ | +| **Subagent planning phase** | Optionally runs the 2-phase planning pipeline (analysis + execution plan) before the subagent's tool loop; sub-agents execute without confirmation. | `profile.subagent_planning_enabled` | `subagent_runner.py` | ✅ | | **Parent session ID passthrough** | Sets session ContextVar to parent's ID so session-aware tools resolve paths correctly. | `parent_session_id` param | `agent.py` | ✅ | | **Dedicated subagent tool list** | Uses `profile.tools.subagent` if non-empty; falls back to `profile.tools.agent`. | `profile.tools.subagent` | `agent.py` | ✅ | | **ContextVar restoration** | Saves/restores `current_session_id`, `current_model`, `current_user_id`, `current_user_role`, `current_user_info` in `finally` block. | None | `agent.py` | ✅ | @@ -69,10 +66,10 @@ | Mechanic | Description | Config / Flags | Files | Docs | |---|---|---|---|---| -| **3-phase planning engine** | Orchestrates Phase 1 (analysis), Phase 2 (review), Phase 3 (execution plan) as async generator. | `profile.planning_phase1_enabled`, `profile.planning_phase2_enabled`, `profile.planning_phase3_enabled`, `profile.planning_mandatory`, `profile.planning_enabled` | `planning.py` | ✅ | -| **Phase 1 — Task analysis** | LLM call reformulates task, identifies subtasks, unknowns, resources. Classifies `MODE: observe | act` (intent). Can output `DIRECT` to skip planning. | `profile.think_enabled`, `profile.planning_phase1_enabled` | `planning.py` | ✅ | -| **Observe short-circuit** | When `observe_skips_plan_enabled=True` and Phase 1 outputs `MODE: observe`, returns after Phase 1 — Phase 2/3, the auto-todo, and the "execute step by step" prompt are all skipped. The agent gathers info with tools and answers directly. `MODE: act` and `force_plan` requests still plan normally. | `profile.observe_skips_plan_enabled` | `planning.py` | ✅ | -| **Phase 2 — Structured review** | One critique pass when `planning_phase2_enabled=True` and Phase 1 outputs `REFLECT: yes`. Returns Critic/Pragmatist/Detailer/Plan Adjustments. | `profile.planning_phase2_enabled` | `planning.py` | ✅ | +| **2-phase planning engine** | Orchestrates Phase 1 (analysis) and Phase 3 (execution plan) as an async generator. Top-level entry is the `plan` tool; sub-agents run it automatically before their tool loop. | `profile.planning_phase1_enabled`, `profile.planning_phase3_enabled` | `planning.py`, `plan.py` | ✅ | +| **Phase 1 — Task analysis** | LLM call (think=False, conversation windowed to ~20k chars with the original task pinned) reformulates task, identifies subtasks, unknowns, resources, and classifies `COMPLEXITY: simple \| medium \| complex` (the `plan` tool picks the confirmation instruction from it). Sub-agents can output `DIRECT` to skip planning for trivial subtasks. | `profile.planning_phase1_enabled` | `planning.py` | ✅ | +| **Confirmation by COMPLEXITY** | The `plan` tool result instructs the agent to present the plan and wait for user confirmation when `COMPLEXITY=complex`, and to proceed immediately otherwise. Sub-agents get the injected "Execute it now" prompt instead. | None | `plan.py`, `planning.py` | ✅ | +| **Re-plan framing** | `plan` with a `reason` runs the pipeline with `is_replan=True` and a packed context (reason + goal + todo + scratchpad findings/errors); Phase 1 frames the run as a revision and the todo is replaced. | None | `plan.py`, `planning.py` | ✅ | | **Phase 3 — Execution plan** | Produces milestones + numbered steps with executor assignments (`TOOL:`, `AGENT:`, `SELF`). Enforces comma-test splitting. | `profile.planning_phase3_enabled` | `planning.py` | ✅ | | **Auto-populate todo from plan** | Parses Phase 3 steps and calls `todo.set_tasks()` to initialize session todo list. | None | `planning.py` | ✅ | | **Plan step parser** | Regex extracts numbered step lines from `**Steps:**` section. | None | `planning.py` | ❌ | @@ -105,14 +102,14 @@ | **Threshold-based trigger** | Returns `True` when `context_tokens >= max_context_tokens * threshold`. | `CONTEXT_COMPRESSION_THRESHOLD` | `compressor.py` | ✅ | | **Turn-based partitioning** | Groups messages into turns. Keeps last `keep_recent` turns verbatim; older go to summarization. Tool call groups never split. Adaptive: `_turn_importance` scores turns and swaps an important old turn into the kept set in place of a filler-recent one. | `CONTEXT_KEEP_RECENT`, `profile.compression_keep_recent` | `compressor.py` | ✅ | | **Intra-turn fallback partitioning** | `partition_current_turn_messages`: for long autonomous loops where the entire conversation is one turn, keeps the current request + newest N messages verbatim and summarizes older messages from the same turn. | `keep_recent_messages` param | `compressor.py` | ✅ | -| **Honest `CompressionStarted` guard** | `would_compress()` runs only the partition decision + token-budget check (no LLM) so the agent emits `CompressionStarted` only when the partition can actually shrink the stored context — never a misleading "compression" status with no work done. | None | `compressor.py` | ✅ | +| **Honest `CompressionStarted` guard** | `would_compress()` runs the same `_plan_compression` decision as the real compression path (no LLM) + the token-budget check, so the agent emits `CompressionStarted` only when the partition can actually shrink the stored context — never a misleading "compression" status with no work done. The partition/hysteresis/fallback decision lives in one function shared with `compress_context`, so prediction and reality cannot drift apart. | None | `compressor.py` | ✅ | | **Token-budget hard-truncate fallback** | When `compress_session` gets a partition no-op but the token gate fired, drops oldest turns (keeping system + newest whole turns) until under `_HARD_TRUNCATE_TOKEN_FRAC=0.5` of the window. Last-resort, no LLM. | `OLLAMA_NUM_CTX` | `compressor.py` | ✅ | | **Per-message view truncation** | `ContextBuilder._truncate_oversized` head/tail-truncates a single `tool`/`assistant` message whose estimated size exceeds the per-message budget in the *built* context only — stored history is never mutated. 0 = auto (`OLLAMA_NUM_CTX // 6`). | `CONTEXT_MESSAGE_TOKEN_BUDGET` | `context_builder.py` | ✅ | | **Profile-aware compression overrides** | `compression_keep_recent`, `compression_max_tokens`, `compression_prompt_file` override the global defaults inside `compress_context` / `compress_session` / the summary system prompt. `Agent.set_profile()` propagates the active profile to the compressor. | `profile.compression_keep_recent`, `profile.compression_max_tokens`, `profile.compression_prompt_file` | `compressor.py`, `profiles/base.py` | ✅ | -| **Real-token baseline estimator** | `record_real_baseline()` caches the last call's real `prompt_tokens` keyed by `len(session.context)`; `real_baseline_estimate()` returns `real_tokens + heuristic(messages since)` so code-heavy contexts are estimated accurately instead of undercounted by chars//3. Cleared after compression. | None | `compressor.py`, `agent.py` | ✅ | -| **Summary input formatter** | Renders messages as plain text for summarizer: preserves summaries, notes image counts, renders tool calls compactly, collects base64 images for vision models. Critical tool results (`is_compression_critical` or critical tool names) survive verbatim up to 4000 chars; others capped at 300. | None | `compressor.py` | ✅ | -| **Summary input truncate** | Hard cap of 24 000 chars on formatted input sent to summarizer LLM. | Hard-coded `_MAX_SUMMARY_INPUT_CHARS=24000` | `compressor.py` | ✅ | -| **Meta-summary consolidation** | When `to_summarize` contains multiple existing summaries totaling > `_META_SUMMARY_THRESHOLD=8000` chars, consolidates them into one via `_meta_summarize` first so old summaries don't crowd the summarizer input. | None | `compressor.py` | ✅ | +| **Real-token baseline estimator** | `record_real_baseline()` caches the last call's real `prompt_tokens` keyed by `len(session.context)`; `real_baseline_estimate()` returns `real_tokens + heuristic(messages since)` so code-heavy contexts are estimated accurately instead of undercounted by chars//3. Used by the pre-turn, mid-turn and `/compact` gates and `check_context_size`. Cleared after compression. | None | `compressor.py`, `agent.py` | ✅ | +| **Summary input formatter** | Renders messages as plain text for summarizer: preserves summaries, notes image counts, renders tool calls compactly, collects base64 images for vision models. Critical tool results (`is_compression_critical` or critical tool names) survive verbatim up to 4000 chars, then head+tail halves (2000+2000) instead of collapsing to the non-critical preview; non-critical results capped at an 800-char preview. | None | `compressor.py` | ✅ | +| **Summary input truncate** | Hard cap of 32 000 chars on formatted input sent to summarizer LLM, keeping head (75%, oldest messages) + tail (25%, the messages closest to the kept window) — the newest summarized work is never silently dropped. | Hard-coded `_MAX_SUMMARY_INPUT_CHARS=32000` | `compressor.py` | ✅ | +| **Meta-summary consolidation** | When `to_summarize` contains multiple existing summaries totaling > `_META_SUMMARY_THRESHOLD≈10666` chars, consolidates them into one via `_meta_summarize` first so old summaries don't crowd the summarizer input. | None | `compressor.py` | ✅ | | **LLM-based summarization** | Calls LLM with structured summarization prompt (`think=False` for speed) and replaces old messages with an `is_summary=True` user message. Failure is non-fatal. | `CONTEXT_SUMMARY_TEMPERATURE`, `CONTEXT_SUMMARY_MAX_TOKENS`, `profile.compression_max_tokens` | `compressor.py` | ✅ | | **Compression result events** | `CompressionStarted` (status) and `ContextCompressed` (carries `summary`, `messages_before`, `messages_after`, `context_tokens`, `max_context_tokens`). The TUI renders the `summary` as a `Context compressed: N → M messages` card. | None | `events.py`, `tui/renderers/summary.py` | ✅ | | **Archive-on-compress** | `compress_and_save_session` archives old `session.messages` rows when `session_messages_window` is exceeded, and appends an `is_compression=True` system marker to `session.messages`. | `SESSION_MESSAGES_WINDOW` | `compressor.py` | ✅ | @@ -245,7 +242,7 @@ | Mechanic | Description | Config / Flags | Files | Docs | |---|---|---|---|---| | **Worker base class** | Abstract base for post-response background tasks. Receives `WorkerContext` (now carrying the active `profile`), may mutate session, return events. | None | `base.py` | ✅ | -| **`CompressionWorker`** | Post-turn compression. Calls `compress_context` with `keep_recent_messages=max(12, CONTEXT_KEEP_RECENT*2)` and `profile=ctx.profile`, mirroring the mid-turn path so a single long autonomous turn compresses too. Replaces old context with summary, marks dropped messages `is_context=False`, resets token count, appends `is_compression=True` marker. | `CONTEXT_COMPRESSION_ENABLED`, `CONTEXT_COMPRESSION_THRESHOLD`, `CONTEXT_KEEP_RECENT`, `CONTEXT_SUMMARY_TEMPERATURE`, `CONTEXT_SUMMARY_MAX_TOKENS`, `profile.compression_*` | `compressor.py` | ✅ | +| **`CompressionWorker`** | Post-turn compression. Gates on the real `context_tokens` from the last LLM call, then delegates to `compress_and_save_session(reason="postturn")` with `keep_recent_messages=max(12, CONTEXT_KEEP_RECENT*2)` — the same pipeline as the pre/mid-turn paths: retry on LLM failure, hard-truncate fallback, token-budget fallback, 65%-target safety net, `is_context` marking, archiving. | `CONTEXT_COMPRESSION_ENABLED`, `CONTEXT_COMPRESSION_THRESHOLD`, `CONTEXT_KEEP_RECENT`, `CONTEXT_SUMMARY_TEMPERATURE`, `CONTEXT_SUMMARY_MAX_TOKENS`, `profile.compression_*` | `compressor.py` | ✅ | | **Worker auto-discovery** | Scans `navi/workers/*.py` and auto-instantiates non-abstract `Worker` subclasses. | None | `__init__.py` | ❌ | ## KV Store (`navi/store/`) @@ -426,7 +423,7 @@ | **Transfer files to/from remote** | `SshExecTool` with connection pooling | | **Process images for LLM** | `ImageViewTool` preprocessing pipeline | | **Validate planning assumptions** | `ReflectTool` (Critic/Pragmatist/Detailer) | -| **Control planning depth** | Planning flags: `planning_phase1/2/3_enabled`, `planning_mandatory` | +| **Control planning depth** | Planning flags: `planning_phase1/3_enabled`, `subagent_planning_enabled` | | **Prevent model drift** | Goal anchoring + iteration budget injection | | **Stop long-running generation** | Cooperative stop via `current_stop_event` | | **Handle model prefill hangs** | Streaming guard wrapper | diff --git a/docs/navi_code.md b/docs/navi_code.md index 986e1ff..431d013 100644 --- a/docs/navi_code.md +++ b/docs/navi_code.md @@ -65,11 +65,11 @@ - Расположение: `navi/profiles/navi_code/`. - База: `developer`, адаптирован под терминал. - Включённые инструменты: - - Native: `terminal`, `filesystem`, `code_exec`, `spawn_agent`, `todo`, `scratchpad`, `reflect`, `list_tools`, `tool_manual`, `switch_profile`, `list_profiles`, `memory`, `schedule_recall`, `manage_recall`. - - MCP: отключены (`"mcp": {}`) для чистого терминального опыта. -- Отключённые инструменты: `share_file`, `content_publish`, `ssh_exec`, `gmail`, `image_view`, `mcp__navi-web`. -- `planning_phase2_enabled: false` — уменьшает latency. -- Ограниченная автономия (bounded autonomy): `scope_boundary_enabled` и `observe_skips_plan_enabled` оба `true`. Агент действует строго в рамках запрошенной области — не лезет в соседние/родительские директории и проекты, не выполняет обнаруженные TODO/roadmap/milestone документы без явного запроса. Запросы `MODE: observe` (посмотреть/прочитать/объяснить) пропускают фазы планирования 2/3 и авто-todo — агент просто собирает информацию инструментами и отвечает. Чтобы воспроизвести прежнее поведение «свободного полёта», отключите оба флага. См. [`docs/profiles.md`](profiles.md#bounded-autonomy) и [`docs/mechanics.md`](mechanics.md). + - Native: `terminal`, `filesystem`, `code_exec`, `spawn_agent`, `todo`, `scratchpad`, `reflect`, `plan`, `image_view`, `ssh_exec`, `list_tools`, `tool_manual`, `switch_profile`, `list_profiles`, `memory`, `schedule_recall`, `manage_recall`. + - MCP: `navi-web` (`search`, `browse`, `request`) — веб-поиск и просмотр страниц. +- Отключённые инструменты: `share_file`, `content_publish`, `gmail`. +- Планирование: фазы 1 и 3 включены, вызываются агентом через инструмент `plan` — нет обязательного пре-тарн гейта. Для комплексных задач (COMPLEXITY=complex) агент излагает план и ждёт подтверждения. +- Ограниченная автономия (bounded autonomy): `scope_boundary_enabled` `true`. Агент действует строго в рамках запрошенной области — не лезет в соседние/родительские директории и проекты, не выполняет обнаруженные TODO/roadmap/milestone документы без явного запроса. Чтобы воспроизвести прежнее поведение «свободного полёта», отключите флаг. См. [`docs/profiles.md`](profiles.md#bounded-autonomy) и [`docs/mechanics.md`](mechanics.md). ## Безопасность diff --git a/docs/profiles.md b/docs/profiles.md index 59b9204..9fd4a90 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -76,33 +76,29 @@ | `anti_stall_enabled` | bool | `true` | Detect looping without todo progress and inject a hard warning. | | `anti_stall_threshold` | int | `8` | Consecutive iterations without progress before stall warning fires. | | `step_validation_enabled` | bool | `false` | Reserved flag — todo validation is unconditional in the current implementation. | -| `adaptive_replan_enabled` | bool | `false` | When a todo step is marked failed, trigger a re-planning pass. Depends on `step_validation_enabled`. | ### Bounded autonomy -Two independent flags that keep an autonomous agent inside the user's literally requested scope. Both default `false` (legacy "free flight" behavior — explore broadly, finish discovered work). Enabled on `navi_code`. +One flag keeps an autonomous agent inside the user's literally requested scope. Defaults `false` (legacy "free flight" behavior — explore broadly, finish discovered work). Enabled on `navi_code`. | Key | Type | Default | Description | |---|---|---|---| | `scope_boundary_enabled` | bool | `false` | Inject a standing `[Scope boundary]` system message telling the agent to act strictly within the requested scope — do not expand to sibling/parent directories or projects, and do not execute discovered TODO/roadmap/milestone/backlog docs unless explicitly asked. Also gates the memory-facts scope filter (see below). | -| `observe_skips_plan_enabled` | bool | `false` | When Phase 1 analysis classifies the request as `MODE: observe` (look/read/explain/inspect/list/find — no changes requested), skip Phase 2/3 — no multi-step execution plan, no auto-todo, no "execute step by step" prompt. The agent gathers info with tools and answers directly. `act` requests still plan normally. | The memory-facts scope filter: when `scope_boundary_enabled` is on, `_memory_facts_msg` drops memory facts whose value is an absolute path outside the session `cwd` (e.g. a stale `project_root` pointing at another project). Facts inside the session cwd and non-path facts are kept. No facts are deleted — only injection is filtered. See [`docs/mechanics.md`](mechanics.md). ### Planning -The planning pipeline runs before the main tool-calling loop and produces a structured execution plan. It has three phases: +Top-level planning is **agent-invoked**: the agent calls the `plan` tool when a task warrants it (add it to `tools.agent.native`). There is no pre-turn gate — free conversation and trivial tasks execute immediately. The pipeline has two phases: -- **Phase 1 — Analysis**: reformulates the task, identifies subtasks and unknowns. Can output `DIRECT` to skip to execution immediately. -- **Phase 2 — Structured review**: one LLM call critiques the Phase 1 analysis through Critic / Pragmatist / Detailer sections and emits Plan Adjustments. Runs only when Phase 1 signals `REFLECT: yes`. +- **Phase 1 — Analysis**: reformulates the task, identifies subtasks and unknowns, classifies `COMPLEXITY: simple | medium | complex`. Sub-agents can output `DIRECT` to skip planning for trivial subtasks (the shortcut is never offered at top level). - **Phase 3 — Execution plan**: assigns each subtask to `TOOL / AGENT / SELF` and uses adaptive plan depth. +When the plan is ready, the tool result tells the agent what to do next based on COMPLEXITY: `complex` — present the plan to the user and wait for confirmation; otherwise — proceed with execution. A re-plan is the same tool called with a `reason` (and optional `updated_goal`); it packs the current todo and scratchpad findings into the planning context and replaces the todo. + | Key | Type | Default | Description | |---|---|---|---| -| `planning_enabled` | bool | `false` | Run the planning pipeline on every user message (not just the first). First-message planning always runs regardless of this flag. | -| `planning_mandatory` | bool | `false` | `true` — the `DIRECT` shortcut is never offered to the model; all three phases always run. `false` — the model can output `DIRECT` in Phase 1 to skip straight to execution. First-message planning is always forced regardless of this flag. | | `planning_phase1_enabled` | bool | `true` | Enable Phase 1 (task analysis). When disabled, Phase 3 runs without analysis context. | -| `planning_phase2_enabled` | bool | `false` | Enable Phase 2 structured review. Adds one LLM call only when Phase 1 signals `REFLECT: yes`. | | `planning_phase3_enabled` | bool | `true` | Enable Phase 3 (structured execution plan). When disabled, only Phase 1 (analysis) runs. | ### Sub-agent planning @@ -157,11 +153,11 @@ Terminal-first local coding assistant. Designed for the Navi Code CLI and single-user local deployments: -- **Native tools:** `todo`, `scratchpad`, `reflect`, `switch_profile`, `list_profiles`, `filesystem`, `code_exec`, `terminal`, `memory`, `list_tools`, `tool_manual`, `spawn_agent`, `schedule_recall`, `manage_recall`. -- **MCP tools:** disabled (`"mcp": {}`) for the terminal experience. -- **Excluded:** `share_file`, `content_publish`, `ssh_exec`, `gmail`, `image_view`, `mcp__navi-web`. -- **Planning:** Phase 1 and Phase 3 enabled, Phase 2 disabled to reduce latency. Phase 1 classifies the request as `MODE: observe | act`. -- **Bounded autonomy:** `scope_boundary_enabled` and `observe_skips_plan_enabled` are both `true` — the agent stays within the requested scope (does not climb to sibling projects or execute discovered milestone/TODO docs) and `observe` requests (look/read/explain) skip the execution plan and just answer. Flip both off to reproduce the legacy "free flight" behavior. +- **Native tools:** `todo`, `scratchpad`, `reflect`, `plan`, `switch_profile`, `list_profiles`, `filesystem`, `code_exec`, `terminal`, `image_view`, `ssh_exec`, `memory`, `list_tools`, `tool_manual`, `spawn_agent`, `schedule_recall`, `manage_recall`. +- **MCP tools:** `navi-web` (`search`, `browse`, `request`) — web lookup and page browsing. +- **Excluded:** `share_file`, `content_publish`, `gmail`. +- **Planning:** Phase 1 and Phase 3 enabled; the `plan` tool is in the native tool set, so the agent plans deliberately when a task warrants it. Phase 1 classifies `COMPLEXITY: simple | medium | complex` — complex tasks get "present the plan and wait for confirmation". +- **Bounded autonomy:** `scope_boundary_enabled` is `true` — the agent stays within the requested scope (does not climb to sibling projects or execute discovered milestone/TODO docs). Flip it off to reproduce the legacy "free flight" behavior. - **Safety:** an authoritative backend permission gate for destructive tool calls is specified in [`permissions.md`](permissions.md) (designed, not yet implemented). The old prompt-level "confirm before destructive ops" nudge and the client-side permission dialog were removed. Use it with `NAVI_DEFAULT_PROFILE_ID=navi_code` so `POST /sessions` without a `profile_id` creates a `navi_code` session automatically. See [`docs/navi_code.md`](navi_code.md) for the full local-terminal setup. @@ -201,7 +197,7 @@ "max_iterations": 20, "tools": { "agent": { - "native": ["todo", "scratchpad", "filesystem", "terminal"], + "native": ["todo", "scratchpad", "plan", "filesystem", "terminal"], "mcp": { "navi-web": ["search"] } @@ -211,10 +207,7 @@ "mcp": {} } }, - "planning_enabled": true, - "planning_mandatory": false, "planning_phase1_enabled": true, - "planning_phase2_enabled": false, "planning_phase3_enabled": true, "think_enabled": true, "iteration_budget_enabled": true, @@ -223,9 +216,7 @@ "anti_stall_enabled": true, "anti_stall_threshold": 8, "step_validation_enabled": false, - "adaptive_replan_enabled": false, "scope_boundary_enabled": false, - "observe_skips_plan_enabled": false, "subagent_planning_enabled": false } ``` diff --git a/docs/sessions.md b/docs/sessions.md index fe030ea..b2a3f9a 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -30,7 +30,7 @@ | `is_plan: bool` | `False` | Message is a planning phase output (shown as plan card in UI, not text) | | `is_compression: bool` | `False` | Marker message injected when context compression ran (carries the summary text, `is_context=False`) | | `is_summary: bool` | `False` | A summary message replacing compressed history in `session.context` (`role=user`) | -| `is_compression_critical: bool` | `False` | Tool result kept verbatim (up to 4000 chars) by the summary formatter instead of capped | +| `is_compression_critical: bool` | `False` | Tool result kept verbatim by the summary formatter (up to 4000 chars, then head+tail halves) instead of the 800-char preview cap | | `is_recall: bool` | `False` | Message was generated by a scheduled recall (styled differently in UI) | | `thinking: str \| None` | `None` | LLM reasoning captured during a tool-calling turn | | `metadata: dict` | `{}` | Tool result metadata (e.g. `is_image`, `base64`, `step_text`) | @@ -86,18 +86,19 @@ Three trigger points: -1. **Pre-turn** (in `run_stream()` → `_compression_events_preturn`): before the first LLM call of a turn, estimates tokens via `estimate_context_tokens(session.context)` (not the stored `context_token_count`) and compresses when `tokens >= num_ctx * threshold`. Guarded by `would_compress()` so `CompressionStarted` is only emitted when the partition can actually shrink the stored context. -2. **Mid-turn** (in `run_stream()` → `_compression_events_midturn`, every iteration > 0): estimates tokens via `real_baseline_estimate(session.context, preflight_ctx)` — real `prompt_tokens` from the previous call (bulk) + a heuristic delta for messages appended since — and compresses with `keep_recent_messages=max(12, context_keep_recent*2)`. This is what keeps long autonomous loops (one user message + many tool iterations = one turn) from exhausting the window. -3. **Post-turn** (via `CompressionWorker`): after `StreamEnd`, the worker re-checks (using the real `context_tokens` from the last call) and compresses if needed, mirroring the mid-turn `keep_recent_messages`. +1. **Pre-turn** (in `run_stream()` → `_compression_events_preturn`): before the first LLM call of a turn, estimates tokens via `real_baseline_estimate(session.context, session.context)` (real `prompt_tokens` from the last call + heuristic delta for the messages appended since it; `chars // 3` heuristic only when no baseline exists yet) and compresses when `tokens >= num_ctx * threshold`. Guarded by `would_compress()` so `CompressionStarted` is only emitted when the partition can actually shrink the stored context. +2. **Mid-turn** (in `run_stream()` → `_compression_events_midturn`, every iteration > 0): estimates tokens via `real_baseline_estimate(session.context, preflight_ctx)` and compresses with `keep_recent_messages=max(12, context_keep_recent*2)`. This is what keeps long autonomous loops (one user message + many tool iterations = one turn) from exhausting the window. +3. **Post-turn** (via `CompressionWorker`): after `StreamEnd`, the worker re-checks (using the real `context_tokens` from the last call) and delegates to `compress_and_save_session(reason="postturn")` with the mid-turn `keep_recent_messages` — the same pipeline (retry, hard-truncate, safety net, archiving) as the pre/mid-turn paths. A fourth, on-demand path is **forced `/compact`** (`compact_stream()`): the client sends `{"type":"compact"}`, bypassing the threshold entirely; emits `CompressionStarted` + `ContextCompressed`; raises `NothingToCompactError` when there is nothing to compress. Config values (`settings`): - `context_compression_enabled: bool = True` -- `context_compression_threshold: float = 0.70` — trigger at 70% of `ollama_num_ctx` +- `context_compression_threshold: float = 0.90` — trigger at 90% of `ollama_num_ctx` +- `context_compression_target: float = 0.65` — hysteresis target: after compression the kept region should fit ~65% of the window, so the trigger doesn't re-fire a few messages later - `context_keep_recent: int = 8` — keep last N conversational turns verbatim - `context_summary_temperature: float = 0.3` -- `context_summary_max_tokens: int = 4000` — max output tokens for the summary LLM call +- `context_summary_max_tokens: int = 6000` — max output tokens for the summary LLM call - `output_reserve_tokens: int = 2048` — headroom reserved for the response in `check_context_size` - `context_message_token_budget: int = 0` — per-message view truncation budget (`0` = auto, `ollama_num_ctx // 6`) @@ -105,29 +106,29 @@ ### Context size guard -Before every LLM call, `check_context_size(built_ctx, session_context=session.context)` raises `ContextTooLargeError` when the estimated input exceeds `ollama_num_ctx - output_reserve_tokens`. The total uses `real_baseline_estimate()` when a baseline is available (real bulk + heuristic delta), falling back to the `chars // 3 + imgs*500` estimate. The error is surfaced to the user as a synthesized assistant response + `StreamEnd` rather than a raw system error. +Before every LLM call, `check_context_size(built_ctx, session_context=session.context)` raises `ContextTooLargeError` when the estimated input exceeds `ollama_num_ctx - output_reserve_tokens`. The total uses `real_baseline_estimate()` when a baseline is available (real bulk + heuristic delta), falling back to the `chars // 3 + 500 per image` estimate. The error is surfaced to the user as a synthesized assistant response + `StreamEnd` rather than a raw system error. ### Compression algorithm `compress_context(context, llm, model, temperature, keep_recent, *, max_tokens=None, keep_recent_messages=None, profile=None)`: -1. Resolve effective `keep_recent`/`max_tokens` from `profile.compression_*` overrides. Partition messages into `to_summarize` (old turns) and `to_keep` (recent `keep_recent` turns). +1. Resolve effective `keep_recent`/`max_tokens` from `profile.compression_*` overrides, then run `_plan_compression` — the single decision point shared with `would_compress()` (dry-run), so the prediction and the real compression can never drift apart. It partitions messages into `to_summarize` (old turns) and `to_keep` (recent `keep_recent` turns), shrinks `keep_recent` until the kept region fits the 65% target (turn-based mode), and retries with `keep_recent_messages=2` when the mid-turn partition found nothing. - A "turn" = one user message + all following assistant/tool messages up to the next user message. - Tool call groups (assistant + results) are never split across the partition. - **Adaptive partitioning:** `_turn_importance` scores each turn; an important old turn can be swapped into the kept set in place of a filler-recent one. - **Intra-turn fallback** (`partition_current_turn_messages`, when `keep_recent_messages` is set): for a single long turn, keeps the current request + newest N messages verbatim and summarizes older messages from the same turn. -2. **Meta-summary:** if `to_summarize` contains multiple existing summaries totaling > `_META_SUMMARY_THRESHOLD = 8000` chars, consolidate them into one via `_meta_summarize` first so old summaries don't crowd the summarizer input. -3. Format `to_summarize` as plain text. Tool calls are shown as compact previews (max 120 chars for args). **Critical** tool results (`is_compression_critical=True` or critical tool names) survive verbatim up to 4000 chars; others are capped at 300 chars. Base64 images are collected for vision models. -4. Truncate formatted input to `_MAX_SUMMARY_INPUT_CHARS = 24_000` chars. +2. **Meta-summary:** if `to_summarize` contains multiple existing summaries totaling > `_META_SUMMARY_THRESHOLD ≈ 10_600` chars, consolidate them into one via `_meta_summarize` first so old summaries don't crowd the summarizer input. +3. Format `to_summarize` as plain text. Tool calls are shown as compact previews (max 120 chars for args). **Critical** tool results (`is_compression_critical=True` or critical tool names) survive verbatim up to 4000 chars, then head+tail halves (2000+2000) instead of collapsing to the non-critical preview; non-critical results are capped at an 800-char preview. Base64 images are collected for vision models. +4. Truncate formatted input to `_MAX_SUMMARY_INPUT_CHARS = 32_000` chars, keeping head (75%, oldest messages) + tail (25%, the messages closest to the kept window) — a head-only cut used to silently drop the newest summarized work. 5. Call `llm.complete()` with `think=False` to produce a bullet-point summary. 6. Replace `to_summarize` with a single summary message (`role=user`, `is_summary=True`). 7. Return `system_msgs + [summary_msg] + to_keep`. `compress_session` wraps `compress_context` with retry + a **token-budget hard-truncate fallback** (`_hard_truncate`): if the LLM summarization fails twice, it drops oldest turns (keeping system + newest whole turns) until under `_HARD_TRUNCATE_TOKEN_FRAC = 0.5` of the window — a last resort with no LLM call. -`compress_and_save_session` then mutates the session: replaces `session.context`, marks dropped messages `is_context=False`, appends the summary (`is_display=False`) and an `is_compression=True` system marker to `session.messages`, resets `context_token_count`, archives old messages when `session_messages_window` is exceeded, and persists. It also clears the real-token baseline (the context just shrank). +`compress_and_save_session` then mutates the session: replaces `session.context`, marks dropped messages `is_context=False`, appends the summary (`is_display=False`) and an `is_compression=True` system marker to `session.messages`, resets `context_token_count`, archives old messages when `session_messages_window` is exceeded, persists, and logs `compressor.compressed` with the trigger `reason` (`preturn` / `midturn` / `postturn` / `forced`). It also clears the real-token baseline (the context just shrank). -If compression fails, the exception propagates to `CompressionWorker`, which logs a warning and continues — compression failure is non-fatal. +All four trigger paths go through `compress_and_save_session`, so a summarizer LLM failure is always non-fatal: retry with a wider keep window, then hard-truncate. `CompressionWorker` only catches genuinely unexpected errors (e.g. a store failure) and logs a warning. ### What is never compressed diff --git a/docs/tools.md b/docs/tools.md index 0a71eb1..681f5bd 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -52,6 +52,7 @@ | `CreateMcpServerTool` | `create_mcp_server` | Scaffold a new MCP server directory with boilerplate | | `TestMcpToolTool` | `test_mcp_tool` | Execute a single MCP tool call in isolation for diagnostics | | `ReflectTool` | `reflect` | Self-reflection and analysis | +| `PlanTool` | `plan` | Agent-invoked planning: fresh plan or re-plan with a `reason` (and optional `updated_goal`). The tool result instructs the agent to wait for user confirmation when the task is complex, and to proceed immediately otherwise | | `ScheduleRecallTool` | `schedule_recall` | Schedule a headless callback for the current session (once/recurring/immediate) | | `ManageRecallTool` | `manage_recall` | Cancel, skip, or list scheduled recalls for the current session | @@ -169,7 +170,7 @@ **Scratchpad** — named sections for working notes within a task. Operations: `write`, `append`, `read`, `clear`. Subagents get isolated scratchpads (unique UUID-based session ID in `run_ephemeral()`). -**Todo** — checklist for tracking multi-step plans. Operations: `set` (replace all tasks), `update` (set status of one task), `read`. Statuses: `pending`, `in_progress`, `done`, `failed`, `skipped`. +**Todo** — checklist for tracking multi-step plans. Operations: `set` (replace all tasks), `update` (set status of one task), `read`. Statuses: `pending`, `in_progress`, `done`, `failed`, `skipped`. A successful `plan` tool call auto-populates the todo from the plan's numbered steps. --- diff --git a/docs/websocket.md b/docs/websocket.md index b25bf37..fbd9b8a 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -120,12 +120,12 @@ | Frame | When | |---|---| -| `{"type": "planning_status", "phase": 1|2|3, "label": "...", "is_subagent": bool}` | During planning phase — progress label for UI. `phase`: 1=analysis, 2=reflect, 3=plan | -| `{"type": "plan_ready", "plan": "...", "is_subagent": bool}` | Before tool-calling loop if `planning_enabled` and a plan was generated | +| `{"type": "planning_status", "phase": 1|3, "label": "...", "is_subagent": bool}` | During a planning phase — progress label for UI. `phase`: 1=analysis, 3=plan | +| `{"type": "plan_ready", "plan": "...", "is_subagent": bool}` | When the `plan` tool finishes generating a plan (mid-turn, after its `tool_started` frame). For subagents: automatically before their tool-calling loop if `subagent_planning_enabled` | -`planning_status` frames arrive during each planning phase (analysis → optional reflect → plan). `is_subagent: true` means the planning is running inside a subagent — route it into the spawn_agent card, never into the top-level UI. +`planning_status` frames arrive during each planning phase (analysis → plan). `is_subagent: true` means the planning is running inside a subagent — route it into the spawn_agent card, never into the top-level UI. -`plan_ready` carries the formatted step list. Rendered as a collapsible plan card in the UI. +`plan_ready` carries the formatted step list. Rendered as a collapsible plan card in the UI. At top level the card appears mid-turn — planning is agent-invoked via the `plan` tool, so there is no pre-turn planning step. ### Tool calls