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
19 changes: 12 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ odek.go Public API (Config, New, Run, Close, ModelProfile,
cmd/odek/
main.go CLI entry point, flag parsing, commands, sandbox setup, system prompt
dispatch.go CLI subcommand dispatch
shell.go Built-in shell tool (local or docker exec; danger-gated)
shell.go Built-in shell tool (local or docker exec; danger-gated; optional timeout_seconds)
serve.go Web UI server (HTTP + WebSocket; @-resource completion)
repl.go Interactive REPL with multi-turn session support
repl_editor.go Terminal raw-mode input editor
Expand Down Expand Up @@ -55,7 +55,7 @@ cmd/odek/
*_test.go 250+ unit + E2E tests covering all tools
internal/
llm/ OpenAI-compatible HTTP client with reasoning_content support
loop/ ReAct engine: observe → think → parallel-act → repeat. signal.go — SignalEvent observability (context_trimmed, tool_recovery).
loop/ ReAct engine: observe → think → parallel-act → repeat. signal.go — SignalEvent observability (context_trimmed, tool_recovery, tool_running heartbeat).
tool/ Thread-safe tool registry, clarify.go, send_message.go
danger/ Command/URL classification + bypass-resistant tokenizer. TTYApprover with friction mode.
auth/ Interactive approval system
Expand Down Expand Up @@ -87,12 +87,12 @@ ReAct cycle: observe → think → act → repeat.
- LLM returns tool calls or a final answer.
- **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** — 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.
- **Tool-failure recovery** — systematic recovery from tool call failures: retry transient errors, skip permanently failed tools, and continue the loop without crashing. Stall detection covers the successful-call case: 3 consecutive identical tool calls (same name + args) inject a corrective hint and fire a `tool_recovery` signal — a hint, not enforcement; the run is never aborted.
- **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. **Rolling compaction** (on by default; disable via `compaction: false`, `ODEK_COMPACTION=false`, or `--no-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.
- Max 90 iterations by default. On budget exhaustion the engine makes one final tool-less LLM call for a partial-progress summary (30s bound) and returns it as the final answer marked `[Iteration budget reached — partial summary]`; if that call fails, the original `reached max iterations` error is returned.
- **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`.
- **Per-turn session persistence** — the loop fires an optional `SetMessagesPersistCallback` after each completed step (after a tool batch's result messages, and after the final assistant message) with a freshly-copied message snapshot. `run --session`, `continue`, the REPL, and `odek serve` wire it to `Store.SaveNoIndex` — an atomic save that skips the vector-index update (embedding can be a remote HTTP call and must not fire every iteration) — so an interrupted run (Ctrl-C, SIGTERM, crash) can be resumed via `odek continue` from the last completed step. Error/interrupt paths additionally persist the partial history minus any trailing assistant message with unanswered tool calls (`persistPartialMessages`). The final successful save still goes through `Save`/`Append`, updating the semantic index once per completed turn. If the loop trimmed history in place, the callback skips the save rather than overwriting richer persisted state. `odek serve` filters dynamically-injected system messages (skills/memory/episodes) out of persisted snapshots, preserving the session's original leading system message.
- **Per-turn session persistence** — the loop fires an optional `SetMessagesPersistCallback` after each completed step (after a tool batch's result messages, and after the final assistant message) with a freshly-copied message snapshot. `run --session`, `continue`, the REPL, `odek serve`, and the Telegram bot wire it to `Store.SaveNoIndex` (Telegram via `SessionManager.SaveNoIndex`) — an atomic save that skips the vector-index update (embedding can be a remote HTTP call and must not fire every iteration) — so an interrupted run (Ctrl-C, SIGTERM, crash) can be resumed via `odek continue` from the last completed step. Error/interrupt paths additionally persist the partial history minus any trailing assistant message with unanswered tool calls (`persistPartialMessages` / `dropDanglingToolCalls`). The final successful save still goes through `Save`/`Append`, updating the semantic index once per completed turn. If the loop trimmed history in place, the callback skips the save rather than overwriting richer persisted state. `odek serve` filters dynamically-injected system messages (skills/memory/episodes) out of persisted snapshots, preserving the session's original leading system message **and** compaction digest messages (`[Compacted earlier context:` prefix) so resumed web sessions keep their compacted history.
- **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).
Expand Down Expand Up @@ -257,4 +257,9 @@ go test -fuzz=FuzzParseSkillContent -fuzztime=30s ./internal/skills/
go test -fuzz=FuzzSessionLoad -fuzztime=30s ./internal/session/
```

Note: MCP client E2E tests build the fakeserver from `internal/mcpclient/testdata/main.go` at test time (no pre-compiled binary). macOS temp dirs are classified as `LocalWrite` (not `SystemWrite`), and the Docker availability check verifies daemon reachability before running sandbox tests.
Note: MCP client E2E tests build the fakeserver from `internal/mcpclient/testdata/main.go` at test time (no pre-compiled binary). macOS temp dirs are classified as `LocalWrite` (not `SystemWrite`), and the Docker availability check verifies daemon reachability (5s timeout) before running sandbox tests.

Agent workflow notes:

- **Scope test runs.** Prefer `go test -count=1 <changed packages>` over `./...`; run `-race` only on changed packages. A full `go test -race ./...` takes several minutes (race-instrumented build + 5–10× runtime slowdown).
- **The `shell` tool is fully buffered.** Nothing is shown or returned until the command exits, and the default timeout is 30 minutes — a long command looks "stuck" even though it is running (a `tool_running` heartbeat signal fires every 60s). Set `timeout_seconds` explicitly for known long-running commands (builds, test suites) so a genuinely stuck command fails fast, and pass `go test -timeout` for test runs.
37 changes: 28 additions & 9 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,9 @@ func parseRunFlags(args []string) (runFlags, error) {
case "--compaction":
f.Compaction = boolPtr(true)
i++
case "--no-compaction":
f.Compaction = boolPtr(false)
i++
case "--session":
f.Session = boolPtr(true)
i++
Expand Down Expand Up @@ -663,6 +666,10 @@ done:
f.Compaction = boolPtr(true)
taskArgs = append(taskArgs[:j], taskArgs[j+1:]...)
j--
case "--no-compaction":
f.Compaction = boolPtr(false)
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 @@ -821,6 +828,9 @@ func parseReplFlags(args []string) (replFlags, error) {
case "--compaction":
f.Compaction = boolPtr(true)
i++
case "--no-compaction":
f.Compaction = boolPtr(false)
i++
case "--interaction-mode":
f.InteractionMode = args[i+1]
i += 2
Expand Down Expand Up @@ -906,7 +916,8 @@ 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
--compaction Enable LLM-based rolling compaction of trimmed context (default: on)
--no-compaction Disable rolling compaction (overrides config/default)
--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 @@ -1008,7 +1019,7 @@ const globalConfigTemplate = `{
"max_iterations": 90,
"max_tool_parallel": 4,
"prompt_caching": false,
"compaction": false,
"compaction": true,
"interaction_mode": "engaging",
"no_color": false,
"no_agents": false,
Expand Down Expand Up @@ -1131,7 +1142,6 @@ const localConfigTemplate = `{
"max_iterations": 0,
"max_tool_parallel": 0,
"prompt_caching": false,
"compaction": false,
"interaction_mode": "",
"no_color": false,
"no_agents": false,
Expand Down Expand Up @@ -1239,7 +1249,7 @@ func initConfig(args []string) error {
fmt.Println(" thinking Reasoning depth (enabled/disabled/low/medium/high)")
fmt.Println(" max_iterations Max think→act cycles (default: 90)")
fmt.Println(" prompt_caching Provider prompt caching (true/false)")
fmt.Println(" compaction Rolling LLM context compaction (true/false)")
fmt.Println(" compaction Rolling LLM context compaction (default: true)")
fmt.Println(" interaction_mode engaging | enhance | verbose | off")
fmt.Println(" sandbox Run in Docker sandbox (true/false)")
fmt.Println(" system System prompt override")
Expand Down Expand Up @@ -2447,6 +2457,19 @@ func expandHome(path string) string {

// ── Continue (Multi-Turn) ─────────────────────────────────────────────

// dropDanglingToolCalls returns messages with any trailing assistant messages
// that carry unanswered tool calls removed. Their tool results never
// completed, and resuming with dangling tool calls is an invalid request for
// OpenAI-compatible APIs.
func dropDanglingToolCalls(messages []llm.Message) []llm.Message {
for len(messages) > 0 &&
messages[len(messages)-1].Role == "assistant" &&
len(messages[len(messages)-1].ToolCalls) > 0 {
messages = messages[:len(messages)-1]
}
return messages
}

// persistPartialMessages saves the in-flight message history of an
// interrupted/failed run so the session keeps progress up to the last
// completed step. A trailing assistant message with unanswered tool calls
Expand All @@ -2456,11 +2479,7 @@ func persistPartialMessages(store *session.Store, sess *session.Session, message
if store == nil || sess == nil || len(messages) == 0 {
return
}
for len(messages) > 0 &&
messages[len(messages)-1].Role == "assistant" &&
len(messages[len(messages)-1].ToolCalls) > 0 {
messages = messages[:len(messages)-1]
}
messages = dropDanglingToolCalls(messages)
if len(messages) < len(sess.Messages) {
// The loop trimmed history in place — keep the richer state already
// persisted by the per-turn callback instead of overwriting it.
Expand Down
73 changes: 72 additions & 1 deletion cmd/odek/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -128,6 +129,56 @@ func TestParseRunFlags_AllFlags(t *testing.T) {
}
}

func TestParseRunFlags_CompactionFlags(t *testing.T) {
// --compaction and --no-compaction are explicit overrides; absent means
// "not set" (nil), so config/default resolution decides.
f, err := parseRunFlags([]string{"--compaction", "do the thing"})
if err != nil {
t.Fatalf("parseRunFlags error: %v", err)
}
if f.Compaction == nil || !*f.Compaction {
t.Error("--compaction should set Compaction to true")
}

f, err = parseRunFlags([]string{"--no-compaction", "do the thing"})
if err != nil {
t.Fatalf("parseRunFlags error: %v", err)
}
if f.Compaction == nil || *f.Compaction {
t.Error("--no-compaction should set Compaction to false")
}

// Trailing form after the task phrase.
f, err = parseRunFlags([]string{"do the thing", "--no-compaction"})
if err != nil {
t.Fatalf("parseRunFlags error: %v", err)
}
if f.Compaction == nil || *f.Compaction {
t.Error("trailing --no-compaction should set Compaction to false")
}
if f.Task != "do the thing" {
t.Errorf("Task = %q, want %q", f.Task, "do the thing")
}

f, err = parseRunFlags([]string{"do the thing"})
if err != nil {
t.Fatalf("parseRunFlags error: %v", err)
}
if f.Compaction != nil {
t.Error("Compaction should be nil (not set) without a compaction flag")
}
}

func TestParseReplFlags_NoCompaction(t *testing.T) {
f, err := parseReplFlags([]string{"--no-compaction", "x"})
if err != nil {
t.Fatalf("parseReplFlags error: %v", err)
}
if f.Compaction == nil || *f.Compaction {
t.Error("--no-compaction should set Compaction to false")
}
}

func TestParseRunFlags_NoTask(t *testing.T) {
_, err := parseRunFlags([]string{})
if err == nil {
Expand Down Expand Up @@ -765,7 +816,10 @@ func dockerAvailable() bool {
return false
}
// Verify the daemon is actually reachable (not just the CLI installed).
cmd := exec.Command("docker", "info")
// Bounded so a stopped/starting daemon cannot hang the test suite.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "info")
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
Expand Down Expand Up @@ -814,6 +868,12 @@ func TestInitConfig_Local(t *testing.T) {
t.Errorf("local config must not contain %q (project configs may only enable it), got: %s", field, content)
}
}
// compaction defaults to ON; an explicit "compaction": false in a fresh
// project config would silently disable it, so the key must be omitted
// (inherit) rather than pinned.
if strings.Contains(content, "compaction") {
t.Errorf("local config must not pin compaction (default-on; omit to inherit), got: %s", content)
}
// Must be valid JSON.
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
Expand Down Expand Up @@ -847,6 +907,11 @@ func TestInitConfig_Global(t *testing.T) {
t.Errorf("global config should contain %q, got: %s", field, content)
}
}
// Compaction is default-on; the global template must pin it to true so a
// fresh operator config matches the documented default explicitly.
if !strings.Contains(content, `"compaction": true`) {
t.Errorf("global config should set compaction to true, got: %s", content)
}
// Must be valid JSON.
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
Expand Down Expand Up @@ -977,6 +1042,9 @@ func TestInitConfig_LocalTemplateLoadsClean(t *testing.T) {
if fc.Sandbox != nil || fc.SandboxReadonly != nil {
t.Error("localConfigTemplate must not pin sandbox/sandbox_readonly (project configs may only enable, never disable)")
}
if fc.Compaction != nil {
t.Error("localConfigTemplate must not pin compaction (default-on; omit the key to inherit)")
}
if fc.Skills != nil && len(fc.Skills.Dirs) > 0 {
t.Error("localConfigTemplate must not set skills.dirs (rejected from project configs)")
}
Expand Down Expand Up @@ -1004,6 +1072,9 @@ func TestInitConfig_GlobalTemplateLoadsClean(t *testing.T) {
if fc.Skills == nil || fc.Skills.AutoSave == nil {
t.Error("global template should include skills.auto_save")
}
if fc.Compaction == nil || !*fc.Compaction {
t.Error("global template should pin compaction to true (the documented default)")
}
}

// TestInitConfig_RestrictivePermissions verifies that config files
Expand Down
Loading
Loading