Mark a callback's tool answer as refused only when it was refused, as the in-process door does - #570
Merged
davidmckayv merged 2 commits intoSep 15, 2026
Conversation
… the in-process door does
`/api/agent-tools/call` put `REFUSAL_MARKER` in front of every throw
from `callTool`. `callTool` throws `PluginRefusedError` when a boundary
holds, and rethrows a vendor that broke after recording
`mcp.call_failed`. A database fault can throw from it too.
The marker is what the transcript draws: `chat-transcript.tsx` labels a
tool result that starts with it as blocked, and the model reads
"Refused." as "not allowed". So for a Bot calling tools back from its
own process, a vendor outage ("Refused. fetch failed") or a fault of
this deployment's own ("Refused. That tool could not be called.") was
shown to the person as a policy refusing, while the audit trail said the
call had failed.
The in-process door (`grantedTools`) has kept these apart from the
start: a refusal is marked, a vendor that failed reads "That tool could
not be called: <sentence>", and a deployment fault reads "That tool
could not be called." The route now gives the same three answers. The
refusal's text is unchanged, and the route still passes everything
through `withoutStatement` and keeps the deployment-fault shelf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kevin9327
requested review from
MikeRyanDev,
davidmckayv,
guidovizoso,
mxmzb and
tylerslaton
as code owners
September 15, 2026 22:25
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Contributor
|
Thanks for these — all three are clean and going in. One small process nudge, not a criticism: #569 and #570 are really two halves of the same idea (make the callback door behave like the in-process door — one for the resolved-error path, one for the thrown path), on the same handler and the same test file. When fixes are that tightly coupled, feel free to land them as a single PR — it's a little less review overhead and skips the rebase between them. The work itself is careful and well-tested; keep it coming. |
# Conflicts: # CHANGELOG.md # server/src/app.ts
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 join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
What this changes
/api/agent-tools/callis the door a Bot running its own loop (the LangGraph Bots) calls tools back through. It putREFUSAL_MARKERin front of every throw frompluginStore.callTool.callToolthrows for three different reasons:PluginRefusedErrorcallToolrecordsmcp.call_failed(store.ts, thecatchthat ends inthrow error)The marker is not just wording.
chat-transcript.tsxchecksanswer.startsWith(REFUSAL_MARKER)and labels that tool line as blocked, and the model reads "Refused." as "not allowed". So on these Bots:Refused. fetch failedRefused. That tool could not be called.Meanwhile the audit trail recorded a failed call. The route's own test says matching the in-process door on a fault "is the property". The in-process door (
grantedTools) has kept the three apart since #31: "A vendor that failed is not a refusal… one means 'not allowed', the other means 'it broke'."The route now gives the in-process door's three answers:
PluginRefusedErrorRefused. <reason>Refused. <reason>(unchanged)ErrorRefused. <sentence>That tool could not be called: <sentence>ErrorRefused. That tool could not be called.That tool could not be called.The route keeps what it already had over the in-process door. Everything still goes through
withoutStatement, and the deployment-fault shelf is unchanged, so nothing new is relayed.isErrorstaystrueon all three.For completeness, the host tools behind
deploymentToolCallershare thiscatch, but they are not affected. Theiranswerhelper already catches its own failures and marks only aHostAccessRefusedError, so they never throw here.Where it runs
Boundary and audit
callToolis untouched.callToolalready writesmcp.call_refusedormcp.call_failedbefore it throws, and this makes the transcript agree with that row.Changelog
CHANGELOG.mdunderUnreleased.Overlap with #569, which also touches this route: the two merge cleanly except on the
CHANGELOG.mdanchor and two adjacent import lines inapp.ts. #569 addsvendorAnswerto the./plugins/toolsimport, and this PR addsPluginRefusedErrorto the./plugins/storeimport just above it. Resolving is keeping both, and the test file merges on its own. I checked withgit merge-tree. I'm happy to rebase whichever lands second.Proof
New test in
server/tests/agent-callback-token.test.ts, in the route's existingdescribe: "a throw is marked as a refusal only when it is one, the way the in-process door marks it". For each of the three throws, it asks the route (the file's existingtoolResultharness, with a signed run) andgrantedTools(...)[0].execute({})about the same throwing store. It records whether the route's answer carries the marker and whether it matches the in-process answer.On
main:With the fix:
bun test tests/agent-callback-token.test.ts tests/host-access-callback-route.test.ts: 32 pass, 0 fail. These include the existing route tests: a query failure never reaches the model, a refusal is still relayed in full, and a thrown vendor sentence is still relayed.bun run typecheck(app, server, worker): exit 0.bunx biome checkon the changed files: clean.All three arms of the new
catchrun in the new test. Neither LangGraph Bot looks for a leading "Refused." in what the deployment returns; they only write their own refusals, so nothing downstream keys on the old marker.🤖 Generated with Claude Code