mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 20:44:16 +02:00
## 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>
65 lines
3.9 KiB
TypeScript
65 lines
3.9 KiB
TypeScript
import { parseFrontmatterMarkdown, skillFrontmatterSchema } from "@paperclipai/shared";
|
|
import { z } from "zod";
|
|
|
|
/** A complete single-file skill. Identity and permissions come from the run. */
|
|
export const createSkillToolInput = z.object({
|
|
name: z.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
slug: z.string().min(1).max(120).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
|
description: z.string().trim().min(1).max(2000),
|
|
markdown: z.string().min(1).max(200000),
|
|
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()
|
|
|| metadata.data.name !== input.name || metadata.data.description !== input.description
|
|
|| (input.slug !== undefined && input.slug !== input.name)) {
|
|
context.addIssue({ code: "custom", path: ["markdown"], message: "Provide a complete SKILL.md with name and description matching the tool inputs, a nonempty body, and slug equal to name when supplied." });
|
|
}
|
|
});
|
|
|
|
/** 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;
|
|
}, fetcher: typeof fetch = fetch) {
|
|
const body = createSkillToolInput.parse(input.arguments);
|
|
const response = await fetcher(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api/companies/${encodeURIComponent(input.companyId)}/skills`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(body), signal: AbortSignal.timeout(60_000),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Skill creation failed (${response.status})`);
|
|
return { id: result.id, name: result.name, slug: result.slug, description: result.description,
|
|
versionId: result.currentVersionId, studioPath: `/skills/studio/${encodeURIComponent(result.id)}` };
|
|
}
|