mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent Chat answers a person's requests with a chat agent that reads the API catalog and the chat guidance > - Paperclip ships a teams catalog of ready-made teams that a company can preview and install > - The chat guidance does not name this catalog, and its API catalog entries have only short summaries > - So agents asked for a "ready-made team" check only the existing agents and skills, or search npm and GitHub > - This pull request names the teams catalog where the agent already looks: the chat guidance and the API catalog entries > - The benefit is that agents find, preview and link ready-made teams instead of saying none exist ## Linked Issues or Issue Description **What happened** An agent was asked "Do we have a ready-made team for building a website?". It read only the company's agents, projects and skills. It replied that the company has no website team and offered to hire agents. It did not read the teams catalog, which has a Product Engineering team. An agent was asked "Show me what installing the website team would add.". It found `GET /api/teams/catalog`, but found no team named "website". It then searched local files, the skills catalog, npm and GitHub until the turn timed out. It did not preview the install. **Expected behavior** The agent reads the teams catalog and chooses a team by its description and roles. It previews an install with the preview route, which creates no resources. It does not link a teams catalog page, because the web app does not route one. It does not search outside Paperclip for other teams. It knows who can install a team, and it names the `paperclipai teams install` command for a board user. Preview also works in Ask and Plan modes, because it saves no data. **Steps to reproduce** 1. Create a company with only a CEO agent. 2. In Agent Chat, ask "Do we have a ready-made team for building a website?". 3. Read the run. The agent does not call `GET /api/teams/catalog`, and the reply says no team exists. 4. In a new chat, ask "Show me what installing the website team would add.". 5. Read the run. The agent searches npm and GitHub and does not call the preview route. ## What Changed - `server/src/services/agent-conversations.ts`: the chat directive has a new short paragraph. It says that ready-made teams are packages in the teams catalog. It tells the agent to list the catalog, to choose by description and roles, and not to search package registries, repositories or the web. It names the preview route and says that preview creates no resources. It says who can install. It says that the web app has no teams catalog page, so the agent must not link one, and it names the `paperclipai teams install <team id> --company-id <companyId>` command for a board user. - `server/src/routes/openapi.ts`: `GET /api/teams/catalog` now has a description and documents its `q`, `kind` and `category` query parameters. The preview route says that it creates no resources. The install route says who can install and names the CLI command. - `server/src/services/native-runtime/runner-api-catalog.ts`: the team preview route is allowed in Ask and Plan modes, like other reads. It computes a plan and saves no data. Install stays in standard mode only. - Tests in `agent-conversations.test.ts` and `runner-api.test.ts`. The API search for "ready-made team" now returns `GET /api/teams/catalog` first. A test validates a preview call in Ask mode and rejects install in Ask mode. ## Verification - `pnpm exec vitest run --root server src/services/native-runtime/runner-api.test.ts src/__tests__/agent-conversations.test.ts src/__tests__/openapi-routes.test.ts src/__tests__/teams-catalog-routes.test.ts`: all tests pass. - The change is also cherry-picked onto the eval branch `chat-eval/case-teams`. The live Runner E2E cells for `teams.find-team`, `teams.preview-install` and `teams.install-team` run there. ## Risks - Low risk. Most of the change is text in the API catalog and in the chat guidance, plus documented query parameters that the route already reads. - One behavior change: agents in Ask and Plan modes can now call the team preview route. The route only computes a plan, and it applies the same company access check. - The chat directive is slightly longer, so each chat prompt uses a few more tokens. - Related open PR #18970 adds request body schemas to the same team routes. The two changes touch nearby lines and can need a small merge. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
182 lines
13 KiB
TypeScript
182 lines
13 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 { 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" | "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 (method === "POST" && path === "/api/agents/me/chat/messages") return ["send_chat_message"];
|
|
if (/\/companies\/\{[^}]+\}\/workflows$/.test(path)) return method === "GET" ? ["list_workflows"] : method === "POST" ? ["create_workflow"] : [];
|
|
if (/\/workflows\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["read_workflow"] : method === "PATCH" ? ["update_workflow"] : [];
|
|
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 (/\/case-handoffs$/.test(path) && method === "POST") return ["handoff_case", "create_case"];
|
|
if (/\/cases\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["read_case"] : method === "PATCH" ? ["update_case"] : [];
|
|
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 conversationalConfirmation = method === "POST"
|
|
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
|
|
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
|
|
// Computes an install plan without saving it, so read-only modes may call it.
|
|
const readOnlyPreview = method === "POST" && path === "/api/companies/{companyId}/teams/catalog/{catalogId}/preview";
|
|
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
|
|
const protocol = operation["x-paperclip-transport"] === "protocol" || (operation["x-paperclip-transport"] !== "rest" && (!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" : "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: ["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."
|
|
: "The HTTP route enforces the same endpoint, payload, company, resource and approval permissions as agent REST access."],
|
|
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
|
|
? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions."
|
|
: "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.",
|
|
allowedModes: operation["x-paperclip-connection-management"] ? (method === "GET" ? ["standard", "ask", "planning"] : ["standard"]) : method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
|
|
: method === "GET" || method === "HEAD" || taskTitleUpdate || readOnlyPreview ? ["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.",
|
|
};
|
|
}
|