From 7d92b6a7cd6274b36ba4c8434fd64bf0daa0470d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:31:49 -0600 Subject: [PATCH] fix(api): use a neutral catalog pointer in the call_api protocol refusal (#20191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path - Paperclip agents reach REST routes through the `call_api` tool; protocol endpoints (MCP, OAuth, WebSocket) are refused there with guidance. - #20170 changed that refusal to tell the agent the endpoint's catalog description says who can use it. - Review on #20170 found that some protocol endpoints' descriptions (for example the WebSocket entries) do not say who may call them, so the refusal could send the agent to look for information that is not there. - The fix (e7ff8ba610) was pushed three minutes after #20170 merged, so it never reached master. This PR carries it. ## Linked Issues or Issue Description Refs #20170 **What happened:** after #20170, a `call_api` refusal for a protocol endpoint says the catalog description names who can use it. Some descriptions, such as `GET /api/companies/{companyId}/events/ws` ("WebSocket upgrade. Not callable through call_api."), do not, so an agent following the refusal looks for an answer the catalog does not give. **Expected behavior:** the refusal points the agent at the catalog description neutrally ("Read its catalog description for details.") without promising it names who may call the endpoint. **Steps to reproduce:** 1. As an agent, call `call_api` with `GET /api/companies/{companyId}/events/ws`. 2. Read the refusal text: it claims the description says who can use the endpoint. 3. Read the catalog entry: it does not. ## What Changed - `server/src/services/native-runtime/runner-api-client.ts`: the protocol-endpoint refusal now says "Read its catalog description for details." instead of promising the description names who can call it. - `server/src/__tests__/assistant-connections-guidance.test.ts`: adds a case on the WebSocket events endpoint asserting the neutral wording. ## Verification - `cd server && npx vitest run src/__tests__/assistant-connections-guidance.test.ts` (4 passed). - Commit was reviewed on #20170's branch by Ponytail (the finding that prompted it). ## Risks - Low: wording-only change to one refusal message, plus a test. Agents may read slightly less specific guidance for endpoints whose descriptions do name the caller; the description itself is unchanged. ## Model Used - Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with tool use and code execution. The change was authored by a Claude subagent on #20170 and carried over here by Claude. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../src/__tests__/assistant-connections-guidance.test.ts | 9 ++++++++- server/src/services/native-runtime/runner-api-client.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/assistant-connections-guidance.test.ts b/server/src/__tests__/assistant-connections-guidance.test.ts index d03719da11a..563b3e04891 100644 --- a/server/src/__tests__/assistant-connections-guidance.test.ts +++ b/server/src/__tests__/assistant-connections-guidance.test.ts @@ -21,7 +21,7 @@ it("tells agents searching for assistant disconnection that only the person can it("says the call_api protocol rejection is final instead of inviting another search", () => { expect(() => validateRunnerApiCall({ operationId: "GET /api/mcp/connections" }, context)) - .toThrow(/no other API route substitutes for it\. Its catalog description says who can use it\./); + .toThrow(/no other API route substitutes for it\. Read its catalog description for details\./); }); it("points at the catalog description only when the protocol endpoint has one", () => { @@ -32,3 +32,10 @@ it("points at the catalog description only when the protocol endpoint has one", expect(message).toContain("no other API route substitutes for it."); expect(message).not.toContain("catalog description"); }); + +it("does not claim a non-guidance protocol description says who can use the endpoint", () => { + let message = ""; + try { validateRunnerApiCall({ operationId: "GET /api/companies/{companyId}/events/ws" }, context); } catch (error) { message = (error as Error).message; } + expect(message).toContain("Read its catalog description for details."); + expect(message).not.toContain("who can use it"); +}); diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts index 42a1af9ad19..ecce7755c8f 100644 --- a/server/src/services/native-runtime/runner-api-client.ts +++ b/server/src/services/native-runtime/runner-api-client.ts @@ -54,7 +54,7 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext) if (!parsed.success) throw badRequest("Invalid call_api arguments", { issues: parsed.error.issues }); const input = parsed.data; const operation = runnerApiOperation(input.operationId); - if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Its catalog description says who can use it." : ""}`); + if (operation.transport !== "rest") throw unprocessable(`This endpoint requires its existing protocol client; call_api supports REST only and no other API route substitutes for it.${operation.description.trim() ? " Read its catalog description for details." : ""}`); if (input.responseText && operation.method !== "GET") throw badRequest("responseText requires a GET operation; read the saved artifact instead of repeating a mutation"); if (!operation.allowedModes.includes(context.workMode)) throw forbidden("call_api permits only reads in Ask and Plan modes; use the permitted dedicated tools"); if (input.pathParams?.companyId && input.pathParams.companyId !== context.companyId) throw forbidden("API call belongs to another company");