Skip to content

Commit 641a5b3

Browse files
authored
feat(loop): graduated context trimming, margin calibration, rolling compaction (#102)
* feat(loop): graduated context trimming, margin calibration, rolling compaction Reworks context-window management in the agent loop: Correctness fixes: - estimateToolDefs now counts JSON parameter schemas (the bulk of every tool definition) instead of ignoring them - estimateMessages now counts ReasoningContent (echoed back to reasoning models, often the largest content in a turn) - trimToSurvival keeps the original task in multi-turn sessions, preserves the compaction digest, and no longer appends a zero-value message when no user message exists Trimming robustness: - budget re-checked after memory/skill/episode injections, right before the LLM call — injected blocks no longer escape the budget for a full iteration - graduated trimming: old tool results >2 KiB (except the 4 most recent) are replaced with a marker before any turn group is dropped - trim warning moved before the most recent user message (cache-stable head preserved), updated in place with cumulative totals, and names up to 5 dropped tools; new headLen protects system prompt + memory block + digest + original task - disk-cap session trimming persists a [Session storage limit: ...] marker into the transcript so resumed sessions know history was removed New features: - self-calibrating safety margin: tightens 0.75 -> 0.65 when provider- reported input tokens exceed the local estimate by >15% (margin_calibrated signal) - optional rolling compaction (compaction / ODEK_COMPACTION / --compaction, default off): dropped turn groups are summarized by the model into a rolling, untrusted-wrapped digest message instead of being lost; summarizer failures never break trimming All new/changed loop, session, and config code is at 100% function coverage. Full unit suite, race detector, golangci-lint, and CLI E2E tests pass. * feat(docker): enable rolling compaction in bundled configs
1 parent c2d6f99 commit 641a5b3

19 files changed

Lines changed: 1375 additions & 107 deletions

AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,11 @@ ReAct cycle: observe → think → act → repeat.
8787
- **Parallel tool execution** — multiple independent tool calls run concurrently (`max_tool_parallel`, default: 4).
8888
- **Batch approval gate** (`internal/loop/loop.go`) — multiple risky tools shown at once in a single prompt. `classifyToolCall` now classifies every command inside `parallel_shell`, every path inside `batch_patch`, and the modern `browser` tool, shows full (untruncated) commands, and withholds blanket `SetTrustAll` when unclassifiable tools remain in the iteration.
8989
- **Tool-failure recovery** — systematic recovery from tool call failures: retry transient errors, skip permanently failed tools, and continue the loop without crashing.
90-
- **Context-limit protection**`trimToSurvival` drops oldest messages when approaching the model's context window, keeping the agent functional under extended sessions. Tool messages stay grouped with their parent assistant message.
90+
- **Context-limit protection** — graduated trimming when approaching the model's context window: old, large tool results are first replaced with a marker (the 4 most recent are always kept intact), then the oldest turn groups are dropped atomically (tool messages stay grouped with their parent assistant message). The protected head (system prompt, memory block, compaction digest, original task) is never dropped. A trim warning is injected before the most recent user message and updated in place with cumulative totals (including which tools lost earlier results). The token estimator counts tool-def parameter schemas and reasoning content, and the safety margin self-tightens (0.75 → 0.65) when provider-reported input tokens exceed the estimate by >15% (`margin_calibrated` signal). `trimToSurvival` (on provider context-length errors) keeps the system prompt, digest, original task, last 2 turn groups, and latest user message. Optional **rolling compaction** (`compaction` config / `--compaction` / `ODEK_COMPACTION`) summarizes dropped groups into a rolling digest system message (untrusted-wrapped) instead of losing them — one extra LLM call per trim.
9191
- **Interaction modes** — engaging (narrated), enhance (persistent), verbose (raw), off.
9292
- Max 300 iterations by default.
9393
- **Post-response async processing** — skill learning, episode extraction, and per-turn extended-memory extraction run in background goroutines tracked by `MemoryManager.RunBackground`; `Agent.Close` drains them via `WaitForBackground` (bounded, ~15s) so the work survives CLI exit without hanging `odek run`.
94-
- **Storage maintenance janitor**`maintenance.Start` (internal/maintenance) runs a periodic sweep of `~/.odek` (expired sessions/audit/plans, oversized-log rotation, media sweep, skill skip-list GC) inside `odek telegram`, `odek serve`, and `odek schedule daemon` when `maintenance.enabled` is set; `odek cleanup [--dry-run]` runs the same sweep on demand. Session files are also trimmed at write time (oldest groups first, system message kept) when they would exceed `MaxSessionFileBytes`, so a session can never grow past the load cap. Operator-only config — project `./odek.json` cannot set it. See docs/MAINTENANCE.md.
94+
- **Storage maintenance janitor**`maintenance.Start` (internal/maintenance) runs a periodic sweep of `~/.odek` (expired sessions/audit/plans, oversized-log rotation, media sweep, skill skip-list GC) inside `odek telegram`, `odek serve`, and `odek schedule daemon` when `maintenance.enabled` is set; `odek cleanup [--dry-run]` runs the same sweep on demand. Session files are also trimmed at write time (oldest groups first, system message kept, `[Session storage limit: ...]` marker persisted into the transcript) when they would exceed `MaxSessionFileBytes`, so a session can never grow past the load cap. Operator-only config — project `./odek.json` cannot set it. See docs/MAINTENANCE.md.
9595
- **Artifact-aware file search**`search_files` and `multi_grep` skip build/artifact directories (`node_modules`, `vendor`, `.git`, `__pycache__`, `.venv`, etc.) automatically, reducing noise and speeding scans.
9696
- **Semantic session search** — the `session_search` tool uses go-vector RandomProjections + k-NN for semantic similarity search through session content, with a two-tier pipeline: vector index (fast, ~1ms) → deepSearch fallback (exhaustive, slower).
9797
- **Security-first defaults** — the latest hardening closes the high/medium/low findings tracked in `sec_findings.md`: default `non_interactive` is `deny`, project-level `odek.json` cannot redirect backends or hijack delivery, project-level sandbox knobs require explicit operator approval, `~/.odek` trust anchors are protected, WebSocket upgrades require a per-instance CSRF token, and all untrusted content is wrapped before reaching the model. See Security Architecture below for the full list.

cmd/odek/main.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ type runFlags struct {
274274
NoColor *bool // nil = not set
275275
NoAgents *bool // nil = not set
276276
PromptCaching *bool // nil = not set; true = enable prompt caching
277+
Compaction *bool // nil = not set; true = enable rolling compaction
277278
Session *bool // nil = not set; true = save session after run
278279
Learn *bool // nil = not set; true = enable skills learning mode
279280
Task string
@@ -412,6 +413,9 @@ func parseRunFlags(args []string) (runFlags, error) {
412413
case "--prompt-caching":
413414
f.PromptCaching = boolPtr(true)
414415
i++
416+
case "--compaction":
417+
f.Compaction = boolPtr(true)
418+
i++
415419
case "--session":
416420
f.Session = boolPtr(true)
417421
i++
@@ -655,6 +659,10 @@ done:
655659
f.PromptCaching = boolPtr(true)
656660
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
657661
j--
662+
case "--compaction":
663+
f.Compaction = boolPtr(true)
664+
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
665+
j--
658666
case "--sandbox-readonly":
659667
f.SandboxReadonly = boolPtr(true)
660668
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
@@ -740,6 +748,7 @@ type replFlags struct {
740748
ThinkingBudget int // 0 = not set; use default
741749
Sandbox *bool // nil = not set
742750
PromptCaching *bool // nil = not set; true = enable prompt caching
751+
Compaction *bool // nil = not set; true = enable rolling compaction
743752
InteractionMode string
744753

745754
// Sandbox-specific CLI flags
@@ -809,6 +818,9 @@ func parseReplFlags(args []string) (replFlags, error) {
809818
case "--prompt-caching":
810819
f.PromptCaching = boolPtr(true)
811820
i++
821+
case "--compaction":
822+
f.Compaction = boolPtr(true)
823+
i++
812824
case "--interaction-mode":
813825
f.InteractionMode = args[i+1]
814826
i += 2
@@ -888,6 +900,7 @@ Run flags:
888900
--no-color Disable colored terminal output
889901
--no-agents Skip loading AGENTS.md from working directory
890902
--prompt-caching Enable prompt caching markers (Anthropic/DeepSeek/OpenAI)
903+
--compaction Enable LLM-based rolling compaction of trimmed context
891904
--session Save conversation as a multi-turn session
892905
--learn Enable skill learning mode — on by default, no flag needed
893906
--no-learn Disable skill learning mode (overrides config/default)
@@ -1154,6 +1167,7 @@ func run(args []string) error {
11541167
NoColor: f.NoColor,
11551168
NoAgents: f.NoAgents,
11561169
PromptCaching: f.PromptCaching,
1170+
Compaction: f.Compaction,
11571171
Learn: f.Learn,
11581172
System: f.System,
11591173
Task: f.Task,
@@ -1326,6 +1340,7 @@ func run(args []string) error {
13261340
Skills: skillsCfg,
13271341
SkillManager: sm,
13281342
PromptCaching: resolved.PromptCaching,
1343+
Compaction: resolved.Compaction,
13291344
MemoryDir: expandHome("~/.odek/memory"),
13301345
MemoryConfig: resolved.Memory,
13311346
Guard: injectionGuard,
@@ -2380,6 +2395,7 @@ func continueCmd(args []string) error {
23802395
Skills: skillsCfg,
23812396
SkillManager: sm,
23822397
PromptCaching: resolved.PromptCaching,
2398+
Compaction: resolved.Compaction,
23832399
MemoryDir: expandHome("~/.odek/memory"),
23842400
MemoryConfig: resolved.Memory,
23852401
Guard: injectionGuard,

cmd/odek/repl.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func replCmd(args []string) error {
5353
Thinking: f.Thinking,
5454
Sandbox: f.Sandbox,
5555
PromptCaching: f.PromptCaching,
56+
Compaction: f.Compaction,
5657
InteractionMode: f.InteractionMode,
5758

5859
SandboxImage: f.SandboxImage,
@@ -165,6 +166,7 @@ func replCmd(args []string) error {
165166
MemoryConfig: resolved.Memory,
166167
MemoryDir: expandHome("~/.odek/memory"),
167168
PromptCaching: resolved.PromptCaching,
169+
Compaction: resolved.Compaction,
168170
Guard: injectionGuard,
169171
GuardConfig: resolved.Guard,
170172
})

cmd/odek/schedule.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
721721
Renderer: render.New(io.Discard, false), // silent: unattended
722722
InteractionMode: "off",
723723
PromptCaching: resolved.PromptCaching,
724+
Compaction: resolved.Compaction,
724725
IterationCallback: func(info loop.IterationInfo) { lastInfo = info },
725726
Guard: injectionGuard,
726727
GuardConfig: resolved.Guard,

cmd/odek/serve.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ func serveCmd(args []string) error {
185185
var sandbox *bool
186186
var sandboxReadonly *bool
187187
var promptCaching *bool
188+
var compaction *bool
188189
var sandboxImage, sandboxNetwork, sandboxMemory, sandboxCPUs, sandboxUser string
189190
var toolsEnabled, toolsDisabled, trustedProxies []string
190191

@@ -234,6 +235,8 @@ func serveCmd(args []string) error {
234235
}
235236
case "--prompt-caching":
236237
promptCaching = boolPtr(true)
238+
case "--compaction":
239+
compaction = boolPtr(true)
237240
case "--tool":
238241
i++
239242
if i >= len(args) {
@@ -263,6 +266,7 @@ func serveCmd(args []string) error {
263266
resolved := config.LoadConfig(config.CLIFlags{
264267
Sandbox: sandbox,
265268
PromptCaching: promptCaching,
269+
Compaction: compaction,
266270
SandboxImage: sandboxImage,
267271
SandboxNetwork: sandboxNetwork,
268272
SandboxReadonly: sandboxReadonly,

docker/config.godmode.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"sandbox": false,
3+
"compaction": true,
34
"interaction_mode": "engaging",
45
"tool_progress": "verbose",
56
"tool_progress_cleanup": false,

docker/config.restricted.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"sandbox": false,
3+
"compaction": true,
34
"interaction_mode": "engaging",
45
"tool_progress": "verbose",
56
"tool_progress_cleanup": false,

docs/CLI.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
| `--interaction-mode <mode>` | string | `engaging` | Tool-call rendering: `engaging` (emoji narration) or `verbose` (raw tool output) |
4949
| `--no-color` | bool | false | Disable colored terminal output |
5050
| `--prompt-caching` | bool | false | Enable Anthropic/OpenAI/DeepSeek prompt caching markers |
51+
| `--compaction` | bool | false | Enable LLM-based rolling compaction of trimmed context |
5152
| `--no-agents` | bool | false | Skip loading AGENTS.md |
5253
| `--session` | bool | false | Save conversation as a multi-turn session |
5354
| `--learn` | bool | `true` | Enable skill learning mode (detects patterns, saves skills). On by default |

docs/CONFIG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Every config knob has a `ODEK_*` counterpart:
118118
| `ODEK_SYSTEM` | `--system` | string |
119119
| `ODEK_SKILLS_LEARN` | `skills.learn` | bool |
120120
| `ODEK_PROMPT_CACHING` | `prompt_caching` | bool |
121+
| `ODEK_COMPACTION` | `compaction` | bool |
121122
| `ODEK_TOOL_PROGRESS` | `tool_progress` | string (all\|new\|verbose\|off) |
122123
| `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string |
123124
| `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string |
@@ -243,6 +244,14 @@ I/O-bound tools (read_file, search_files, shell) benefit most — latency drops
243244

244245
**Approval gate:** When an approver is configured and the LLM returns multiple tool calls, a single batch approval prompt is shown before any tool executes. If approved, all tools run in parallel. If denied, no tools run.
245246

247+
## Rolling compaction (`compaction`)
248+
249+
When context trimming drops old conversation turns to stay within the model's context window, those turns are normally lost. With `compaction` enabled, the dropped turns are instead summarized by the model into a rolling digest message, preserving a compressed history of the session.
250+
251+
| Field | Default | Env var | CLI flag | Description |
252+
|-------|---------|---------|----------|-------------|
253+
| `compaction` | `false` | `ODEK_COMPACTION` | `--compaction` | Enable LLM-based rolling compaction of trimmed context. Each compaction costs one extra LLM call per trim. |
254+
246255
## Concurrency and reverse-proxy trust
247256

248257
| Field | Default | Env var | Description |

internal/config/loader.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ type CLIFlags struct {
7272
// Config: prompt_caching, ODEK_PROMPT_CACHING, --prompt-caching.
7373
PromptCaching *bool // nil = not set
7474

75+
// Compaction enables LLM-based rolling compaction of trimmed context.
76+
// Config: compaction, ODEK_COMPACTION, --compaction.
77+
Compaction *bool // nil = not set
78+
7579
// Sandbox-specific
7680
SandboxImage string
7781
SandboxNetwork string
@@ -240,6 +244,9 @@ type FileConfig struct {
240244
// PromptCaching enables prompt caching markers for supported providers.
241245
PromptCaching *bool `json:"prompt_caching,omitempty"`
242246

247+
// Compaction enables LLM-based rolling compaction of trimmed context.
248+
Compaction *bool `json:"compaction,omitempty"`
249+
243250
System string `json:"system,omitempty"`
244251

245252
// Sandbox-specific fields.
@@ -376,6 +383,7 @@ type ResolvedConfig struct {
376383
NoColor bool
377384
NoAgents bool
378385
PromptCaching bool
386+
Compaction bool
379387
System string
380388

381389
// SandboxImage is the Docker image for the sandbox container.
@@ -1078,6 +1086,9 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
10781086
if v := envBool("PROMPT_CACHING"); v != nil {
10791087
cfg.PromptCaching = v
10801088
}
1089+
if v := envBool("COMPACTION"); v != nil {
1090+
cfg.Compaction = v
1091+
}
10811092
if v := envString("SYSTEM"); v != "" {
10821093
cfg.System = v
10831094
}
@@ -1431,6 +1442,9 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
14311442
if cli.PromptCaching != nil {
14321443
cfg.PromptCaching = cli.PromptCaching
14331444
}
1445+
if cli.Compaction != nil {
1446+
cfg.Compaction = cli.Compaction
1447+
}
14341448
if cli.Learn != nil {
14351449
if cfg.Skills == nil {
14361450
cfg.Skills = &SkillsConfig{}
@@ -1702,6 +1716,9 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
17021716
if cfg.PromptCaching != nil {
17031717
resolved.PromptCaching = *cfg.PromptCaching
17041718
}
1719+
if cfg.Compaction != nil {
1720+
resolved.Compaction = *cfg.Compaction
1721+
}
17051722
if cfg.SandboxReadonly != nil {
17061723
resolved.SandboxReadonly = *cfg.SandboxReadonly
17071724
}
@@ -2273,6 +2290,9 @@ func overlayFile(base, override FileConfig) FileConfig {
22732290
if override.PromptCaching != nil {
22742291
base.PromptCaching = override.PromptCaching
22752292
}
2293+
if override.Compaction != nil {
2294+
base.Compaction = override.Compaction
2295+
}
22762296
if override.MaxConcurrency > 0 {
22772297
base.MaxConcurrency = override.MaxConcurrency
22782298
}

0 commit comments

Comments
 (0)