feat: refusal as a first-class terminal outcome - #13
Merged
Conversation
|
All contributors have signed the CLA. ✅ |
Owner
Author
|
I have read the CLA Document and I hereby sign the CLA |
Owner
Author
|
recheck |
Owner
Author
|
recheck |
Owner
Author
|
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backports the refusal work from the desktop build. Two defects with one root cause: a structured refusal was computed and then thrown away.
1. Structure lost at the boundary
The acceptance gate already produces the right objects —
{check, expected, message, severity}— and the escalation path joined them into an English sentence, so the UI and the next planner turn got prose they couldn't act on.2. No terminal state, hence a re-raise loop
Escalations surfaced as ordinary phase gates with Approve/Deny. "Approve" cannot satisfy "this needs a human to fix it", so the gate re-evaluated, refused again and re-raised — observed ~70 times against an auto-approving harness.
What this adds
src/shared/refusal.ts:retryabletyped as the literalfalse— nothing can construct a retryable refusal, so it can't be fed back into a retry loop by accidentintentId(project:phase:reason), which makes a repeat refusal recognisable as the same closed intentdetailskeeps the violation objects intactbestAttemptpreserves the least-broken artifact across the refusalpremiseForRetrynames what would have to change for another attempt to be meaningful — so a retry with no new information is structurally unjustified, catching the wall-clock and token burn that a spend cap can't seePhaseGateManager.requestApprovaltakes an optional refusal, dedupes byintentId, and logs a repeat instead of re-publishing. Resolving the gate clears it.Credit
The shape was sharpened in a public conversation with the author of Agent Pump, who hit the identical failure against a wallet boundary: an agent that was financially safe yet hit
INSUFFICIENT_BALANCE46 times, because the failure returned as a retryable tool error and nothing forced it to acknowledge that the original intent was dead. Thanks to them — theretryable: false+ closed-intent + changed-premise shape is theirs.Follow-ups (not in this PR)
approval.requiredpayload, so the renderer has what it needs.formatRefusalForPromptinto the remediation task so a retrying agent reads structure and a closed intent rather than prose.Test plan
tsc --strictcleanphase-gates.tsconfirmed a clean superset before copying, andsensei.tshad the edit applied to this repo's version so itslast_reopened_athandling is preserved🤖 Generated with Claude Code