mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## 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>
91 lines
5.9 KiB
TypeScript
91 lines
5.9 KiB
TypeScript
import type { AgentInstructionCommitReceipt } from "@paperclipai/shared";
|
|
import { z } from "zod";
|
|
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";
|
|
import { deriveBundleState } from "../agent-instructions.js";
|
|
import { agentService } from "../agents.js";
|
|
import type { AuthorizationActor } from "../authorization.js";
|
|
|
|
const target = { targetAgentId: z.string().uuid().optional() };
|
|
const entryFile = z.string().min(1).max(4096);
|
|
const baseRevisionId = z.string().uuid().nullable();
|
|
const schemas = {
|
|
read_agent_instructions: z.object({ ...target, entryFile: entryFile.optional(), revisionId: z.string().uuid().optional() }).strict()
|
|
.refine((input) => Boolean(input.entryFile) === Boolean(input.revisionId), "Historical reads require both entryFile and revisionId"),
|
|
update_agent_instructions: z.object({ ...target, entryFile, content: z.string().max(1024 * 1024), baseRevisionId }).strict(),
|
|
get_agent_instruction_history: z.object({ ...target, entryFile, cursor: z.string().min(1).max(2048).optional(), limit: z.number().int().min(1).max(100).optional() }).strict(),
|
|
restore_agent_instructions: z.object({ ...target, entryFile, revisionId: z.string().uuid(), baseRevisionId: z.string().uuid() }).strict(),
|
|
};
|
|
|
|
export type AgentInstructionToolName = keyof typeof schemas;
|
|
|
|
/** Called only after the native authority validates its server-bound active run. */
|
|
export async function executeAgentInstructionTool(input: {
|
|
db: Db;
|
|
binding: { companyId: string; agentId: string; runId: string };
|
|
tool: AgentInstructionToolName;
|
|
arguments: unknown;
|
|
}) {
|
|
// Never accept an actor, responsible user, company, or source run from tool JSON.
|
|
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.binding.companyId, agentId: input.binding.agentId, runId: input.binding.runId };
|
|
const service = agentInstructionRevisionService(input.db);
|
|
const parse = <T>(schema: z.ZodType<T>): T => {
|
|
const result = schema.safeParse(input.arguments);
|
|
if (!result.success) throw badRequest("Invalid instruction tool arguments", result.error.flatten());
|
|
return result.data;
|
|
};
|
|
const scope = (targetAgentId?: string) => ({ companyId: input.binding.companyId, agentId: targetAgentId ?? input.binding.agentId });
|
|
const acknowledge = async (receipt: AgentInstructionCommitReceipt, targetAgentId?: string) => {
|
|
// The commit is already durable. A failed baseline refresh keeps the old CAS
|
|
// fence, so later cleanup preserves a conflict instead of overwriting it.
|
|
await agentInstructionWorkingCopyService(input.db).acknowledgeExplicitSave({
|
|
...scope(targetAgentId), runId: input.binding.runId, entryFile: receipt.revision.entryFile,
|
|
revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash,
|
|
}).catch(() => undefined);
|
|
return { ...receipt, ...(await savedAgentResource(input.db, scope(targetAgentId))) };
|
|
};
|
|
switch (input.tool) {
|
|
case "read_agent_instructions": {
|
|
const args = parse(schemas.read_agent_instructions);
|
|
const target = scope(args.targetAgentId);
|
|
if (args.entryFile && args.revisionId) return service.readRevision({ ...target, entryFile: args.entryFile, revisionId: args.revisionId }, actor);
|
|
const snapshot = await service.readCurrent(target, actor);
|
|
if (snapshot) return { ...snapshot, entryFile: snapshot.revision.entryFile };
|
|
// An unseeded empty entry still needs its configured filename for the first CAS.
|
|
const agent = await agentService(input.db).getById(target.agentId);
|
|
if (!agent || agent.companyId !== target.companyId) throw notFound("Agent not found");
|
|
return { entryFile: deriveBundleState(agent).entryFile, revision: null, content: null };
|
|
}
|
|
case "update_agent_instructions": {
|
|
const args = parse(schemas.update_agent_instructions);
|
|
const receipt = await service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor);
|
|
return acknowledge(receipt, args.targetAgentId);
|
|
}
|
|
case "get_agent_instruction_history": {
|
|
const args = parse(schemas.get_agent_instruction_history);
|
|
return service.history({ ...scope(args.targetAgentId), entryFile: args.entryFile, cursor: args.cursor, limit: args.limit }, actor);
|
|
}
|
|
case "restore_agent_instructions": {
|
|
const args = parse(schemas.restore_agent_instructions);
|
|
const receipt = await service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor);
|
|
return acknowledge(receipt, args.targetAgentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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 }] };
|
|
}
|