From 988094c256d3502849758976f92ff313283643b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Nov 2026 19:58:14 -0600 Subject: [PATCH] fix(runner): return the edited agent's link and effect in instruction save receipts Completion feedback forbids guessing routes when no href was returned, and the instruction tools returned none, so chat replies after an instruction edit could not link the edited agent. Save and restore receipts now carry a resourceLinks entry with the agent's real profile href and state that runs starting after the save use the new instructions. Co-Authored-By: Claude Opus 5.5 --- ...agent-instruction-tools.integration.test.ts | 8 +++++++- .../native-runtime/agent-instruction-tools.ts | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts index 0b617ba3a17..3a870cfaff1 100644 --- a/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts +++ b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts @@ -59,6 +59,12 @@ describe("canonical instruction tools through native authority", () => { expect(first).toMatchObject({ entryFile, content: original }); const changed = await call("update_agent_instructions", { entryFile, content: "changed\r\n", baseRevisionId: first.revision.id }); expect(changed.revision).toMatchObject({ responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId }); + // The chat reply must not guess routes, so the receipt carries the edited + // agent's real profile href and when the saved instructions take effect. + const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)); + expect(changed.resourceLinks).toEqual([{ kind: "agent", id: targetAgentId, name: "Target", + href: `/${encodeURIComponent(company!.issuePrefix)}/agents/${targetAgentId}` }]); + expect(changed.appliesTo).toMatch(/start after this save/); expect((await call("get_agent_instruction_history", { entryFile })).revisions).toHaveLength(0); expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("changed\r\n"); await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 409 }); @@ -200,7 +206,7 @@ describe("canonical instruction tools through native authority", () => { await expect(authority.execute(request)).rejects.toThrow("injected lost commit acknowledgement"); const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); const receipt = (run!.resultJson as any).semanticToolReceipts[`instruction:${request.callId}`].result; - expect(receipt).toMatchObject({ changed: true, content }); + expect(receipt).toMatchObject({ changed: true, content, resourceLinks: [{ kind: "agent", id: targetAgentId, name: "Target" }] }); authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); expect(await authority.recover(request)).toEqual({ result: receipt }); expect(await authority.recover({ ...request, callId: randomUUID() })).toBeNull(); diff --git a/server/src/services/native-runtime/agent-instruction-tools.ts b/server/src/services/native-runtime/agent-instruction-tools.ts index 032e66699fa..7b6cbea1948 100644 --- a/server/src/services/native-runtime/agent-instruction-tools.ts +++ b/server/src/services/native-runtime/agent-instruction-tools.ts @@ -1,6 +1,7 @@ import type { AgentInstructionCommitReceipt } from "@paperclipai/shared"; import { z } from "zod"; -import type { Db } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; +import { agents, companies, type Db } from "@paperclipai/db"; import { badRequest, notFound } from "../../errors.js"; import { agentInstructionRevisionService } from "../agent-instruction-revisions.js"; import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js"; @@ -44,7 +45,7 @@ export async function executeAgentInstructionTool(input: { ...scope(targetAgentId), runId: input.binding.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash, }).catch(() => undefined); - return receipt; + return { ...receipt, ...(await savedAgentResource(input.db, scope(targetAgentId))) }; }; switch (input.tool) { case "read_agent_instructions": { @@ -74,3 +75,16 @@ export async function executeAgentInstructionTool(input: { } } } + +/** Runs snapshot instructions when their runtime context is built, so a save + * reaches only runs that start afterward. The chat reply needs a real profile + * href for the edited agent; completion guidance forbids guessing routes. */ +const INSTRUCTION_SAVE_EFFECT = "Runs that start after this save use these instructions. Runs already in progress keep the instructions they started with."; +async function savedAgentResource(db: Db, target: { companyId: string; agentId: string }) { + const [row] = await db.select({ name: agents.name, issuePrefix: companies.issuePrefix }).from(agents) + .innerJoin(companies, eq(companies.id, agents.companyId)) + .where(and(eq(agents.id, target.agentId), eq(agents.companyId, target.companyId))); + if (!row) return { appliesTo: INSTRUCTION_SAVE_EFFECT }; + const href = `/${encodeURIComponent(row.issuePrefix)}/agents/${encodeURIComponent(target.agentId)}`; + return { appliesTo: INSTRUCTION_SAVE_EFFECT, resourceLinks: [{ kind: "agent", id: target.agentId, name: row.name, href }] }; +}