Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ ReAct cycle: observe → think → act → repeat.
- **Parallel tool execution** — multiple independent tool calls run concurrently (`max_tool_parallel`, default: 4).
- **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.
- **Tool-failure recovery** — systematic recovery from tool call failures: retry transient errors, skip permanently failed tools, and continue the loop without crashing.
- **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.
- **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.
- **Interaction modes** — engaging (narrated), enhance (persistent), verbose (raw), off.
- Max 300 iterations by default.
- **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`.
- **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.
- **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.
- **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.
- **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).
- **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.
Expand Down
16 changes: 16 additions & 0 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ type runFlags struct {
NoColor *bool // nil = not set
NoAgents *bool // nil = not set
PromptCaching *bool // nil = not set; true = enable prompt caching
Compaction *bool // nil = not set; true = enable rolling compaction
Session *bool // nil = not set; true = save session after run
Learn *bool // nil = not set; true = enable skills learning mode
Task string
Expand Down Expand Up @@ -412,6 +413,9 @@ func parseRunFlags(args []string) (runFlags, error) {
case "--prompt-caching":
f.PromptCaching = boolPtr(true)
i++
case "--compaction":
f.Compaction = boolPtr(true)
i++
case "--session":
f.Session = boolPtr(true)
i++
Expand Down Expand Up @@ -655,6 +659,10 @@ done:
f.PromptCaching = boolPtr(true)
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
j--
case "--compaction":
f.Compaction = boolPtr(true)
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
j--
case "--sandbox-readonly":
f.SandboxReadonly = boolPtr(true)
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
Expand Down Expand Up @@ -740,6 +748,7 @@ type replFlags struct {
ThinkingBudget int // 0 = not set; use default
Sandbox *bool // nil = not set
PromptCaching *bool // nil = not set; true = enable prompt caching
Compaction *bool // nil = not set; true = enable rolling compaction
InteractionMode string

// Sandbox-specific CLI flags
Expand Down Expand Up @@ -809,6 +818,9 @@ func parseReplFlags(args []string) (replFlags, error) {
case "--prompt-caching":
f.PromptCaching = boolPtr(true)
i++
case "--compaction":
f.Compaction = boolPtr(true)
i++
case "--interaction-mode":
f.InteractionMode = args[i+1]
i += 2
Expand Down Expand Up @@ -888,6 +900,7 @@ Run flags:
--no-color Disable colored terminal output
--no-agents Skip loading AGENTS.md from working directory
--prompt-caching Enable prompt caching markers (Anthropic/DeepSeek/OpenAI)
--compaction Enable LLM-based rolling compaction of trimmed context
--session Save conversation as a multi-turn session
--learn Enable skill learning mode — on by default, no flag needed
--no-learn Disable skill learning mode (overrides config/default)
Expand Down Expand Up @@ -1154,6 +1167,7 @@ func run(args []string) error {
NoColor: f.NoColor,
NoAgents: f.NoAgents,
PromptCaching: f.PromptCaching,
Compaction: f.Compaction,
Learn: f.Learn,
System: f.System,
Task: f.Task,
Expand Down Expand Up @@ -1326,6 +1340,7 @@ func run(args []string) error {
Skills: skillsCfg,
SkillManager: sm,
PromptCaching: resolved.PromptCaching,
Compaction: resolved.Compaction,
MemoryDir: expandHome("~/.odek/memory"),
MemoryConfig: resolved.Memory,
Guard: injectionGuard,
Expand Down Expand Up @@ -2380,6 +2395,7 @@ func continueCmd(args []string) error {
Skills: skillsCfg,
SkillManager: sm,
PromptCaching: resolved.PromptCaching,
Compaction: resolved.Compaction,
MemoryDir: expandHome("~/.odek/memory"),
MemoryConfig: resolved.Memory,
Guard: injectionGuard,
Expand Down
2 changes: 2 additions & 0 deletions cmd/odek/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func replCmd(args []string) error {
Thinking: f.Thinking,
Sandbox: f.Sandbox,
PromptCaching: f.PromptCaching,
Compaction: f.Compaction,
InteractionMode: f.InteractionMode,

SandboxImage: f.SandboxImage,
Expand Down Expand Up @@ -165,6 +166,7 @@ func replCmd(args []string) error {
MemoryConfig: resolved.Memory,
MemoryDir: expandHome("~/.odek/memory"),
PromptCaching: resolved.PromptCaching,
Compaction: resolved.Compaction,
Guard: injectionGuard,
GuardConfig: resolved.Guard,
})
Expand Down
1 change: 1 addition & 0 deletions cmd/odek/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system
Renderer: render.New(io.Discard, false), // silent: unattended
InteractionMode: "off",
PromptCaching: resolved.PromptCaching,
Compaction: resolved.Compaction,
IterationCallback: func(info loop.IterationInfo) { lastInfo = info },
Guard: injectionGuard,
GuardConfig: resolved.Guard,
Expand Down
4 changes: 4 additions & 0 deletions cmd/odek/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ func serveCmd(args []string) error {
var sandbox *bool
var sandboxReadonly *bool
var promptCaching *bool
var compaction *bool
var sandboxImage, sandboxNetwork, sandboxMemory, sandboxCPUs, sandboxUser string
var toolsEnabled, toolsDisabled, trustedProxies []string

Expand Down Expand Up @@ -234,6 +235,8 @@ func serveCmd(args []string) error {
}
case "--prompt-caching":
promptCaching = boolPtr(true)
case "--compaction":
compaction = boolPtr(true)
case "--tool":
i++
if i >= len(args) {
Expand Down Expand Up @@ -263,6 +266,7 @@ func serveCmd(args []string) error {
resolved := config.LoadConfig(config.CLIFlags{
Sandbox: sandbox,
PromptCaching: promptCaching,
Compaction: compaction,
SandboxImage: sandboxImage,
SandboxNetwork: sandboxNetwork,
SandboxReadonly: sandboxReadonly,
Expand Down
1 change: 1 addition & 0 deletions docker/config.godmode.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"sandbox": false,
"compaction": true,
"interaction_mode": "engaging",
"tool_progress": "verbose",
"tool_progress_cleanup": false,
Expand Down
1 change: 1 addition & 0 deletions docker/config.restricted.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"sandbox": false,
"compaction": true,
"interaction_mode": "engaging",
"tool_progress": "verbose",
"tool_progress_cleanup": false,
Expand Down
1 change: 1 addition & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
| `--interaction-mode <mode>` | string | `engaging` | Tool-call rendering: `engaging` (emoji narration) or `verbose` (raw tool output) |
| `--no-color` | bool | false | Disable colored terminal output |
| `--prompt-caching` | bool | false | Enable Anthropic/OpenAI/DeepSeek prompt caching markers |
| `--compaction` | bool | false | Enable LLM-based rolling compaction of trimmed context |
| `--no-agents` | bool | false | Skip loading AGENTS.md |
| `--session` | bool | false | Save conversation as a multi-turn session |
| `--learn` | bool | `true` | Enable skill learning mode (detects patterns, saves skills). On by default |
Expand Down
9 changes: 9 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Every config knob has a `ODEK_*` counterpart:
| `ODEK_SYSTEM` | `--system` | string |
| `ODEK_SKILLS_LEARN` | `skills.learn` | bool |
| `ODEK_PROMPT_CACHING` | `prompt_caching` | bool |
| `ODEK_COMPACTION` | `compaction` | bool |
| `ODEK_TOOL_PROGRESS` | `tool_progress` | string (all\|new\|verbose\|off) |
| `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string |
| `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string |
Expand Down Expand Up @@ -243,6 +244,14 @@ I/O-bound tools (read_file, search_files, shell) benefit most — latency drops

**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.

## Rolling compaction (`compaction`)

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.

| Field | Default | Env var | CLI flag | Description |
|-------|---------|---------|----------|-------------|
| `compaction` | `false` | `ODEK_COMPACTION` | `--compaction` | Enable LLM-based rolling compaction of trimmed context. Each compaction costs one extra LLM call per trim. |

## Concurrency and reverse-proxy trust

| Field | Default | Env var | Description |
Expand Down
20 changes: 20 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ type CLIFlags struct {
// Config: prompt_caching, ODEK_PROMPT_CACHING, --prompt-caching.
PromptCaching *bool // nil = not set

// Compaction enables LLM-based rolling compaction of trimmed context.
// Config: compaction, ODEK_COMPACTION, --compaction.
Compaction *bool // nil = not set

// Sandbox-specific
SandboxImage string
SandboxNetwork string
Expand Down Expand Up @@ -240,6 +244,9 @@ type FileConfig struct {
// PromptCaching enables prompt caching markers for supported providers.
PromptCaching *bool `json:"prompt_caching,omitempty"`

// Compaction enables LLM-based rolling compaction of trimmed context.
Compaction *bool `json:"compaction,omitempty"`

System string `json:"system,omitempty"`

// Sandbox-specific fields.
Expand Down Expand Up @@ -376,6 +383,7 @@ type ResolvedConfig struct {
NoColor bool
NoAgents bool
PromptCaching bool
Compaction bool
System string

// SandboxImage is the Docker image for the sandbox container.
Expand Down Expand Up @@ -1078,6 +1086,9 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
if v := envBool("PROMPT_CACHING"); v != nil {
cfg.PromptCaching = v
}
if v := envBool("COMPACTION"); v != nil {
cfg.Compaction = v
}
if v := envString("SYSTEM"); v != "" {
cfg.System = v
}
Expand Down Expand Up @@ -1431,6 +1442,9 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
if cli.PromptCaching != nil {
cfg.PromptCaching = cli.PromptCaching
}
if cli.Compaction != nil {
cfg.Compaction = cli.Compaction
}
if cli.Learn != nil {
if cfg.Skills == nil {
cfg.Skills = &SkillsConfig{}
Expand Down Expand Up @@ -1702,6 +1716,9 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
if cfg.PromptCaching != nil {
resolved.PromptCaching = *cfg.PromptCaching
}
if cfg.Compaction != nil {
resolved.Compaction = *cfg.Compaction
}
if cfg.SandboxReadonly != nil {
resolved.SandboxReadonly = *cfg.SandboxReadonly
}
Expand Down Expand Up @@ -2273,6 +2290,9 @@ func overlayFile(base, override FileConfig) FileConfig {
if override.PromptCaching != nil {
base.PromptCaching = override.PromptCaching
}
if override.Compaction != nil {
base.Compaction = override.Compaction
}
if override.MaxConcurrency > 0 {
base.MaxConcurrency = override.MaxConcurrency
}
Expand Down
50 changes: 50 additions & 0 deletions internal/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,32 @@ func TestGlobalOverlay_PromptCaching(t *testing.T) {
}
}

// TestGlobalOverlay_Compaction verifies that Compaction from global config
// survives the merge.
func TestGlobalOverlay_Compaction(t *testing.T) {
t.Setenv("HOME", t.TempDir())
globalDir := filepath.Join(os.Getenv("HOME"), ".odek")
os.MkdirAll(globalDir, 0755)

if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{
"compaction": true
}`), 0644); err != nil {
t.Fatal(err)
}

t.Chdir(t.TempDir())
if err := os.WriteFile("odek.json", []byte(`{
"model": "project-model"
}`), 0644); err != nil {
t.Fatal(err)
}

cfg := LoadConfig(CLIFlags{})
if !cfg.Compaction {
t.Error("Compaction should be true (global value should survive project merge)")
}
}

// TestGlobalOverlay_MCPServers verifies that MCPServers from global config
// survive the merge. BUG: overlayFile doesn't transfer MCPServers.
func TestGlobalOverlay_MCPServers(t *testing.T) {
Expand Down Expand Up @@ -1678,3 +1704,27 @@ func TestIsVarCont(t *testing.T) {
}
}
}

// TestEnvVar_Compaction verifies ODEK_COMPACTION enables compaction.
func TestEnvVar_Compaction(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Chdir(t.TempDir())
t.Setenv("ODEK_COMPACTION", "true")

cfg := LoadConfig(CLIFlags{})
if !cfg.Compaction {
t.Error("Compaction should be true when ODEK_COMPACTION=true")
}
}

// TestCLIFlags_Compaction verifies the --compaction CLI flag enables compaction.
func TestCLIFlags_Compaction(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Chdir(t.TempDir())

enabled := true
cfg := LoadConfig(CLIFlags{Compaction: &enabled})
if !cfg.Compaction {
t.Error("Compaction should be true when CLIFlags.Compaction is set")
}
}
Loading
Loading