feat: add resetsCounter tool property to reset step budget on active progress

Tools like file_write, file_edit, and shell now reset the agent's step
counter when 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, running commands) rather than
looping on research/reading.

Changes:
- Add ResetsCounter bool to ToolInfo and MetaFile structs
- Parse resetsCounter from .meta JSON files
- Reset step counter in agent loop when ResetsCounter tool succeeds
- Add resetsCounter: true to file_write, file_edit, shell
- Reduce maxSteps from 50 to 25 (resetsCounter makes this safe)
This commit is contained in:
Levi Neely 2026-08-07 17:13:14 +02:00
parent 0d6871a974
commit e84b4ffef7
12 changed files with 87 additions and 42 deletions

View File

@ -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,

View File

@ -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

View File

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

View File

@ -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) {

View File

@ -11,7 +11,7 @@
"file_write",
"tool_load"
],
"maxSteps": 50,
"maxSteps": 25,
"temperature": 0.5,
"maxTokens": 16384
}

View File

@ -37,7 +37,7 @@
],
"backend": "openrouter",
"model": "deepseek/deepseek-v4-flash",
"maxSteps": 50,
"maxSteps": 25,
"temperature": 0.4,
"maxTokens": 16384
}

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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,
}
}

View File

@ -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).