mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +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>
176 lines
12 KiB
TypeScript
176 lines
12 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { z } from "zod";
|
|
import { buildOpenApiDocument } from "../../routes/openapi.js";
|
|
import { badRequest, notFound } from "../../errors.js";
|
|
import { runnerApiReference } from "./runner-api-reference.js";
|
|
import { runnerApiRestriction } from "./runner-api-policy.js";
|
|
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
|
|
|
|
type Json = Record<string, any>;
|
|
export interface RunnerApiOperation {
|
|
operationId: string;
|
|
method: string;
|
|
path: string;
|
|
summary: string;
|
|
description: string;
|
|
parameters: Json[];
|
|
requestBody?: Json;
|
|
responses: Json;
|
|
authorization: Json;
|
|
transport: "rest" | "protocol";
|
|
dedicatedTools: string[];
|
|
allowedModes: string[];
|
|
skillReference?: { section: string; description?: string; examples?: { body: unknown }[] };
|
|
dedicatedToolGuidance: string;
|
|
dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[];
|
|
runnerRestrictions?: string[];
|
|
callPolicy: "rest" | "restricted" | "protocol";
|
|
}
|
|
|
|
const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
|
const SYNONYMS: Record<string, string> = {
|
|
task: "issue", tasks: "issues", employee: "agent", employees: "agents",
|
|
hire: "agent", hiring: "agents", expense: "cost", expenses: "costs",
|
|
cron: "routines", schedule: "routines", recurring: "routines",
|
|
blocker: "dependencies", blockers: "dependencies", files: "attachments",
|
|
};
|
|
|
|
function words(text: string): string[] {
|
|
return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
|
.flatMap((word) => [word, ...(SYNONYMS[word] ? [SYNONYMS[word]] : [])])
|
|
.map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word);
|
|
}
|
|
|
|
function dedicatedTools(method: string, path: string): string[] {
|
|
if (/\/issues\/\{[^}]+\}\/title$/.test(path) && method === "PUT") return ["set_task_title"];
|
|
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
|
|
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
|
|
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
|
|
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"];
|
|
if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"];
|
|
if (/\/companies\/\{[^}]+\}\/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"];
|
|
if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
|
|
if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
|
|
if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];
|
|
if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["reassign_task", "set_dependencies", "finish_task", "block_task", "request_review"];
|
|
if (/\/agents$/.test(path) && method === "GET") return ["list_agents"];
|
|
if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"];
|
|
if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"];
|
|
if (/\/approvals\/\{[^}]+\}/.test(path) && method === "GET") return ["get_approval", "get_approval_context"];
|
|
return [];
|
|
}
|
|
|
|
// Descriptions and schemas are documentation, never authorization. Routes remain
|
|
// authoritative, including conditional role, company and resource checks.
|
|
export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): RunnerApiOperation[] {
|
|
function dereference(value: any, seen = new Set<string>()): any {
|
|
if (Array.isArray(value)) return value.map((entry) => dereference(entry, seen));
|
|
if (!value || typeof value !== "object") return value;
|
|
if (typeof value.$ref === "string" && value.$ref.startsWith("#/")) {
|
|
if (seen.has(value.$ref)) return { description: `Recursive schema: ${value.$ref}` };
|
|
const target = value.$ref.slice(2).split("/").reduce((node: any, key: string) => node?.[key.replace(/~1/g, "/").replace(/~0/g, "~")], document);
|
|
if (!target) throw new Error(`Unresolved API schema: ${value.$ref}`);
|
|
return dereference(target, new Set([...seen, value.$ref]));
|
|
}
|
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, dereference(entry, seen)]));
|
|
}
|
|
const result: RunnerApiOperation[] = [];
|
|
for (const [path, item] of Object.entries<Json>(document.paths)) {
|
|
for (const [verb, operation] of Object.entries<Json>(item)) {
|
|
if (!METHODS.has(verb)) continue;
|
|
const method = verb.toUpperCase();
|
|
const restriction = runnerApiRestriction(method, path);
|
|
const conversationalConfirmation = method === "POST"
|
|
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
|
|
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
|
|
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
|
|
const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|
|
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
|
|
|| /event-stream|websocket/i.test(JSON.stringify(operation.responses));
|
|
result.push({
|
|
operationId: `${method} ${path}`, method, path,
|
|
summary: operation.summary ?? `${method} ${path}`,
|
|
description: operation.description ?? "",
|
|
parameters: dereference([...(item.parameters ?? []), ...(operation.parameters ?? [])]),
|
|
...(operation.requestBody ? { requestBody: dereference(operation.requestBody) } : {}),
|
|
responses: dereference(operation.responses ?? {}),
|
|
authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" },
|
|
transport: protocol ? "protocol" : "rest",
|
|
callPolicy: protocol ? "protocol" : restriction ? "restricted" : "rest",
|
|
dedicatedTools: dedicatedTools(method, path),
|
|
dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => {
|
|
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name);
|
|
return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : [];
|
|
}),
|
|
runnerRestrictions: [...(restriction ? [restriction] : []), "Active run and assignment must remain authorized.",
|
|
conversationalConfirmation
|
|
? "This records an ordinary conversational confirmation under existing resolver permissions. It cannot decide governed approvals or impersonate the user."
|
|
: "Cannot replace checkout, completion, task status/ownership changes, governed approval decisions, or runner execution control. Dedicated tools retain their existing permissions."],
|
|
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
|
|
? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration."
|
|
: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
|
|
allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
|
|
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
|
|
...(skillReference ? { skillReference } : {}),
|
|
});
|
|
}
|
|
}
|
|
for (const [path, summary, actor] of [
|
|
["/api/companies/{companyId}/events/ws", "Live company events WebSocket; use the existing event client", "board_or_agent"],
|
|
["/api/runner/v1/connect/{runId}", "Authenticated runner PRP WebSocket; runner-owned transport", "runner"],
|
|
["/api/environment-custom-image-setup-sessions/{sessionId}/terminal/ws", "Image setup terminal WebSocket; use the existing terminal client", "board"],
|
|
]) {
|
|
if (result.some(operation => operation.path === path)) continue;
|
|
result.push({ operationId: `GET ${path}`, method: "GET", path, summary,
|
|
description: "WebSocket upgrade. Not callable through call_api.",
|
|
parameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => ({ in: "path", name: match[1], required: true, schema: { type: "string" } })),
|
|
responses: { "101": { description: "Switching Protocols" } }, authorization: { actor },
|
|
transport: "protocol", callPolicy: "protocol", dedicatedTools: [], dedicatedToolGuidance: "Use the existing protocol client.", allowedModes: [],
|
|
});
|
|
}
|
|
return result.sort((a, b) => a.operationId.localeCompare(b.operationId));
|
|
}
|
|
|
|
let cached: RunnerApiOperation[] | undefined;
|
|
let cachedDigest: string | undefined;
|
|
export function runnerApiCatalog(): RunnerApiOperation[] { return cached ??= buildRunnerApiCatalog(); }
|
|
export function runnerApiOperation(id: string): RunnerApiOperation {
|
|
const operation = runnerApiCatalog().find((entry) => entry.operationId === id);
|
|
if (!operation) throw notFound("Unknown API operation; use search_api to discover its exact operationId");
|
|
return operation;
|
|
}
|
|
|
|
export const runnerApiSearchSchema = z.object({ query: z.string().trim().min(1).max(500), limit: z.number().int().min(1).max(10).default(5), cursor: z.string().max(200).optional() }).strict();
|
|
export function searchRunnerApi(value: unknown) {
|
|
const parsed = runnerApiSearchSchema.safeParse(value);
|
|
if (!parsed.success) throw badRequest("Invalid API search query or limit");
|
|
const input = parsed.data;
|
|
const { query, limit } = input;
|
|
const catalog = runnerApiCatalog();
|
|
cachedDigest ??= createHash("sha256").update(JSON.stringify(catalog)).digest("hex");
|
|
const fingerprint = createHash("sha256").update(cachedDigest).update(query).digest("hex").slice(0, 16);
|
|
let offset = 0;
|
|
if (input.cursor) {
|
|
const match = /^([a-f0-9]{16}):(\d+)$/.exec(input.cursor);
|
|
if (!match || match[1] !== fingerprint) throw badRequest("Search cursor belongs to a different query or catalog");
|
|
offset = Number(match[2]);
|
|
if (!Number.isSafeInteger(offset)) throw badRequest("Invalid search cursor");
|
|
}
|
|
const exact = catalog.find((entry) => entry.operationId.toLowerCase() === query.trim().toLowerCase());
|
|
const terms = [...new Set(words(query))];
|
|
const matches = exact ? [exact] : catalog.map((entry) => {
|
|
const title = new Set(words(`${entry.method} ${entry.path} ${entry.summary}`));
|
|
const description = new Set(words(`${entry.description} ${entry.skillReference?.description ?? ""} ${entry.skillReference?.section ?? ""}`));
|
|
return { entry, score: terms.reduce((sum, word) => sum + (title.has(word) ? 5 : description.has(word) ? 1 : 0), 0) };
|
|
}).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score || a.entry.operationId.localeCompare(b.entry.operationId)).map(({ entry }) => entry);
|
|
const results = matches.slice(offset, offset + limit);
|
|
return {
|
|
results, total: matches.length,
|
|
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
|
|
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit reads and active-task title updates; Plan also permits conversational confirmation recording. Check each operation's allowedModes.",
|
|
};
|
|
}
|