diff --git a/agent/agent.go b/agent/agent.go index 22f74aa..f19bb5c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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 // tools, execute them and feed results back; stop on a final answer, // 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) { var rc runConfig 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...) 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 - // callCounts tracks a run-length of identical (name+arguments) tool calls - // 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) + repeatStates := make(map[string]*repeatState) maxSteps := func() int { 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) // call only counts toward the loop trip when its result is unchanged from - // the previous identical call. A call whose result advances each time — - // canonically polling a long-running background job (elapsed/status keeps - // moving) — resets its count and never trips, while a genuinely stuck - // call returning the same output repeats until it exceeds the ceiling. - // results[i] pairs with resp.ToolCalls[i]: every call appends exactly one - // result (unknown tools append an error result before continue), and the + // the previous identical call: a call whose result keeps advancing — + // canonically polling a long-running background job — is progress and + // resets its count, while a genuinely stuck call returning the same output + // trips once it exceeds the ceiling. Result equality is exact-string on + // the full encoded content, chosen to err toward NOT tripping: a hung job + // 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. if a.maxSameCallRepeats > 0 { for i, call := range resp.ToolCalls { sig := call.Name + "\x00" + string(call.Arguments) - resKey := "" - if i < len(results) { - if results[i].IsError { - resKey = "e\x00" - } - resKey += results[i].Content + resKey := results[i].Content + if results[i].IsError { + resKey = "e\x00" + resKey } - if prev, seen := lastResults[sig]; seen && prev == resKey { - callCounts[sig]++ + st := repeatStates[sig] + if st == nil { + st = &repeatState{} + repeatStates[sig] = st + } + if st.count > 0 && st.lastResult == resKey { + st.count++ } else { - callCounts[sig] = 1 + st.count = 1 } - lastResults[sig] = resKey - if callCounts[sig] > a.maxSameCallRepeats { + st.lastResult = resKey + if st.count > a.maxSameCallRepeats { repeatTripped = call.Name } }