diff --git a/agent/build_runtime.go b/agent/build_runtime.go index 3bf4454..58e1529 100644 --- a/agent/build_runtime.go +++ b/agent/build_runtime.go @@ -116,6 +116,20 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string } } + // Build resetsCounter lookup from tool infos. + resetsCounterMap := make(map[string]bool, len(allToolInfos)) + for _, ti := range allToolInfos { + if ti.ResetsCounter { + resetsCounterMap[ti.Name] = true + } + } + var resetsCounterFn func(string) bool + if len(resetsCounterMap) > 0 { + resetsCounterFn = func(name string) bool { + return resetsCounterMap[name] + } + } + var backendName, modelName, compactionModel string if cfg != nil { backendName = cfg.Backend @@ -175,6 +189,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string ClassifyTool: classify, OutputFormat: outputFormat, ClassifyTier: tierFn, + ResetsCounter: resetsCounterFn, Preamble: preamble, GenParams: genParams, MaxSteps: maxSteps, diff --git a/agent/loop.go b/agent/loop.go index 576ec46..f6366d5 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -112,6 +112,7 @@ type agentConfig struct { ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe OutputFormat func(name string) string // empty means plaintext fence ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention + ResetsCounter func(name string) bool // nil=false for all; true=reset step counter Output EventHandler preamble string // compiled system+agent prompt sent as the system role GenerationParams backend.GenerationParams @@ -653,6 +654,19 @@ func run(ctx context.Context, cfg agentConfig, state state) error { } } + // Reset step counter when an action-oriented tool is called successfully. + // This allows the agent to continue working without hitting the soft + // step-budget guardrail, as long as it's making active progress (writing + // files, renaming symbols, etc.) rather than looping on research. + if cfg.ResetsCounter != nil { + for _, r := range results { + if !r.IsError && cfg.ResetsCounter(r.Name) { + step = 0 + break + } + } + } + // Soft step-budget guardrail: when MaxSteps is set and the budget is // exhausted, nudge the model to wrap up and exit cleanly. This is not // a hard abort — the model gets one final turn without tools to emit diff --git a/agent/runtime.go b/agent/runtime.go index aec2f63..bb65354 100644 --- a/agent/runtime.go +++ b/agent/runtime.go @@ -12,16 +12,17 @@ import ( // agent. The agent struct stores a pointer to the active Runtime; switching // agents replaces it atomically. type Runtime struct { - Backend backend.Backend - ToolServer toolsrv.Runner // the execute server (tool runtime, env, cwd) - Preamble string // compiled system prompt - Tools []backend.Tool - Exec toolExecutor - ClassifyTool toolClassifier - OutputFormat func(string) string - ClassifyTier func(string, json.RawMessage) ResultTier - GenParams backend.GenerationParams - MaxSteps int + Backend backend.Backend + ToolServer toolsrv.Runner // the execute server (tool runtime, env, cwd) + Preamble string // compiled system prompt + Tools []backend.Tool + Exec toolExecutor + ClassifyTool toolClassifier + OutputFormat func(string) string + ClassifyTier func(string, json.RawMessage) ResultTier + ResetsCounter func(string) bool // true if tool resets step counter + GenParams backend.GenerationParams + MaxSteps int // CfgBackend and CfgModel are the backend/model overrides from the agent // config JSON. Empty means no override. Applied by the caller after // BuildRuntime returns. diff --git a/agent/turn.go b/agent/turn.go index d96c998..95b24db 100644 --- a/agent/turn.go +++ b/agent/turn.go @@ -132,10 +132,11 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { ClassifyTool: ag.runtime.ClassifyTool, OutputFormat: outputFormatFn, ClassifyTier: ag.runtime.ClassifyTier, - GenerationParams: ag.runtime.GenParams, - MaxSteps: ag.runtime.MaxSteps, - ReadPlanStep: ag.readPlanStep, - } + ResetsCounter: ag.runtime.ResetsCounter, + GenerationParams: ag.runtime.GenParams, + MaxSteps: ag.runtime.MaxSteps, + ReadPlanStep: ag.readPlanStep, + } var replyBuf strings.Builder ag.cfg.Output = func(ev Event) { diff --git a/data/agents/default.json b/data/agents/default.json index 59def11..6394022 100644 --- a/data/agents/default.json +++ b/data/agents/default.json @@ -11,7 +11,7 @@ "file_write", "tool_load" ], - "maxSteps": 50, + "maxSteps": 25, "temperature": 0.5, "maxTokens": 16384 } diff --git a/data/agents/driver.json b/data/agents/driver.json index ed4851b..4a7c3c2 100644 --- a/data/agents/driver.json +++ b/data/agents/driver.json @@ -37,7 +37,7 @@ ], "backend": "openrouter", "model": "deepseek/deepseek-v4-flash", - "maxSteps": 50, + "maxSteps": 25, "temperature": 0.4, "maxTokens": 16384 } diff --git a/data/scripts/o b/data/scripts/o index 463e469..b3d26a9 100755 --- a/data/scripts/o +++ b/data/scripts/o @@ -482,7 +482,7 @@ cmd_tui() { # State shown in tmux status-right. # Pane 0: chat (top, 80%) - tmux new-session -d -s "$tmux_session" -c "$HOME" \ + tmux -CC new-session -d -s "$tmux_session" -c "$HOME" \ -e "session=$s" -e "agent=$a" tmux send-keys -t "$tmux_session" "clear; o tail | grep -v '\\[\\[\\[.*\\]\\]\\]'" Enter diff --git a/data/tools/file_edit.meta b/data/tools/file_edit.meta index ef9e487..5b8a7c0 100644 --- a/data/tools/file_edit.meta +++ b/data/tools/file_edit.meta @@ -1,6 +1,7 @@ { "description": "Replace text in a file. Has fuzzy matching (whitespace/indent flexible).", "outputFormat": "diff", + "resetsCounter": true, "prompt": "## file_edit\n\nReplace text in a file. Has fuzzy matching (whitespace/indent flexible).\n\n**Args**: `path`, `old_string`, `new_string` (required); `replace_all` (optional)\n\n```\nfile_edit(path=\"/abs/path\", old_string=\"old text\", new_string=\"new text\")\nfile_edit(path=\"/abs/path\", old_string=\"old text\", new_string=\"new text\", replace_all=\"true\")\n```\n\n**Constraints**: Absolute paths only. Errors if `old_string` matches multiple locations — add surrounding context to disambiguate, or use `replace_all=true`.", "args": { "type": "object", diff --git a/data/tools/file_write.meta b/data/tools/file_write.meta index 4ece576..c525e4a 100644 --- a/data/tools/file_write.meta +++ b/data/tools/file_write.meta @@ -1,6 +1,7 @@ { "description": "Create or overwrite a file. Produces a unified diff.", "outputFormat": "diff", + "resetsCounter": true, "prompt": "## file_write\n\nCreate or overwrite a file. Produces a unified diff.\n\n**Args**: `[path, content]`\n\n```\nfile_write(path=\"/abs/path\", content=\"file content here\")\n```\n\n**Constraints**: Absolute paths only. Parent directory must exist.", "args": { "type": "object", diff --git a/data/tools/shell.meta b/data/tools/shell.meta index 35c898f..a5c3c5b 100644 --- a/data/tools/shell.meta +++ b/data/tools/shell.meta @@ -1,5 +1,6 @@ { "description": "Execute a single bash command in a sandboxed environment.", + "resetsCounter": true, "prompt": "Execute a single bash command in a sandboxed environment.\n\nUsage: {\"cmd\": \"your command here\"}\n\nSandbox prevents dangerous operations. Use for computation, builds, scripting.\ntimeout applies to each call (default: 30s). A non-zero exit is an error.", "args": { "type": "object", diff --git a/toolsrv/meta.go b/toolsrv/meta.go index 2bc1f98..f299568 100644 --- a/toolsrv/meta.go +++ b/toolsrv/meta.go @@ -11,30 +11,32 @@ import ( // MetaFile is the JSON structure of a .meta sidecar file. type MetaFile struct { - Description string `json:"description"` - Prompt string `json:"prompt,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - Tier string `json:"tier,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - OutputFormat string `json:"outputFormat,omitempty"` - Cmd string `json:"cmd,omitempty"` - Sudo bool `json:"sudo,omitempty"` - Variants []Variant `json:"variants,omitempty"` + Description string `json:"description"` + Prompt string `json:"prompt,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Tier string `json:"tier,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OutputFormat string `json:"outputFormat,omitempty"` + Cmd string `json:"cmd,omitempty"` + Sudo bool `json:"sudo,omitempty"` + ResetsCounter bool `json:"resetsCounter,omitempty"` + Variants []Variant `json:"variants,omitempty"` } // Variant is a conditional tool definition. The first variant whose // match conditions are all satisfied determines the tool's schema, // documentation, and executable. type Variant struct { - Match map[string]any `json:"match"` - Description string `json:"description,omitempty"` - Prompt string `json:"prompt,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - Tier string `json:"tier,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - OutputFormat string `json:"outputFormat,omitempty"` - Cmd string `json:"cmd,omitempty"` - Sudo bool `json:"sudo,omitempty"` + Match map[string]any `json:"match"` + Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Tier string `json:"tier,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OutputFormat string `json:"outputFormat,omitempty"` + Cmd string `json:"cmd,omitempty"` + Sudo bool `json:"sudo,omitempty"` + ResetsCounter bool `json:"resetsCounter,omitempty"` } // LoadMetaFile reads and parses a .meta sidecar file for the given tool name. @@ -88,6 +90,9 @@ func (m *MetaFile) Resolve() *MetaFile { if v.Sudo { resolved.Sudo = true } + if v.ResetsCounter { + resolved.ResetsCounter = true + } if v.Cmd != "" { resolved.Cmd = v.Cmd } @@ -176,13 +181,14 @@ func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo { tier = "hot" } return ToolInfo{ - Name: name, - Description: resolved.Description, - InputSchema: args, - Prompt: resolved.Prompt, - Tier: tier, - ReadOnly: resolved.ReadOnly, - OutputFormat: resolved.OutputFormat, + Name: name, + Description: resolved.Description, + InputSchema: args, + Prompt: resolved.Prompt, + Tier: tier, + ReadOnly: resolved.ReadOnly, + OutputFormat: resolved.OutputFormat, + ResetsCounter: resolved.ResetsCounter, } } diff --git a/toolsrv/tools.go b/toolsrv/tools.go index 8a05c24..ef4e0af 100644 --- a/toolsrv/tools.go +++ b/toolsrv/tools.go @@ -23,6 +23,11 @@ type ToolInfo struct { ReadOnly bool // OutputFormat is the source-fence language for tool output. Empty means plaintext. OutputFormat string + // ResetsCounter is true for action-oriented tools (file_write, lsp_rename, etc.) + // that indicate active progress rather than passive research. When true, the + // agent loop resets its step counter, allowing continued work without hitting + // the soft step-budget guardrail. + ResetsCounter bool } // Runner is the minimal interface satisfied by any tool server (local or remote).