Make auto-compact threshold configurable
- commit
- 31e85d198e3c5611ff1e337875300f9ba03916cd
- author
- mi-skam · · 2026-07-28 11:20 UTC
- parent
- 6b2855e
- signature
- SSH Unverified
SHA256:Yu1uEv3+xppM2hvm7BsWney9tUWafRiTe/kq8zX8X0wThe key is unknown
9 files · +523 -11
M README.md +3 -2
| @@ -282,7 +282,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 282 | 282 | | `/unjail` | Allow tools to touch paths outside again. | | |
| 283 | 283 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | |
| 284 | 284 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | |
| 285 | - | | `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 285 | + | | `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | |
| 286 | 286 | | `/clear` | Clear the chat transcript. | | |
| 287 | 287 | | `/exit` | Exit zot. | | |
| 288 | 288 | ||
| @@ -378,6 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 378 | 378 | ||
| 379 | 379 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 380 | 380 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 381 | + | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses. | |
| 381 | 382 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | |
| 382 | 383 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | |
| 383 | 384 | - **show loaded resources at startup**: list loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | |
| @@ -394,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||
| 394 | 395 | ||
| 395 | 396 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | |
| 396 | 397 | ||
| 397 | - | zot also auto-compacts in the background: after any turn that leaves context usage at or above **85%** of the model's window, the agent kicks off a condense pass on its own. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. | |
| 398 | + | zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response. | |
| 398 | 399 | ||
| 399 | 400 | ### `/jail` | |
| 400 | 401 | ||
| @@ -282,7 +282,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 282 | | `/unjail` | Allow tools to touch paths outside again. | | 282 | | `/unjail` | Allow tools to touch paths outside again. | | ||
| 283 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | 283 | | `/reload-ext` | Hot-reload all extensions (re-read manifests, respawn subprocesses, rebuild tool registry). | | ||
| 284 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | 284 | | `/telegram` | Connect, disconnect, or show status of the Telegram bridge (takes `connect` / `disconnect` / `status` as an optional argument; opens a picker without one). When connected, DMs from the paired user become prompts in the running session and the assistant's replies are mirrored back to Telegram. Alias: `/tg`. | | ||
| 285 | - | | `/settings` | Toggle persistent settings (inline images, auto-swarm) with `enter`/`space`. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | | 285 | + | | `/settings` | Change persistent settings, including inline images, auto-swarm, and the auto-compact threshold. Saved to `$ZOT_HOME/config.json`; takes effect immediately. | |
| 286 | | `/clear` | Clear the chat transcript. | | 286 | | `/clear` | Clear the chat transcript. | | ||
| 287 | | `/exit` | Exit zot. | | 287 | | `/exit` | Exit zot. | | ||
| 288 | 288 | ||||
| @@ -378,6 +378,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 378 | 378 | ||||
| 379 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | 379 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | ||
| 380 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | 380 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | ||
| 381 | + | - **auto-compact threshold** — choose `off`, `70%`, `80%`, `85%` (default), or `90%` of the model's advertised context window. The selected percentage controls automatic compaction before and after interactive turns and persists as `auto_compact_threshold`. `off` disables percentage-based triggers but keeps manual `/compact` and automatic recovery from payload-too-large responses. | |||
| 381 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | 382 | - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default. | ||
| 382 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | 383 | - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`. | ||
| 383 | - **show loaded resources at startup**: list loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | 384 | - **show loaded resources at startup**: list loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`. | ||
| @@ -394,7 +395,7 @@ Opens a picker listing every discovered SKILL.md file, built-ins hidden. Each ro | |||||
| 394 | 395 | ||||
| 395 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | 396 | Sends the current transcript through the model with a structured summarization prompt. The returned summary replaces the transcript as one synthetic user message, with the last few exchanges kept verbatim for continuity. The status bar's context meter resets. Use it when the context meter creeps past ~80%. | ||
| 396 | 397 | ||||
| 397 | - | zot also auto-compacts in the background: after any turn that leaves context usage at or above **85%** of the model's window, the agent kicks off a condense pass on its own. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. | 398 | + | zot also auto-compacts in the background: after any turn that reaches the configured context threshold, the agent kicks off a condense pass on its own. Choose `off`, `70%`, `80%`, `85%` (default), or `90%` under `/settings` → **auto-compact threshold**. You'll see `condensing history, esc to cancel` above the status bar and an `(auto)` tag next to the context percentage; `esc` aborts it without touching the transcript. Turning the percentage trigger off does not disable automatic compaction and retry after a payload-too-large response. |
| 398 | 399 | ||||
| 399 | ### `/jail` | 400 | ### `/jail` | ||
| 400 | 401 | ||||
A docs/superpowers/plans/2026-07-28-configurable-auto-compact-threshold.md +239 -0
| @@ -0,0 +1,239 @@ | |||
| 1 | + | # Configurable Auto-Compact Threshold Implementation Plan | |
| 2 | + | ||
| 3 | + | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | + | ||
| 5 | + | **Goal:** Add persisted auto-compaction threshold presets to the interactive TUI settings. | |
| 6 | + | ||
| 7 | + | **Architecture:** Keep automatic triggering in `packages/agent/modes`. Thread an optional integer percentage from persisted `agent.Config` into `modes.InteractiveConfig`, normalize missing or invalid values to 85%, and let zero disable only percentage-based triggers. Reuse the settings dialog's existing fixed-option picker and settings-store persistence path. | |
| 8 | + | ||
| 9 | + | **Tech Stack:** Go, standard library, Zot's existing TUI settings dialog, JSON configuration. | |
| 10 | + | ||
| 11 | + | ## Global Constraints | |
| 12 | + | ||
| 13 | + | - Supported values are `0` (off), `70`, `80`, `85`, and `90`. | |
| 14 | + | - Missing or invalid persisted values resolve to `85`. | |
| 15 | + | - Disabling the threshold does not disable manual compaction or HTTP 413 recovery. | |
| 16 | + | - Do not add dependencies or change non-interactive compaction behavior. | |
| 17 | + | - Preserve unrelated working-tree changes and stage only this feature. | |
| 18 | + | ||
| 19 | + | --- | |
| 20 | + | ||
| 21 | + | ### Task 1: Configuration and threshold decision | |
| 22 | + | ||
| 23 | + | **Files:** | |
| 24 | + | - Modify: `packages/agent/config.go` | |
| 25 | + | - Modify: `packages/agent/modes/interactive.go` | |
| 26 | + | - Create: `packages/agent/modes/auto_compact_test.go` | |
| 27 | + | - Modify: `packages/agent/cli.go` | |
| 28 | + | ||
| 29 | + | **Interfaces:** | |
| 30 | + | - Produces: `Config.AutoCompactThreshold *int` | |
| 31 | + | - Produces: `InteractiveConfig.AutoCompactThreshold *int` | |
| 32 | + | - Produces: `normalizeAutoCompactThreshold(*int) int` | |
| 33 | + | - Produces: `shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool` | |
| 34 | + | ||
| 35 | + | - [ ] **Step 1: Write failing normalization and decision tests** | |
| 36 | + | ||
| 37 | + | Add table-driven tests proving nil and invalid values resolve to 85, supported presets survive unchanged, zero disables percentage checks, and 70/80/85/90 trigger exactly at their boundaries. | |
| 38 | + | ||
| 39 | + | ```go | |
| 40 | + | func TestNormalizeAutoCompactThreshold(t *testing.T) { | |
| 41 | + | tests := []struct { | |
| 42 | + | name string | |
| 43 | + | value *int | |
| 44 | + | want int | |
| 45 | + | }{ | |
| 46 | + | {name: "missing", value: nil, want: 85}, | |
| 47 | + | {name: "off", value: intPtr(0), want: 0}, | |
| 48 | + | {name: "seventy", value: intPtr(70), want: 70}, | |
| 49 | + | {name: "invalid", value: intPtr(42), want: 85}, | |
| 50 | + | } | |
| 51 | + | // Assert normalizeAutoCompactThreshold(test.value) == test.want. | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |
| 55 | + | if shouldAutoCompact(84, 100, 85) { | |
| 56 | + | t.Fatal("84% must remain below an 85% threshold") | |
| 57 | + | } | |
| 58 | + | if !shouldAutoCompact(85, 100, 85) { | |
| 59 | + | t.Fatal("85% must trigger an 85% threshold") | |
| 60 | + | } | |
| 61 | + | if shouldAutoCompact(100, 100, 0) { | |
| 62 | + | t.Fatal("off must disable percentage-triggered compaction") | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ``` | |
| 66 | + | ||
| 67 | + | - [ ] **Step 2: Run tests and verify RED** | |
| 68 | + | ||
| 69 | + | Run: | |
| 70 | + | ||
| 71 | + | ```sh | |
| 72 | + | go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1 | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | Expected: compilation failure because the normalization and decision helpers do not exist. | |
| 76 | + | ||
| 77 | + | - [ ] **Step 3: Add optional configuration and minimal helpers** | |
| 78 | + | ||
| 79 | + | Add `AutoCompactThreshold *int` with JSON name `auto_compact_threshold` to both configuration structs. Implement normalization for exactly `0`, `70`, `80`, `85`, and `90`, defaulting all other values to `85`. Replace the hard-coded fraction check with the normalized percentage helper and pass the config field through `cli.go`. | |
| 80 | + | ||
| 81 | + | - [ ] **Step 4: Run focused tests and verify GREEN** | |
| 82 | + | ||
| 83 | + | Run: | |
| 84 | + | ||
| 85 | + | ```sh | |
| 86 | + | go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1 | |
| 87 | + | ``` | |
| 88 | + | ||
| 89 | + | Expected: PASS. | |
| 90 | + | ||
| 91 | + | ### Task 2: Persistence and TUI preset picker | |
| 92 | + | ||
| 93 | + | **Files:** | |
| 94 | + | - Modify: `packages/agent/settings_store.go` | |
| 95 | + | - Modify: `packages/agent/settings_store_test.go` | |
| 96 | + | - Modify: `packages/agent/modes/interactive.go` | |
| 97 | + | - Modify: `packages/agent/modes/auto_compact_test.go` | |
| 98 | + | ||
| 99 | + | **Interfaces:** | |
| 100 | + | - Extends: `SettingsStore.SetAutoCompactThreshold(percent int) error` | |
| 101 | + | - Consumes: `normalizeAutoCompactThreshold(*int) int` | |
| 102 | + | ||
| 103 | + | - [ ] **Step 1: Write failing persistence and settings-item tests** | |
| 104 | + | ||
| 105 | + | Add a settings-store test that writes `70` into an isolated `$ZOT_HOME` and verifies `auto_compact_threshold` plus an unrelated config field. Add a modes test that opens settings with missing configuration and verifies the option values are `0`, `70`, `80`, `85`, `90` with `85` selected. | |
| 106 | + | ||
| 107 | + | ```go | |
| 108 | + | func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) { | |
| 109 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 110 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 111 | + | t.Fatal(err) | |
| 112 | + | } | |
| 113 | + | if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | cfg, err := LoadConfig() | |
| 117 | + | if err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 { | |
| 121 | + | t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold) | |
| 122 | + | } | |
| 123 | + | if cfg.Theme != "dark" { | |
| 124 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ``` | |
| 128 | + | ||
| 129 | + | - [ ] **Step 2: Run tests and verify RED** | |
| 130 | + | ||
| 131 | + | Run: | |
| 132 | + | ||
| 133 | + | ```sh | |
| 134 | + | go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1 | |
| 135 | + | ``` | |
| 136 | + | ||
| 137 | + | Expected: compilation or assertion failure because persistence and the picker row do not exist. | |
| 138 | + | ||
| 139 | + | - [ ] **Step 3: Implement persistence and live setting application** | |
| 140 | + | ||
| 141 | + | Add the settings-store method, fixed picker options, choice resolution, action routing, integer parsing, live `InteractiveConfig` update, and status message. Persist only values exposed by the picker; normalize the live value defensively before use. | |
| 142 | + | ||
| 143 | + | - [ ] **Step 4: Run focused tests and verify GREEN** | |
| 144 | + | ||
| 145 | + | Run: | |
| 146 | + | ||
| 147 | + | ```sh | |
| 148 | + | go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1 | |
| 149 | + | ``` | |
| 150 | + | ||
| 151 | + | Expected: PASS. | |
| 152 | + | ||
| 153 | + | ### Task 3: Documentation and full verification | |
| 154 | + | ||
| 155 | + | **Files:** | |
| 156 | + | - Modify: `README.md` | |
| 157 | + | - Review: `docs/superpowers/specs/2026-07-28-configurable-auto-compact-threshold-design.md` | |
| 158 | + | - Review: `docs/superpowers/plans/2026-07-28-configurable-auto-compact-threshold.md` | |
| 159 | + | ||
| 160 | + | **Interfaces:** | |
| 161 | + | - Documents: `auto_compact_threshold` | |
| 162 | + | - Documents: `/settings` presets and `off` semantics | |
| 163 | + | ||
| 164 | + | - [ ] **Step 1: Update user documentation** | |
| 165 | + | ||
| 166 | + | Document `off`, `70%`, `80%`, `85%` default, and `90%` in the `/settings` section. Update `/compact` to state that threshold-based auto-compaction is configurable and that payload-too-large recovery remains active when the percentage trigger is off. | |
| 167 | + | ||
| 168 | + | - [ ] **Step 2: Format changed Go files** | |
| 169 | + | ||
| 170 | + | Run: | |
| 171 | + | ||
| 172 | + | ```sh | |
| 173 | + | gofmt -w packages/agent/config.go packages/agent/settings_store.go packages/agent/settings_store_test.go packages/agent/cli.go packages/agent/modes/interactive.go packages/agent/modes/auto_compact_test.go | |
| 174 | + | ``` | |
| 175 | + | ||
| 176 | + | - [ ] **Step 3: Run the complete test suite** | |
| 177 | + | ||
| 178 | + | Run: | |
| 179 | + | ||
| 180 | + | ```sh | |
| 181 | + | go test ./... | |
| 182 | + | ``` | |
| 183 | + | ||
| 184 | + | Expected: PASS. | |
| 185 | + | ||
| 186 | + | - [ ] **Step 4: Run static analysis** | |
| 187 | + | ||
| 188 | + | Run: | |
| 189 | + | ||
| 190 | + | ```sh | |
| 191 | + | go vet ./... | |
| 192 | + | ``` | |
| 193 | + | ||
| 194 | + | Expected: PASS. | |
| 195 | + | ||
| 196 | + | - [ ] **Step 5: Inspect the final change** | |
| 197 | + | ||
| 198 | + | Run: | |
| 199 | + | ||
| 200 | + | ```sh | |
| 201 | + | git diff --check | |
| 202 | + | git status --short | |
| 203 | + | git diff | |
| 204 | + | ``` | |
| 205 | + | ||
| 206 | + | Confirm only the configuration, TUI, tests, README, design, and plan belong to the feature. | |
| 207 | + | ||
| 208 | + | ### Task 4: Publish the pull request | |
| 209 | + | ||
| 210 | + | **Files:** | |
| 211 | + | - Stage only the files listed in Tasks 1-3. | |
| 212 | + | ||
| 213 | + | **Interfaces:** | |
| 214 | + | - Produces: branch `codex/configurable-auto-compact-threshold` | |
| 215 | + | - Produces: one feature commit and a ready GitHub pull request | |
| 216 | + | ||
| 217 | + | - [ ] **Step 1: Create the feature branch** | |
| 218 | + | ||
| 219 | + | ```sh | |
| 220 | + | git switch -c codex/configurable-auto-compact-threshold | |
| 221 | + | ``` | |
| 222 | + | ||
| 223 | + | - [ ] **Step 2: Stage explicit feature paths and review** | |
| 224 | + | ||
| 225 | + | Stage only the implementation, tests, README, design, and plan. Review `git diff --cached --check` and `git diff --cached`. | |
| 226 | + | ||
| 227 | + | - [ ] **Step 3: Commit** | |
| 228 | + | ||
| 229 | + | ```sh | |
| 230 | + | git commit -m "Make auto-compact threshold configurable" | |
| 231 | + | ``` | |
| 232 | + | ||
| 233 | + | - [ ] **Step 4: Push the branch** | |
| 234 | + | ||
| 235 | + | Push to the appropriate writable GitHub remote with upstream tracking. | |
| 236 | + | ||
| 237 | + | - [ ] **Step 5: Open a ready pull request** | |
| 238 | + | ||
| 239 | + | Target the upstream default branch. Include the behavior, compatibility, user impact, and exact validation commands in the PR body. | |
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | # Configurable Auto-Compact Threshold Implementation Plan | |||
| 2 | + | ||||
| 3 | + | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |||
| 4 | + | ||||
| 5 | + | **Goal:** Add persisted auto-compaction threshold presets to the interactive TUI settings. | |||
| 6 | + | ||||
| 7 | + | **Architecture:** Keep automatic triggering in `packages/agent/modes`. Thread an optional integer percentage from persisted `agent.Config` into `modes.InteractiveConfig`, normalize missing or invalid values to 85%, and let zero disable only percentage-based triggers. Reuse the settings dialog's existing fixed-option picker and settings-store persistence path. | |||
| 8 | + | ||||
| 9 | + | **Tech Stack:** Go, standard library, Zot's existing TUI settings dialog, JSON configuration. | |||
| 10 | + | ||||
| 11 | + | ## Global Constraints | |||
| 12 | + | ||||
| 13 | + | - Supported values are `0` (off), `70`, `80`, `85`, and `90`. | |||
| 14 | + | - Missing or invalid persisted values resolve to `85`. | |||
| 15 | + | - Disabling the threshold does not disable manual compaction or HTTP 413 recovery. | |||
| 16 | + | - Do not add dependencies or change non-interactive compaction behavior. | |||
| 17 | + | - Preserve unrelated working-tree changes and stage only this feature. | |||
| 18 | + | ||||
| 19 | + | --- | |||
| 20 | + | ||||
| 21 | + | ### Task 1: Configuration and threshold decision | |||
| 22 | + | ||||
| 23 | + | **Files:** | |||
| 24 | + | - Modify: `packages/agent/config.go` | |||
| 25 | + | - Modify: `packages/agent/modes/interactive.go` | |||
| 26 | + | - Create: `packages/agent/modes/auto_compact_test.go` | |||
| 27 | + | - Modify: `packages/agent/cli.go` | |||
| 28 | + | ||||
| 29 | + | **Interfaces:** | |||
| 30 | + | - Produces: `Config.AutoCompactThreshold *int` | |||
| 31 | + | - Produces: `InteractiveConfig.AutoCompactThreshold *int` | |||
| 32 | + | - Produces: `normalizeAutoCompactThreshold(*int) int` | |||
| 33 | + | - Produces: `shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool` | |||
| 34 | + | ||||
| 35 | + | - [ ] **Step 1: Write failing normalization and decision tests** | |||
| 36 | + | ||||
| 37 | + | Add table-driven tests proving nil and invalid values resolve to 85, supported presets survive unchanged, zero disables percentage checks, and 70/80/85/90 trigger exactly at their boundaries. | |||
| 38 | + | ||||
| 39 | + | ```go | |||
| 40 | + | func TestNormalizeAutoCompactThreshold(t *testing.T) { | |||
| 41 | + | tests := []struct { | |||
| 42 | + | name string | |||
| 43 | + | value *int | |||
| 44 | + | want int | |||
| 45 | + | }{ | |||
| 46 | + | {name: "missing", value: nil, want: 85}, | |||
| 47 | + | {name: "off", value: intPtr(0), want: 0}, | |||
| 48 | + | {name: "seventy", value: intPtr(70), want: 70}, | |||
| 49 | + | {name: "invalid", value: intPtr(42), want: 85}, | |||
| 50 | + | } | |||
| 51 | + | // Assert normalizeAutoCompactThreshold(test.value) == test.want. | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |||
| 55 | + | if shouldAutoCompact(84, 100, 85) { | |||
| 56 | + | t.Fatal("84% must remain below an 85% threshold") | |||
| 57 | + | } | |||
| 58 | + | if !shouldAutoCompact(85, 100, 85) { | |||
| 59 | + | t.Fatal("85% must trigger an 85% threshold") | |||
| 60 | + | } | |||
| 61 | + | if shouldAutoCompact(100, 100, 0) { | |||
| 62 | + | t.Fatal("off must disable percentage-triggered compaction") | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ``` | |||
| 66 | + | ||||
| 67 | + | - [ ] **Step 2: Run tests and verify RED** | |||
| 68 | + | ||||
| 69 | + | Run: | |||
| 70 | + | ||||
| 71 | + | ```sh | |||
| 72 | + | go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1 | |||
| 73 | + | ``` | |||
| 74 | + | ||||
| 75 | + | Expected: compilation failure because the normalization and decision helpers do not exist. | |||
| 76 | + | ||||
| 77 | + | - [ ] **Step 3: Add optional configuration and minimal helpers** | |||
| 78 | + | ||||
| 79 | + | Add `AutoCompactThreshold *int` with JSON name `auto_compact_threshold` to both configuration structs. Implement normalization for exactly `0`, `70`, `80`, `85`, and `90`, defaulting all other values to `85`. Replace the hard-coded fraction check with the normalized percentage helper and pass the config field through `cli.go`. | |||
| 80 | + | ||||
| 81 | + | - [ ] **Step 4: Run focused tests and verify GREEN** | |||
| 82 | + | ||||
| 83 | + | Run: | |||
| 84 | + | ||||
| 85 | + | ```sh | |||
| 86 | + | go test ./packages/agent/modes -run 'TestNormalizeAutoCompactThreshold|TestShouldAutoCompactUsesConfiguredThreshold' -count=1 | |||
| 87 | + | ``` | |||
| 88 | + | ||||
| 89 | + | Expected: PASS. | |||
| 90 | + | ||||
| 91 | + | ### Task 2: Persistence and TUI preset picker | |||
| 92 | + | ||||
| 93 | + | **Files:** | |||
| 94 | + | - Modify: `packages/agent/settings_store.go` | |||
| 95 | + | - Modify: `packages/agent/settings_store_test.go` | |||
| 96 | + | - Modify: `packages/agent/modes/interactive.go` | |||
| 97 | + | - Modify: `packages/agent/modes/auto_compact_test.go` | |||
| 98 | + | ||||
| 99 | + | **Interfaces:** | |||
| 100 | + | - Extends: `SettingsStore.SetAutoCompactThreshold(percent int) error` | |||
| 101 | + | - Consumes: `normalizeAutoCompactThreshold(*int) int` | |||
| 102 | + | ||||
| 103 | + | - [ ] **Step 1: Write failing persistence and settings-item tests** | |||
| 104 | + | ||||
| 105 | + | Add a settings-store test that writes `70` into an isolated `$ZOT_HOME` and verifies `auto_compact_threshold` plus an unrelated config field. Add a modes test that opens settings with missing configuration and verifies the option values are `0`, `70`, `80`, `85`, `90` with `85` selected. | |||
| 106 | + | ||||
| 107 | + | ```go | |||
| 108 | + | func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) { | |||
| 109 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 110 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 111 | + | t.Fatal(err) | |||
| 112 | + | } | |||
| 113 | + | if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | cfg, err := LoadConfig() | |||
| 117 | + | if err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 { | |||
| 121 | + | t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold) | |||
| 122 | + | } | |||
| 123 | + | if cfg.Theme != "dark" { | |||
| 124 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ``` | |||
| 128 | + | ||||
| 129 | + | - [ ] **Step 2: Run tests and verify RED** | |||
| 130 | + | ||||
| 131 | + | Run: | |||
| 132 | + | ||||
| 133 | + | ```sh | |||
| 134 | + | go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1 | |||
| 135 | + | ``` | |||
| 136 | + | ||||
| 137 | + | Expected: compilation or assertion failure because persistence and the picker row do not exist. | |||
| 138 | + | ||||
| 139 | + | - [ ] **Step 3: Implement persistence and live setting application** | |||
| 140 | + | ||||
| 141 | + | Add the settings-store method, fixed picker options, choice resolution, action routing, integer parsing, live `InteractiveConfig` update, and status message. Persist only values exposed by the picker; normalize the live value defensively before use. | |||
| 142 | + | ||||
| 143 | + | - [ ] **Step 4: Run focused tests and verify GREEN** | |||
| 144 | + | ||||
| 145 | + | Run: | |||
| 146 | + | ||||
| 147 | + | ```sh | |||
| 148 | + | go test ./packages/agent ./packages/agent/modes -run 'AutoCompactThreshold|SettingsDialog.*AutoCompact' -count=1 | |||
| 149 | + | ``` | |||
| 150 | + | ||||
| 151 | + | Expected: PASS. | |||
| 152 | + | ||||
| 153 | + | ### Task 3: Documentation and full verification | |||
| 154 | + | ||||
| 155 | + | **Files:** | |||
| 156 | + | - Modify: `README.md` | |||
| 157 | + | - Review: `docs/superpowers/specs/2026-07-28-configurable-auto-compact-threshold-design.md` | |||
| 158 | + | - Review: `docs/superpowers/plans/2026-07-28-configurable-auto-compact-threshold.md` | |||
| 159 | + | ||||
| 160 | + | **Interfaces:** | |||
| 161 | + | - Documents: `auto_compact_threshold` | |||
| 162 | + | - Documents: `/settings` presets and `off` semantics | |||
| 163 | + | ||||
| 164 | + | - [ ] **Step 1: Update user documentation** | |||
| 165 | + | ||||
| 166 | + | Document `off`, `70%`, `80%`, `85%` default, and `90%` in the `/settings` section. Update `/compact` to state that threshold-based auto-compaction is configurable and that payload-too-large recovery remains active when the percentage trigger is off. | |||
| 167 | + | ||||
| 168 | + | - [ ] **Step 2: Format changed Go files** | |||
| 169 | + | ||||
| 170 | + | Run: | |||
| 171 | + | ||||
| 172 | + | ```sh | |||
| 173 | + | gofmt -w packages/agent/config.go packages/agent/settings_store.go packages/agent/settings_store_test.go packages/agent/cli.go packages/agent/modes/interactive.go packages/agent/modes/auto_compact_test.go | |||
| 174 | + | ``` | |||
| 175 | + | ||||
| 176 | + | - [ ] **Step 3: Run the complete test suite** | |||
| 177 | + | ||||
| 178 | + | Run: | |||
| 179 | + | ||||
| 180 | + | ```sh | |||
| 181 | + | go test ./... | |||
| 182 | + | ``` | |||
| 183 | + | ||||
| 184 | + | Expected: PASS. | |||
| 185 | + | ||||
| 186 | + | - [ ] **Step 4: Run static analysis** | |||
| 187 | + | ||||
| 188 | + | Run: | |||
| 189 | + | ||||
| 190 | + | ```sh | |||
| 191 | + | go vet ./... | |||
| 192 | + | ``` | |||
| 193 | + | ||||
| 194 | + | Expected: PASS. | |||
| 195 | + | ||||
| 196 | + | - [ ] **Step 5: Inspect the final change** | |||
| 197 | + | ||||
| 198 | + | Run: | |||
| 199 | + | ||||
| 200 | + | ```sh | |||
| 201 | + | git diff --check | |||
| 202 | + | git status --short | |||
| 203 | + | git diff | |||
| 204 | + | ``` | |||
| 205 | + | ||||
| 206 | + | Confirm only the configuration, TUI, tests, README, design, and plan belong to the feature. | |||
| 207 | + | ||||
| 208 | + | ### Task 4: Publish the pull request | |||
| 209 | + | ||||
| 210 | + | **Files:** | |||
| 211 | + | - Stage only the files listed in Tasks 1-3. | |||
| 212 | + | ||||
| 213 | + | **Interfaces:** | |||
| 214 | + | - Produces: branch `codex/configurable-auto-compact-threshold` | |||
| 215 | + | - Produces: one feature commit and a ready GitHub pull request | |||
| 216 | + | ||||
| 217 | + | - [ ] **Step 1: Create the feature branch** | |||
| 218 | + | ||||
| 219 | + | ```sh | |||
| 220 | + | git switch -c codex/configurable-auto-compact-threshold | |||
| 221 | + | ``` | |||
| 222 | + | ||||
| 223 | + | - [ ] **Step 2: Stage explicit feature paths and review** | |||
| 224 | + | ||||
| 225 | + | Stage only the implementation, tests, README, design, and plan. Review `git diff --cached --check` and `git diff --cached`. | |||
| 226 | + | ||||
| 227 | + | - [ ] **Step 3: Commit** | |||
| 228 | + | ||||
| 229 | + | ```sh | |||
| 230 | + | git commit -m "Make auto-compact threshold configurable" | |||
| 231 | + | ``` | |||
| 232 | + | ||||
| 233 | + | - [ ] **Step 4: Push the branch** | |||
| 234 | + | ||||
| 235 | + | Push to the appropriate writable GitHub remote with upstream tracking. | |||
| 236 | + | ||||
| 237 | + | - [ ] **Step 5: Open a ready pull request** | |||
| 238 | + | ||||
| 239 | + | Target the upstream default branch. Include the behavior, compatibility, user impact, and exact validation commands in the PR body. | |||
A docs/superpowers/specs/2026-07-28-configurable-auto-compact-threshold-design.md +73 -0
| @@ -0,0 +1,73 @@ | |||
| 1 | + | # Configurable Auto-Compact Threshold | |
| 2 | + | ||
| 3 | + | ## Goal | |
| 4 | + | ||
| 5 | + | Let users choose when Zot automatically compacts an interactive transcript from the existing `/settings` dialog, while preserving the current 85% behavior by default. | |
| 6 | + | ||
| 7 | + | ## User Experience | |
| 8 | + | ||
| 9 | + | Add a top-level `/settings` option named **auto-compact threshold** with these choices: | |
| 10 | + | ||
| 11 | + | - `off` | |
| 12 | + | - `70%` | |
| 13 | + | - `80%` | |
| 14 | + | - `85%` (default) | |
| 15 | + | - `90%` | |
| 16 | + | ||
| 17 | + | Changing the option takes effect for the next turn without restarting Zot. | |
| 18 | + | ||
| 19 | + | The `off` choice disables context-percentage triggers before and after turns. It does not disable: | |
| 20 | + | ||
| 21 | + | - manual `/compact` | |
| 22 | + | - explicit RPC or SDK compaction | |
| 23 | + | - automatic compaction and retry after an HTTP 413 or equivalent payload-too-large response | |
| 24 | + | ||
| 25 | + | ## Configuration | |
| 26 | + | ||
| 27 | + | Persist the selected percentage in `$ZOT_HOME/config.json` as: | |
| 28 | + | ||
| 29 | + | ```json | |
| 30 | + | { | |
| 31 | + | "auto_compact_threshold": 85 | |
| 32 | + | } | |
| 33 | + | ``` | |
| 34 | + | ||
| 35 | + | The field is an optional integer percentage: | |
| 36 | + | ||
| 37 | + | - A missing field resolves to `85` for backward compatibility. | |
| 38 | + | - `0` represents `off`. | |
| 39 | + | - Valid non-zero values are `70`, `80`, `85`, and `90`. | |
| 40 | + | - Any other persisted value resolves to `85` at runtime. | |
| 41 | + | - When a user selects a setting in the TUI, Zot writes only one of the supported values. | |
| 42 | + | ||
| 43 | + | ## Implementation Boundaries | |
| 44 | + | ||
| 45 | + | The interactive host continues to own automatic triggering. `core.Agent.Compact`, RPC, SDK, bot, print, and JSON mode behavior remain unchanged. | |
| 46 | + | ||
| 47 | + | Replace the hard-coded `0.85` comparison with a helper that resolves the configured integer percentage and compares the most recent context usage against the model's advertised context window. A resolved value of `0` returns false for percentage-based checks. | |
| 48 | + | ||
| 49 | + | The payload-too-large recovery path continues to invoke automatic compaction independently of the configured percentage. | |
| 50 | + | ||
| 51 | + | The settings dialog uses its existing fixed-option picker. No free-form numeric input or new dialog control is introduced. | |
| 52 | + | ||
| 53 | + | ## Error Handling | |
| 54 | + | ||
| 55 | + | Configuration writes use the existing settings-store error path and show failures in the TUI status area. An invalid value already present in `config.json` does not prevent startup; Zot falls back to 85%. | |
| 56 | + | ||
| 57 | + | ## Tests | |
| 58 | + | ||
| 59 | + | Add focused tests covering: | |
| 60 | + | ||
| 61 | + | - missing configuration resolves to 85% | |
| 62 | + | - every supported preset resolves correctly | |
| 63 | + | - invalid persisted values resolve to 85% | |
| 64 | + | - `off` suppresses percentage-triggered compaction | |
| 65 | + | - threshold comparison uses the selected percentage | |
| 66 | + | - the settings store persists the selected integer | |
| 67 | + | - the settings dialog exposes all presets with 85% selected by default | |
| 68 | + | ||
| 69 | + | Run the focused package tests during development, then `gofmt`, `go test ./...`, `go vet ./...`, `git diff --check`, and final status/diff inspection. | |
| 70 | + | ||
| 71 | + | ## Documentation | |
| 72 | + | ||
| 73 | + | Update the README sections for `/settings` and `/compact` to list the presets, default, immediate application, and the fact that `off` does not disable payload-too-large recovery. | |
| @@ -0,0 +1,73 @@ | |||||
| 1 | + | # Configurable Auto-Compact Threshold | |||
| 2 | + | ||||
| 3 | + | ## Goal | |||
| 4 | + | ||||
| 5 | + | Let users choose when Zot automatically compacts an interactive transcript from the existing `/settings` dialog, while preserving the current 85% behavior by default. | |||
| 6 | + | ||||
| 7 | + | ## User Experience | |||
| 8 | + | ||||
| 9 | + | Add a top-level `/settings` option named **auto-compact threshold** with these choices: | |||
| 10 | + | ||||
| 11 | + | - `off` | |||
| 12 | + | - `70%` | |||
| 13 | + | - `80%` | |||
| 14 | + | - `85%` (default) | |||
| 15 | + | - `90%` | |||
| 16 | + | ||||
| 17 | + | Changing the option takes effect for the next turn without restarting Zot. | |||
| 18 | + | ||||
| 19 | + | The `off` choice disables context-percentage triggers before and after turns. It does not disable: | |||
| 20 | + | ||||
| 21 | + | - manual `/compact` | |||
| 22 | + | - explicit RPC or SDK compaction | |||
| 23 | + | - automatic compaction and retry after an HTTP 413 or equivalent payload-too-large response | |||
| 24 | + | ||||
| 25 | + | ## Configuration | |||
| 26 | + | ||||
| 27 | + | Persist the selected percentage in `$ZOT_HOME/config.json` as: | |||
| 28 | + | ||||
| 29 | + | ```json | |||
| 30 | + | { | |||
| 31 | + | "auto_compact_threshold": 85 | |||
| 32 | + | } | |||
| 33 | + | ``` | |||
| 34 | + | ||||
| 35 | + | The field is an optional integer percentage: | |||
| 36 | + | ||||
| 37 | + | - A missing field resolves to `85` for backward compatibility. | |||
| 38 | + | - `0` represents `off`. | |||
| 39 | + | - Valid non-zero values are `70`, `80`, `85`, and `90`. | |||
| 40 | + | - Any other persisted value resolves to `85` at runtime. | |||
| 41 | + | - When a user selects a setting in the TUI, Zot writes only one of the supported values. | |||
| 42 | + | ||||
| 43 | + | ## Implementation Boundaries | |||
| 44 | + | ||||
| 45 | + | The interactive host continues to own automatic triggering. `core.Agent.Compact`, RPC, SDK, bot, print, and JSON mode behavior remain unchanged. | |||
| 46 | + | ||||
| 47 | + | Replace the hard-coded `0.85` comparison with a helper that resolves the configured integer percentage and compares the most recent context usage against the model's advertised context window. A resolved value of `0` returns false for percentage-based checks. | |||
| 48 | + | ||||
| 49 | + | The payload-too-large recovery path continues to invoke automatic compaction independently of the configured percentage. | |||
| 50 | + | ||||
| 51 | + | The settings dialog uses its existing fixed-option picker. No free-form numeric input or new dialog control is introduced. | |||
| 52 | + | ||||
| 53 | + | ## Error Handling | |||
| 54 | + | ||||
| 55 | + | Configuration writes use the existing settings-store error path and show failures in the TUI status area. An invalid value already present in `config.json` does not prevent startup; Zot falls back to 85%. | |||
| 56 | + | ||||
| 57 | + | ## Tests | |||
| 58 | + | ||||
| 59 | + | Add focused tests covering: | |||
| 60 | + | ||||
| 61 | + | - missing configuration resolves to 85% | |||
| 62 | + | - every supported preset resolves correctly | |||
| 63 | + | - invalid persisted values resolve to 85% | |||
| 64 | + | - `off` suppresses percentage-triggered compaction | |||
| 65 | + | - threshold comparison uses the selected percentage | |||
| 66 | + | - the settings store persists the selected integer | |||
| 67 | + | - the settings dialog exposes all presets with 85% selected by default | |||
| 68 | + | ||||
| 69 | + | Run the focused package tests during development, then `gofmt`, `go test ./...`, `go vet ./...`, `git diff --check`, and final status/diff inspection. | |||
| 70 | + | ||||
| 71 | + | ## Documentation | |||
| 72 | + | ||||
| 73 | + | Update the README sections for `/settings` and `/compact` to list the presets, default, immediate application, and the fact that `off` does not disable payload-too-large recovery. | |||
M packages/agent/cli.go +1 -0
| @@ -989,6 +989,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 989 | 989 | Theme: theme, | |
| 990 | 990 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 991 | 991 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 992 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 992 | 993 | JailByDefault: initialCfg.JailByDefault, | |
| 993 | 994 | QuickModelShortcuts: quickModelShortcuts, | |
| 994 | 995 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| @@ -989,6 +989,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 989 | Theme: theme, | 989 | Theme: theme, | ||
| 990 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 990 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | ||
| 991 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 991 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | ||
| 992 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |||
| 992 | JailByDefault: initialCfg.JailByDefault, | 993 | JailByDefault: initialCfg.JailByDefault, | ||
| 993 | QuickModelShortcuts: quickModelShortcuts, | 994 | QuickModelShortcuts: quickModelShortcuts, | ||
| 994 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 995 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
M packages/agent/config.go +5 -0
| @@ -58,6 +58,11 @@ type Config struct { | |||
| 58 | 58 | // default; nil/missing means disabled. Toggle from /settings. | |
| 59 | 59 | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | |
| 60 | 60 | ||
| 61 | + | // AutoCompactThreshold is the percentage of the model context window | |
| 62 | + | // that triggers automatic transcript compaction in interactive mode. | |
| 63 | + | // nil/missing means 85; zero disables percentage-based triggers. | |
| 64 | + | AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"` | |
| 65 | + | ||
| 61 | 66 | // JailByDefault confines tools to the session working directory when | |
| 62 | 67 | // a new agent starts. Off by default; nil/missing means disabled. | |
| 63 | 68 | // The live session can still override this with /jail or /unjail. | |
| @@ -58,6 +58,11 @@ type Config struct { | |||||
| 58 | // default; nil/missing means disabled. Toggle from /settings. | 58 | // default; nil/missing means disabled. Toggle from /settings. | ||
| 59 | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | 59 | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | ||
| 60 | 60 | ||||
| 61 | + | // AutoCompactThreshold is the percentage of the model context window | |||
| 62 | + | // that triggers automatic transcript compaction in interactive mode. | |||
| 63 | + | // nil/missing means 85; zero disables percentage-based triggers. | |||
| 64 | + | AutoCompactThreshold *int `json:"auto_compact_threshold,omitempty"` | |||
| 65 | + | ||||
| 61 | // JailByDefault confines tools to the session working directory when | 66 | // JailByDefault confines tools to the session working directory when | ||
| 62 | // a new agent starts. Off by default; nil/missing means disabled. | 67 | // a new agent starts. Off by default; nil/missing means disabled. | ||
| 63 | // The live session can still override this with /jail or /unjail. | 68 | // The live session can still override this with /jail or /unjail. | ||
A packages/agent/modes/auto_compact_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestNormalizeAutoCompactThreshold(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | value *int | |
| 9 | + | want int | |
| 10 | + | }{ | |
| 11 | + | {name: "missing", value: nil, want: 85}, | |
| 12 | + | {name: "off", value: autoCompactIntPtr(0), want: 0}, | |
| 13 | + | {name: "seventy", value: autoCompactIntPtr(70), want: 70}, | |
| 14 | + | {name: "eighty", value: autoCompactIntPtr(80), want: 80}, | |
| 15 | + | {name: "eighty five", value: autoCompactIntPtr(85), want: 85}, | |
| 16 | + | {name: "ninety", value: autoCompactIntPtr(90), want: 90}, | |
| 17 | + | {name: "invalid low", value: autoCompactIntPtr(42), want: 85}, | |
| 18 | + | {name: "invalid high", value: autoCompactIntPtr(100), want: 85}, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | if got := normalizeAutoCompactThreshold(tt.value); got != tt.want { | |
| 24 | + | t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want) | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |
| 31 | + | tests := []struct { | |
| 32 | + | name string | |
| 33 | + | used int | |
| 34 | + | window int | |
| 35 | + | threshold int | |
| 36 | + | want bool | |
| 37 | + | }{ | |
| 38 | + | {name: "below threshold", used: 84, window: 100, threshold: 85, want: false}, | |
| 39 | + | {name: "at threshold", used: 85, window: 100, threshold: 85, want: true}, | |
| 40 | + | {name: "above threshold", used: 86, window: 100, threshold: 85, want: true}, | |
| 41 | + | {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true}, | |
| 42 | + | {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true}, | |
| 43 | + | {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true}, | |
| 44 | + | {name: "off", used: 100, window: 100, threshold: 0, want: false}, | |
| 45 | + | {name: "missing usage", used: 0, window: 100, threshold: 85, want: false}, | |
| 46 | + | {name: "missing window", used: 85, window: 0, threshold: 85, want: false}, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | for _, tt := range tests { | |
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |
| 51 | + | if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want { | |
| 52 | + | t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want) | |
| 53 | + | } | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) { | |
| 59 | + | interactive := NewInteractive(InteractiveConfig{}) | |
| 60 | + | interactive.openSettingsDialog() | |
| 61 | + | ||
| 62 | + | var thresholdItem *settingsItem | |
| 63 | + | for idx := range interactive.settingsDialog.items { | |
| 64 | + | item := &interactive.settingsDialog.items[idx] | |
| 65 | + | if item.key == "auto_compact_threshold" { | |
| 66 | + | thresholdItem = item | |
| 67 | + | break | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if thresholdItem == nil { | |
| 71 | + | t.Fatal("settings dialog is missing auto-compact threshold") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | wantValues := []string{"0", "70", "80", "85", "90"} | |
| 75 | + | if len(thresholdItem.options) != len(wantValues) { | |
| 76 | + | t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues)) | |
| 77 | + | } | |
| 78 | + | for idx, want := range wantValues { | |
| 79 | + | if got := thresholdItem.options[idx].value; got != want { | |
| 80 | + | t.Fatalf("auto-compact option %d = %q, want %q", idx, got, want) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | if got := thresholdItem.options[thresholdItem.choice].value; got != "85" { | |
| 84 | + | t.Fatalf("default auto-compact choice = %q, want 85", got) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func autoCompactIntPtr(value int) *int { | |
| 89 | + | return &value | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestNormalizeAutoCompactThreshold(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | value *int | |||
| 9 | + | want int | |||
| 10 | + | }{ | |||
| 11 | + | {name: "missing", value: nil, want: 85}, | |||
| 12 | + | {name: "off", value: autoCompactIntPtr(0), want: 0}, | |||
| 13 | + | {name: "seventy", value: autoCompactIntPtr(70), want: 70}, | |||
| 14 | + | {name: "eighty", value: autoCompactIntPtr(80), want: 80}, | |||
| 15 | + | {name: "eighty five", value: autoCompactIntPtr(85), want: 85}, | |||
| 16 | + | {name: "ninety", value: autoCompactIntPtr(90), want: 90}, | |||
| 17 | + | {name: "invalid low", value: autoCompactIntPtr(42), want: 85}, | |||
| 18 | + | {name: "invalid high", value: autoCompactIntPtr(100), want: 85}, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | if got := normalizeAutoCompactThreshold(tt.value); got != tt.want { | |||
| 24 | + | t.Fatalf("normalizeAutoCompactThreshold(%v) = %d, want %d", tt.value, got, tt.want) | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestShouldAutoCompactUsesConfiguredThreshold(t *testing.T) { | |||
| 31 | + | tests := []struct { | |||
| 32 | + | name string | |||
| 33 | + | used int | |||
| 34 | + | window int | |||
| 35 | + | threshold int | |||
| 36 | + | want bool | |||
| 37 | + | }{ | |||
| 38 | + | {name: "below threshold", used: 84, window: 100, threshold: 85, want: false}, | |||
| 39 | + | {name: "at threshold", used: 85, window: 100, threshold: 85, want: true}, | |||
| 40 | + | {name: "above threshold", used: 86, window: 100, threshold: 85, want: true}, | |||
| 41 | + | {name: "seventy percent preset", used: 70, window: 100, threshold: 70, want: true}, | |||
| 42 | + | {name: "eighty percent preset", used: 80, window: 100, threshold: 80, want: true}, | |||
| 43 | + | {name: "ninety percent preset", used: 90, window: 100, threshold: 90, want: true}, | |||
| 44 | + | {name: "off", used: 100, window: 100, threshold: 0, want: false}, | |||
| 45 | + | {name: "missing usage", used: 0, window: 100, threshold: 85, want: false}, | |||
| 46 | + | {name: "missing window", used: 85, window: 0, threshold: 85, want: false}, | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | for _, tt := range tests { | |||
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | + | if got := shouldAutoCompact(tt.used, tt.window, tt.threshold); got != tt.want { | |||
| 52 | + | t.Fatalf("shouldAutoCompact(%d, %d, %d) = %t, want %t", tt.used, tt.window, tt.threshold, got, tt.want) | |||
| 53 | + | } | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestSettingsDialogOffersAutoCompactThresholdPresets(t *testing.T) { | |||
| 59 | + | interactive := NewInteractive(InteractiveConfig{}) | |||
| 60 | + | interactive.openSettingsDialog() | |||
| 61 | + | ||||
| 62 | + | var thresholdItem *settingsItem | |||
| 63 | + | for idx := range interactive.settingsDialog.items { | |||
| 64 | + | item := &interactive.settingsDialog.items[idx] | |||
| 65 | + | if item.key == "auto_compact_threshold" { | |||
| 66 | + | thresholdItem = item | |||
| 67 | + | break | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if thresholdItem == nil { | |||
| 71 | + | t.Fatal("settings dialog is missing auto-compact threshold") | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | wantValues := []string{"0", "70", "80", "85", "90"} | |||
| 75 | + | if len(thresholdItem.options) != len(wantValues) { | |||
| 76 | + | t.Fatalf("auto-compact options = %d, want %d", len(thresholdItem.options), len(wantValues)) | |||
| 77 | + | } | |||
| 78 | + | for idx, want := range wantValues { | |||
| 79 | + | if got := thresholdItem.options[idx].value; got != want { | |||
| 80 | + | t.Fatalf("auto-compact option %d = %q, want %q", idx, got, want) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | if got := thresholdItem.options[thresholdItem.choice].value; got != "85" { | |||
| 84 | + | t.Fatalf("default auto-compact choice = %q, want 85", got) | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func autoCompactIntPtr(value int) *int { | |||
| 89 | + | return &value | |||
| 90 | + | } | |||
M packages/agent/modes/interactive.go +82 -9
| @@ -62,6 +62,11 @@ type InteractiveConfig struct { | |||
| 62 | 62 | // re-reading config.json on every open. | |
| 63 | 63 | AutoSwarmEnabled *bool | |
| 64 | 64 | ||
| 65 | + | // AutoCompactThreshold is the context-window percentage that triggers | |
| 66 | + | // automatic compaction. nil means 85; zero disables percentage-based | |
| 67 | + | // triggers while preserving payload-too-large recovery. | |
| 68 | + | AutoCompactThreshold *int | |
| 69 | + | ||
| 65 | 70 | // JailByDefault mirrors the persisted jail_by_default preference. | |
| 66 | 71 | // The current sandbox may differ after a session-scoped /jail or /unjail. | |
| 67 | 72 | JailByDefault *bool | |
| @@ -322,6 +327,10 @@ type showInstructionsSettingsStore interface { | |||
| 322 | 327 | SetShowInstructionsAtStartup(enabled bool) error | |
| 323 | 328 | } | |
| 324 | 329 | ||
| 330 | + | type autoCompactThresholdSettingsStore interface { | |
| 331 | + | SetAutoCompactThreshold(percent int) error | |
| 332 | + | } | |
| 333 | + | ||
| 325 | 334 | type Interactive struct { | |
| 326 | 335 | cfg InteractiveConfig | |
| 327 | 336 | view *tui.View | |
| @@ -3151,6 +3160,22 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3151 | 3160 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | |
| 3152 | 3161 | quickItems := i.quickModelSettingItems() | |
| 3153 | 3162 | ||
| 3163 | + | autoCompactOptions := []settingsOption{ | |
| 3164 | + | {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"}, | |
| 3165 | + | {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"}, | |
| 3166 | + | {value: "80", label: "80%", desc: "compact with moderate context headroom"}, | |
| 3167 | + | {value: "85", label: "85%", desc: "default balance between history and headroom"}, | |
| 3168 | + | {value: "90", label: "90%", desc: "retain more history before compacting"}, | |
| 3169 | + | } | |
| 3170 | + | autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 3171 | + | autoCompactChoice := 0 | |
| 3172 | + | for idx, opt := range autoCompactOptions { | |
| 3173 | + | if opt.value == strconv.Itoa(autoCompactThreshold) { | |
| 3174 | + | autoCompactChoice = idx | |
| 3175 | + | break | |
| 3176 | + | } | |
| 3177 | + | } | |
| 3178 | + | ||
| 3154 | 3179 | reasoningOptions := []settingsOption{ | |
| 3155 | 3180 | {value: "", label: "off", desc: "no reasoning"}, | |
| 3156 | 3181 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | |
| @@ -3251,6 +3276,13 @@ func (i *Interactive) openSettingsDialog() { | |||
| 3251 | 3276 | disabled: autoSwarmDisabled, | |
| 3252 | 3277 | hint: autoSwarmHint, | |
| 3253 | 3278 | }, | |
| 3279 | + | { | |
| 3280 | + | key: "auto_compact_threshold", | |
| 3281 | + | label: "auto-compact threshold", | |
| 3282 | + | desc: "choose how full the model context can get before zot condenses conversation history", | |
| 3283 | + | options: autoCompactOptions, | |
| 3284 | + | choice: autoCompactChoice, | |
| 3285 | + | }, | |
| 3254 | 3286 | { | |
| 3255 | 3287 | key: "jail_by_default", | |
| 3256 | 3288 | label: "jail new sessions by default", | |
| @@ -3344,6 +3376,8 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||
| 3344 | 3376 | i.applyReasoningSetting(act.StringValue) | |
| 3345 | 3377 | case act.Key == "theme": | |
| 3346 | 3378 | i.applyThemeSetting(act.StringValue) | |
| 3379 | + | case act.Key == "auto_compact_threshold": | |
| 3380 | + | i.applyAutoCompactThresholdSetting(act.StringValue) | |
| 3347 | 3381 | case act.Key == "tui_input_style": | |
| 3348 | 3382 | i.applyTUIInputStyleSetting(act.StringValue) | |
| 3349 | 3383 | case act.Key == "tui_status_position": | |
| @@ -3355,6 +3389,32 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||
| 3355 | 3389 | } | |
| 3356 | 3390 | } | |
| 3357 | 3391 | ||
| 3392 | + | func (i *Interactive) applyAutoCompactThresholdSetting(value string) { | |
| 3393 | + | threshold, err := strconv.Atoi(value) | |
| 3394 | + | if err != nil { | |
| 3395 | + | return | |
| 3396 | + | } | |
| 3397 | + | threshold = normalizeAutoCompactThreshold(&threshold) | |
| 3398 | + | i.cfg.AutoCompactThreshold = &threshold | |
| 3399 | + | if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok { | |
| 3400 | + | if err := store.SetAutoCompactThreshold(threshold); err != nil { | |
| 3401 | + | i.mu.Lock() | |
| 3402 | + | i.statusErr = "settings: " + err.Error() | |
| 3403 | + | i.mu.Unlock() | |
| 3404 | + | return | |
| 3405 | + | } | |
| 3406 | + | } | |
| 3407 | + | i.mu.Lock() | |
| 3408 | + | if threshold == 0 { | |
| 3409 | + | i.statusOK = "auto-compact threshold off" | |
| 3410 | + | } else { | |
| 3411 | + | i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold) | |
| 3412 | + | } | |
| 3413 | + | i.statusErr = "" | |
| 3414 | + | i.mu.Unlock() | |
| 3415 | + | i.invalidate() | |
| 3416 | + | } | |
| 3417 | + | ||
| 3358 | 3418 | func (i *Interactive) quickModelSettingItems() []settingsItem { | |
| 3359 | 3419 | if len(i.cfg.QuickModelShortcuts) < 9 { | |
| 3360 | 3420 | next := make([]QuickModelShortcut, 9) | |
| @@ -5561,11 +5621,26 @@ func isPayloadTooLargeError(err error) bool { | |||
| 5561 | 5621 | return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large") | |
| 5562 | 5622 | } | |
| 5563 | 5623 | ||
| 5564 | - | // autoCompactThreshold is the context-window fraction at which the | |
| 5565 | - | // agent will auto-compact after a turn ends. 0.85 leaves enough | |
| 5566 | - | // headroom for one more user prompt + response before we bump the | |
| 5567 | - | // hard limit. | |
| 5568 | - | const autoCompactThreshold = 0.85 | |
| 5624 | + | const defaultAutoCompactThreshold = 85 | |
| 5625 | + | ||
| 5626 | + | func normalizeAutoCompactThreshold(threshold *int) int { | |
| 5627 | + | if threshold == nil { | |
| 5628 | + | return defaultAutoCompactThreshold | |
| 5629 | + | } | |
| 5630 | + | switch *threshold { | |
| 5631 | + | case 0, 70, 80, 85, 90: | |
| 5632 | + | return *threshold | |
| 5633 | + | default: | |
| 5634 | + | return defaultAutoCompactThreshold | |
| 5635 | + | } | |
| 5636 | + | } | |
| 5637 | + | ||
| 5638 | + | func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool { | |
| 5639 | + | if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 { | |
| 5640 | + | return false | |
| 5641 | + | } | |
| 5642 | + | return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100 | |
| 5643 | + | } | |
| 5569 | 5644 | ||
| 5570 | 5645 | // shouldAutoCompactLocked reports whether the last turn pushed context | |
| 5571 | 5646 | // usage past the auto-compact threshold. Must be called with i.mu | |
| @@ -5581,10 +5656,8 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||
| 5581 | 5656 | if err != nil || m.ContextWindow <= 0 { | |
| 5582 | 5657 | return false | |
| 5583 | 5658 | } | |
| 5584 | - | if i.lastCtxInput <= 0 { | |
| 5585 | - | return false | |
| 5586 | - | } | |
| 5587 | - | return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold | |
| 5659 | + | threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |
| 5660 | + | return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) | |
| 5588 | 5661 | } | |
| 5589 | 5662 | ||
| 5590 | 5663 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | |
| @@ -62,6 +62,11 @@ type InteractiveConfig struct { | |||||
| 62 | // re-reading config.json on every open. | 62 | // re-reading config.json on every open. | ||
| 63 | AutoSwarmEnabled *bool | 63 | AutoSwarmEnabled *bool | ||
| 64 | 64 | ||||
| 65 | + | // AutoCompactThreshold is the context-window percentage that triggers | |||
| 66 | + | // automatic compaction. nil means 85; zero disables percentage-based | |||
| 67 | + | // triggers while preserving payload-too-large recovery. | |||
| 68 | + | AutoCompactThreshold *int | |||
| 69 | + | ||||
| 65 | // JailByDefault mirrors the persisted jail_by_default preference. | 70 | // JailByDefault mirrors the persisted jail_by_default preference. | ||
| 66 | // The current sandbox may differ after a session-scoped /jail or /unjail. | 71 | // The current sandbox may differ after a session-scoped /jail or /unjail. | ||
| 67 | JailByDefault *bool | 72 | JailByDefault *bool | ||
| @@ -322,6 +327,10 @@ type showInstructionsSettingsStore interface { | |||||
| 322 | SetShowInstructionsAtStartup(enabled bool) error | 327 | SetShowInstructionsAtStartup(enabled bool) error | ||
| 323 | } | 328 | } | ||
| 324 | 329 | ||||
| 330 | + | type autoCompactThresholdSettingsStore interface { | |||
| 331 | + | SetAutoCompactThreshold(percent int) error | |||
| 332 | + | } | |||
| 333 | + | ||||
| 325 | type Interactive struct { | 334 | type Interactive struct { | ||
| 326 | cfg InteractiveConfig | 335 | cfg InteractiveConfig | ||
| 327 | view *tui.View | 336 | view *tui.View | ||
| @@ -3151,6 +3160,22 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3151 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | 3160 | workingPosition := tui.NormalizeWorkingPosition(i.cfg.TUIWorkingPosition) | ||
| 3152 | quickItems := i.quickModelSettingItems() | 3161 | quickItems := i.quickModelSettingItems() | ||
| 3153 | 3162 | ||||
| 3163 | + | autoCompactOptions := []settingsOption{ | |||
| 3164 | + | {value: "0", label: "off", desc: "disable context-percentage triggers; payload-too-large recovery stays enabled"}, | |||
| 3165 | + | {value: "70", label: "70%", desc: "compact earlier to keep more context headroom"}, | |||
| 3166 | + | {value: "80", label: "80%", desc: "compact with moderate context headroom"}, | |||
| 3167 | + | {value: "85", label: "85%", desc: "default balance between history and headroom"}, | |||
| 3168 | + | {value: "90", label: "90%", desc: "retain more history before compacting"}, | |||
| 3169 | + | } | |||
| 3170 | + | autoCompactThreshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) | |||
| 3171 | + | autoCompactChoice := 0 | |||
| 3172 | + | for idx, opt := range autoCompactOptions { | |||
| 3173 | + | if opt.value == strconv.Itoa(autoCompactThreshold) { | |||
| 3174 | + | autoCompactChoice = idx | |||
| 3175 | + | break | |||
| 3176 | + | } | |||
| 3177 | + | } | |||
| 3178 | + | ||||
| 3154 | reasoningOptions := []settingsOption{ | 3179 | reasoningOptions := []settingsOption{ | ||
| 3155 | {value: "", label: "off", desc: "no reasoning"}, | 3180 | {value: "", label: "off", desc: "no reasoning"}, | ||
| 3156 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | 3181 | {value: "minimum", label: "minimum", desc: "very brief (~1k tokens)"}, | ||
| @@ -3251,6 +3276,13 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 3251 | disabled: autoSwarmDisabled, | 3276 | disabled: autoSwarmDisabled, | ||
| 3252 | hint: autoSwarmHint, | 3277 | hint: autoSwarmHint, | ||
| 3253 | }, | 3278 | }, | ||
| 3279 | + | { | |||
| 3280 | + | key: "auto_compact_threshold", | |||
| 3281 | + | label: "auto-compact threshold", | |||
| 3282 | + | desc: "choose how full the model context can get before zot condenses conversation history", | |||
| 3283 | + | options: autoCompactOptions, | |||
| 3284 | + | choice: autoCompactChoice, | |||
| 3285 | + | }, | |||
| 3254 | { | 3286 | { | ||
| 3255 | key: "jail_by_default", | 3287 | key: "jail_by_default", | ||
| 3256 | label: "jail new sessions by default", | 3288 | label: "jail new sessions by default", | ||
| @@ -3344,6 +3376,8 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||||
| 3344 | i.applyReasoningSetting(act.StringValue) | 3376 | i.applyReasoningSetting(act.StringValue) | ||
| 3345 | case act.Key == "theme": | 3377 | case act.Key == "theme": | ||
| 3346 | i.applyThemeSetting(act.StringValue) | 3378 | i.applyThemeSetting(act.StringValue) | ||
| 3379 | + | case act.Key == "auto_compact_threshold": | |||
| 3380 | + | i.applyAutoCompactThresholdSetting(act.StringValue) | |||
| 3347 | case act.Key == "tui_input_style": | 3381 | case act.Key == "tui_input_style": | ||
| 3348 | i.applyTUIInputStyleSetting(act.StringValue) | 3382 | i.applyTUIInputStyleSetting(act.StringValue) | ||
| 3349 | case act.Key == "tui_status_position": | 3383 | case act.Key == "tui_status_position": | ||
| @@ -3355,6 +3389,32 @@ func (i *Interactive) applySettingChange(act settingsAction) { | |||||
| 3355 | } | 3389 | } | ||
| 3356 | } | 3390 | } | ||
| 3357 | 3391 | ||||
| 3392 | + | func (i *Interactive) applyAutoCompactThresholdSetting(value string) { | |||
| 3393 | + | threshold, err := strconv.Atoi(value) | |||
| 3394 | + | if err != nil { | |||
| 3395 | + | return | |||
| 3396 | + | } | |||
| 3397 | + | threshold = normalizeAutoCompactThreshold(&threshold) | |||
| 3398 | + | i.cfg.AutoCompactThreshold = &threshold | |||
| 3399 | + | if store, ok := i.cfg.SettingsStore.(autoCompactThresholdSettingsStore); ok { | |||
| 3400 | + | if err := store.SetAutoCompactThreshold(threshold); err != nil { | |||
| 3401 | + | i.mu.Lock() | |||
| 3402 | + | i.statusErr = "settings: " + err.Error() | |||
| 3403 | + | i.mu.Unlock() | |||
| 3404 | + | return | |||
| 3405 | + | } | |||
| 3406 | + | } | |||
| 3407 | + | i.mu.Lock() | |||
| 3408 | + | if threshold == 0 { | |||
| 3409 | + | i.statusOK = "auto-compact threshold off" | |||
| 3410 | + | } else { | |||
| 3411 | + | i.statusOK = fmt.Sprintf("auto-compact threshold %d%%", threshold) | |||
| 3412 | + | } | |||
| 3413 | + | i.statusErr = "" | |||
| 3414 | + | i.mu.Unlock() | |||
| 3415 | + | i.invalidate() | |||
| 3416 | + | } | |||
| 3417 | + | ||||
| 3358 | func (i *Interactive) quickModelSettingItems() []settingsItem { | 3418 | func (i *Interactive) quickModelSettingItems() []settingsItem { | ||
| 3359 | if len(i.cfg.QuickModelShortcuts) < 9 { | 3419 | if len(i.cfg.QuickModelShortcuts) < 9 { | ||
| 3360 | next := make([]QuickModelShortcut, 9) | 3420 | next := make([]QuickModelShortcut, 9) | ||
| @@ -5561,11 +5621,26 @@ func isPayloadTooLargeError(err error) bool { | |||||
| 5561 | return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large") | 5621 | return strings.Contains(msg, "http 413") || strings.Contains(msg, " 413") || strings.HasPrefix(msg, "413 ") || strings.Contains(msg, "payload too large") || strings.Contains(msg, "request entity too large") | ||
| 5562 | } | 5622 | } | ||
| 5563 | 5623 | ||||
| 5564 | - | // autoCompactThreshold is the context-window fraction at which the | 5624 | + | const defaultAutoCompactThreshold = 85 |
| 5565 | - | // agent will auto-compact after a turn ends. 0.85 leaves enough | 5625 | + | |
| 5566 | - | // headroom for one more user prompt + response before we bump the | 5626 | + | func normalizeAutoCompactThreshold(threshold *int) int { |
| 5567 | - | // hard limit. | 5627 | + | if threshold == nil { |
| 5568 | - | const autoCompactThreshold = 0.85 | 5628 | + | return defaultAutoCompactThreshold |
| 5629 | + | } | |||
| 5630 | + | switch *threshold { | |||
| 5631 | + | case 0, 70, 80, 85, 90: | |||
| 5632 | + | return *threshold | |||
| 5633 | + | default: | |||
| 5634 | + | return defaultAutoCompactThreshold | |||
| 5635 | + | } | |||
| 5636 | + | } | |||
| 5637 | + | ||||
| 5638 | + | func shouldAutoCompact(inputTokens, contextWindow, thresholdPercent int) bool { | |||
| 5639 | + | if inputTokens <= 0 || contextWindow <= 0 || thresholdPercent <= 0 { | |||
| 5640 | + | return false | |||
| 5641 | + | } | |||
| 5642 | + | return float64(inputTokens)/float64(contextWindow) >= float64(thresholdPercent)/100 | |||
| 5643 | + | } | |||
| 5569 | 5644 | ||||
| 5570 | // shouldAutoCompactLocked reports whether the last turn pushed context | 5645 | // shouldAutoCompactLocked reports whether the last turn pushed context | ||
| 5571 | // usage past the auto-compact threshold. Must be called with i.mu | 5646 | // usage past the auto-compact threshold. Must be called with i.mu | ||
| @@ -5581,10 +5656,8 @@ func (i *Interactive) shouldAutoCompactLocked() bool { | |||||
| 5581 | if err != nil || m.ContextWindow <= 0 { | 5656 | if err != nil || m.ContextWindow <= 0 { | ||
| 5582 | return false | 5657 | return false | ||
| 5583 | } | 5658 | } | ||
| 5584 | - | if i.lastCtxInput <= 0 { | 5659 | + | threshold := normalizeAutoCompactThreshold(i.cfg.AutoCompactThreshold) |
| 5585 | - | return false | 5660 | + | return shouldAutoCompact(i.lastCtxInput, m.ContextWindow, threshold) |
| 5586 | - | } | |||
| 5587 | - | return float64(i.lastCtxInput)/float64(m.ContextWindow) >= autoCompactThreshold | |||
| 5588 | } | 5661 | } | ||
| 5589 | 5662 | ||||
| 5590 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | 5663 | func (i *Interactive) handleEvent(ev core.AgentEvent) { | ||
M packages/agent/settings_store.go +9 -0
| @@ -50,6 +50,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |||
| 50 | 50 | return SaveConfig(cfg) | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | + | func (configSettingsStore) SetAutoCompactThreshold(percent int) error { | |
| 54 | + | cfg, err := LoadConfig() | |
| 55 | + | if err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | cfg.AutoCompactThreshold = &percent | |
| 59 | + | return SaveConfig(cfg) | |
| 60 | + | } | |
| 61 | + | ||
| 53 | 62 | func (configSettingsStore) SetJailByDefault(enabled bool) error { | |
| 54 | 63 | cfg, err := LoadConfig() | |
| 55 | 64 | if err != nil { | |
| @@ -50,6 +50,15 @@ func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |||||
| 50 | return SaveConfig(cfg) | 50 | return SaveConfig(cfg) | ||
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | + | func (configSettingsStore) SetAutoCompactThreshold(percent int) error { | |||
| 54 | + | cfg, err := LoadConfig() | |||
| 55 | + | if err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | cfg.AutoCompactThreshold = &percent | |||
| 59 | + | return SaveConfig(cfg) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 53 | func (configSettingsStore) SetJailByDefault(enabled bool) error { | 62 | func (configSettingsStore) SetJailByDefault(enabled bool) error { | ||
| 54 | cfg, err := LoadConfig() | 63 | cfg, err := LoadConfig() | ||
| 55 | if err != nil { | 64 | if err != nil { | ||
M packages/agent/settings_store_test.go +21 -0
| @@ -43,3 +43,24 @@ func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | |||
| 43 | 43 | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 44 | 44 | } | |
| 45 | 45 | } | |
| 46 | + | ||
| 47 | + | func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) { | |
| 48 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 49 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | cfg, err := LoadConfig() | |
| 57 | + | if err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 { | |
| 61 | + | t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold) | |
| 62 | + | } | |
| 63 | + | if cfg.Theme != "dark" { | |
| 64 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |
| 65 | + | } | |
| 66 | + | } | |
| @@ -43,3 +43,24 @@ func TestConfigSettingsStorePersistsJailByDefault(t *testing.T) { | |||||
| 43 | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | 43 | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | ||
| 44 | } | 44 | } | ||
| 45 | } | 45 | } | ||
| 46 | + | ||||
| 47 | + | func TestConfigSettingsStorePersistsAutoCompactThreshold(t *testing.T) { | |||
| 48 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 49 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | if err := (configSettingsStore{}).SetAutoCompactThreshold(70); err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | cfg, err := LoadConfig() | |||
| 57 | + | if err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | if cfg.AutoCompactThreshold == nil || *cfg.AutoCompactThreshold != 70 { | |||
| 61 | + | t.Fatalf("auto_compact_threshold = %v, want 70", cfg.AutoCompactThreshold) | |||
| 62 | + | } | |||
| 63 | + | if cfg.Theme != "dark" { | |||
| 64 | + | t.Fatalf("unrelated config changed: theme = %q, want dark", cfg.Theme) | |||
| 65 | + | } | |||
| 66 | + | } | |||