mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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:
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"
|
||||||
|
|||||||
Reference in new issue
Block a user