From 8e3b64a257a16ddc004cd0894b0bce152a4c92ba Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 8 Nov 2026 18:26:11 -0600 Subject: [PATCH] fix(runner): an unknown agent id says which id and how to find the right one (#20135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents delegate by creating subtasks through Paperclip's tools. A tool error must tell the agent how to recover, or it improvises > - Restart chaos t06 ended a delegation with four children instead of two > - Evidence showed no replay: the model built an assignee id from two real agents' halves and got a bare `paperclip_runner_agent_not_found` four times > - To debug the error it created "diagnostic test" subtasks without an assignee. Those succeeded and were left behind as duplicates > - This pull request makes the error say which id was not found, which real agents it most resembles, and to call `list_agents` > - The benefit is that an agent corrects the id instead of creating throwaway tasks ## Linked Issues or Issue Description Refs #19831 (durable ownership plan). Restart chaos wave `w-20261108T225520-s9501`, task t06 (claude, M1, K2 drain). **Problem:** parent run 2751fe3c created Part 1 correctly. For Part 2 it passed `assigneeActorId: 0cb62ac2-742d-442c-acc9-d21dd936e716`: Chaos claude's prefix with Chaos codex's suffix. - Four `create_task` calls (keys `part2`, `part2` again, `-v2`, `-backlog`) each returned only `paperclip_runner_agent_not_found`. One of them crossed the K2 drain and failed the same way as the one before it. - The model then created "Part 2 06f119cf test" (`-noassignee`) and "Part 2 06f119cf" (`-title-test`), both described as "diagnostic test - will be removed/ignored". With no assignee they defaulted to the caller. They stayed in backlog. - Only after `list_agents` did it create the real Part 2. - Same-key replays were never the issue: `create_task` stores the `idempotencyKey` as the child's `origin_id`. ## What Changed - `server/src/services/native-runtime/paperclip-runner-tool-authority.ts`: - `create_task` (assignee) and `get_agent` now fail with `paperclip_runner_agent_not_found: no agent in this company has id "". Closest ids: (), …. Call list_agents and copy a valid id exactly; nothing was created or changed.` - Suggestions are up to three agents of the caller's company whose ids share at least eight leading or trailing characters with the missing id. Another company's agents are never named. - The recoverable-read check matches the error code before the `:` details, so a recovered `get_agent` read still returns the error as a tool result. ## Verification Red/green: - `paperclip-runner-tool-authority.test.ts` "names a missing agent id and its closest real agent instead of a bare code (restart chaos t06)". It uses t06's spliced id, with a second agent "Chaos claude" in the company. - On master: the error is the bare code (red). - With this PR: both `create_task` and `get_agent` name the id, `Chaos claude (0cb62ac2-…)` and `list_agents`, and no task is created. - The file passes (42 tests), including the existing foreign-company assignee rejection. `tsc --noEmit -p server` is clean. ## Risks - One extra read of the company's agents, only on the failure path. - The message names agents of the caller's own company, which `list_agents` already returns to it. - No schema or persisted state change. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code, with tool use (shell, file editing). ## 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 (#20069, #20108) - [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 (no user docs affected) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green (pending) - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (pending) - [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 --- .../paperclip-runner-tool-authority.test.ts | 28 ++++++++++++++++++ .../paperclip-runner-tool-authority.ts | 29 ++++++++++++++++--- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 444bf3df5a9..a7efadbf80a 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -159,6 +159,34 @@ describe("PaperclipRunnerToolAuthority", () => { .rejects.toThrow("This connector is no longer assigned or authorized"); }); + it("names a missing agent id and its closest real agent instead of a bare code (restart chaos t06)", async () => { + const claudeId = "0cb62ac2-742d-442c-abb4-edfcc60defe4"; + await db.insert(agents).values({ id: claudeId, companyId, name: "Chaos claude", adapterType: "paperclip_runner", + adapterConfig: { provider: "claude" }, runtimeConfig: {}, status: "active" }); + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + const spliced = "0cb62ac2-742d-442c-acc9-d21dd936e716"; + for (const call of [ + { tool: "create_task", callId: "t06-spliced-assignee", arguments: { idempotencyKey: "t06-spliced", title: "Part 2", assigneeActorId: spliced } }, + { tool: "get_agent", callId: "t06-spliced-read", arguments: { actorId: spliced } }, + ]) { + const error = await authority.execute(call).then(() => null, (failure: Error) => failure); + expect(error?.message).toMatch(/^paperclip_runner_agent_not_found: /); + expect(error?.message).toContain(spliced); + expect(error?.message).toContain(`Chaos claude (${claudeId})`); + expect(error?.message).toContain("list_agents"); + } + const recovered = await authority.recover({ tool: "get_agent", callId: "t06-spliced-recovered", arguments: { actorId: spliced } }); + expect(recovered).toMatchObject({ isError: true }); + const text = JSON.stringify(recovered); + expect(text).toContain("paperclip_runner_agent_not_found: "); + expect(text).toContain(`Chaos claude (${claudeId})`); + expect(text).toContain("list_agents"); + const created = await db.select({ id: issues.id }).from(issues).where(eq(issues.originId, + `paperclip-runner:create-task:${issueId}:t06-spliced`)); + expect(created).toEqual([]); + await db.delete(agents).where(eq(agents.id, claudeId)); + }); + it("advertises only real bindings and reads the bound task", async () => { const authority = new PaperclipRunnerToolAuthority(db, { companyId, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 2db9f54c878..dcfc6b26ef5 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -176,6 +176,14 @@ const RECOVERABLE_READ_ERRORS = new Set([ "paperclip_runner_document_not_found", "paperclip_runner_agent_not_found", "paperclip_runner_approval_not_found", "paperclip_runner_tool_input_invalid", ]); +const idSimilarity = (left: string, right: string) => { + const a = left.toLowerCase(), b = right.toLowerCase(); + let prefix = 0; + while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix += 1; + let suffix = 0; + while (suffix < a.length - prefix && suffix < b.length - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) suffix += 1; + return Math.max(prefix, suffix); +}; const readOnlyApiMethod = (method: string) => ["GET", "HEAD", "OPTIONS"].includes(method); const toolCallKey = (callId: string) => createHash("sha256").update(callId).digest("hex"); const toolInputDigest = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex"); @@ -519,13 +527,25 @@ export class PaperclipRunnerToolAuthority { return null; } + async #agentNotFound(actorId: string): Promise { + const actors = await this.db.select({ id: agents.id, name: agents.name }).from(agents) + .where(and(eq(agents.companyId, this.binding.companyId), sql`${agents.status} <> 'terminated'`)); + const closest = actors.map(actor => ({ actor, score: idSimilarity(actorId, actor.id) })) + .filter(candidate => candidate.score >= 8) + .sort((left, right) => right.score - left.score).slice(0, 3) + .map(({ actor }) => `${actor.name} (${actor.id})`); + return new Error(`paperclip_runner_agent_not_found: no agent in this company has id ${JSON.stringify(actorId.slice(0, 80))}.` + + (closest.length ? ` Closest ids: ${closest.join(", ")}.` : "") + + " Call list_agents and copy a valid id exactly; nothing was created or changed."); + } + async #recoverRead(call: Parameters[0]) { try { return { result: await this.execute(call) }; } catch (error) { // A known read failure is a tool outcome, not a failed transport. Keep // authorization, uncertain effects, and unexpected storage errors fatal. - if (!(error instanceof Error && (RECOVERABLE_READ_ERRORS.has(error.message) + if (!(error instanceof Error && (RECOVERABLE_READ_ERRORS.has(error.message.split(":")[0]!) || (error instanceof HttpError && [400, 404, 422].includes(error.status))))) throw error; await this.binding.assertBridgeAuthority?.(); await this.#boundContext(); @@ -981,8 +1001,9 @@ export class PaperclipRunnerToolAuthority { case "list_agents": return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) }; case "get_agent": { - const actor = await agentService(this.db).getById(requiredString(input.actorId)); - if (!actor || actor.companyId !== this.binding.companyId) throw new Error("paperclip_runner_agent_not_found"); + const actorId = requiredString(input.actorId); + const actor = await agentService(this.db).getById(actorId); + if (!actor || actor.companyId !== this.binding.companyId) throw await this.#agentNotFound(actorId); return { actor: redactedActor(actor) }; } case "list_approvals": @@ -1880,7 +1901,7 @@ export class PaperclipRunnerToolAuthority { const assigneeAgentId = assigneeUserId ? null : input.assigneeActorId == null ? this.binding.agentId : requiredString(input.assigneeActorId); if (assigneeAgentId) { const assignee = await agentService(this.db).getById(assigneeAgentId); - if (!assignee || assignee.companyId !== this.binding.companyId) throw new Error("paperclip_runner_agent_not_found"); + if (!assignee || assignee.companyId !== this.binding.companyId) throw await this.#agentNotFound(assigneeAgentId); } const priority = input.priority === "critical" || input.priority === "high" || input.priority === "medium" || input.priority === "low"