fix(runner): return the edited agent's link and effect in instruction save receipts (#19940)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - In chat, a person can ask the CEO agent to change another agent's
saved instructions
> - The native instruction tools save the change, but their receipts
contain no link to the edited agent
> - Completion feedback tells the agent to use only hrefs that tools
returned and not to guess routes
> - So the agent can not link the agent it changed, and it has no
grounded fact about when the change applies
> - This pull request adds the edited agent's real profile href and the
save's effect to the save and restore receipts
> - The benefit is a chat reply that lets the person open the changed
agent and know which runs use the new rule

## Linked Issues or Issue Description

Refs #19344

**What happened**
A person asked in chat: "Add a rule for Morgan to run the relevant tests
before handing work back." The agent saved the rule correctly and kept
concurrent human guidance. The reply did not link Morgan and did not say
when the rule applies. The run's tool events show the cause:
`update_agent_instructions` returned the revision but no href, and the
accepted `paperclip_finish` feedback said "Do not guess routes or invent
links when no resource href was returned."

**Expected behavior**
After an instruction save or restore, the agent has a real profile href
for the edited agent and a statement of when the saved instructions take
effect. The reply links the agent and explains that later runs use the
change.

**Steps to reproduce**
1. In a chat with the CEO agent, ask it to add a rule to another agent's
instructions.
2. Let the agent call `update_agent_instructions`.
3. Read the tool receipt. It contains no href for the edited agent and
no effect statement.

## What Changed

- `update_agent_instructions` and `restore_agent_instructions` receipts
now include `resourceLinks` with the edited agent's company-scoped
profile href (`/<prefix>/agents/<agentId>`) and name.
- The same receipts include `appliesTo`. It states that runs that start
after the save use the new instructions and that runs in progress keep
their snapshot. This matches `materializeInstructionBundle`, which reads
the committed head when it builds the run context.
- The link uses the agent id, which is unique. It does not use the
name-derived URL key.

## Verification

- `cd server && pnpm exec vitest run
src/services/native-runtime/agent-instruction-tools.integration.test.ts
src/services/native-runtime/agent-instruction-tools.test.ts`: 14 passed.
- Without the source change, the new assertions fail in 3 tests (update
receipt and both lost-acknowledgement recovery receipts).
- The recovery test confirms that the persisted receipt, which recovery
replays, also carries the link.
- The end-to-end chat eval `agents.edit-instructions` runs this change
on its case branch.

## Risks

- Low risk. The change adds fields to a tool receipt. Existing fields do
not change.
- Receipts that were saved before this change replay without the new
fields. That is acceptable: the agent then has no link, as before.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## 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
- [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
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [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-07 21:11:18 -06:00
1 parent 196108acb9
commit 83dec71a8f
2 files changed
+23 -3

No files matched your search

@@ -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();
@@ -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 }] };
}