review(agent): address gadfly on progress-aware guard
Merge the parallel callCounts/lastResults maps into one repeatState struct map (removes the "two maps in sync" smell + double lookup), drop the dead i<len(results) bounds branch that contradicted the documented index invariant, and note in-code that exact-string result equality is a deliberate err-toward- not-tripping choice (a hung job whose poll reports a ticking field is left to MaxRuntime / the job ceiling rather than risking a false kill of real progress). No behavior change; guard tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HgEuVfZJN9mhRhzEsMEVog
This commit is contained in:
+36
-25
@@ -253,6 +253,15 @@ func (a *Agent) mergedTools() (map[string]llm.Tool, []llm.Tool, error) {
|
|||||||
// Run executes the loop: send the conversation; while the model requests
|
// Run executes the loop: send the conversation; while the model requests
|
||||||
// tools, execute them and feed results back; stop on a final answer,
|
// tools, execute them and feed results back; stop on a final answer,
|
||||||
// MaxSteps, or an unrecoverable model error.
|
// MaxSteps, or an unrecoverable model error.
|
||||||
|
// repeatState is the per-signature bookkeeping for the progress-aware same-call
|
||||||
|
// guard: count is the run-length of consecutive identical calls that returned
|
||||||
|
// lastResult (the previous call's encoded result). A changed result resets count
|
||||||
|
// to 1. See the guard block in Run.
|
||||||
|
type repeatState struct {
|
||||||
|
count int
|
||||||
|
lastResult string
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) Run(ctx context.Context, input string, opts ...RunOption) (*Result, error) {
|
func (a *Agent) Run(ctx context.Context, input string, opts ...RunOption) (*Result, error) {
|
||||||
var rc runConfig
|
var rc runConfig
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
@@ -276,15 +285,12 @@ func (a *Agent) Run(ctx context.Context, input string, opts ...RunOption) (*Resu
|
|||||||
reqOpts := append(append([]llm.Option(nil), a.reqOpts...), rc.reqOpts...)
|
reqOpts := append(append([]llm.Option(nil), a.reqOpts...), rc.reqOpts...)
|
||||||
system := a.systemPrompt()
|
system := a.systemPrompt()
|
||||||
|
|
||||||
// Loop-guard state (WithToolErrorLimits).
|
// Loop-guard state (WithToolErrorLimits). repeatStates tracks, per identical
|
||||||
|
// (name+arguments) signature, the run-length of consecutive calls that
|
||||||
|
// returned the same result and that last result — see the same-call guard
|
||||||
|
// below.
|
||||||
consecutiveErrorSteps := 0
|
consecutiveErrorSteps := 0
|
||||||
// callCounts tracks a run-length of identical (name+arguments) tool calls
|
repeatStates := make(map[string]*repeatState)
|
||||||
// that ALSO returned the same result. lastResults holds the previous result
|
|
||||||
// per signature so a call whose result keeps changing (e.g. polling a
|
|
||||||
// background job whose progress advances) resets the count instead of
|
|
||||||
// tripping the guard — a changing result is progress, not a stuck loop.
|
|
||||||
callCounts := make(map[string]int)
|
|
||||||
lastResults := make(map[string]string)
|
|
||||||
|
|
||||||
maxSteps := func() int {
|
maxSteps := func() int {
|
||||||
if a.maxStepsFunc != nil {
|
if a.maxStepsFunc != nil {
|
||||||
@@ -359,30 +365,35 @@ func (a *Agent) Run(ctx context.Context, input string, opts ...RunOption) (*Resu
|
|||||||
|
|
||||||
// Same-call repeat guard (progress-aware). An identical (name+arguments)
|
// Same-call repeat guard (progress-aware). An identical (name+arguments)
|
||||||
// call only counts toward the loop trip when its result is unchanged from
|
// call only counts toward the loop trip when its result is unchanged from
|
||||||
// the previous identical call. A call whose result advances each time —
|
// the previous identical call: a call whose result keeps advancing —
|
||||||
// canonically polling a long-running background job (elapsed/status keeps
|
// canonically polling a long-running background job — is progress and
|
||||||
// moving) — resets its count and never trips, while a genuinely stuck
|
// resets its count, while a genuinely stuck call returning the same output
|
||||||
// call returning the same output repeats until it exceeds the ceiling.
|
// trips once it exceeds the ceiling. Result equality is exact-string on
|
||||||
// results[i] pairs with resp.ToolCalls[i]: every call appends exactly one
|
// the full encoded content, chosen to err toward NOT tripping: a hung job
|
||||||
// result (unknown tools append an error result before continue), and the
|
// whose poll still reports a ticking field is left to MaxRuntime / the
|
||||||
|
// job's own ceiling rather than risking a false kill of real progress.
|
||||||
|
// results[i] pairs with resp.ToolCalls[i] — every call appends exactly one
|
||||||
|
// result (unknown tools append an error result before continue) and the
|
||||||
// only early exit above is a full return on ctx cancellation.
|
// only early exit above is a full return on ctx cancellation.
|
||||||
if a.maxSameCallRepeats > 0 {
|
if a.maxSameCallRepeats > 0 {
|
||||||
for i, call := range resp.ToolCalls {
|
for i, call := range resp.ToolCalls {
|
||||||
sig := call.Name + "\x00" + string(call.Arguments)
|
sig := call.Name + "\x00" + string(call.Arguments)
|
||||||
resKey := ""
|
resKey := results[i].Content
|
||||||
if i < len(results) {
|
if results[i].IsError {
|
||||||
if results[i].IsError {
|
resKey = "e\x00" + resKey
|
||||||
resKey = "e\x00"
|
|
||||||
}
|
|
||||||
resKey += results[i].Content
|
|
||||||
}
|
}
|
||||||
if prev, seen := lastResults[sig]; seen && prev == resKey {
|
st := repeatStates[sig]
|
||||||
callCounts[sig]++
|
if st == nil {
|
||||||
|
st = &repeatState{}
|
||||||
|
repeatStates[sig] = st
|
||||||
|
}
|
||||||
|
if st.count > 0 && st.lastResult == resKey {
|
||||||
|
st.count++
|
||||||
} else {
|
} else {
|
||||||
callCounts[sig] = 1
|
st.count = 1
|
||||||
}
|
}
|
||||||
lastResults[sig] = resKey
|
st.lastResult = resKey
|
||||||
if callCounts[sig] > a.maxSameCallRepeats {
|
if st.count > a.maxSameCallRepeats {
|
||||||
repeatTripped = call.Name
|
repeatTripped = call.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user