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"