Skip to content

fix(agent): stop a denied tool looping past the repeated-failure halt - #866

Open
Vasanthdev2004 wants to merge 12 commits into
mainfrom
fix/guardrail-denial-counter-rekey
Open

fix(agent): stop a denied tool looping past the repeated-failure halt#866
Vasanthdev2004 wants to merge 12 commits into
mainfrom
fix/guardrail-denial-counter-rekey

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

The repeated-failure guard keys its streak on the first 80 characters of the error text. A permission denial reads Error: Permission denied for <tool>: <reason>, and reason names the path or command that was refused — so the text differs on every call while describing the same unchanging refusal. Each call rebuilt the record at count: 1, and toolFailureStopAt = 6 was never reached.

I hit this for real, not in theory. A headless run made 384 denied calls over 26 minutes, produced zero files, and reported nothing. The guard was working exactly as written the whole time.

#702 already hit this shape once — the unknown-session error leaked its session id into the signature — and fixed it by making that one message id-invariant. That works, but it's per-message and depends on every future error remembering to be invariant. Denials now key on DenialCategory instead, a small closed enum the loop already sets on the result. That fixes the class rather than one instance.

The second counter

The signature-keyed streak cannot, by construction, see a tool that fails with a genuinely different error every time — and that is still a tool that isn't working. So there's now a content-blind counter beside it: consecutive failures of that tool regardless of error, cleared only by a success of that same tool. Changing how a tool fails isn't progress, and neither is some other tool succeeding while this one is refused.

It stops at 12, not 6, deliberately. A model iterating on a tricky edit legitimately fails a few times with different errors while converging — the same reasoning that moved toolFailureStopAt from 4 to 6. Cutting that short would be a worse bug than the one being fixed.

Two counters tripping on either is also where both of the agent CLIs I compared against landed independently, after hitting this same bug: a tight bound on identical failures OR'd with a looser one that no amount of varying the error text can reset. Convergent design, not my taste.

Verification

Six tests, and every guard mutation-checked:

mutation fails
revert the denial re-key to text signature TestPermissionDenialStreakSurvivesVaryingReasonText, TestAnotherToolSucceedingDoesNotClearAFailingToolsStreak
delete the content-blind bound TestToolFailingWithDifferentErrorsEveryTimeStillStops, TestSuccessResetsBothFailureCounters
let a signature change reset the content-blind counter same two

TestSuccessResetsBothFailureCounters is the regression guard that makes the new bound safe to add — it drives the tool to one below the bound, succeeds once, and requires a full fresh count afterwards rather than a resumed one.

Behaviour when nothing is looping is unchanged: toolFailureStopAt and toolFailureHintAt keep their values and their existing semantics.

One existing test call site gains the new parameter. internal/agent green, gofmt and vet clean.

Summary by CodeRabbit

  • Bug Fixes
    • Improved safeguards against tools repeatedly failing, including with different errors.
    • Prevented repeated access-denied attempts from bypassing failure limits when details vary.
    • Ensured policy-denied operations stop retrying appropriately, including uncategorized and disabled-tool refusals.
    • Reset failure tracking after successful tool calls while preserving per-tool isolation.
    • Improved stop messages to accurately describe repeated, varied, or denied failures.
    • Prevented successful results and refusal-like output text from being mistaken for policy refusals.
    • Preserved retry guidance for genuine tool failures and malformed requests.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The tool loop classifies structured policy refusals only for failed results and passes denial categories to guardrails. Guardrails track repeated signatures and varied failures, reset counters after success, and stop at either threshold. Tests cover classification, isolation, retry hints, end-to-end halting, posture behavior, and stop-message wording.

Changes

Tool failure guardrails

Layer / File(s) Summary
Policy refusal classification
internal/tools/types.go, internal/tools/registry.go, internal/tools/local_capture.go, internal/agent/loop.go, internal/agent/policy_refusal_test.go, internal/agent/policy_refusal_status_test.go, internal/agent/loop_test.go
Tool denials now use stable refusal metadata. The loop checks structured refusal provenance only on error results. Executed failures with refusal-like output remain retriable. Disabled capture_artifact calls are classified as policy refusals, while malformed arguments remain retriable.
Failure counters and stop conditions
internal/agent/guardrails.go
Guardrails track same-error and all-error counters, normalize denial categories, reset counters after success, apply both thresholds, limit hints to hintable failures, and report the matching stop cause.
Tool result observation wiring
internal/agent/loop.go
The loop counts policy refusals without retry or posture escalation. It passes denial reasons and varied-failure state through the tool loop. Permission cancellation uses the exported ErrPermissionApprovalCanceled sentinel.
Failure streak and refusal-path coverage
internal/agent/*_test.go
Tests cover varying denial text, distinct errors, counter resets, per-tool isolation, retry hints, refusal halting, posture behavior, and stop-message reporting.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: anandh8, gnanam1990

Sequence Diagram(s)

sequenceDiagram
  participant ToolRegistry
  participant ToolExecutionLoop
  participant PolicyClassifier
  participant Guardrails
  ToolRegistry-->>ToolExecutionLoop: Return result with refusal metadata
  ToolExecutionLoop->>PolicyClassifier: Classify failed result
  PolicyClassifier-->>ToolExecutionLoop: Return refusal and retriable status
  ToolExecutionLoop->>Guardrails: Pass result and denial category
  Guardrails-->>ToolExecutionLoop: Return stop outcome and stop-answer cause
Loading

Merge Risk: 🔴 Critical · up to d692f

The PR currently cannot pass the internal/agent build because a test type is declared twice; remove the duplicate declaration before merging.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: stopping denied tools from exceeding the repeated-failure halt.
Docstring Coverage ✅ Passed Docstring coverage is 82.35% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 34 functions across 13 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/guardrail-denial-counter-rekey

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/agent/guardrails_test.go (1)

262-283: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise the same-signature counter reset.

Both loops use a new error string on every call. Therefore, count stays at 1 and this test only proves the anyErrorCount reset. Add repeated identical failures before and after the success, then assert that the sixth post-success failure stops the tool.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/guardrails_test.go` around lines 262 - 283, Update
TestSuccessResetsBothFailureCounters to use the same failure signature
repeatedly in both loops, rather than generating distinct error strings. Ensure
the pre-success sequence establishes both counters, then verify that after the
success the sixth identical post-success failure stops the tool, proving the
signature-specific count reset as well as the any-error reset.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/loop.go`:
- Around line 742-743: In internal/agent/loop.go at lines 742-743, update the
failure flag passed to observeToolResult to include cases where
toolResult.DenialReason is non-empty, so that policy denials are tracked as
failures. In internal/agent/guardrails.go at lines 510-512, preserve the
category-based counting logic for denials but prevent InjectHint from being
called when a denial is present, since schema hints should not encourage
retrying blocked behavior. In internal/agent/guardrails_test.go at lines
217-234, add a new Run-level regression test that submits repeated categorized
denials and asserts that the run terminates at the toolFailureStopAt limit
rather than continuing until the turn limit.

---

Nitpick comments:
In `@internal/agent/guardrails_test.go`:
- Around line 262-283: Update TestSuccessResetsBothFailureCounters to use the
same failure signature repeatedly in both loops, rather than generating distinct
error strings. Ensure the pre-success sequence establishes both counters, then
verify that after the success the sixth identical post-success failure stops the
tool, proving the signature-specific count reset as well as the any-error reset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2eb0a201-6787-4a37-9f48-63bf467db61d

📥 Commits

Reviewing files that changed from the base of the PR and between 021281e and 4641b18.

📒 Files selected for processing (3)
  • internal/agent/guardrails.go
  • internal/agent/guardrails_test.go
  • internal/agent/loop.go

Comment thread internal/agent/loop.go Outdated
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 69900830a6a0
Changed files (21): internal/agent/capture_artifact_refusal_test.go, internal/agent/capture_artifact_streak_test.go, internal/agent/capture_disabled_driver_test.go, internal/agent/guardrails.go, internal/agent/guardrails_test.go, internal/agent/loop.go, internal/agent/loop_test.go, internal/agent/parallel_readahead_halt_test.go, internal/agent/parallel_tools.go, internal/agent/policy_refusal_provenance_run_test.go, internal/agent/policy_refusal_run_path_test.go, internal/agent/policy_refusal_status_test.go, and 9 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@jatmn @anandh8x @gnanam1990 — one guard change, but it lands differently for each of you, so here's the short version of why I'm tagging all three.

The repeated-failure halt has never been able to fire on a permission denial. It keys the streak on the first 80 characters of the error text, and a denial message embeds the path or command that was refused — so the text is different every call while the refusal is identical. The record rebuilt at 1 each time and a halt set to 6 was simply unreachable. I hit it for real: 384 denied calls, 26 minutes, no files, no error.

@jatmn — the part worth your scepticism is the second counter, not the re-key. It's content-blind, so nothing about the error text can reset it, and it stops at 12 rather than 6. I chose the looser bound because a model iterating on a tricky edit legitimately fails several times with different errors while converging, and cutting those runs short would be a worse bug than the one I'm fixing. That's the same argument that moved toolFailureStopAt from 4 to 6 originally. If you think 12 is wrong, that's the number I'd most like challenged.

@anandh8x — this touches the agent loop, one line at the observeToolResult call site to pass the denial category the result already carries. No behaviour change when nothing is looping: both existing thresholds keep their values and semantics. Worth a look mainly because it's your area.

@gnanam1990 — most relevant to #829. Zeromaxing raises the turn budget 80 → 480 and says so in the banner, which means it multiplies this exact failure by six: a run that would have burned 80 turns going nowhere now burns 480. The 384-call run I measured was under zeromaxing. This fix is upstream of your PR, so #829 gets it for free, but it's worth knowing the posture was amplifying a real unbounded loop rather than just a slow one.

This generalises #702 rather than replacing it. That fix made one error message id-invariant so its streak could count; this keys denials on DenialCategory so every future denial message is invariant by construction and nobody has to remember.

Six tests, and every guard mutation-checked — reverting the re-key, deleting the content-blind bound, or letting a signature change reset it each turn tests red. TestSuccessResetsBothFailureCounters is the one that makes the new bound safe: it drives to one below the limit, succeeds once, and demands a full fresh count after.

@anandh8x anandh8x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blocking issues remain on the latest commit:

  1. internal/agent/loop.go:742-743 still passes only isRetriableToolError(toolResult) as the guard failure flag. Categorized denials intentionally return false there, so observeToolResult takes its success branch and deletes the record before it can key on DenialReason. I reproduced this against the exact head: six varying DenialPermissionDenied results never stop. Please count retriableFailure OR a non-empty toolResult.DenialReason, while keeping schema-hint injection disabled for denials, and add a Run-level regression so the production call path, not only the guard helper, is covered.

  2. internal/agent/guardrails.go:529-530 returns the signature-specific record.count even when the new content-blind anyErrorCount is what trips the stop. With twelve distinct errors, count is 1, so loop.go:753 reports that the tool failed 1 time with the same error. Return enough outcome information to produce the correct count and a truthful generic or differentiated stop message; cover the rendered final answer.

The focused tests added by the PR pass and focused vet is clean, but they do not exercise either integration behavior above.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x both fixed in c519809. You were right on both, and the first one was fatal — the previous commit was a no-op in production and I shipped it claiming otherwise.

1. Denials now count. The flag is split rather than widened. failed counts a denial toward the streaks; a new hintable stays retriable-only, because a schema hint is the wrong answer to a policy refusal — the call shape is fine, the refusal isn't about JSON. That was the real reason the caller reused retriableFailure for both, and it couldn't express "count this but don't coach the model about it" until now.

2. The count is truthful. toolFailureOutcome carries the counter that actually tripped plus a Varied flag, and the stop answer reads "each with a different error" when the content-blind bound fires instead of claiming a same-error loop.

3. The Run-level regression you asked for: TestRunStopsARepeatedlyDeniedToolAtTheFailureBound. A tool that always prompts, an approver that always denies, a different command each turn so the reason varies exactly as in a real run.

I checked it catches your bug rather than assuming. Reverting the flag split:

the run made 10 denied calls, want it halted at 6
final answer = "Agent stopped after 13 turns with no output..."

It loops past the bound and dies on the no-output guard 13 turns later — and the helper-level TestPermissionDenialStreakSurvivesVaryingReasonText stays green throughout. That's the whole lesson here: all six of my original tests called observeToolResult directly with failed=true, so they proved the helper and nothing about the path that reaches it. Thanks for driving the actual head instead of trusting the diff — I'd have shipped a guard that never fires.

internal/agent green, vet and gofmt clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/agent/guardrails_test.go`:
- Around line 310-312: The alwaysPromptingTool type is declared twice at package
scope in the test file, which causes a Go redeclaration error. Locate the second
alwaysPromptingTool declaration elsewhere in the file and remove it, preserving
the one shown in the diff that includes the explanatory comment about its
purpose in the Run-level test.

In `@internal/agent/loop.go`:
- Around line 743-749: Update toolResultFromPrePermissionReject to set
ToolResult.DenialReason when converting a pre-permission rejection, mapping the
rejection error type or message to the appropriate DenialCategory such as
DenialFiltered or DenialPermissionDenied. Preserve the existing output and
non-retriable behavior while ensuring categorized pre-permission denials are
counted by the observeToolResult countedFailure logic.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e568f8b9-8d71-42a2-82e8-ad5992a3d842

📥 Commits

Reviewing files that changed from the base of the PR and between 4641b18 and c519809.

📒 Files selected for processing (3)
  • internal/agent/guardrails.go
  • internal/agent/guardrails_test.go
  • internal/agent/loop.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/agent/guardrails.go

Comment on lines +310 to +312
// alwaysPromptingTool is never allowed to run: it exists so a Run-level test can
// drive real permission denials through the loop.
type alwaysPromptingTool struct{ ran int }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicate alwaysPromptingTool declaration.

alwaysPromptingTool is declared twice at package scope. Go rejects the test package with a redeclaration error. Keep one declaration so the regression tests compile.

Proposed fix
 type alwaysPromptingTool struct{ ran int }
-type alwaysPromptingTool struct{ ran int }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/guardrails_test.go` around lines 310 - 312, The
alwaysPromptingTool type is declared twice at package scope in the test file,
which causes a Go redeclaration error. Locate the second alwaysPromptingTool
declaration elsewhere in the file and remove it, preserving the one shown in the
diff that includes the explanatory comment about its purpose in the Run-level
test.

Comment thread internal/agent/loop.go Outdated
gnanam1990
gnanam1990 previously approved these changes Aug 5, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: Approve

Verified empirically on the branch (checked out, built).

What I checked

  • Gut-the-fix: disabling the category keying at guardrails.go:524 turns TestRunStopsARepeatedlyDeniedToolAtTheFailureBound red — a 10-denial run no longer halts at 6; it loops until the no-output guard trips at turn 13. The tests exercise the fix, not just the shape.
  • Not a leaky deny-list — this is the important part. observeToolResult keeps a content-blind anyErrorCount backstop (guardrails.go:541,548, toolFailureAnyErrorStopAt = 12) incremented on every failure regardless of signature. So a denial that isn't categorized (DenialNone), or any non-denial error whose prose varies, still halts. DenialCategory doesn't need to be exhaustive, which is what makes this hold up where #702's per-message id-invariance couldn't. Good call superseding that approach with a structural one.
  • Reports the counter that tripped (Varied + anyErrorCount, :553), so a tool that failed 12 different ways isn't described as "failed once".
  • hintable/failed split (:505-509): a categorized denial counts toward the streak but gets no schema hint — a policy refusal isn't a call-shape problem. Correct.
  • Clean scope: guardrails.go, its test, and the one call site in loop.go.

Well shaped. The two-tier bound is the right design.

jatmn
jatmn previously approved these changes Aug 6, 2026

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x your changes-requested is the only thing blocking this now, and I believe it is stale.

You filed it at 12:27 on 4 August, against the head before c5198095. That commit is the fix for exactly what you found: the re-key was a no-op on the production path, because isRetriableToolError returns false for denials, so observeToolResult deleted the record instead of counting it. You were right, and the six original tests all passed because they called the helper with failed=true rather than going through the loop.

loop.go now counts a denial toward the failure streak while still not treating it as hintable, and the regression is at Run level rather than helper level, which is what makes it actually pin the behaviour.

gnanam approved on 5 August and jatmn on 6 August, both after that commit. A look when you get a moment would unblock it.

@Vasanthdev2004
Vasanthdev2004 dismissed stale reviews from jatmn and gnanam1990 via ca6ff84 August 9, 2026 13:20
@Vasanthdev2004
Vasanthdev2004 force-pushed the fix/guardrail-denial-counter-rekey branch from c519809 to ca6ff84 Compare August 9, 2026 13:20
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main, so this is mergeable again. Force-pushed, which dismissed the approvals; sorry @gnanam1990 @jatmn, re-requesting.

One conflict, in the failure-stop branch of the loop. Main had added messages = append(messages, toolImageMessages...) there and this branch had changed toolFailureStopAnswer to take outcome.Varied. Both were kept.

go build, go vet, gofmt -l clean. The guardrail tests this PR is about all pass, including TestRunStopsARepeatedlyDeniedToolAtTheFailureBound and TestVariedFailureStopAnswerReportsTheRightCounter. The one internal/agent failure is TestEagerToolSchemaTokenBudget, which reproduces on a clean tree here and is what #877 raises the ceiling for.

Still open on this PR, unchanged by the rebase: the denial re-key does not fire on the production path, because isRetriableToolError returns false as soon as DenialReason != DenialNone, so a denial never reaches the re-key. I confirmed that again on this head. Worth fixing before merge rather than after, since the PR's headline behaviour depends on it.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: I was wrong. The re-key is NOT a no-op, and there is nothing outstanding here.

I said isRetriableToolError returning false for a denial means denials never reach the counter. That was a bad inference from one half of the path. isRetriableToolError returning false for a denial is deliberate (retrying a refusal verbatim is pointless), and the counter does not depend on it: loop.go computes

countedFailure := retriableFailure || toolResult.DenialReason != DenialNone

and passes that as the counted-failure argument while still passing retriableFailure separately for the retry decision. So a denial is counted without being retried, which is the whole point.

Proven rather than re-read, and at the call path rather than the helper, since a helper-level test is exactly what let the original defect through. Mutating that line back to plain retriableFailure and running TestRunStopsARepeatedlyDeniedToolAtTheFailureBound, which drives Run with a real registry and an OnPermissionRequest that denies with varying text:

--- FAIL: TestRunStopsARepeatedlyDeniedToolAtTheFailureBound
    the run made 10 denied calls, want it halted at 6

Restored, it halts at 6 and passes, along with TestPermissionDenialStreakSurvivesVaryingReasonText and TestVariedFailureStopAnswerReportsTheRightCounter.

So the rebase is the only thing that happened here, and this is ready as far as I am concerned. Sorry for the noise, @gnanam1990 @jatmn.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x your changes-requested here is from the 4th and predates the fix, so this is only blocked on a re-look.

You reproduced the failure on the head at the time, and you were right: my six unit tests all called observeToolResult directly, so the helper was correct and unreachable. The counter now takes the denial independently of the retry decision, and the test that matters drives Run with an OnPermissionRequest that denies with varying text rather than calling the helper.

I re-checked it today by mutation rather than by reading, after wrongly telling this thread it was still broken: reverting that line makes the run take 10 denied calls instead of halting at 6.

Rebased onto main, mergeable, CI green. gnanam1990 and jatmn approved after your review.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Cover the headless Permission required path in this loop fix
    internal/agent/loop.go:742
    The author’s verified regression covers an OnPermissionRequest denial, which correctly reaches this PR’s typed DenialPermissionDenied path. This separate, existing headless fallback still escapes the same guard: without that callback, the loop skips the prompt branch and registry.RunWithOptions returns Error: Permission required ... without a category; isRetriableToolError deliberately returns false for that text. Consequently countedFailure is false and observeToolResult clears the record on every repeated prompt-tool call, so this blocked execution path still runs until MaxTurns instead of reaching the new halt. Categorize this fallback result or include it in the counted-denial condition, and add a Run-level regression without a permission callback.

  • [P2] Keep policy denials out of the execution-profile failure trigger
    internal/agent/loop.go:744
    The new nonzero outcomes for categorized denials are passed directly to profileController, whose OnToolFailureStreak trigger only checks outcome.Count. This changes the built-in Fast profile after two repeated permission/filter/sandbox/hook denials: it restores the displaced turn budget and effort even though no tool executed. That contradicts the trigger's stated contract as a same-tool retriable failure streak and turns a user/policy refusal into an avoidable cost and behavior escalation. Continue counting denials for the guard halt, but exclude them from the profile failure-escalation signal.

  • [P3] Do not claim all failures had different errors without tracking that
    internal/agent/guardrails.go:548
    Reaching anyErrorCount proves only that the tool failed consecutively without a success. It does not prove pairwise-distinct errors: for example, five A failures, five B failures, then two C failures reaches 12 while never hitting the six-identical-error stop. The new Varied flag nevertheless makes the final answer say that every failure had a different error. Use wording such as “with varying errors,” or record uniqueness before making the stronger claim; the current test covers only twelve distinct errors.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Findings

  • [P1] Count the uncategorized policy refusals in this guard
    internal/agent/loop.go:742
    countedFailure only accepts retriable errors or results that already carry a DenialReason, but the headless prompt path has neither: when OnPermissionRequest is nil, the loop skips its typed-denial branch and registry.RunWithOptions returns Error: Permission required ... with an empty category. isRetriableToolError deliberately rejects that output, so every repeated prompt call takes observeToolResult's success branch and clears the record. Direct sandbox preflight denials for non-shell tools have the same problem: their SandboxDecision is discarded during tools.Result to ToolResult conversion, leaving the Sandbox block error uncategorized. Thus headless prompt calls and varying out-of-workspace writes can still loop to MaxTurns rather than the new halt. Categorize those registry outcomes (or count these policy refusals explicitly) and cover both paths through Run.

  • [P2] Keep policy denials out of the execution-profile failure trigger
    internal/agent/loop.go:744
    The new nonzero outcomes for categorized denials are forwarded straight to profileController, whose OnToolFailureStreak trigger only tests outcome.Count. Consequently, two repeated permission, filter, sandbox, or hook denials in the Fast profile restore the displaced turn budget and reasoning effort even though no tool executed. That contradicts the trigger's documented same-tool retriable-failure contract and spends the one-shot escalation on a user/policy refusal. Continue counting denials for the loop halt, but exclude them from the profile failure-escalation signal.

  • [P3] Do not state an error pattern the guard does not track
    internal/agent/guardrails.go:387
    anyErrorCount establishes only that the tool failed consecutively without a success; it does not establish that all failures differed. For example, five A failures, five B failures, and two C failures reach the new bound without reaching the six-same-signature bound, yet Varied makes the final answer say every failure had a different error. The category-keyed denial path has the inverse problem: it intentionally aggregates refusal reasons that can differ by path or command, then the six-count branch calls them the same error. Use neutral wording such as repeated/varying failures, or record the information needed to make either stronger claim, and add a mixed-signature regression.

@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn August 26, 2026 06:21

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found issues that need to be addressed before this is ready.

Merge readiness

  • [P1] Rebase onto current main before merge
    internal/agent/loop.go
    The branch merge base is ad34dc8d, while live main is 27b319ca and has advanced through eight commits, including changes to the same agent, guardrail, loop, and tool surfaces. GitHub is currently BLOCKED despite reporting the head mergeable. Please rebase and revalidate the resolved diff against current main.

Review guidance

This PR has accumulated follow-up findings because it changes a cross-layer control-flow contract rather than an isolated counter. A single tool result is interpreted at several boundaries: a tool or registry produces it; the agent converts it into a ToolResult; retry, posture, and guardrail logic derive behavior from it; terminal paths serialize it into the transcript and callbacks; and headless callers consume the final status. Fixing one observation point without establishing one authoritative fact leaves nearby consumers able to disagree.

Before another update, please treat the affected paths as two small end-to-end contracts and validate them at their boundaries rather than adding a local special case:

  1. Refusal provenance. Define one registry-owned fact for “the registry prevented execution on policy grounds.” It must be impossible for an executed tool result—whether built in-tree, by an adapter, or by a future implementation—to set that fact accidentally or deliberately. Derive retry eligibility, posture treatment, streak identity, stop wording, and headless status from the same fact. Preserve the inverse: an executed failure remains an executed failure regardless of output or ordinary metadata. Test each producer (permission, sandbox, filter, early configuration refusal), then test the loop behavior through Run, including deliberately lookalike executed results.

  2. Advertised-call lifecycle. For every terminal path, distinguish calls that never started from calls that have produced a result, even when that result is accompanied by an error directing the enclosing run to stop. Finalize completed calls through one shared path for transcript entries, callbacks, trace counters, task state, loaded tools, and images; emit placeholders only for genuinely unstarted calls. Test a parallel batch where terminal state is selected before every precomputed result is consumed, including cancellation, ordinary error, and successful sibling cases.

The practical check is not merely that the newly added unit test passes. For each claimed invariant, mutate the exact producer or bridge that supplies the fact and verify a Run-level regression fails. Also review every early return after tool execution/precomputation against the same lifecycle helper. This avoids repeated review cycles caused by tests pinning a helper while the production conversion, terminal path, or sibling consumer still uses a different definition.

Findings

  • [P2] Authenticate policy-refusal provenance at the registry boundary
    internal/tools/types.go:99
    This PR correctly stops inferring a refusal from tool output, but replaces that text-based identity with an unauthenticated metadata key. Tool.Run returns a tools.Result, and Registry.RunWithOptions forwards an executed result and its Meta unchanged. IsPolicyRefusalResult then treats any nonempty Meta["policy_refusal"] as proof that the registry refused the call before execution.

    Consequently, a tool that actually ran and failed can return that key (whether with a recognized value such as sandbox_denied or an unknown nonempty value) and enter the policy-refusal path. The loop withholds its retry hint, suppresses the profile failure-streak recovery, and includes the result in refusal-oriented guard accounting; recognized values can also make the final answer say the tool was refused although it executed. This violates the new provenance contract and recreates the classification trust problem one layer below output.

    Please make pre-execution refusal provenance unforgeable by an executed tool result: keep the fact in registry-owned state or strip/reserve the marker at the execution boundary, then derive both classification and streak identity from that trusted fact. Preserve ordinary result metadata and the existing real registry refusal paths. Add regression coverage for an allowed tool that executes and fails while returning both a recognized and an unknown policy_refusal value, verifying that it stays an executed retriable failure.

  • [P2] Finalize result-plus-cancellation entries when draining a parallel batch
    internal/agent/loop.go:3614
    The new terminal closeout correctly fixes the common case where a read-ahead sibling has already completed, but it treats every precomputed entry with a non-nil abortErr as unstarted. A canceled permission request is different: executeToolCall first creates canceledPermissionResult (with its call ID, error output, and cancellation/permission information) and returns it together with ErrPermissionApprovalCanceled; executeParallelReadBatch stores both fields.

    If an earlier precomputed sibling triggers a terminal guard or stop path, closeOutRemaining reaches that canceled sibling through precomputedResultFor. Its abortErr != nil check discards the populated result and emits an aborted placeholder. The transcript then denies that the call completed permission handling, while the real cancellation result is omitted from OnToolResult, trace/output accounting, and task observation despite the permission event having occurred.

    Please model precomputed completion separately from whether it asks the enclosing run to return an error. Drain any entry that has a real result through the same finalization path as other completed siblings, and reserve the aborted placeholder for entries that never produced a result. Keep the terminal decision unchanged: draining a canceled sibling should make the recorded lifecycle truthful, not let it override the already selected stop/abort outcome. Add a batch regression with a populated cancellation result after an earlier terminal sibling and assert exact tool-result pairing plus callback, trace, and task-observation preservation.

@Vasanthdev2004
Vasanthdev2004 force-pushed the fix/guardrail-denial-counter-rekey branch from 54b2755 to 10c43f4 Compare August 27, 2026 07:55
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both in, and the branch is on current main.

Forgeable refusal provenance. You are right that this moved the trust problem down a layer instead of closing it. RunWithOptions now strips policy_refusal from every result that came back from a tool actually running, so there is no value a tool can return that survives execution, recognized or invented. Real pre-execution refusals are untouched, RejectBeforePermission included, since it decides before any of that. Ordinary metadata is preserved and the tool's own map is not mutated. Covered at the boundary across all three execution call sites (plain Run, RunWithSandbox, RunWithOptions) and again through Run, where a forged marker has to leave the retry hint injected and DenialReason empty. Turning the strip into a pass-through fails both.

Result plus cancellation when draining. Fixed the way you describe. The batch records whether an entry produced a result, at the point where both halves are in hand, and the aborted placeholder is reserved for entries that produced nothing. The terminal decision is unchanged.

One correction on that one: I could not reach it. A cancelled permission inside a batch needs shouldRequestPermission to be true, and for a PermissionAllow tool the sandbox short-circuits to allow ("tool safety allows execution") before it can prompt, while parallelSafeToolCall admits only PermissionAllow. The other two cancel producers sit behind isShellCommandTool, and a shell tool is never read-only, so it never enters a batch. So the entry precomputedResultFor was discarding cannot exist today. What it was is a check keyed on the wrong fact, one gate change away from being real, which is worth fixing on its own. I changed the shape and the regression pins the invariant, but I did not want to claim a live data-loss bug I could not produce. Tell me if you can reach it from an angle I missed.

@Vasanthdev2004
Vasanthdev2004 requested a review from jatmn August 27, 2026 11:05

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x both of these were right, and both are fixed on the current head (19c839d). The branch was rebased since you looked, so your review is anchored to 4641b18 which is no longer one of the commits here. That is why it is still sitting as blocking.

Counting. The failure flag is now countedFailure := retriableFailure || policyRefusal, and hintable stays plain retriableFailure, so denials are counted but still draw no schema hint. One deliberate difference from your wording: I used isPolicyRefusal rather than a literal DenialReason != "", because a headless prompt refusal and a sandbox preflight denial on a non-shell tool both arrive with an empty DenialReason, and the literal form would have left exactly those two uncounted. It works out to every StatusError result being counted.

Count and wording. The outcome now carries anyErrorCount when the content blind bound is what trips, so twelve distinct errors report twelve rather than one, and the stop answer picks its wording from the cause instead of always claiming the same error.

Run level coverage is in a74d2b2. Five tests drive Run rather than the guard helper, including the varying denial case you described.

Two things still open that you may want to look at while you are in here, both of which I would rather fix than have you find again:

TestRunStopsAnUncategorizedVaryingSandboxRefusalAtTheVariedBound is misnamed. Its fixture is PermissionAllow with no metadata, and since the classifier is structured only now, that result is an ordinary executed failure, not a refusal. The assertion is right for the varied executed bound, the name is wrong. The same stale claim is in the doc comment above isPolicyRefusal, which still lists the bare Sandbox block case as a gap it closes.

Happy to push both if you want them in before you re-review, but that will dismiss the existing approval, so say the word rather than me doing it under you.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x your review is on 4641b18a and the branch is 12 commits past it, so I re-checked both points at 19c839d3. Both are fixed, driven rather than read.

1. Denials counting toward the halt. loop.go:806-808 now computes countedFailure := retriableFailure || policyRefusal and passes that as the halt flag while retriableFailure stays the hint flag, so categorized denials stop the loop without turning on schema hints. DenialReason is threaded through as well.

2. The reported count. guardrails.go:649 returns record.anyErrorCount when that is the counter that tripped, so the final answer no longer says the tool failed once.

--- PASS: TestPermissionDenialStreakSurvivesVaryingReasonText
--- PASS: TestRunStopsARepeatedlyDeniedToolAtTheFailureBound
--- PASS: TestToolFailingWithDifferentErrorsEveryTimeStillStops
--- PASS: TestVariedFailureStopAnswerReportsTheRightCounter

Both are Run-level, so they cover the production call path you asked for, not the guard helper. Falsified one at a time. Passing only retriableFailure again:

guardrails_test.go:380: the run made 10 denied calls, want it halted at 6

Returning record.count again:

guardrails_test.go:411: Count = 1, want the counter that tripped (12)

Ready for another look.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@anandh8x whenever you have time, this is ready for another look.

Your review is on 4641b18a and the head is 19c839d3. Both points were addressed and I posted the detail on 4 September: the halt now counts categorized denials without turning on schema hints, and the reported count comes from the counter that actually tripped. jatmn has approved at the head since.

Nothing has changed on the branch since that reply, so there is nothing new to read beyond it.

@Vasanthdev2004
Vasanthdev2004 requested review from anandh8x and removed request for anandh8x September 11, 2026 15:42

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found no new code-level blocker on exact head 19c839d3264edadde8985db755ed2577e3de1e92, but the branch must be refreshed before it is merge-ready.

Merge readiness

  • Rebase onto current main. The head still has merge base 27b319ca88a3180bed5183f0c599e9307f3ece12; live main is 6937a309cf00825572210a7610a1f3ea8b74c2f9. The branch is 31 mainline commits behind. GitHub reports it mergeable but BLOCKED / CHANGES_REQUESTED; its passing checks do not validate the resolved change against today's target. Rebase, preserve the registry-owned refusal provenance and completed-parallel-result accounting, rerun the full required gates, and request a fresh review on the rebased exact head.

Current-head review

  • jatmn approved this exact head on 2026-08-27 after the prior provenance, headless status, pre-execution refusal, and parallel read-ahead findings were closed.
  • All recorded CI, CodeQL, smoke, Zero Review, and CodeRabbit statuses on this head are green.
  • I rechecked the two mechanically open CodeRabbit threads. alwaysPromptingTool has one declaration, so the duplicate-declaration report is closed in code. The requested real headless and sandbox refusal coverage exists in policy_refusal_run_path_test.go; the older helper-only test is no longer the only proof. Neither thread is a current blocker.
  • No third-party module, external service integration, or dependency change is introduced; the diff stays in Zero's internal agent/tool refusal and control-flow surfaces.

Verdict: Changes requested for stale-base rebase and fresh exact-head validation only.

This pass checked current review-thread closure and merge readiness; it is not a claim that I reran the full suite or validated a rebased result locally. No author-branch changes were made.

The repeated-failure guard keys its streak on the first 80 characters of the
error text. A permission denial reads "Error: Permission denied for <tool>:
<reason>", and reason names the path or command that was refused, so the text
differs on every call while describing the same unchanging refusal. Each call
therefore rebuilt the record at count 1 and toolFailureStopAt was never
reached.

Not hypothetical. A headless run made 384 denied calls over 26 minutes under a
halt set to 6, produced no files, and reported nothing. #702 already hit this
shape once and fixed it by making one error message id-invariant; that works
per message and needs every future message to remember. Denials now key on
their DenialCategory instead, which is a small closed enum the loop already
sets on the result, so the class is fixed rather than one instance of it.

Adds a second, content-blind counter beside the streak. The signature-keyed
one cannot by construction see a tool that fails with a genuinely different
error every time, and that is still a tool that is not working. It counts
consecutive failures regardless of the error and is cleared only by a success
of that same tool, so changing how a tool fails is not progress and neither is
some other tool succeeding. It stops at 12 rather than 6 on purpose: a model
iterating on a tricky edit legitimately fails a few times with different errors
while converging, which is the same reasoning that moved toolFailureStopAt from
4 to 6.

Two counters, tripping on either, is what both of the agent CLIs I compared
against arrived at independently after hitting this bug — a tight bound on
identical failures ORed with a looser bound that no amount of varying the error
can reset.

Every guard is mutation-checked. Reverting the denial re-key fails
TestPermissionDenialStreakSurvivesVaryingReasonText; deleting the content-blind
bound, or letting a signature change reset it, each fail
TestToolFailingWithDifferentErrorsEveryTimeStillStops and
TestSuccessResetsBothFailureCounters.

One existing test call site gains the new parameter.
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.
Reported by jatmn, and he is right that the guard missed the paths it most
needed to cover.

countedFailure asked `DenialReason != DenialNone`, but a category is only
attached where a TYPED denial is built. A headless run leaves
OnPermissionRequest nil, so the loop never reaches that branch and the registry
returns a bare `Error: Permission required ...` with no category. A sandbox
preflight denial on a non-shell tool loses its SandboxDecision converting to
ToolResult and arrives as an uncategorized `Sandbox block`. isRetriableToolError
rejects both, so both operands were false, observeToolResult took its success
branch, and the record the guard accumulates was cleared. The same refused call
could then repeat to MaxTurns, which is the loop this PR exists to stop.

The text patterns for those outcomes already existed, enumerated inside
isRetriableToolError. They simply were not reachable from the counting question.
They are now a shared isPolicyRefusal predicate that both callers use, so the
two questions cannot drift apart again, which is how they diverged in the first
place.

Also, denials no longer feed the execution-profile failure-streak trigger. That
trigger restores the displaced turn budget and reasoning effort on the theory
that a tool is struggling and needs room. A policy refusal is not a struggling
tool, it is an answer, and spending the one-shot escalation on one contradicts
the trigger's documented retriable-failure contract. Denials still count for the
halt; they just no longer buy more budget.

On coverage, honestly: the new tests pin the PREDICATE, including both
uncategorized shapes, and I verified by mutation that removing the text branch
fails them. They do NOT pin the wiring. Mutating countedFailure leaves them
green, which is the same unit-versus-call-path gap that produced the original
defect here. A Run-level test through the headless path is what would close it
and this commit does not add one.
…ot track

jatmn's P3. The final answer overclaimed in both directions.

The content-blind bound said "each with a different error". anyErrorCount only
establishes that the tool failed consecutively without a success. Five A, five B
and two C reaches 12 without any signature repeating six times, and three of
those errors were shared, so "each different" is false. It now says "varying
errors", which is what reaching 12 without tripping the signature bound actually
proves: no signature repeated six times in a row.

The signature bound said "with the same error", which is false the other way for
a denial streak. A denial keys on its CATEGORY precisely because the prose
embeds the path or command refused and therefore differs on every call. That
streak now reports as refused rather than as one repeated error, carried on a
Refused flag derived from the signature prefix.

The one claim that IS justified is kept: an error-signature streak really did
repeat the same signature, so that wording stands.

The existing denial test asserted the old "same error" phrasing, so it was
describing the very defect this fixes; it now expects the refusal wording.

Tests: the three wordings against their counters, plus the mixed-signature
regression jatmn asked for, driven through the real counter rather than asserted
about the strings in isolation, so it proves the 5/5/2 run trips the
content-blind bound and is not described as all-different.
isPolicyRefusal decides on denial category, then permission metadata,
then output text. None of those questions is meaningful about a call the
tool completed, and the last one is answered by content the model does
not control.

isRetriableToolError gated on StatusError before calling in, so the
boundary held while that was the only caller. Extracting the helper and
calling it from the counting path dropped the gate: an allowed bash
printing "Sandbox block", or a read_file returning a document that quotes
it, set policyRefusal, made countedFailure true, and recorded a failure
against the tool's signature. Six such successes tripped the
same-signature stop and ended a healthy run with "the `bash` tool failed
6 times in a row with the same error".

The gate belongs in the classifier rather than at each caller, because
the next caller will forget it too.

Covered by a direct StatusOK classifier case over every signal the helper
reads, and by an end-to-end run of ten successful greps whose output
quotes the phrase. Both fail against the ungated helper: the run halts at
6 with the refusal answer above.
The categorized denial was never the gap. The gap is a refusal arriving
with DenialReason empty, because a category is attached only where a
typed denial is built: a headless run leaves OnPermissionRequest nil and
the registry gate returns a bare "Permission required for ...", and a
sandbox preflight denial on a non-shell tool loses its SandboxDecision
converting to ToolResult and arrives as a bare "Sandbox block".

Testing that through the helper proves nothing. The first version of this
fix passed every helper test while being a no-op in production, because
the loop asked a different question than the tests did. Both cases here
go through Run and pin what the loop does with the classification: halt
at the bound, never execute the tool, and withhold the profile's one-shot
failure escalation.

Each half of the wiring falsifies the tests on its own. Dropping
policyRefusal from countedFailure lets the headless refusal run 13 turns
instead of halting at 6. Dropping the empty-outcome branch for the
posture controller reports posture_escalations = 1 instead of 0.
…l output

isPolicyRefusal fell back to matching phrases in the model-visible output, and
output is tool-controlled. bash preserves arbitrary stdout and stderr on a
StatusError for any nonzero exit, so an allowed command running
`printf 'Sandbox block\n' >&2; exit 1` had actually executed, carried no denial
category, and was still classified as refused. read_file returning a document
that quotes one of the phrases did the same, which is the likelier way a real
session hits it. The loop then withheld the retry hint and the profile
failure-streak recovery and accumulated the executed failure toward the refusal
halt, so a later stop told the user a tool had been refused when it had run.

The registry already had the structured facts and dropped them on the floor.
Every path that returns BEFORE the tool runs now carries one marker naming which
gate refused: sandbox deny, sandbox approval required, permission required,
permission denied. That includes the two cases that were previously
uncategorized, the headless prompt refusal and the sandbox preflight denial on a
non-shell tool, neither of which builds a typed DenialReason. isPolicyRefusal
reads DenialReason, permission metadata and that marker, and nothing else.

markStructuredSandboxDenial already stated this rule for the sandbox adapter,
"Classification is never inferred from stdout or stderr"; this carries the same
guarantee across the remaining gates.

Coverage runs in both directions. The existing refusal fixtures now carry the
provenance their production paths attach rather than relying on their text, and
there is a Run-level regression where an allowed tool that ran, failed, and
printed each recognized phrase still receives the retry hint. Reverting the
classifier to substrings fails all three tests, including every phrase of the
Run-level one.
…rovenance as the gates

capture_artifact rejects in RejectBeforePermission, which the registry returns
straight back before any of the gates that attach provenance. Its
valid-but-unavailable calls therefore reached the classifier with no denial
category, no permission metadata and no refusal marker, so they were read as
ordinary retriable failures: the model got the schema hint telling it to fix
arguments that were already valid, and the call could consume the profile
failure-streak escalation, for a tool that never executed and that no argument
change can enable.

PolicyRefusalToolNotEnabled existed for exactly this and I never wired it. The
missing-artifact-directory and disabled-driver branches carry it now.

The malformed-argument branch deliberately stays an ordinary error. That one IS
fixable by trying again differently, which is what the hint is for, so marking
every early rejection would trade one wrong answer for another. Both directions
are covered.

Checked the rest of the class rather than only the reported tool: web_fetch,
browser_launch, browser_connect, browser_open, desktop_windows,
desktop_snapshot and terminal_session all reject on arguments alone, which is
correctly retriable. capture_artifact was the only one refusing on
configuration.

Also rebased onto current main rather than carrying the two merge commits, per
the same requirement raised on #886.
…ys on

The registry marks its pre-execution refusals in metadata, and isPolicyRefusal
read that marker while observeToolResult keyed on DenialReason, which those
paths leave empty. The guard fell back to errorSignature(output), so two
refusals of the same category with different wording looked like two different
failures and the streak restarted at 1 on every call.

A model alternating capture_artifact's browser_screenshot and browser_pdf
against a disabled driver is refused identically each time, and never tripped
the six-call refusal halt. Only the generic twelve-error fallback stopped the
run, reporting varied errors rather than a repeated refusal.

The category is derived once now, at the boundary where a tools.Result becomes a
ToolResult, and both the classification and the streak read that one value. It
had to go in twice, because a RejectBeforePermission refusal takes its own
constructor, and that is the route capture_artifact actually takes. Deriving it
at the producers instead would have left the same gap for the next path that
returns before the gates.

One behaviour change worth stating plainly rather than burying. A headless
prompt refusal is marked too, so it now carries a category and the stop answer
says the tool was refused rather than that it failed with the same error. The
bound is unchanged, and the new wording is the accurate one: the tool never ran.
The test that pinned the old wording is updated, along with the comment that
explained why it was uncategorized.
…status

Three defects with one shape: provenance was encoded in a string, recovered by
inspecting that string, and then not carried far enough.

The same-identity streak keyed on one string namespace holding both a
normalized error signature and a synthetic "denial:<category>" key, with
provenance recovered afterwards by testing for that prefix. Trusted provenance
and untrusted content in one namespace is a namespace the untrusted side can
write into: a command printing exactly "denial:permission_denied" and exiting
non-zero acquired the identity of a real permission refusal and the run
reported it as refused although it executed every time. The identity is now a
(kind, key) pair, so no output can spell a refusal.

The content-blind bound reported only that the failures varied, and it fires
precisely when no identity repeated, so the identity present at the end says
nothing about the eleven before it. Alternating two refusal categories reached
twelve without either reaching six and the answer described a tool that never
ran as having failed. The record now carries the aggregate, and the guard
returns a typed cause instead of two overlapping booleans, with the mixed case
named rather than left to whichever field a switch tested first.

The halt returned straight out of the tool loop, so it never crossed the
completion gate the max-turns paths go through. Under RequireCompletionSignal
zero exec treats only Incomplete as exit 4, so a task denied six times came
back as a successful automation result having done none of the work.

Also closes two coverage gaps that made the markers untestable: reverting
either the sandbox deny marker in registry.go or the disabled-driver refusal in
local_capture.go left both suites green. The sandbox case now runs a real
engine evaluation through Run and asserts the body was skipped; the capture
case configures an artifact root with one driver enabled so it reaches the
disabled-driver branch instead of returning at the missing-directory one, with
a sibling case pinning that a malformed argument stays retriable.
Parallel read-ahead broke the assumption the aborted placeholders were written
under. executeParallelReadBatch runs an entire eligible run of read calls before
the loop consumes any of them, so "not consumed yet" stopped meaning "not
executed". Every terminal branch closed out the calls after the current index as
aborted, and a sibling that had already run was recorded that way: its real
result discarded, and its callbacks, trace counter, task observation, loaded
tools and images lost with it. Where the sibling is a successful read,
execution may already have committed file-observation credit for content the
model never receives, so the authorization state disagreed with the transcript.

Each remaining call is now put in the state that is true of it. A completed one
is finalized exactly once with the same bookkeeping the main path performs; an
unstarted one still gets a placeholder so every tool_use keeps its answering
tool_result. The guard is deliberately not consulted for a drained sibling: it
cannot reverse a decision already made, it is only owed an honest record.

All three early returns go through one helper rather than repeating the
assumption, so the next stop condition inherits the fix instead of the bug.
…rom abort

Two things this branch left keyed on something that could disagree with the
fact it stands for.

A tool that ran and failed could claim the registry refused it before it ran.
IsPolicyRefusalResult trusted a metadata key, and Registry.RunWithOptions
forwards an executed result and its Meta unchanged, so a tool could set it by
mistake, by copying metadata forward from something it called, or on purpose.
The loop then withheld the retry hint, suppressed the failure-streak recovery,
counted the call in refusal accounting, and could tell the user a tool was
refused when it had executed. That is the output-text trust problem one layer
down. The execution boundary now strips the marker, so no value survives
running, recognized or invented. Pre-execution refusals are untouched, including
RejectBeforePermission, which decides before any of this.

precomputedResultFor treated any batch entry carrying an abort error as
unstarted. Producing a result and asking the run to stop are different facts,
and executeToolCall's cancelled-permission path returns both: an earlier
sibling reaching a terminal branch would discard the real cancellation result
and write an aborted placeholder over it. The batch now records what it ran,
where that is known, and the placeholder is reserved for entries that produced
nothing. The terminal decision is unchanged; draining only makes the record
honest.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at 6990083 as asked. Clean replay of the twelve commits, nothing resolved by hand; the registry-owned refusal provenance and the completed-parallel-result accounting are unchanged. agent and tools packages green here, linux and darwin cross-builds pass. @gnanam1990 this is the only change since your review. @jatmn your approval at 19c839d will have been dismissed by the push; the content is identical, one more look when you have a moment. @anandh8x your review at 4641b18 is well behind this.

@Vasanthdev2004
Vasanthdev2004 force-pushed the fix/guardrail-denial-counter-rekey branch from 19c839d to 6990083 Compare September 12, 2026 06:38
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Both of your asks are done, so this one is only waiting on a fresh look.

@anandh8x both of your items are fixed. The guard failure flag no longer passes isRetriableToolError for both purposes: a categorized denial is counted by the streaks while schema-hint injection stays off for it, which is the split you asked for, and the reason is written down next to the code so it does not regress quietly. For the count, anyErrorCount counts consecutive failures of a tool regardless of the error text and is cleared only by a success, so twelve distinct errors no longer report as one, and the stop message is driven from that rather than from the signature-specific counter. There are Run-level regressions rather than only guard-helper ones.

@gnanam1990 the rebase you asked for is done. Head 69900830 sits on current main's line, two commits behind c1937dfa with no conflicts, and CI 12 of 12 ran against that head rather than the stale pair. jatmn's approval carried across the rebase and is live at this head. The registry-owned refusal provenance and the completed-parallel-result accounting are both preserved; the failure-kind separation you would want to check is in guardrails.go, where tool-produced and policy-produced failures no longer share a string namespace.

@jatmn jatmn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants