fix(agent): make same-call repeat guard progress-aware
The maxSameCallRepeats guard counted identical (name+arguments) tool calls across a run and tripped ErrToolLoop past the ceiling — regardless of whether each call made progress. This killed legitimate polling of long-running background jobs: code_exec_poll must be called with identical args (same job_id), so a render/encode that needs more than N polls was guillotined mid-flight even as each poll returned an advancing result (elapsed/status moving forward). Only count an identical call toward the trip when its RESULT is unchanged from the previous identical call. A call whose result keeps changing is progress and resets its count; a genuinely stuck call returning the same output still trips. This can never trip more than before, only less, and covers every idempotent poller with no per-tool configuration — matching the progress-over-usage thesis behind the stall-detection work. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01HgEuVfZJN9mhRhzEsMEVog
This commit is contained in:
@@ -173,3 +173,51 @@ func TestSameCallRepeatGuard(t *testing.T) {
|
||||
t.Errorf("varied calls must not trip the guard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameCallRepeatGuardProgressAware: identical (name+args) calls whose
|
||||
// RESULT keeps changing — canonically polling a long-running background job
|
||||
// whose progress advances — do not trip the repeat guard even well past the
|
||||
// limit; but identical calls returning an unchanged result still trip it.
|
||||
func TestSameCallRepeatGuardProgressAware(t *testing.T) {
|
||||
// A poll tool that advances every call, so identical args yield a
|
||||
// different result each time.
|
||||
calls := 0
|
||||
polling := llm.NewToolbox("jobs", llm.Tool{
|
||||
Name: "poll",
|
||||
Handler: func(context.Context, json.RawMessage) (any, error) {
|
||||
calls++
|
||||
return map[string]any{"status": "running", "elapsed": calls}, nil
|
||||
},
|
||||
})
|
||||
n := 0
|
||||
fp := fake.New("fp", fake.WithDefault(func(string, llm.Request) fake.Step {
|
||||
n++
|
||||
if n > 6 { // six identical polls, well past the limit of 3
|
||||
return fake.Reply("done")
|
||||
}
|
||||
return toolCallReply("c", "poll", `{"job":"x"}`)
|
||||
}))
|
||||
a := New(newModel(t, fp), "", WithToolbox(polling), WithToolErrorLimits(0, 3), WithMaxSteps(20))
|
||||
res, err := a.Run(context.Background(), "go")
|
||||
if err != nil {
|
||||
t.Fatalf("advancing-result polls must not trip the guard: %v", err)
|
||||
}
|
||||
if res.Output != "done" {
|
||||
t.Errorf("output = %q, want run to complete after polling", res.Output)
|
||||
}
|
||||
|
||||
// A call that returns an UNCHANGED result each time still trips the guard.
|
||||
frozen := llm.NewToolbox("jobs", llm.Tool{
|
||||
Name: "poll",
|
||||
Handler: func(context.Context, json.RawMessage) (any, error) {
|
||||
return map[string]any{"status": "running"}, nil // never advances
|
||||
},
|
||||
})
|
||||
fp2 := fake.New("fp", fake.WithDefault(func(string, llm.Request) fake.Step {
|
||||
return toolCallReply("c", "poll", `{"job":"x"}`)
|
||||
}))
|
||||
a2 := New(newModel(t, fp2), "", WithToolbox(frozen), WithToolErrorLimits(0, 3), WithMaxSteps(20))
|
||||
if _, err := a2.Run(context.Background(), "go"); !errors.Is(err, ErrToolLoop) {
|
||||
t.Fatalf("frozen identical result must still trip the guard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user