mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 05:17:41 +02:00
feat(skills): let agents update company skills safely (#15049)
## Thinking Path > - Paperclip is an open source control plane for AI-agent companies. > - Company skills give agents reusable work instructions. > - Skill Studio can edit skill files and save version history. > - Agents can create a skill, but they do not have a first-class update tool. > - An agent update needs a version check and safe retry behavior to prevent lost edits. > - This pull request adds `update_skill` through the existing company skill file API. > - The change keeps company policy, version history, and audit records in one path. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: the server API, shared validation, and runner tool catalog. **Problem or motivation** An agent can create a company skill but cannot update its `SKILL.md` through a first-class tool. An unguarded retry can also create duplicate versions or overwrite a newer edit. **Proposed solution** Add `update_skill` with a required current version ID and a retry key. Route it through the existing skill file API. Reject stale versions and changed-input retries. Save the version and audit event together. **Alternatives considered** A separate write endpoint would duplicate the Skill Studio mutation path and policy checks. This PR reuses that path instead. **Roadmap alignment** This work extends Skills Manager and Skill Studio, which are listed in `ROADMAP.md`. **Additional context** The tool accepts a complete `SKILL.md`, not a partial patch. Callers must read the current version before they edit it. ## What Changed - Add optional version and retry fields to the existing skill file update contract. - Add a guarded API update with a stable retry receipt and attributed audit event. - Add `update_skill` to native and semantic runner tool catalogs, with mode and policy gates. - Add unit, integration, protocol, and semantic-tool regression coverage. - Document agent use and extend the OpenAPI request contract. ## Verification - Focused tests and direct server and runner TypeScript checks passed before this PR. - `git diff --check` passed after the rebase onto `master`. - CI passed on the latest PR head, including the full test matrix, typecheck, and build. Local full typecheck and build stopped because `cargo` is not installed. The local full test run ended without a verdict. - No dedicated end-to-end eval scenario was added or run. The protocol coverage and semantic-tool test cover the new action deterministically. - Reviewers can read a skill version, call `update_skill`, repeat the same key, then try a stale version and a changed-input key. Only the first edit must create a new version. ## Risks - File writes and database transactions must stay in sync when a write fails. The integration tests cover failed writes and retry behavior, but CI must verify them on the PR head. - Existing Skill Studio callers do not send the new optional coordination fields. Their request shape remains valid. ## Model Used - OpenAI Codex CLI assisted with this change. The runner did not expose the exact model ID or context window. The agent used code execution and repository tools. ## 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; exact model ID and context window were not exposed) - [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 (focused tests; full suite is pending CI) - [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 - [x] All Paperclip CI gates are green (53 pass, 4 skip on the latest head) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b17019e14d
commit
eb049aebf2
32 files changed
+481
-72
No files matched your search
@@ -4,6 +4,27 @@ The native Runner exposes `create_skill` in Auto and skill-test work. An agent
|
||||
can save a reusable, single-file skill directly in the company's skill library.
|
||||
The tool is unavailable in Ask and pre-acceptance Plan modes.
|
||||
|
||||
The native Runner also exposes `update_skill` in the same modes. Read the
|
||||
existing skill and its `currentVersionId` through the company skill API before
|
||||
editing. Supply the **complete** primary file, not a fragment:
|
||||
|
||||
```json
|
||||
{
|
||||
"skillId": "<existing skill UUID>",
|
||||
"expectedVersionId": "<current version UUID>",
|
||||
"markdown": "---\nname: release-review\ndescription: Review release notes.\n---\n\n# Review\nCheck each release note and its tests.\n",
|
||||
"idempotencyKey": "review-update-1"
|
||||
}
|
||||
```
|
||||
|
||||
The tool uses the existing Skill Studio file endpoint and company `skills.edit`
|
||||
policy. It returns a skill ID, path, resulting version ID, and Skill Studio
|
||||
path. Retry a lost response with **exactly** the same key and inputs; that
|
||||
returns the original receipt without another version or activity record.
|
||||
Reusing the key with different inputs, or updating after the version changes,
|
||||
returns a conflict. After a version conflict, reread the skill and choose a
|
||||
new key for the revised edit. Policy is checked again even on exact retries.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "release-review",
|
||||
|
||||
@@ -26,7 +26,7 @@ list, not a disposition — they are never exposed as tools. See
|
||||
[capability disposition](capability-disposition.md) and
|
||||
[authorization and exposure](capability-authorization-and-exposure.md).
|
||||
|
||||
The catalog holds **39 tools**: 14 always-agent tools and 25 optional tools
|
||||
The catalog holds **40 tools**: 14 always-agent tools and 26 optional tools
|
||||
across 10 groups.
|
||||
|
||||
<!-- These counts are drift-checked against src/tools/capability-semantic-tool-catalog.ts
|
||||
@@ -39,7 +39,7 @@ by src/catalog/catalog-docs.test.ts; update the catalog, not the numbers. -->
|
||||
`finish_task`, `block_task`, `request_review`, `write_document`,
|
||||
`request_human_input`, `register_deliverable`, `inspect_operation_result`.
|
||||
|
||||
### Optional tools (25), by group
|
||||
### Optional tools (26), by group
|
||||
|
||||
| Group | Tools |
|
||||
| --- | --- |
|
||||
@@ -49,7 +49,7 @@ by src/catalog/catalog-docs.test.ts; update the catalog, not the numbers. -->
|
||||
| cases | `list_cases`, `upsert_case` |
|
||||
| workspace_runtime | `get_workspace_runtime`, `control_workspace_service` |
|
||||
| routines | `list_routines`, `manage_routine` |
|
||||
| company_skills | `create_skill`, `list_company_skills`, `sync_company_skills` |
|
||||
| company_skills | `create_skill`, `update_skill`, `list_company_skills`, `sync_company_skills` |
|
||||
| secrets | `list_secret_metadata`, `read_secret_value` |
|
||||
| portability_admin | `export_company`, `administer_company` |
|
||||
| test_escape_hatch | `generic_api_request` |
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:81ed59a7fa4ceb6e0f28dffabf7a7ae7bdfa9e23e6bb643a14612368262abfe3",
|
||||
"catalogSha256": "sha256:1dd4904ba9ae5c2b6bb0cbd4cfef65734d855bb5ed10ed05c70b603b8589bec3",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "a5fde52c41fe77a8872ec1d1613ba9bb426b9231835e1464d943930ab799d933",
|
||||
"sha256": "b4e488f922dd328a3967b2560951c2377f4c1411fb2c4c9eef7a44195a8f7514",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/scenarios/scenario-plan.ts"
|
||||
],
|
||||
"counts": {
|
||||
"actions": 53,
|
||||
"actions": 54,
|
||||
"legacyRequirements": 106
|
||||
},
|
||||
"actions": [
|
||||
@@ -126,6 +126,26 @@
|
||||
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "update_skill",
|
||||
"ownership": "optional_agent_tool",
|
||||
"surfaces": [
|
||||
"scenario",
|
||||
"live"
|
||||
],
|
||||
"legacyAliases": [],
|
||||
"contractCase": "protocol-action:update_skill",
|
||||
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::update_skill has a schema-valid canonical example and every declared projection",
|
||||
"legacyBehavioralCases": [],
|
||||
"deterministicCases": [
|
||||
"protocol-action:update_skill"
|
||||
],
|
||||
"legacyRequirementCases": [],
|
||||
"deterministicOwners": [
|
||||
"src/catalog/protocol-action-contracts.test.ts::update_skill 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_project",
|
||||
"ownership": "optional_agent_tool",
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
},
|
||||
{
|
||||
"id": "company_skills",
|
||||
"description": "Create or inspect company skills and synchronize the current agent's skills.",
|
||||
"operationIds": ["create_skill", "list_company_skills", "sync_company_skills"]
|
||||
"description": "Create, update, or inspect company skills and synchronize the current agent's skills.",
|
||||
"operationIds": ["create_skill", "update_skill", "list_company_skills", "sync_company_skills"]
|
||||
},
|
||||
{
|
||||
"id": "secrets",
|
||||
@@ -264,7 +264,7 @@
|
||||
"legacyGroup": 13,
|
||||
"name": "Reference files",
|
||||
"owner": "always instruction tools + optional domain tools + test-only escape hatch",
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "create_skill", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project", "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions"],
|
||||
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "create_skill", "update_skill", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project", "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions"],
|
||||
"controlPlaneOperationIds": ["append_audit_record"],
|
||||
"realSurface": "agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services",
|
||||
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
|
||||
|
||||
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
|
||||
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **53 reconciled semantic operations** (18 always, 35 optional), and **106 scenarios in 16 behavior groups**.
|
||||
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **54 reconciled semantic operations** (18 always, 36 optional), and **106 scenarios in 16 behavior groups**.
|
||||
|
||||
## Axis 1: PRP v1 event and command families
|
||||
|
||||
@@ -87,7 +87,7 @@ Placement has exactly three outcomes:
|
||||
|
||||
`answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`.
|
||||
|
||||
### Optional operations (35) and grant groups (13)
|
||||
### Optional operations (36) and grant groups (13)
|
||||
|
||||
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
|
||||
|
||||
@@ -101,7 +101,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
||||
| `workspace_runtime` | `get_workspace_runtime`<br>`control_workspace_service` | `workspace:control`<br>`workspace:read` | Inspect and control the active issue workspace runtime. |
|
||||
| `wake_scheduling` | `schedule_wake` | `control_plane:wakes` | Schedule a bounded continuation wake when the current task owns the future check. |
|
||||
| `routines` | `list_routines`<br>`manage_routine` | `routines:read`<br>`routines:write` | Inspect or manage company routines. |
|
||||
| `company_skills` | `create_skill`<br>`list_company_skills`<br>`sync_company_skills` | `company_skills:read`<br>`company_skills:write` | Create or inspect company skills and synchronize the current agent's skills. |
|
||||
| `company_skills` | `create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills` | `company_skills:read`<br>`company_skills:write` | Create, update, or inspect company skills and synchronize the current agent's skills. |
|
||||
| `secrets` | `list_secret_metadata`<br>`read_secret_value` | `secrets:metadata:read`<br>`secrets:values:read` | Inspect secret metadata or use a brokered secret value without exposing plaintext evidence. |
|
||||
| `portability_admin` | `export_company`<br>`administer_company` | `company:admin`<br>`portability:export` | Export portable company state; broad administration remains deferred until split into governed operations. |
|
||||
| `test_escape_hatch` | `generic_api_request` | `test:generic_api_request` | Controlled skill-test transport only; never product coverage. |
|
||||
@@ -164,6 +164,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
|
||||
| `set_task_title` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `setIssueTitle`<br>Run-bound title update and transactional activity record.<br>catalog PRP status: `bound` |
|
||||
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `update_agent_instructions` | `always_agent_tool` | none | `standard`<br>`planning` | `company_write` | `recommended` | no | inline/no mapping | `live`<br>`live_codex` | `agentInstructionRevisionService`<br>tool-result item event plus canonical revision receipt for writes; no scenario mock coverage<br>catalog PRP status: `bound` |
|
||||
| `update_skill` | `optional_agent_tool` | none | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:update_skill` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated skill file API with version guard, durable receipt, and attributed activity.<br>catalog PRP status: `bound` |
|
||||
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard`<br>`skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario`<br>`scenario_mock` | `unbound`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `audit_pending` |
|
||||
| `write_document` | `always_agent_tool` | none | `standard`<br>`planning`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
|
||||
|
||||
@@ -187,7 +188,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
|
||||
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
|
||||
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
|
||||
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project`<br>`read_agent_instructions`<br>`update_agent_instructions`<br>`get_agent_instruction_history`<br>`restore_agent_instructions` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`create_skill`<br>`update_skill`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project`<br>`read_agent_instructions`<br>`update_agent_instructions`<br>`get_agent_instruction_history`<br>`restore_agent_instructions` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
|
||||
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
|
||||
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
|
||||
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
|
||||
@@ -463,9 +464,9 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
|
||||
|
||||
### Catalog split and deliberate replacement
|
||||
|
||||
- Scenario/eval catalog: **39** operations.
|
||||
- Live dispatcher catalog: **41** operations.
|
||||
- Shared: **27**; union/canonical authority: **53**.
|
||||
- Scenario/eval catalog: **40** operations.
|
||||
- Live dispatcher catalog: **42** operations.
|
||||
- Shared: **28**; union/canonical authority: **54**.
|
||||
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
|
||||
- Live-only: `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`, `set_task_title`, `update_agent_instructions`.
|
||||
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("capability-semantic-tools.md stays in sync with the catalog", () => {
|
||||
expect(Number(alwaysHeader)).toBe(always.length);
|
||||
expect(Number(optionalHeader)).toBe(optional.length);
|
||||
// Guard against the historical 36-vs-37 drift the reconciliation plan flagged.
|
||||
expect(SCENARIO_CATALOG.length).toBe(39);
|
||||
expect(SCENARIO_CATALOG.length).toBe(40);
|
||||
});
|
||||
|
||||
it("lists exactly the catalog's operation ids in the counts region", () => {
|
||||
|
||||
@@ -19,10 +19,10 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..")
|
||||
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(41);
|
||||
expect(summary.sharedCount).toBe(27);
|
||||
expect(summary.unionCount).toBe(53);
|
||||
expect(summary.scenarioCount).toBe(40);
|
||||
expect(summary.liveCount).toBe(42);
|
||||
expect(summary.sharedCount).toBe(28);
|
||||
expect(summary.unionCount).toBe(54);
|
||||
// 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([
|
||||
@@ -58,7 +58,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
|
||||
});
|
||||
|
||||
it("is the single source both catalogs derive their operation set from", () => {
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(53);
|
||||
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(54);
|
||||
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);
|
||||
@@ -92,7 +92,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(53);
|
||||
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(54);
|
||||
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
|
||||
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
|
||||
expect(Array.isArray(operation.requiredClaims)).toBe(true);
|
||||
@@ -118,7 +118,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: 40,
|
||||
live_codex: 41,
|
||||
scenario_mock: 12,
|
||||
test_only: 1,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export type PaperclipSemanticActionId =
|
||||
| "reassign_task"
|
||||
| "set_dependencies"
|
||||
| "create_skill"
|
||||
| "update_skill"
|
||||
| "create_project"
|
||||
| "list_project_repositories"
|
||||
| "list_projects"
|
||||
|
||||
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
|
||||
candidateFailures: 36,
|
||||
});
|
||||
expect(report.coverage).toMatchObject({
|
||||
canonicalOperations: 53,
|
||||
canonicalOperations: 54,
|
||||
capabilityCases: 106,
|
||||
workflows: 12,
|
||||
stressFindings: 44,
|
||||
stressExclusions: 1,
|
||||
});
|
||||
expect(report.coverage.operations).toHaveLength(53);
|
||||
expect(report.coverage.operations).toHaveLength(54);
|
||||
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(
|
||||
"53 operations · 106 capability cases · 12 workflows",
|
||||
"54 operations · 106 capability cases · 12 workflows",
|
||||
);
|
||||
expect(renderRunnerWorkflowJUnit(report)).toContain(
|
||||
'tests="36" failures="36" skipped="0"',
|
||||
|
||||
@@ -359,6 +359,7 @@ interface CapabilityBaseCommand {
|
||||
|
||||
export type CapabilitySemanticCommand =
|
||||
| (CapabilityBaseCommand & { kind: "create_skill"; name: string; slug?: string; description: string; markdown: string })
|
||||
| (CapabilityBaseCommand & { kind: "update_skill"; skillId: string; markdown: string; expectedVersionId: string })
|
||||
| (CapabilityBaseCommand & { kind: "report_progress"; body: string })
|
||||
| (CapabilityBaseCommand & {
|
||||
kind: "write_document";
|
||||
@@ -479,6 +480,7 @@ export type CapabilityCommandOutcome =
|
||||
*/
|
||||
export const CAPABILITY_COMMAND_REQUIRED_CLAIMS = {
|
||||
create_skill: [],
|
||||
update_skill: [],
|
||||
report_progress: [],
|
||||
write_document: [],
|
||||
request_human_input: [],
|
||||
|
||||
@@ -734,6 +734,21 @@ export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlP
|
||||
entityRefs.push(`skill:${skill.id}`);
|
||||
break;
|
||||
}
|
||||
case "update_skill": {
|
||||
const skill = this.#state.skills?.find(candidate => candidate.id === command.skillId && candidate.companyId === run.companyId);
|
||||
if (!skill) throw new CapabilityMockControlPlaneError("fixture_state_invalid", "Skill not found");
|
||||
if (skill.versionId !== command.expectedVersionId) throw new CapabilityMockControlPlaneError("fixture_state_invalid", "Skill version changed");
|
||||
const document = parseFrontmatterMarkdown(command.markdown);
|
||||
if (!document.hasFrontmatter || !validateSkillFrontmatter(document.frontmatter) || !document.body.trim()) {
|
||||
throw new CapabilityMockControlPlaneError("invalid_skill_document", "Provide a complete SKILL.md with valid frontmatter and a nonempty body");
|
||||
}
|
||||
skill.name = String(document.frontmatter.name);
|
||||
skill.description = String(document.frontmatter.description);
|
||||
skill.markdown = command.markdown;
|
||||
skill.versionId = this.#id("skill-version");
|
||||
entityRefs.push(`skill:${skill.id}`);
|
||||
break;
|
||||
}
|
||||
case "write_document": {
|
||||
requireText(command.key, "document key");
|
||||
requireText(command.title, "document title");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getAgentInstructionHistoryAction } from "./get-agent-instruction-histor
|
||||
import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
|
||||
import { reassignTaskAction } from "./reassign-task.js";
|
||||
import { createSkillAction } from "./create-skill.js";
|
||||
import { updateSkillAction } from "./update-skill.js";
|
||||
import { createProjectAction } from "./create-project.js";
|
||||
import { listProjectRepositoriesAction } from "./list-project-repositories.js";
|
||||
import { searchApiAction } from "./search-api.js";
|
||||
@@ -61,6 +62,7 @@ export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
|
||||
restoreAgentInstructionsAction,
|
||||
|
||||
createSkillAction,
|
||||
updateSkillAction,
|
||||
createProjectAction,
|
||||
listProjectRepositoriesAction,
|
||||
searchApiAction,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Canonical definition and documentation for `update_skill`. */
|
||||
export const updateSkillAction = {
|
||||
id: "update_skill",
|
||||
canonical: {
|
||||
operationId: "update_skill", surfaces: ["scenario", "live"], placement: "optional_agent_tool",
|
||||
optionalGroup: "company_skills", requiredClaims: [], taskModes: ["standard", "skill_test"],
|
||||
sideEffectClass: "company_write", idempotency: "required", disabledByDefault: false,
|
||||
realBindingStatus: "live_codex", realServiceBinding: "PaperclipRunnerToolAuthority",
|
||||
prpEvidence: "Authenticated skill file API with version guard, durable receipt, and attributed activity.",
|
||||
prpBindingStatus: "bound", legacyAliases: [],
|
||||
},
|
||||
documentation: {
|
||||
title: "Update skill",
|
||||
description: "Replace the primary SKILL.md of an existing company skill. Read the current skill and version first. Supply the complete file, expectedVersionId, and an idempotencyKey; reuse the key on retries. A version conflict requires a fresh read. This uses the existing file API and company edit policy.",
|
||||
note: null,
|
||||
},
|
||||
examples: {
|
||||
call: { operationId: "update_skill", input: { skillId: "00000000-0000-4000-8000-000000000001", expectedVersionId: "00000000-0000-4000-8000-000000000002", idempotencyKey: "edit-1", markdown: "---\nname: review\ndescription: Review changes.\n---\n\n# Review\nInspect the changes.\n" } },
|
||||
scenarioCall: { operationId: "update_skill", idempotencyKey: "edit-1", input: { skillId: "skill-example", expectedVersionId: "version-example", markdown: "---\nname: review\ndescription: Review changes.\n---\n\n# Review\nInspect the changes.\n" } },
|
||||
success: { ok: true, operationId: "update_skill", result: { skillId: "skill-example", path: "SKILL.md", versionId: "version-next", studioPath: "/skills/studio/skill-example" } },
|
||||
},
|
||||
live: {
|
||||
order: 47,
|
||||
descriptor: {
|
||||
schema: "paperclip.semantic-tool.v1", operationId: "update_skill", version: 1,
|
||||
title: "Update skill", description: "Replace an existing company skill's complete SKILL.md using the current version as a guard. Reuse idempotencyKey on lost-response retries; read again after a version conflict.",
|
||||
exposure: "optional", requiredClaims: [], allowedModes: ["standard", "skill_test"],
|
||||
inputSchema: { type: "object", properties: {
|
||||
skillId: { type: "string", minLength: 1 },
|
||||
markdown: { type: "string", minLength: 1, maxLength: 200000 },
|
||||
expectedVersionId: { type: "string", minLength: 1 },
|
||||
idempotencyKey: { type: "string", minLength: 1, maxLength: 240 },
|
||||
}, required: ["skillId", "markdown", "expectedVersionId", "idempotencyKey"], additionalProperties: false },
|
||||
outputSchema: { type: "object", additionalProperties: true },
|
||||
},
|
||||
},
|
||||
scenario: {
|
||||
order: 47,
|
||||
successExample: { schema: "paperclip.capability.tool-result.v1", ok: true, operationId: "update_skill", operationResultId: "example-result", value: { commandId: "example", disposition: "applied", stateRevision: 1, entityRefs: ["skill:skill-example"], scheduledWakeIds: [] }, commandResult: null, authorization: {} },
|
||||
descriptor: {
|
||||
operationId: "update_skill", version: 1, title: "Update skill",
|
||||
description: "Replace an existing company skill's complete SKILL.md with an expected version guard and a retry key.",
|
||||
inputSchema: { type: "object", properties: {
|
||||
skillId: { type: "string", minLength: 1 },
|
||||
markdown: { type: "string", minLength: 1, maxLength: 200000 },
|
||||
expectedVersionId: { type: "string", minLength: 1 },
|
||||
}, required: ["skillId", "markdown", "expectedVersionId"], additionalProperties: false },
|
||||
outputSchema: { type: "object", additionalProperties: true },
|
||||
disposition: "optional_agent_tool", optionalGroup: "company_skills", requiredClaims: [],
|
||||
taskModes: ["standard", "skill_test"], sideEffectClass: "company_write", idempotency: "required", redaction: [],
|
||||
mockCommandMapping: { kind: "semantic_command", commandKind: "update_skill" },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -33,7 +33,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
|
||||
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",
|
||||
create_skill: "skills", update_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",
|
||||
|
||||
@@ -331,6 +331,10 @@ export class CapabilitySemanticDispatcher {
|
||||
slug: typeof input.slug === "string" ? input.slug : undefined,
|
||||
description: requiredString(input.description), markdown: requiredString(input.markdown) };
|
||||
break;
|
||||
case "update_skill":
|
||||
command = { kind: "update_skill", taskId, skillId: requiredString(input.skillId),
|
||||
expectedVersionId: requiredString(input.expectedVersionId), markdown: requiredString(input.markdown) };
|
||||
break;
|
||||
case "write_document":
|
||||
command = {
|
||||
kind: "write_document",
|
||||
@@ -420,6 +424,13 @@ export class CapabilitySemanticDispatcher {
|
||||
versionId: skill.versionId, studioPath: `/skills/studio/${skill.id}`,
|
||||
});
|
||||
}
|
||||
if (operationId === "update_skill" && outcome.ok) {
|
||||
const id = outcome.result.entityRefs.find(ref => ref.startsWith("skill:"))?.slice(6);
|
||||
const skill = this.port.snapshot().skills?.find(candidate => candidate.id === id);
|
||||
if (skill) return readSuccess(outcome.result.stateRevision, {
|
||||
skillId: skill.id, path: "SKILL.md", versionId: skill.versionId, studioPath: `/skills/studio/${skill.id}`,
|
||||
});
|
||||
}
|
||||
return commandOutcome(outcome);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
|
||||
get_workspace_runtime: "workspace",
|
||||
control_workspace_service: "workspace",
|
||||
set_dependencies: "delegation", reassign_task: "delegation",
|
||||
create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
|
||||
create_skill: "skills", update_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
|
||||
request_approval: "governance",
|
||||
decide_approval: "governance",
|
||||
comment_on_approval: "governance",
|
||||
|
||||
@@ -43,6 +43,25 @@ async function running(
|
||||
}
|
||||
|
||||
describe("Capability semantic catalog and authorization", () => {
|
||||
it("updates an existing skill once, rejects a stale version, and denies an ungranted capability", async () => {
|
||||
const adapter = await running();
|
||||
const dispatcher = new CapabilitySemanticDispatcher(adapter);
|
||||
const markdown = "---\nname: release-review\ndescription: Review release notes.\n---\n# Review\nCheck each note.";
|
||||
const created = await dispatcher.dispatch({ runId: OPEN.identity.runId, callId: "create", operationId: "create_skill",
|
||||
input: { name: "release-review", description: "Review release notes.", markdown, idempotencyKey: "create" } });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const args = { skillId: (created.result as { id: string }).id, expectedVersionId: (created.result as { versionId: string }).versionId,
|
||||
markdown: markdown + " Inspect tests.", idempotencyKey: "update-once" };
|
||||
const call = { runId: OPEN.identity.runId, callId: "update", operationId: "update_skill" as const, input: args };
|
||||
const first = await dispatcher.dispatch(call);
|
||||
expect(first).toMatchObject({ ok: true, result: { skillId: args.skillId, path: "SKILL.md", versionId: expect.any(String) } });
|
||||
expect(await dispatcher.dispatch({ ...call, callId: "retry" })).toMatchObject({ ok: true, result: first.ok ? first.result : {} });
|
||||
expect(await dispatcher.dispatch({ ...call, callId: "stale", input: { ...args, idempotencyKey: "next" } })).toMatchObject({ ok: false });
|
||||
const denied = new CapabilitySemanticDispatcher(adapter, { scenario: { id: "no-update", claims: [], denyOperations: ["update_skill"] } });
|
||||
expect(await denied.dispatch({ ...call, callId: "denied", input: { ...args, idempotencyKey: "denied" } })).toMatchObject({ ok: false, denial: { code: "scenario_denied" } });
|
||||
});
|
||||
|
||||
it("creates a durable skill once and returns its reference on retry", async () => {
|
||||
const adapter = await running();
|
||||
const dispatcher = new CapabilitySemanticDispatcher(adapter);
|
||||
@@ -94,7 +113,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(41);
|
||||
expect(names).toHaveLength(42);
|
||||
expect(names).toContain("get_task_context");
|
||||
expect(names).toContain("finish_task");
|
||||
expect(names).not.toContain("checkout_task");
|
||||
|
||||
@@ -47,6 +47,7 @@ export type CapabilitySemanticOperationId =
|
||||
| "reassign_task"
|
||||
| "set_dependencies"
|
||||
| "create_skill"
|
||||
| "update_skill"
|
||||
| "create_project"
|
||||
| "list_project_repositories"
|
||||
| "list_projects"
|
||||
|
||||
@@ -1543,6 +1543,9 @@ function commandForOperation(
|
||||
return { kind: "create_skill", name: requireString(input.name),
|
||||
slug: typeof input.slug === "string" ? input.slug : undefined,
|
||||
description: requireString(input.description), markdown: requireString(input.markdown) };
|
||||
case "update_skill":
|
||||
return { kind: "update_skill", skillId: requireString(input.skillId),
|
||||
expectedVersionId: requireString(input.expectedVersionId), markdown: requireString(input.markdown) };
|
||||
case "write_document":
|
||||
return {
|
||||
kind: "write_document",
|
||||
|
||||
@@ -395,6 +395,8 @@ export const companySkillFileUpdateSchema = z.object({
|
||||
content: z.string(),
|
||||
encoding: z.enum(["utf8", "base64"]).optional(),
|
||||
executable: z.boolean().optional(),
|
||||
expectedVersionId: z.string().guid().nullable().optional(),
|
||||
idempotencyKey: z.string().min(1).max(240).optional(),
|
||||
});
|
||||
|
||||
export const companySkillFileDeleteSchema = z.object({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { once } from "node:events";
|
||||
import type { SkillSourceDiscoveryEvent } from "@paperclipai/shared";
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { activityLog } from "@paperclipai/db";
|
||||
import { activityLog, companySkills } from "@paperclipai/db";
|
||||
import { persistActivity, publishActivity } from "../services/activity-log.js";
|
||||
import { projectToolContext } from "../services/project-tool-context.js";
|
||||
import { Router, type Request } from "express";
|
||||
@@ -1219,13 +1219,66 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId, "skills.edit", () => skillPolicyResource({ companyId, skillId }));
|
||||
const { idempotencyKey, expectedVersionId, ...input } = req.body;
|
||||
if (idempotencyKey) {
|
||||
const actor = getActorInfo(req);
|
||||
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
|
||||
? await projectToolContext(db, req.actor, true, "Skill") : null;
|
||||
const receiptKey = `skill-file:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${skillId}:${idempotencyKey}`;
|
||||
const fingerprint = createHash("sha256").update(JSON.stringify({ ...input, expectedVersionId })).digest("hex");
|
||||
const rollbackState: { current: { restore: () => Promise<void>; versionId: string | null } | null } = { current: null };
|
||||
const outcome = await db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${receiptKey}, 0))`);
|
||||
if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true, "Skill");
|
||||
const [prior] = await tx.select().from(activityLog).where(and(
|
||||
eq(activityLog.companyId, companyId), eq(activityLog.action, "company.skill_file_updated"),
|
||||
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
|
||||
));
|
||||
if (prior) {
|
||||
if (prior.details?.fingerprint !== fingerprint) throw conflict("Skill file idempotency key was used with different inputs");
|
||||
return { receipt: prior.details?.receipt, publication: null };
|
||||
}
|
||||
let publication: ActivityPublication | null = null;
|
||||
let receipt: { skillId: string; path: string; versionId: string | null; studioPath: string } | null = null;
|
||||
await companySkillService(tx as unknown as Db).updateFile(companyId, skillId, input.path, input.content, skillActor(req), {
|
||||
encoding: input.encoding, executable: input.executable, expectedVersionId,
|
||||
onRollback: (restore, versionId) => { rollbackState.current = { restore, versionId }; },
|
||||
afterUpdate: async (versionId) => {
|
||||
receipt = { skillId, path: input.path, versionId, studioPath: `/skills/studio/${encodeURIComponent(skillId)}` };
|
||||
const activity = await persistActivity(tx as unknown as Db, {
|
||||
companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId,
|
||||
runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, issueId: runContext?.issue.id,
|
||||
action: "company.skill_file_updated", entityType: "company_skill", entityId: skillId,
|
||||
details: { path: input.path, markdown: input.path === "SKILL.md", versionId,
|
||||
sourceIssueId: runContext?.issue.id ?? null, idempotencyKey: receiptKey, fingerprint, receipt },
|
||||
});
|
||||
publication = activity.publication;
|
||||
},
|
||||
});
|
||||
return { receipt, publication };
|
||||
}).catch(async (error) => {
|
||||
if (rollbackState.current) {
|
||||
const { restore, versionId } = rollbackState.current;
|
||||
await db.transaction(async tx => {
|
||||
await tx.execute(sql`select ${companySkills.id} from ${companySkills} where ${companySkills.id} = ${skillId} and ${companySkills.companyId} = ${companyId} for update`);
|
||||
const [current] = await tx.select({ versionId: companySkills.currentVersionId }).from(companySkills)
|
||||
.where(and(eq(companySkills.id, skillId), eq(companySkills.companyId, companyId)));
|
||||
if (current && current.versionId === versionId) await restore();
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (outcome.publication) publishActivity(outcome.publication);
|
||||
res.json(outcome.receipt);
|
||||
return;
|
||||
}
|
||||
const result = await svc.updateFile(
|
||||
companyId,
|
||||
skillId,
|
||||
String(req.body.path ?? ""),
|
||||
String(req.body.content ?? ""),
|
||||
skillActor(req),
|
||||
{ encoding: req.body.encoding, executable: req.body.executable },
|
||||
{ encoding: req.body.encoding, executable: req.body.executable, expectedVersionId },
|
||||
);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
||||
@@ -7740,12 +7740,12 @@ registry.registerPath({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/skills/{skillId}/files",
|
||||
tags: ["skills"],
|
||||
summary: "Update a skill file",
|
||||
summary: "Update a skill file (optional expectedVersionId and idempotencyKey guard agent retries)",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), skillId: z.string() }),
|
||||
body: jsonBody(companySkillFileUpdateSchema),
|
||||
},
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
|
||||
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 409: r.conflict },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
|
||||
@@ -4643,6 +4643,11 @@ export function companySkillService(db: Db) {
|
||||
for (const slug of [...new Set([initial.slug, ...additionalSlugs])].filter(Boolean).sort()) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`${companyId}:${slug}`}, 0))`);
|
||||
}
|
||||
await tx.execute(sql`
|
||||
select ${companySkills.id} from ${companySkills}
|
||||
where ${companySkills.id} = ${skillId} and ${companySkills.companyId} = ${companyId}
|
||||
for update
|
||||
`);
|
||||
const skill = await getById(companyId, skillId, tx);
|
||||
if (!skill) throw notFound("Skill not found");
|
||||
if (skill.slug !== initial.slug) throw conflict("Skill was renamed. Retry the operation.");
|
||||
@@ -4669,10 +4674,16 @@ export function companySkillService(db: Db) {
|
||||
relativePath: string,
|
||||
content: string,
|
||||
actor: SkillActor | null = null,
|
||||
format: { encoding?: "utf8" | "base64"; executable?: boolean } = {},
|
||||
format: { encoding?: "utf8" | "base64"; executable?: boolean; expectedVersionId?: string | null;
|
||||
afterUpdate?: (versionId: string | null) => Promise<void>;
|
||||
onRollback?: (restore: () => Promise<void>, versionId: string | null) => void } = {},
|
||||
): Promise<CompanySkillFileDetail> {
|
||||
return withSkillFileMutation(companyId, skillId, async (skill, tx) => {
|
||||
|
||||
if (format.expectedVersionId !== undefined && skill.currentVersionId !== format.expectedVersionId) {
|
||||
throw conflict("Skill version changed. Read the current version before retrying.");
|
||||
}
|
||||
|
||||
const source = deriveSkillSourceInfo(skill);
|
||||
if (!source.editable || skill.sourceType !== "local_path") {
|
||||
throw unprocessable(source.editableReason ?? "This skill cannot be edited.");
|
||||
@@ -4689,41 +4700,55 @@ export function companySkillService(db: Db) {
|
||||
}
|
||||
const previousContent = await fs.readFile(absolutePath).catch(() => null);
|
||||
const previousMode = (await fs.stat(absolutePath).catch(() => null))?.mode ?? 0o644;
|
||||
const restore = async () => {
|
||||
if (previousContent === null) await fs.rm(absolutePath, { force: true });
|
||||
else {
|
||||
await fs.writeFile(absolutePath, previousContent);
|
||||
await fs.chmod(absolutePath, previousMode);
|
||||
}
|
||||
};
|
||||
format.onRollback?.(restore, skill.currentVersionId);
|
||||
const mode = (format.executable ?? Boolean(previousMode & 0o111)) ? 0o755 : 0o644;
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, bytes);
|
||||
await fs.chmod(absolutePath, mode);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
||||
await fs.writeFile(absolutePath, bytes);
|
||||
await fs.chmod(absolutePath, mode);
|
||||
|
||||
if (normalizedPath === "SKILL.md") {
|
||||
const parsed = parseFrontmatterMarkdown(content);
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({
|
||||
name: asString(parsed.frontmatter.name) ?? skill.name,
|
||||
description: asString(parsed.frontmatter.description) ?? skill.description,
|
||||
markdown: content,
|
||||
...readSkillStoreMetadata(parsed.frontmatter, skill.metadata),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
} else {
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
if (normalizedPath === "SKILL.md") {
|
||||
const parsed = parseFrontmatterMarkdown(content);
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({
|
||||
name: asString(parsed.frontmatter.name) ?? skill.name,
|
||||
description: asString(parsed.frontmatter.description) ?? skill.description,
|
||||
markdown: content,
|
||||
...readSkillStoreMetadata(parsed.frontmatter, skill.metadata),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
} else {
|
||||
await tx
|
||||
.update(companySkills)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(companySkills.id, skill.id));
|
||||
}
|
||||
|
||||
const inventory = await refreshEditedSkillInventory(skill, tx);
|
||||
if (!previousContent?.equals(bytes) || Boolean(previousMode & 0o111) !== Boolean(mode & 0o111)) {
|
||||
await createVersion(companyId, skillId, {}, actor, {
|
||||
database: tx, skipInventoryRefresh: true, skill, fileInventory: inventory,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await getById(companyId, skillId, tx);
|
||||
const detail = updated ? await readLoadedSkillFile(updated, normalizedPath) : null;
|
||||
if (!detail) throw notFound("Skill file not found");
|
||||
await format.afterUpdate?.(updated?.currentVersionId ?? null);
|
||||
return detail;
|
||||
} catch (error) {
|
||||
await restore();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const inventory = await refreshEditedSkillInventory(skill, tx);
|
||||
if (!previousContent?.equals(bytes) || Boolean(previousMode & 0o111) !== Boolean(mode & 0o111)) {
|
||||
await createVersion(companyId, skillId, {}, actor, {
|
||||
database: tx, skipInventoryRefresh: true, skill, fileInventory: inventory,
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await getById(companyId, skillId, tx);
|
||||
const detail = updated ? await readLoadedSkillFile(updated, normalizedPath) : null;
|
||||
if (!detail) throw notFound("Skill file not found");
|
||||
return detail;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
issueId,
|
||||
runId,
|
||||
});
|
||||
expect(authority.definitions()).toHaveLength(35);
|
||||
expect(authority.definitions()).toHaveLength(36);
|
||||
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 fabricate answers");
|
||||
@@ -131,6 +131,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
"list_document_revisions",
|
||||
"write_document",
|
||||
"create_skill",
|
||||
"update_skill",
|
||||
"list_agents",
|
||||
"get_agent",
|
||||
"list_approvals",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { resolveCoreTrustPreset } from "../trust-preset-resolver.js";
|
||||
import { normalizeIssueExecutionPolicy } from "../issue-execution-policy.js";
|
||||
import { buildLowTrustSourceTrust } from "../source-trust.js";
|
||||
import { handoffPlanContext } from "./handoff-plan-context.js";
|
||||
import { callCreateSkillTool } from "../skill-tools.js";
|
||||
import { callCreateSkillTool, callUpdateSkillTool } from "../skill-tools.js";
|
||||
import { callProjectTool } from "../project-tools.js";
|
||||
import { isConnectorTool, executeConnectorTool, type ConnectorAssignment } from "../connector-runtime.js";
|
||||
import { resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
|
||||
@@ -97,7 +97,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
|
||||
"search_api", "call_api", "hire_agent",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
|
||||
"request_human_input",
|
||||
"create_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
|
||||
"create_skill", "update_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
|
||||
"list_documents", "read_document", "list_document_revisions", "write_document",
|
||||
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
|
||||
]);
|
||||
@@ -414,6 +414,12 @@ export class PaperclipRunnerToolAuthority {
|
||||
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
|
||||
return callCreateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
|
||||
}
|
||||
case "update_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);
|
||||
if (!apiUrl || !token) throw new Error("Skill tool authentication is unavailable");
|
||||
return callUpdateSkillTool({ arguments: input, apiUrl, token, companyId: this.binding.companyId });
|
||||
}
|
||||
case "create_project":
|
||||
case "list_project_repositories":
|
||||
case "list_projects": {
|
||||
|
||||
@@ -48,6 +48,7 @@ function dedicatedTools(method: string, path: string): string[] {
|
||||
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\/\{[^}]+\}\/skills\/\{[^}]+\}\/files$/.test(path) && method === "PATCH") return ["update_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"] : [];
|
||||
if (/\/project-repositories$/.test(path) && method === "GET") return ["list_project_repositories"];
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { activityLog, companyMemberships, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { companySkillPolicyService } from "../company-skill-policy.js";
|
||||
import { companySkillService } from "../company-skills.js";
|
||||
|
||||
describe("runner update_skill through the real skill API", () => {
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
||||
let home: string;
|
||||
const previousSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
const previousHome = process.env.PAPERCLIP_HOME;
|
||||
beforeAll(async () => {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||
home = await mkdtemp(join(tmpdir(), "paperclip-update-skill-"));
|
||||
process.env.PAPERCLIP_HOME = home;
|
||||
server = await startRunnerApiTestServer();
|
||||
}, 60_000);
|
||||
afterAll(async () => {
|
||||
await server?.close();
|
||||
if (previousSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
else process.env.PAPERCLIP_AGENT_JWT_SECRET = previousSecret;
|
||||
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousHome;
|
||||
if (home) await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function fixture(options: Parameters<typeof server.fixture>[0] = {}) {
|
||||
const result = await server.fixture(options);
|
||||
await server.db.insert(companyMemberships).values({ companyId: result.companyId, principalType: "agent", principalId: result.agentId, status: "active", membershipRole: "member" });
|
||||
const skill = await companySkillService(server.db).createLocalSkill(result.companyId, {
|
||||
name: "release-review", description: "Review releases.",
|
||||
markdown: "---\nname: release-review\ndescription: Review releases.\n---\n\n# Review\nInspect the release.\n",
|
||||
});
|
||||
const args = { skillId: skill.id, expectedVersionId: skill.currentVersionId, idempotencyKey: randomUUID(),
|
||||
markdown: "---\nname: release-review\ndescription: Review releases.\n---\n\n# Review\nInspect the release and its tests.\n" };
|
||||
return { ...result, skill, args };
|
||||
}
|
||||
|
||||
it("persists exactly one version and attributed activity across retries and a replacement run", async () => {
|
||||
const testFixture = await fixture();
|
||||
const { authority, args, companyId, skill, issueId } = testFixture;
|
||||
expect(authority.definitions().some(tool => tool.name === "update_skill")).toBe(true);
|
||||
const first = await authority.execute({ tool: "update_skill", callId: "first", arguments: args });
|
||||
const second = await authority.execute({ tool: "update_skill", callId: "retry", arguments: args });
|
||||
expect(second).toEqual(first);
|
||||
expect(first).toMatchObject({ skillId: skill.id, path: "SKILL.md", versionId: expect.any(String) });
|
||||
const replacementRunId = randomUUID();
|
||||
await server.db.update(heartbeatRuns).set({ status: "failed" }).where(eq(heartbeatRuns.id, testFixture.runId));
|
||||
await server.db.insert(heartbeatRuns).values({ id: replacementRunId, companyId, agentId: testFixture.agentId,
|
||||
status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
|
||||
await server.db.update(issues).set({ executionRunId: replacementRunId }).where(eq(issues.id, issueId));
|
||||
const replacement = new PaperclipRunnerToolAuthority(server.db, { ...testFixture, runId: replacementRunId });
|
||||
expect(await replacement.execute({ tool: "update_skill", callId: "replacement", arguments: args })).toEqual(first);
|
||||
expect(await companySkillService(server.db).listVersions(companyId, skill.id)).toHaveLength(2);
|
||||
expect((await server.db.select().from(activityLog).where(eq(activityLog.action, "company.skill_file_updated")))
|
||||
.filter(event => event.entityId === skill.id))
|
||||
.toEqual([expect.objectContaining({ entityId: skill.id, details: expect.objectContaining({ sourceIssueId: issueId, versionId: (first as { versionId: string }).versionId }) })]);
|
||||
await expect(replacement.execute({ tool: "update_skill", callId: "changed-key", arguments: { ...args, markdown: args.markdown + "More." } })).rejects.toThrow(/different inputs/);
|
||||
await expect(replacement.execute({ tool: "update_skill", callId: "stale", arguments: { ...args, idempotencyKey: randomUUID() } })).rejects.toThrow(/version changed/);
|
||||
});
|
||||
|
||||
it("denies policy changes and cross-company skill IDs", async () => {
|
||||
const first = await fixture();
|
||||
const second = await fixture();
|
||||
await expect(second.authority.execute({ tool: "update_skill", callId: "foreign", arguments: { ...first.args, idempotencyKey: randomUUID() } })).rejects.toThrow();
|
||||
await companySkillPolicyService(server.db).replace({ companyId: first.companyId, expectedRevision: 0,
|
||||
policy: { schemaVersion: 1, defaultEffect: "deny", rules: [] }, activity: { actorType: "user", actorId: "test-board" } });
|
||||
await expect(first.authority.execute({ tool: "update_skill", callId: "denied", arguments: first.args })).rejects.toThrow(/company policy/);
|
||||
expect(await companySkillService(server.db).listVersions(first.companyId, first.skill.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("serializes simultaneous retries and checks policy again after a successful edit", async () => {
|
||||
const testFixture = await fixture();
|
||||
const calls = await Promise.all(["parallel-a", "parallel-b"].map(callId =>
|
||||
testFixture.authority.execute({ tool: "update_skill", callId, arguments: testFixture.args })));
|
||||
expect(calls[0]).toEqual(calls[1]);
|
||||
expect(await companySkillService(server.db).listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(2);
|
||||
await companySkillPolicyService(server.db).replace({ companyId: testFixture.companyId, expectedRevision: 0,
|
||||
policy: { schemaVersion: 1, defaultEffect: "deny", rules: [] }, activity: { actorType: "user", actorId: "test-board" } });
|
||||
await expect(testFixture.authority.execute({ tool: "update_skill", callId: "revoked-retry", arguments: testFixture.args })).rejects.toThrow(/company policy/);
|
||||
expect(await companySkillService(server.db).listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("restores the file when the required audit callback fails", async () => {
|
||||
const testFixture = await fixture();
|
||||
const service = companySkillService(server.db);
|
||||
const original = await service.readFile(testFixture.companyId, testFixture.skill.id, "SKILL.md");
|
||||
await expect(service.updateFile(testFixture.companyId, testFixture.skill.id, "SKILL.md", testFixture.args.markdown,
|
||||
null, { expectedVersionId: testFixture.skill.currentVersionId, afterUpdate: async () => { throw new Error("audit unavailable"); } }))
|
||||
.rejects.toThrow("audit unavailable");
|
||||
expect((await service.readFile(testFixture.companyId, testFixture.skill.id, "SKILL.md"))?.content).toBe(original?.content);
|
||||
expect(await service.listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a competing edit with a different key and the same expected version", async () => {
|
||||
const testFixture = await fixture();
|
||||
const attempts = await Promise.allSettled([testFixture.args, { ...testFixture.args,
|
||||
idempotencyKey: randomUUID(), markdown: testFixture.args.markdown + "Another edit." }].map((argumentsValue, index) =>
|
||||
testFixture.authority.execute({ tool: "update_skill", callId: `race-${index}`, arguments: argumentsValue })));
|
||||
expect(attempts.filter(attempt => attempt.status === "fulfilled")).toHaveLength(1);
|
||||
expect(attempts.filter(attempt => attempt.status === "rejected")).toHaveLength(1);
|
||||
expect(await companySkillService(server.db).listVersions(testFixture.companyId, testFixture.skill.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides the tool and rejects invocation in planning mode", async () => {
|
||||
const testFixture = await fixture({ mode: "planning" });
|
||||
expect(new PaperclipRunnerToolAuthority(server.db, { ...testFixture, workMode: "planning" }).definitions().some(tool => tool.name === "update_skill")).toBe(false);
|
||||
await expect(testFixture.authority.execute({ tool: "update_skill", callId: "planning", arguments: testFixture.args })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { callCreateSkillTool, createSkillToolInput } from "./skill-tools.js";
|
||||
import { callCreateSkillTool, createSkillToolInput, callUpdateSkillTool, updateSkillToolInput } from "./skill-tools.js";
|
||||
|
||||
const input = {
|
||||
name: "release-review", description: "Review release notes.", idempotencyKey: "release-review-1",
|
||||
@@ -36,3 +36,29 @@ describe("create skill tool", () => {
|
||||
expect(result).toEqual({ id: "skill-1", name: input.name, slug: input.name, description: input.description, versionId: "version-1", studioPath: "/skills/studio/skill-1" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("update skill tool", () => {
|
||||
const args = { skillId: "00000000-0000-4000-8000-000000000001", expectedVersionId: "00000000-0000-4000-8000-000000000002",
|
||||
markdown: input.markdown, idempotencyKey: "update-1" };
|
||||
|
||||
it("rejects incomplete content and untrusted scope without a request", async () => {
|
||||
const fetcher = vi.fn();
|
||||
expect(updateSkillToolInput.parse(args)).toEqual(args);
|
||||
for (const invalid of [{ markdown: "# Missing frontmatter" }, { markdown: input.markdown.replace("# Review\nCheck each release note against the change.", " ") },
|
||||
{ companyId: "other" }, { expectedVersionId: "not-a-uuid" }]) {
|
||||
await expect(callUpdateSkillTool({ arguments: { ...args, ...invalid }, apiUrl: "http://localhost", token: "token", companyId: "company" }, fetcher)).rejects.toThrow();
|
||||
}
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls the company file endpoint and returns a compact receipt", async () => {
|
||||
const receipt = { skillId: args.skillId, versionId: "next-version", path: "SKILL.md", studioPath: `/skills/studio/${args.skillId}` };
|
||||
const fetcher = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ...receipt, content: args.markdown }), { status: 200 }));
|
||||
expect(await callUpdateSkillTool({ arguments: args, apiUrl: "http://localhost/api", token: "token", companyId: "company" }, fetcher)).toEqual(receipt);
|
||||
expect(fetcher).toHaveBeenCalledWith(`http://localhost/api/companies/company/skills/${args.skillId}/files`, expect.objectContaining({ method: "PATCH" }));
|
||||
expect(JSON.parse(fetcher.mock.calls[0]![1].body)).toEqual({ path: "SKILL.md", content: args.markdown,
|
||||
expectedVersionId: args.expectedVersionId, idempotencyKey: args.idempotencyKey });
|
||||
fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Skill version changed" }), { status: 409 }));
|
||||
await expect(callUpdateSkillTool({ arguments: args, apiUrl: "http://localhost", token: "token", companyId: "company" }, fetcher)).rejects.toThrow("Skill version changed");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,35 @@ export const createSkillToolInput = z.object({
|
||||
}
|
||||
});
|
||||
|
||||
/** Replace the complete primary file of an existing skill, never a partial patch. */
|
||||
export const updateSkillToolInput = z.object({
|
||||
skillId: z.string().guid(),
|
||||
markdown: z.string().min(1).max(200000),
|
||||
expectedVersionId: z.string().guid(),
|
||||
idempotencyKey: z.string().min(1).max(240),
|
||||
}).strict().superRefine((input, context) => {
|
||||
const document = parseFrontmatterMarkdown(input.markdown);
|
||||
const metadata = skillFrontmatterSchema.safeParse(document.frontmatter);
|
||||
if (!document.hasFrontmatter || !metadata.success || !document.body.trim()) {
|
||||
context.addIssue({ code: "custom", path: ["markdown"], message: "Provide a complete SKILL.md with valid name and description frontmatter and a nonempty body." });
|
||||
}
|
||||
});
|
||||
|
||||
export async function callUpdateSkillTool(input: {
|
||||
arguments: Record<string, unknown>; apiUrl: string; token: string; companyId: string;
|
||||
}, fetcher: typeof fetch = fetch) {
|
||||
const { skillId, markdown, expectedVersionId, idempotencyKey } = updateSkillToolInput.parse(input.arguments);
|
||||
const response = await fetcher(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api/companies/${encodeURIComponent(input.companyId)}/skills/${encodeURIComponent(skillId)}/files`, {
|
||||
method: "PATCH",
|
||||
headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path: "SKILL.md", content: markdown, expectedVersionId, idempotencyKey }),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Skill update failed (${response.status})`);
|
||||
return { skillId: result.skillId, versionId: result.versionId, path: result.path, studioPath: result.studioPath };
|
||||
}
|
||||
|
||||
/** Use the same API and company skill policy as Skill Studio. */
|
||||
export async function callCreateSkillTool(input: {
|
||||
arguments: Record<string, unknown>; apiUrl: string; token: string; companyId: string;
|
||||
|
||||
Reference in new issue
Block a user