Name a vendor's error as the vendor's to a Bot running its own loop, as the in-process door does - #569
Merged
davidmckayv merged 2 commits intoSep 15, 2026
Conversation
…as the in-process door does
A vendor that says no by answering `{ isError: true }` with a sentence,
the way an MCP server refuses, reaches a Bot's model through one of two
doors to the same `callTool`. The in-process door (`grantedTools`) has
named that sentence as the vendor's since CopilotKit#97, because handing it over
as content cost a diagnosis: Google's "The caller does not have
permission" read as a result, and the model told the person it had no
access to their Drive.
`/api/agent-tools/call`, the door a framework Bot calls back through,
answered with the bare text. Neither framework Bot words it on the way
through: the LangGraph Bot passes an `isError` answer on untouched, and
the Python LangGraph Bot reads only `text`. So on those Bots the model
was still handed the vendor's refusal as an ordinary result.
The wording now lives in one helper, `vendorAnswer`, and both doors use
it. A result that is not an error is unchanged, and so are this
deployment's own refusals, which keep `REFUSAL_MARKER` and never reach
this helper.
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:17
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Merged
9 tasks
# Conflicts: # CHANGELOG.md
davidmckayv
approved these changes
Sep 15, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Code-verified clean; CI green on this sha.
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
A vendor can say no in two ways. It can throw, or it can answer
{ isError: true }with a sentence, which is how an MCP server refuses a call. The second answer reaches a Bot's model through one of two doors to the samepluginStore.callTool:grantedToolsinserver/src/plugins/tools.ts), for a Bot running here. Since Answer from Google Drive, as the person asking #97 it hands the modelThe vendor reported an error: <sentence>. The comment there says why: handing the sentence over as content "cost a diagnosis". Google's "The caller does not have permission" read as a result, and the model told the person it had no access to their Drive./api/agent-tools/callinserver/src/app.ts), for a Bot running its own loop. It answered with the bare text.Neither framework Bot adds the wording itself:
agent-langgraphpasses anisErroranswer on untouched.tool-answer.test.tspins that: "a refusal the store made is passed on untouched".agent-langgraph-aguireturnsbody["text"]and nothing else.So on those Bots, a vendor's refusal still reached the model as an ordinary result. That is the failure the in-process comment describes, through the other door.
The wording moves out of
grantedToolsinto an exportedvendorAnswerbesideREFUSAL_MARKER, with its comment, and both doors call it. What stays the same:REFUSAL_MARKER. They are thrown asPluginRefusedErrorand answered in the route'scatch, so they never reach this helper.deploymentToolCaller) is not touched.Where it runs
Boundary and audit
callTooland its audit rows are unchanged; only the text of an answer already on its way to the model changes.isErroranswer is recorded bycallToolexactly as before.Changelog
CHANGELOG.mdunderUnreleased.This entry sits at the top of
Unreleased, which is also where #568 inserts. If one lands first, I'm happy to rebase the other.Proof
Two tests added to
server/tests/agent-callback-token.test.ts, in the route's existingdescribe. Each builds one fake store and asks both doors about the same call:createApp(...).request("/api/agent-tools/call")with a signed run, andgrantedTools(...)[0].execute({}).{ text: "The caller does not have permission.", isError: true }.On
main, the first fails and the guard passes:With the fix:
bun test tests/agent-callback-token.test.ts tests/host-access-callback-route.test.ts: 33 pass, 0 fail. This includes the route's existing tests that a deployment fault is not relayed, and that a refusal and a thrown vendor sentence still are.bun run typecheck(app, server, worker): exit 0.bunx biome checkon the three changed files: clean.Both branches of
vendorAnswer, the route's answer line andgrantedTools'executeall run in these tests.grantedTools' other tests are Postgres integration tests, which I could not run locally. No test anywhere asserted the bare callback text or the inline prefix.🤖 Generated with Claude Code