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:
parent
0d6871a974
commit
e84b4ffef7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"file_write",
|
||||
"tool_load"
|
||||
],
|
||||
"maxSteps": 50,
|
||||
"maxSteps": 25,
|
||||
"temperature": 0.5,
|
||||
"maxTokens": 16384
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
],
|
||||
"backend": "openrouter",
|
||||
"model": "deepseek/deepseek-v4-flash",
|
||||
"maxSteps": 50,
|
||||
"maxSteps": 25,
|
||||
"temperature": 0.4,
|
||||
"maxTokens": 16384
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue