Skip to content

Commit c519809

Browse files
fix(agent): count denials as failures and report the bound that tripped
Addresses both blocking findings from @anandh8x's review. He was right on both, and the first was fatal: the previous commit was a no-op in production. loop.go passed isRetriableToolError as the guard's `failed` flag, and that returns false for any categorized denial (a policy refusal is deliberately not retriable). observeToolResult therefore took its success branch and DELETED the record before it could key on DenialReason, so a denied tool still looped to the turn limit. The re-key was correct and unreachable. The flag is now split. `failed` counts a denial toward the streaks; `hintable` stays retriable-only, because a schema hint is the wrong response to a refusal — the call shape is fine, the answer was no. Collapsing the two is what made a caller unable to express "count this but do not coach the model about it". Second finding: outcome.Count returned the signature-keyed record.count even when the content-blind counter was what tripped the stop. With twelve distinct errors that count is 1, so the final answer told the user a tool "failed 1 time in a row with the same error". The outcome now carries the counter that actually fired plus a Varied flag, and the stop answer says "each with a different error" in that case. Every earlier test passed while the production path was broken, because they called observeToolResult directly with failed=true. So the important addition here is TestRunStopsARepeatedlyDeniedToolAtTheFailureBound, which drives Run itself: a tool that always prompts, an approver that always denies, and a different command per turn so the denial reason varies as it does in a real run. Verified by reverting the fix: the run makes 10 denied calls instead of halting at 6 and dies on the no-output guard 13 turns later, while the helper-level test stays green — which is precisely why this shipped in the first place.
1 parent 4641b18 commit c519809

3 files changed

Lines changed: 157 additions & 16 deletions

File tree

‎internal/agent/guardrails.go‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ type toolFailureOutcome struct {
354354
InjectHint bool
355355
Stop bool
356356
Count int
357+
// Varied reports that the stop came from the content-blind bound, i.e. Count
358+
// failures with DIFFERENT errors rather than the same one repeated. The final
359+
// answer says so, because "failed 12 times with the same error" would be a
360+
// plainly false description of a tool that failed 12 different ways.
361+
Varied bool
357362
}
358363

359364
// errorSignature normalizes a tool error to a short, comparable signature so
@@ -379,9 +384,13 @@ func toolFailureHint(toolName, schemaJSON, errOutput string) string {
379384

380385
// toolFailureStopAnswer is the final answer when the repeated-failure guard halts
381386
// a run.
382-
func toolFailureStopAnswer(toolName string, count int) string {
387+
func toolFailureStopAnswer(toolName string, count int, varied bool) string {
388+
cause := " times in a row with the same error, "
389+
if varied {
390+
cause = " times in a row, each with a different error, "
391+
}
383392
return "Agent stopped: the `" + toolName + "` tool failed " + strconv.Itoa(count) +
384-
" times in a row with the same error, so I halted instead of looping further. " +
393+
cause + "so I halted instead of looping further. " +
385394
"Please check the request or adjust the tool arguments."
386395
}
387396

@@ -493,7 +502,12 @@ func newGuardState() *guardState {
493502
// observeToolResult tracks repeated identical failures of a tool. A successful
494503
// result clears that tool's failure streak. Returns whether to inject a one-shot
495504
// corrective hint and/or stop the run.
496-
func (state *guardState) observeToolResult(name string, failed bool, output string, denial DenialCategory) toolFailureOutcome {
505+
// hintable is separate from failed on purpose. A categorized denial MUST count
506+
// toward the streaks — that is the whole point of keying on the category — but a
507+
// schema hint is the wrong response to a policy refusal: the call shape is fine,
508+
// the answer was no. Collapsing the two is what made the earlier version of this
509+
// fix a no-op, since the caller could only pass a flag that excluded denials.
510+
func (state *guardState) observeToolResult(name string, failed bool, hintable bool, output string, denial DenialCategory) toolFailureOutcome {
497511
if state.toolFailures == nil {
498512
state.toolFailures = map[string]*toolFailureRecord{}
499513
}
@@ -507,7 +521,7 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
507521
// let a denied tool loop indefinitely under a halt set to 6. The category is
508522
// a small closed enum the loop already sets on the result.
509523
sig := errorSignature(output)
510-
if denial != "" {
524+
if denial != DenialNone {
511525
sig = "denial:" + string(denial)
512526
}
513527
record := state.toolFailures[name]
@@ -527,11 +541,20 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
527541
record.anyErrorCount++
528542

529543
outcome := toolFailureOutcome{Count: record.count}
530-
if record.count >= toolFailureStopAt || record.anyErrorCount >= toolFailureAnyErrorStopAt {
544+
switch {
545+
case record.count >= toolFailureStopAt:
546+
outcome.Stop = true
547+
return outcome
548+
case record.anyErrorCount >= toolFailureAnyErrorStopAt:
549+
// Report the counter that actually tripped. record.count is the
550+
// same-signature streak and is often 1 here, which would describe a tool
551+
// that failed a dozen different ways as having failed once.
531552
outcome.Stop = true
553+
outcome.Count = record.anyErrorCount
554+
outcome.Varied = true
532555
return outcome
533556
}
534-
if record.count >= toolFailureHintAt && !record.hintShown {
557+
if hintable && record.count >= toolFailureHintAt && !record.hintShown {
535558
record.hintShown = true
536559
outcome.InjectHint = true
537560
}

‎internal/agent/guardrails_test.go‎

Lines changed: 120 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
191191
var state guardState
192192
var stoppedAt int
193193
for i := 1; i <= toolFailureStopAt; i++ {
194-
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i), "")
194+
out := state.observeToolResult(tools.WriteStdinToolName, true, true, tools.UnknownExecSessionError(i), "")
195195
if out.Stop {
196196
stoppedAt = i
197197
break
@@ -222,7 +222,7 @@ func TestPermissionDenialStreakSurvivesVaryingReasonText(t *testing.T) {
222222
// different path every time.
223223
output := "Error: Permission denied for write_file: cannot write " +
224224
filepath.Join("C:", "ws", "pkg", "file"+strconv.Itoa(i)+".go")
225-
out := state.observeToolResult("write_file", true, output, DenialPermissionDenied)
225+
out := state.observeToolResult("write_file", true, true, output, DenialPermissionDenied)
226226
if out.Stop {
227227
stoppedAt = i
228228
break
@@ -245,7 +245,7 @@ func TestToolFailingWithDifferentErrorsEveryTimeStillStops(t *testing.T) {
245245
var state guardState
246246
stoppedAt := 0
247247
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
248-
out := state.observeToolResult("bash", true, "distinct failure "+strconv.Itoa(i), "")
248+
out := state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), "")
249249
if out.Stop {
250250
stoppedAt = i
251251
break
@@ -262,17 +262,17 @@ func TestToolFailingWithDifferentErrorsEveryTimeStillStops(t *testing.T) {
262262
func TestSuccessResetsBothFailureCounters(t *testing.T) {
263263
var state guardState
264264
for i := 1; i < toolFailureAnyErrorStopAt; i++ {
265-
if out := state.observeToolResult("bash", true, "distinct failure "+strconv.Itoa(i), ""); out.Stop {
265+
if out := state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), ""); out.Stop {
266266
t.Fatalf("stopped at %d before the success that should reset it", i)
267267
}
268268
}
269-
state.observeToolResult("bash", false, "ok", "")
269+
state.observeToolResult("bash", false, false, "ok", "")
270270

271271
// Same again from zero. Reaching the bound a second time proves the counter
272272
// restarted rather than merely paused.
273273
stoppedAt := 0
274274
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
275-
if out := state.observeToolResult("bash", true, "later failure "+strconv.Itoa(i), ""); out.Stop {
275+
if out := state.observeToolResult("bash", true, true, "later failure "+strconv.Itoa(i), ""); out.Stop {
276276
stoppedAt = i
277277
break
278278
}
@@ -294,8 +294,8 @@ func TestAnotherToolSucceedingDoesNotClearAFailingToolsStreak(t *testing.T) {
294294
var state guardState
295295
stoppedAt := 0
296296
for i := 1; i <= toolFailureStopAt; i++ {
297-
state.observeToolResult("read_file", false, "ok", "")
298-
out := state.observeToolResult("write_file", true,
297+
state.observeToolResult("read_file", false, false, "ok", "")
298+
out := state.observeToolResult("write_file", true, false,
299299
"Error: Permission denied for write_file: "+strconv.Itoa(i), DenialPermissionDenied)
300300
if out.Stop {
301301
stoppedAt = i
@@ -307,6 +307,118 @@ func TestAnotherToolSucceedingDoesNotClearAFailingToolsStreak(t *testing.T) {
307307
}
308308
}
309309

310+
// alwaysPromptingTool is never allowed to run: it exists so a Run-level test can
311+
// drive real permission denials through the loop.
312+
type alwaysPromptingTool struct{ ran int }
313+
314+
func (tool *alwaysPromptingTool) Name() string { return "bash" }
315+
func (tool *alwaysPromptingTool) Description() string { return "test shell tool" }
316+
func (tool *alwaysPromptingTool) Parameters() tools.Schema {
317+
return tools.Schema{
318+
Type: "object",
319+
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
320+
Required: []string{"command"},
321+
AdditionalProperties: false,
322+
}
323+
}
324+
func (tool *alwaysPromptingTool) Safety() tools.Safety {
325+
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
326+
}
327+
func (tool *alwaysPromptingTool) Run(context.Context, map[string]any) tools.Result {
328+
tool.ran++
329+
return tools.Result{Status: tools.StatusOK, Output: "should never run"}
330+
}
331+
332+
// The regression for the gap this whole change existed to close, driven through
333+
// Run rather than through the guard helper.
334+
//
335+
// The first version of this fix was a no-op in production and every one of its
336+
// unit tests passed, because they called observeToolResult directly with
337+
// failed=true. The loop passes isRetriableToolError, which returns false for a
338+
// categorized denial, so the guard took its success branch and deleted the
339+
// record before it could key on the category. A denied tool still looped to the
340+
// turn limit.
341+
//
342+
// This drives the real path: a tool that always prompts, an approver that always
343+
// denies, and a different command each turn so the denial REASON — and therefore
344+
// the error text — varies exactly as it does in a real run.
345+
func TestRunStopsARepeatedlyDeniedToolAtTheFailureBound(t *testing.T) {
346+
tool := &alwaysPromptingTool{}
347+
registry := tools.NewRegistry()
348+
registry.Register(tool)
349+
350+
// Comfortably more turns than the bound, so reaching the bound is what stops
351+
// the run rather than exhausting the provider or MaxTurns.
352+
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureStopAt+4)
353+
for i := range toolFailureStopAt + 4 {
354+
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash",
355+
`{"command":"touch /etc/file`+strconv.Itoa(i)+`"}`))
356+
}
357+
provider := &mockProvider{turns: turns}
358+
359+
denials := 0
360+
result, err := Run(context.Background(), "do the thing", provider, Options{
361+
Registry: registry,
362+
PermissionMode: PermissionModeAsk,
363+
MaxTurns: len(turns) + 5,
364+
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
365+
denials++
366+
// The varying half. In production this is the refused path or command;
367+
// here it is the command, which lands in the denial message the guard
368+
// used to key on.
369+
return PermissionDecision{
370+
Action: PermissionDecisionDeny,
371+
Reason: "refused " + request.ToolName + " call " + strconv.Itoa(denials),
372+
}, nil
373+
},
374+
})
375+
if err != nil {
376+
t.Fatal(err)
377+
}
378+
379+
if denials != toolFailureStopAt {
380+
t.Errorf("the run made %d denied calls, want it halted at %d", denials, toolFailureStopAt)
381+
}
382+
if tool.ran != 0 {
383+
t.Errorf("the denied tool executed %d times; the denial must precede execution", tool.ran)
384+
}
385+
want := toolFailureStopAnswer("bash", toolFailureStopAt, false)
386+
if result.FinalAnswer != want {
387+
t.Errorf("final answer =\n %q\nwant\n %q", result.FinalAnswer, want)
388+
}
389+
}
390+
391+
// The stop message must describe the bound that actually tripped. When the
392+
// content-blind counter is what halts the run, the same-signature streak is
393+
// usually 1, and reporting that would tell the user a tool failed once after it
394+
// failed a dozen different ways.
395+
func TestVariedFailureStopAnswerReportsTheRightCounter(t *testing.T) {
396+
var state guardState
397+
var outcome toolFailureOutcome
398+
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
399+
outcome = state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), "")
400+
if outcome.Stop {
401+
break
402+
}
403+
}
404+
if !outcome.Stop {
405+
t.Fatal("never stopped")
406+
}
407+
if !outcome.Varied {
408+
t.Error("Varied = false for a stop driven by the content-blind counter")
409+
}
410+
if outcome.Count != toolFailureAnyErrorStopAt {
411+
t.Errorf("Count = %d, want the counter that tripped (%d)", outcome.Count, toolFailureAnyErrorStopAt)
412+
}
413+
answer := toolFailureStopAnswer("bash", outcome.Count, outcome.Varied)
414+
if !strings.Contains(answer, "each with a different error") {
415+
t.Errorf("stop answer describes the wrong cause: %q", answer)
416+
}
417+
if strings.Contains(answer, "with the same error") {
418+
t.Errorf("stop answer claims a same-error loop after distinct failures: %q", answer)
419+
}
420+
}
421+
310422
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
311423
var state guardState
312424
toolOnly := zeroruntime.CollectedStream{

‎internal/agent/loop.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -740,7 +740,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
740740
// aren't fixed by reformatting the call, so a "match this schema" hint
741741
// would misdirect the model toward JSON shape or blocked behavior.
742742
retriableFailure := isRetriableToolError(toolResult)
743-
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output, toolResult.DenialReason)
743+
// A categorized denial is NOT retriable — retrying it verbatim is
744+
// pointless — but it is still a failure the streaks must count, or a
745+
// refused tool loops until the turn limit. Passing retriableFailure for
746+
// both is what let that happen: observeToolResult took its success
747+
// branch and deleted the record before it could key on the category.
748+
countedFailure := retriableFailure || toolResult.DenialReason != DenialNone
749+
outcome := guards.observeToolResult(call.Name, countedFailure, retriableFailure, toolResult.Output, toolResult.DenialReason)
744750
posture.observeToolOutcome(outcome, toolResult)
745751
if outcome.Stop {
746752
// The assistant message advertised EVERY collected tool call, but
@@ -750,7 +756,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
750756
// messages stay valid for a strict provider replay (Anthropic
751757
// rejects a tool_use with no answering tool_result).
752758
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
753-
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
759+
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count, outcome.Varied)
754760
result.Messages = copyMessages(messages)
755761
return result, nil
756762
}

0 commit comments

Comments
 (0)