fix(runner): an unknown agent id says which id and how to find the right one (#20135)

## 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
"<id>". Closest ids: <name> (<id>), …. 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 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 18:26:11 -06:00
1 parent c49ab6b9ee
commit 8e3b64a257
2 files changed
+53 -4

No files matched your search

@@ -159,6 +159,34 @@ describe("PaperclipRunnerToolAuthority", () => {
.rejects.toThrow("This connector is no longer assigned or authorized"); .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 () => { it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { const authority = new PaperclipRunnerToolAuthority(db, {
companyId, companyId,
@@ -176,6 +176,14 @@ const RECOVERABLE_READ_ERRORS = new Set([
"paperclip_runner_document_not_found", "paperclip_runner_agent_not_found", "paperclip_runner_document_not_found", "paperclip_runner_agent_not_found",
"paperclip_runner_approval_not_found", "paperclip_runner_tool_input_invalid", "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 readOnlyApiMethod = (method: string) => ["GET", "HEAD", "OPTIONS"].includes(method);
const toolCallKey = (callId: string) => createHash("sha256").update(callId).digest("hex"); const toolCallKey = (callId: string) => createHash("sha256").update(callId).digest("hex");
const toolInputDigest = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex"); const toolInputDigest = (value: unknown) => createHash("sha256").update(canonicalJson(value)).digest("hex");
@@ -519,13 +527,25 @@ export class PaperclipRunnerToolAuthority {
return null; return null;
} }
async #agentNotFound(actorId: string): Promise<Error> {
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<PaperclipRunnerToolAuthority["execute"]>[0]) { async #recoverRead(call: Parameters<PaperclipRunnerToolAuthority["execute"]>[0]) {
try { try {
return { result: await this.execute(call) }; return { result: await this.execute(call) };
} catch (error) { } catch (error) {
// A known read failure is a tool outcome, not a failed transport. Keep // A known read failure is a tool outcome, not a failed transport. Keep
// authorization, uncertain effects, and unexpected storage errors fatal. // 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; || (error instanceof HttpError && [400, 404, 422].includes(error.status))))) throw error;
await this.binding.assertBridgeAuthority?.(); await this.binding.assertBridgeAuthority?.();
await this.#boundContext(); await this.#boundContext();
@@ -981,8 +1001,9 @@ export class PaperclipRunnerToolAuthority {
case "list_agents": case "list_agents":
return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) }; return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) };
case "get_agent": { case "get_agent": {
const actor = await agentService(this.db).getById(requiredString(input.actorId)); const actorId = requiredString(input.actorId);
if (!actor || actor.companyId !== this.binding.companyId) throw new Error("paperclip_runner_agent_not_found"); const actor = await agentService(this.db).getById(actorId);
if (!actor || actor.companyId !== this.binding.companyId) throw await this.#agentNotFound(actorId);
return { actor: redactedActor(actor) }; return { actor: redactedActor(actor) };
} }
case "list_approvals": case "list_approvals":
@@ -1880,7 +1901,7 @@ export class PaperclipRunnerToolAuthority {
const assigneeAgentId = assigneeUserId ? null : input.assigneeActorId == null ? this.binding.agentId : requiredString(input.assigneeActorId); const assigneeAgentId = assigneeUserId ? null : input.assigneeActorId == null ? this.binding.agentId : requiredString(input.assigneeActorId);
if (assigneeAgentId) { if (assigneeAgentId) {
const assignee = await agentService(this.db).getById(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" const priority = input.priority === "critical" || input.priority === "high"
|| input.priority === "medium" || input.priority === "low" || input.priority === "medium" || input.priority === "low"