From 5774c98d5081592817c512aafc829e8fe688aec3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 27 Sep 2026 16:18:54 -0500 Subject: [PATCH] feat(runner): expose canonical instruction revision tools Add native read, update, history, and restore operations with server-bound identity and revision compare-and-swap safety. Register the default catalog and API discovery paths; cover real authority authorization and exact revision restore. Co-Authored-By: Paperclip --- doc/agent-instruction-revisions.md | 25 +++++ .../spec/capability/protocol-coverage.json | 78 +++++++++++++- .../src/catalog/reconciliation.test.ts | 14 ++- .../src/eval/workflow-evals.test.ts | 6 +- .../src/protocol-actions/core.ts | 9 ++ .../get-agent-instruction-history.ts | 99 +++++++++++++++++ .../src/protocol-actions/index.ts | 9 ++ .../read-agent-instructions.ts | 90 ++++++++++++++++ .../restore-agent-instructions.ts | 99 +++++++++++++++++ .../update-agent-instructions.ts | 102 ++++++++++++++++++ .../src/semantic-tools/discovery.ts | 7 +- .../src/semantic-tools/semantic-tools.test.ts | 2 +- .../src/semantic-tools/types.ts | 4 + ...gent-instruction-tools.integration.test.ts | 91 ++++++++++++++++ .../agent-instruction-tools.test.ts | 20 ++++ .../native-runtime/agent-instruction-tools.ts | 63 +++++++++++ .../paperclip-runner-tool-authority.test.ts | 2 +- .../paperclip-runner-tool-authority.ts | 9 ++ .../native-runtime/runner-api-catalog.ts | 4 + .../native-runtime/runner-api.test.ts | 4 + 20 files changed, 725 insertions(+), 12 deletions(-) create mode 100644 packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts create mode 100644 packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts create mode 100644 server/src/services/native-runtime/agent-instruction-tools.integration.test.ts create mode 100644 server/src/services/native-runtime/agent-instruction-tools.test.ts create mode 100644 server/src/services/native-runtime/agent-instruction-tools.ts diff --git a/doc/agent-instruction-revisions.md b/doc/agent-instruction-revisions.md index 126ce8727cf..cf925689982 100644 --- a/doc/agent-instruction-revisions.md +++ b/doc/agent-instruction-revisions.md @@ -162,3 +162,28 @@ conflicts/loss evidence, and arrange bounded recovery. They must not write share instruction caches or call the old filesystem `writeFile` for an entry. The next run should read the canonical snapshot and materialize it when disk is needed. Runtime cleanup parity is not established by these service/editor tests. + +## Dedicated native tools + +Ordinary native runs expose `read_agent_instructions`, +`update_agent_instructions`, `get_agent_instruction_history`, and +`restore_agent_instructions` by default. `targetAgentId` is optional and defaults +to the calling agent; another target must be in the same company. Read and +history are available in all task modes. Update and restore are available in +standard and planning modes, subject to the canonical service's current +responsible-user permissions, containment, and consent requirements. + +Read returns the configured `entryFile`, exact content, and current revision. +Retain that entry filename and revision ID when preparing an update. Both writes +require an explicit `baseRevisionId`; only update accepts null for a genuinely +new entry. Restore requires the existing current revision ID. +Historical reads take both `entryFile` and `revisionId`. History pages contain at +most 100 entries and include a continuation cursor. Restore appends a new +revision; it does not delete history. Tool JSON cannot supply company, caller, +run, responsible user, or source attribution. The native authority binds those +values from the active run, and the canonical service reloads authorization. + +A tool commit is immediately durable. If a run-local working copy still contains +older content, cleanup must preserve its conflict candidate rather than replace +the newer tool commit. A 409 response requires explicit conflict resolution; +never silently retry an old candidate against the latest revision. diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json index 8c72856beb7..2371a89f6df 100644 --- a/packages/paperclip-runner/spec/capability/protocol-coverage.json +++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json @@ -7,10 +7,86 @@ "src/scenarios/scenario-plan.ts" ], "counts": { - "actions": 48, + "actions": 52, "legacyRequirements": 106 }, "actions": [ + { + "id": "read_agent_instructions", + "ownership": "always_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:read_agent_instructions", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:read_agent_instructions" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, + { + "id": "update_agent_instructions", + "ownership": "always_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:update_agent_instructions", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:update_agent_instructions" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, + { + "id": "get_agent_instruction_history", + "ownership": "always_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:get_agent_instruction_history", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:get_agent_instruction_history" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, + { + "id": "restore_agent_instructions", + "ownership": "always_agent_tool", + "surfaces": [ + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:restore_agent_instructions", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:restore_agent_instructions" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, { "id": "create_skill", "ownership": "optional_agent_tool", diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 9ab2cdf7c8c..0f69be379c1 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -20,21 +20,25 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("pins the reconciled op-set relationship between the two catalogs", () => { const summary = capabilityCatalogReconciliation(); expect(summary.scenarioCount).toBe(39); - expect(summary.liveCount).toBe(36); + expect(summary.liveCount).toBe(40); expect(summary.sharedCount).toBe(27); - expect(summary.unionCount).toBe(48); + expect(summary.unionCount).toBe(52); // Any operation added to or removed from either catalog without a // reconciliation decision changes these exact sets and fails the gate. expect(summary.liveOnly).toEqual([ "call_api", "create_project", "get_agent", + "get_agent_instruction_history", "get_approval", "get_approval_context", "hire_agent", "list_project_repositories", + "read_agent_instructions", + "restore_agent_instructions", "schedule_wake", "search_api", + "update_agent_instructions", ]); expect(summary.scenarioOnly).toEqual([ "administer_company", @@ -53,7 +57,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("is the single source both catalogs derive their operation set from", () => { - expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(48); + expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(52); const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId)); // Neither catalog may contain an operation absent from the canonical source. for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true); @@ -87,7 +91,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => { - expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(48); + expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(52); for (const operation of CAPABILITY_CANONICAL_CATALOG) { expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/); expect(Array.isArray(operation.requiredClaims)).toBe(true); @@ -113,7 +117,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("classifies real binding status so generic_api_request is never product coverage", () => { const summary = capabilityCatalogReconciliation(); expect(summary.byRealBindingStatus).toEqual({ - live_codex: 35, + live_codex: 39, scenario_mock: 12, test_only: 1, }); diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts index 8deb3154b18..14cbe7199c0 100644 --- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts +++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts @@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => { candidateFailures: 36, }); expect(report.coverage).toMatchObject({ - canonicalOperations: 48, + canonicalOperations: 52, capabilityCases: 106, workflows: 12, stressFindings: 44, stressExclusions: 1, }); - expect(report.coverage.operations).toHaveLength(48); + expect(report.coverage.operations).toHaveLength(52); expect(report.coverage.composedWorkflows).toHaveLength(12); expect( report.coverage.operations.find( @@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => { )?.workflowIds.length, ).toBeGreaterThan(0); expect(renderRunnerWorkflowMarkdown(report)).toContain( - "48 operations · 106 capability cases · 12 workflows", + "52 operations · 106 capability cases · 12 workflows", ); expect(renderRunnerWorkflowJUnit(report)).toContain( 'tests="36" failures="36" skipped="0"', diff --git a/packages/paperclip-runner/src/protocol-actions/core.ts b/packages/paperclip-runner/src/protocol-actions/core.ts index ee195815279..8e0a5695dac 100644 --- a/packages/paperclip-runner/src/protocol-actions/core.ts +++ b/packages/paperclip-runner/src/protocol-actions/core.ts @@ -1,3 +1,7 @@ +import { readAgentInstructionsAction } from "./read-agent-instructions.js"; +import { updateAgentInstructionsAction } from "./update-agent-instructions.js"; +import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js"; +import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js"; import { answerStatusQuestionAction } from "./answer-status-question.js"; import { blockTaskAction } from "./block-task.js"; import { finishTaskAction } from "./finish-task.js"; @@ -16,6 +20,11 @@ import { deepFreezeProtocolAction } from "./freeze.js"; /** Core actions that are always present in an authorized runner projection. */ export const PAPERCLIP_CORE_PROTOCOL_ACTIONS = deepFreezeProtocolAction([ + readAgentInstructionsAction, + updateAgentInstructionsAction, + getAgentInstructionHistoryAction, + restoreAgentInstructionsAction, + answerStatusQuestionAction, blockTaskAction, finishTaskAction, diff --git a/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts b/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts new file mode 100644 index 00000000000..11046457e0b --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts @@ -0,0 +1,99 @@ +/** Canonical definition for `get_agent_instruction_history`. */ +export const getAgentInstructionHistoryAction = { + "id": "get_agent_instruction_history", + "canonical": { + "operationId": "get_agent_instruction_history", + "surfaces": [ + "live" + ], + "placement": "always_agent_tool", + "optionalGroup": null, + "requiredClaims": [], + "taskModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "sideEffectClass": "read", + "idempotency": "none", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "agentInstructionRevisionService", + "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage", + "prpBindingStatus": "bound", + "legacyAliases": [], + "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation." + }, + "documentation": { + "title": "Get agent instruction history", + "description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.", + "note": null + }, + "examples": { + "call": { + "operationId": "get_agent_instruction_history", + "input": { + "entryFile": "AGENTS.md" + } + }, + "success": { + "ok": true, + "operationId": "get_agent_instruction_history", + "result": {} + } + }, + "live": { + "order": 42, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "get_agent_instruction_history", + "version": 1, + "title": "Get agent instruction history", + "description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.", + "exposure": "always", + "requiredClaims": [], + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": { + "targetAgentId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "description": "Same-company target agent. Omit to use the calling agent." + }, + "entryFile": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision." + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "entryFile" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + } + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts index 8f07df6695d..4dcfcc66b0e 100644 --- a/packages/paperclip-runner/src/protocol-actions/index.ts +++ b/packages/paperclip-runner/src/protocol-actions/index.ts @@ -1,3 +1,7 @@ +import { readAgentInstructionsAction } from "./read-agent-instructions.js"; +import { updateAgentInstructionsAction } from "./update-agent-instructions.js"; +import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js"; +import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js"; import { reassignTaskAction } from "./reassign-task.js"; import { createSkillAction } from "./create-skill.js"; import { createProjectAction } from "./create-project.js"; @@ -49,6 +53,11 @@ import { writeDocumentAction } from "./write-document.js"; import { deepFreezeProtocolAction } from "./freeze.js"; export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([ + readAgentInstructionsAction, + updateAgentInstructionsAction, + getAgentInstructionHistoryAction, + restoreAgentInstructionsAction, + createSkillAction, createProjectAction, listProjectRepositoriesAction, diff --git a/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts new file mode 100644 index 00000000000..9a947d1a2cb --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts @@ -0,0 +1,90 @@ +/** Canonical definition for `read_agent_instructions`. */ +export const readAgentInstructionsAction = { + "id": "read_agent_instructions", + "canonical": { + "operationId": "read_agent_instructions", + "surfaces": [ + "live" + ], + "placement": "always_agent_tool", + "optionalGroup": null, + "requiredClaims": [], + "taskModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "sideEffectClass": "read", + "idempotency": "none", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "agentInstructionRevisionService", + "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage", + "prpBindingStatus": "bound", + "legacyAliases": [], + "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation." + }, + "documentation": { + "title": "Read canonical agent instructions", + "description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.", + "note": null + }, + "examples": { + "call": { + "operationId": "read_agent_instructions", + "input": {} + }, + "success": { + "ok": true, + "operationId": "read_agent_instructions", + "result": {} + } + }, + "live": { + "order": 40, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "read_agent_instructions", + "version": 1, + "title": "Read canonical agent instructions", + "description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.", + "exposure": "always", + "requiredClaims": [], + "allowedModes": [ + "standard", + "ask", + "planning", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": { + "targetAgentId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "description": "Same-company target agent. Omit to use the calling agent." + }, + "entryFile": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision." + }, + "revisionId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "description": "Historical revision to inspect; also provide its entryFile." + } + }, + "required": [], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + } + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts new file mode 100644 index 00000000000..464a556f410 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts @@ -0,0 +1,99 @@ +/** Canonical definition for `restore_agent_instructions`. */ +export const restoreAgentInstructionsAction = { + "id": "restore_agent_instructions", + "canonical": { + "operationId": "restore_agent_instructions", + "surfaces": [ + "live" + ], + "placement": "always_agent_tool", + "optionalGroup": null, + "requiredClaims": [], + "taskModes": [ + "standard", + "planning" + ], + "sideEffectClass": "company_write", + "idempotency": "recommended", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "agentInstructionRevisionService", + "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage", + "prpBindingStatus": "bound", + "legacyAliases": [], + "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation." + }, + "documentation": { + "title": "Restore canonical agent instructions", + "description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.", + "note": null + }, + "examples": { + "call": { + "operationId": "restore_agent_instructions", + "input": { + "entryFile": "AGENTS.md", + "revisionId": "00000000-0000-4000-8000-000000000001", + "baseRevisionId": "00000000-0000-4000-8000-000000000002" + } + }, + "success": { + "ok": true, + "operationId": "restore_agent_instructions", + "result": {} + } + }, + "live": { + "order": 43, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "restore_agent_instructions", + "version": 1, + "title": "Restore canonical agent instructions", + "description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.", + "exposure": "always", + "requiredClaims": [], + "allowedModes": [ + "standard", + "planning" + ], + "inputSchema": { + "type": "object", + "properties": { + "targetAgentId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "description": "Same-company target agent. Omit to use the calling agent." + }, + "entryFile": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision." + }, + "revisionId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "description": "Historical revision whose exact content should be restored." + }, + "baseRevisionId": { + "type": "string", + "description": "Current revision read before restoring. Never replace a stale base silently.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + } + }, + "required": [ + "entryFile", + "revisionId", + "baseRevisionId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + } + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts new file mode 100644 index 00000000000..a42356b6ca3 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts @@ -0,0 +1,102 @@ +/** Canonical definition for `update_agent_instructions`. */ +export const updateAgentInstructionsAction = { + "id": "update_agent_instructions", + "canonical": { + "operationId": "update_agent_instructions", + "surfaces": [ + "live" + ], + "placement": "always_agent_tool", + "optionalGroup": null, + "requiredClaims": [], + "taskModes": [ + "standard", + "planning" + ], + "sideEffectClass": "company_write", + "idempotency": "recommended", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "agentInstructionRevisionService", + "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage", + "prpBindingStatus": "bound", + "legacyAliases": [], + "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation." + }, + "documentation": { + "title": "Update canonical agent instructions", + "description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.", + "note": null + }, + "examples": { + "call": { + "operationId": "update_agent_instructions", + "input": { + "entryFile": "AGENTS.md", + "content": "# Instructions\n", + "baseRevisionId": null + } + }, + "success": { + "ok": true, + "operationId": "update_agent_instructions", + "result": {} + } + }, + "live": { + "order": 41, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "update_agent_instructions", + "version": 1, + "title": "Update canonical agent instructions", + "description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.", + "exposure": "always", + "requiredClaims": [], + "allowedModes": [ + "standard", + "planning" + ], + "inputSchema": { + "type": "object", + "properties": { + "targetAgentId": { + "type": "string", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "description": "Same-company target agent. Omit to use the calling agent." + }, + "entryFile": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision." + }, + "content": { + "type": "string", + "maxLength": 1048576, + "description": "Complete UTF-8 instruction content, at most 1 MiB; empty content is valid." + }, + "baseRevisionId": { + "type": [ + "string", + "null" + ], + "description": "Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.", + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + } + }, + "required": [ + "entryFile", + "content", + "baseRevisionId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + } + } + }, + "scenario": null +} as const; diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts index d1bf2bd4335..18c7644c760 100644 --- a/packages/paperclip-runner/src/semantic-tools/discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -27,7 +27,12 @@ const NAMESPACE: Readonly> = Objec report_progress: "active_task", answer_status_question: "active_task", write_document: "documents", request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task", block_task: "active_task", request_review: "active_task", search_tasks: "discovery", - list_agents: "discovery", get_agent: "discovery", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", + list_agents: "discovery", get_agent: "discovery", + read_agent_instructions: "agent_instructions", + update_agent_instructions: "agent_instructions", + get_agent_instruction_history: "agent_instructions", + restore_agent_instructions: "agent_instructions", + create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance", get_approval_context: "governance", request_approval: "governance", decide_approval: "governance", comment_on_approval: "governance", diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index af1344a2984..1e6ebc1bb50 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => { it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => { const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId); expect(new Set(names).size).toBe(names.length); - expect(names).toHaveLength(36); + expect(names).toHaveLength(40); expect(names).toContain("get_task_context"); expect(names).toContain("finish_task"); expect(names).not.toContain("checkout_task"); diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts index ea9339edf8d..31178d2690a 100644 --- a/packages/paperclip-runner/src/semantic-tools/types.ts +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -33,6 +33,10 @@ export type CapabilitySemanticOperationId = | "request_review" | "list_agents" | "get_agent" + | "read_agent_instructions" + | "update_agent_instructions" + | "get_agent_instruction_history" + | "restore_agent_instructions" | "search_tasks" | "list_approvals" | "get_approval" 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 new file mode 100644 index 00000000000..ac8b61a70a9 --- /dev/null +++ b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { agents, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, issues, agentInstructionRevisions, createDb } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { resolveManagedInstructionsRoot } from "../agent-instructions.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +describe("canonical instruction tools through native authority", () => { + let database: Awaited>; + let db: ReturnType; + let home: string; + const previousHome = process.env.PAPERCLIP_HOME; + let companyId: string, agentId: string, targetAgentId: string, userId: string, runId: string, issueId: string, root: string; + let authority: PaperclipRunnerToolAuthority; + const entryFile = "policy/ENTRY.md"; + const original = "\uFEFF# Original\r\n\0☃\n"; + beforeAll(async () => { + home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-tool-home-"))); + process.env.PAPERCLIP_HOME = home; + database = await startEmbeddedPostgresTestDatabase("instruction-tool-db-"); + db = createDb(database.connectionString); + }, 90_000); + afterAll(async () => { + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome; + await database?.cleanup(); + if (home) await fs.rm(home, { recursive: true, force: true }); + }); + beforeEach(async () => { + [companyId, agentId, targetAgentId, userId, runId, issueId] = Array.from({ length: 6 }, () => randomUUID()); + await db.insert(companies).values({ id: companyId, name: "Tool scope", issuePrefix: randomUUID().slice(0, 8) }); + await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + root = resolveManagedInstructionsRoot({ id: targetAgentId, companyId, name: "Target", adapterConfig: {} }); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Caller", status: "active" }, + { id: targetAgentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } }, + ]); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "operator" }, + { companyId, principalType: "agent", principalId: agentId, membershipRole: "member" }, + ]); + await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [targetAgentId] } }); + await db.insert(issues).values({ id: issueId, companyId, title: "Maintain instructions", status: "in_progress", workMode: "standard", assigneeAgentId: agentId }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "on_demand", responsibleUserId: userId }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true }); + await fs.writeFile(path.join(root, entryFile), original); + }); + const call = (tool: string, args: Record = {}) => authority.execute({ tool, callId: randomUUID(), arguments: { targetAgentId, ...args } }) as Promise; + it("reads, commits, lists, inspects, and restores exact content with server provenance", async () => { + const first = await call("read_agent_instructions"); + 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({ source: "tool", responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId }); + const history = await call("get_agent_instruction_history", { entryFile, limit: 1 }); + expect(history.revisions.map((revision: { id: string }) => revision.id)).toEqual([changed.revision.id]); + expect(history.nextCursor).toEqual(expect.any(String)); + expect(await call("read_agent_instructions", { entryFile, revisionId: first.revision.id })).toMatchObject({ content: original }); + const restored = await call("restore_agent_instructions", { entryFile, revisionId: first.revision.id, baseRevisionId: changed.revision.id }); + expect(restored.revision).toMatchObject({ source: "restore", restoredFromRevisionId: first.revision.id }); + expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original); + await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: changed.revision.id })).rejects.toMatchObject({ status: 409 }); + expect((await call("read_agent_instructions")).content).toBe(original); + }); + it("rejects supplied identity and missing CAS bases before changing content", async () => { + const first = await call("read_agent_instructions"); + for (const identity of ["companyId", "agentId", "runId", "responsibleUserId", "onBehalfOfUserId", "actor"]) { + await expect(call("update_agent_instructions", { entryFile, content: "forged", baseRevisionId: first.revision.id, [identity]: randomUUID() })).rejects.toMatchObject({ status: 400 }); + } + await expect(call("update_agent_instructions", { entryFile, content: "missing base" })).rejects.toMatchObject({ status: 400 }); + expect((await call("read_agent_instructions")).content).toBe(original); + expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, targetAgentId))).toHaveLength(1); + }); + it("rechecks responsible-user access and rejects a cross-company target", async () => { + const first = await call("read_agent_instructions"); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); + await expect(call("update_agent_instructions", { entryFile, content: "revoked", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 403 }); + const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID(); + await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: randomUUID().slice(0, 8) }); + await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign" }); + await expect(call("read_agent_instructions", { targetAgentId: foreignAgentId })).rejects.toMatchObject({ status: 404 }); + await expect(call("update_agent_instructions", { targetAgentId: foreignAgentId, entryFile, content: "foreign", baseRevisionId: null })).rejects.toMatchObject({ status: 404 }); + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId)); + await expect(call("read_agent_instructions")).rejects.toThrow("paperclip_runner_tool_binding_not_authorized"); + expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original); + }); +}); diff --git a/server/src/services/native-runtime/agent-instruction-tools.test.ts b/server/src/services/native-runtime/agent-instruction-tools.test.ts new file mode 100644 index 00000000000..619d05a9f6b --- /dev/null +++ b/server/src/services/native-runtime/agent-instruction-tools.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +const binding = { companyId: "00000000-0000-4000-8000-000000000001", agentId: "00000000-0000-4000-8000-000000000002", issueId: "00000000-0000-4000-8000-000000000003", runId: "00000000-0000-4000-8000-000000000004" }; + +describe("dedicated canonical instruction tools", () => { + it("advertises read, CAS update, history, and restore on ordinary native runs", () => { + const authority = new PaperclipRunnerToolAuthority({} as Db, binding); + const tools = authority.definitions(); + expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([ + "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions", + ])); + for (const name of ["update_agent_instructions", "restore_agent_instructions"]) { + const tool = tools.find((candidate) => candidate.name === name)!; + expect(tool.inputSchema).toMatchObject({ required: expect.arrayContaining(["baseRevisionId"]), additionalProperties: false }); + expect(tool.inputSchema).not.toHaveProperty("properties.responsibleUserId"); + } + }); +}); diff --git a/server/src/services/native-runtime/agent-instruction-tools.ts b/server/src/services/native-runtime/agent-instruction-tools.ts new file mode 100644 index 00000000000..46cee4fcb55 --- /dev/null +++ b/server/src/services/native-runtime/agent-instruction-tools.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import type { Db } from "@paperclipai/db"; +import { badRequest, notFound } from "../../errors.js"; +import { agentInstructionRevisionService } from "../agent-instruction-revisions.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 = (schema: z.ZodType): 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 }); + 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); + return service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor); + } + 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); + return service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor); + } + } +} diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 5b1c58b5ad6..6b5e6f0d33f 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => { issueId, runId, }); - expect(authority.definitions()).toHaveLength(30); + expect(authority.definitions()).toHaveLength(34); const questions = authority.definitions().find(tool => tool.name === "request_human_input")!; expect(questions.description).toContain("ask only the next unanswered question"); expect(questions.description).toContain("Never infer answers"); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 82093f10255..206508c5b52 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,3 +1,4 @@ +import { executeAgentInstructionTool } from "./agent-instruction-tools.js"; import { publicChatTaskUrl } from "../chat-task-url.js"; import type { createAssignedMcpTools } from "./assigned-mcp-tools.js"; import { assertAssignableAgent } from "../agent-assignability.js"; @@ -79,6 +80,7 @@ import { } from "./chat-attachment-read.js"; const IMPLEMENTED_OPERATIONS = new Set([ + "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions", "search_api", "call_api", "hire_agent", "get_task_context", "get_task_history", "search_tasks", "report_progress", "request_human_input", @@ -364,6 +366,13 @@ export class PaperclipRunnerToolAuthority { throw new Error("paperclip_runner_tool_mode_denied"); } switch (call.tool) { + case "read_agent_instructions": + case "update_agent_instructions": + case "get_agent_instruction_history": + case "restore_agent_instructions": + return executeAgentInstructionTool({ db: this.db, binding: { + companyId: this.binding.companyId, agentId: this.binding.agentId, runId: this.binding.runId, + }, tool: call.tool, arguments: call.arguments }); case "create_skill": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index d8f8fd7d79f..85da534726f 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -42,6 +42,10 @@ function words(text: string): string[] { } function dedicatedTools(method: string, path: string): string[] { + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : []; + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"]; + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"]; + if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"]; if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"]; if (/\/companies\/\{[^}]+\}\/agent-hires$/.test(path) && method === "POST") return ["hire_agent"]; if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : []; diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index 047bf1a9248..0fd60a74ddf 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -52,6 +52,10 @@ describe("runner API catalog", () => { expect(runnerApiOperation(createProject).dedicatedTools).toEqual(["create_project"]); expect(runnerApiOperation(projects).dedicatedTools).toEqual(["list_projects"]); expect(runnerApiOperation("GET /api/companies/{companyId}/project-repositories").dedicatedTools).toEqual(["list_project_repositories"]); + expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["read_agent_instructions"]); + expect(runnerApiOperation("PUT /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["update_agent_instructions"]); + expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/history").dedicatedTools).toEqual(["get_agent_instruction_history"]); + expect(runnerApiOperation("POST /api/agents/{id}/instructions-bundle/restore").dedicatedTools).toEqual(["restore_agent_instructions"]); expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedTools).toEqual(["hire_agent"]); expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedToolGuidance).toContain("inherits the caller's native runtime"); });