/** * Paperclip API tools. * * Eight tools that map 1:1 to the most-common Paperclip board operations. * They are defined as OpenAI-style function-calling schemas and shipped * to MiniMax on every turn. The model picks which to call; we dispatch * to the corresponding Paperclip REST endpoint and return the result. * * Approval-gated tools (`hire_agent`, `request_approval`) route through * Paperclip's `/api/companies/:id/approvals` flow so the board stays in * the loop — even autonomous agents can't sidestep a human sign-off. */ import type { AdapterConfig, ToolDefinition, JsonValue, JsonObject } from "./types.js"; import { authHeadersFor } from "./auth.js"; const toolDefs: ToolDefinition[] = [ { type: "function", function: { name: "get_issue", description: "Fetch a Paperclip issue by id or identifier (e.g. 'CRE-42'). Returns the full issue document: title, body, status, assignee, parent, blockers, comments summary, and any document attachments.", parameters: { type: "object", properties: { issueId: { type: "string", description: "The issue UUID, or its short identifier like 'CRE-42'." }, }, required: ["issueId"], }, }, }, { type: "function", function: { name: "list_issues", description: "List issues scoped to the current company. Use status, assignee, parent, or label filters to narrow. The default scope is your own company; pass companyId only if you have a cross-company board role.", parameters: { type: "object", properties: { status: { type: "string", description: "e.g. 'open', 'in_progress', 'done', 'blocked'." }, assigneeId: { type: "string" }, parentId: { type: "string" }, label: { type: "string" }, limit: { type: "number", description: "Default 25, max 200." }, }, }, }, }, { type: "function", function: { name: "update_issue_status", description: "Move an issue to a new status. Allowed transitions depend on the company's workflow policy; the typical set is open → in_progress → done, with blocked available from any non-terminal state.", parameters: { type: "object", properties: { issueId: { type: "string" }, status: { type: "string", enum: ["open", "in_progress", "done", "blocked", "cancelled"] }, reason: { type: "string", description: "Optional human-readable explanation that becomes a comment on the issue." }, }, required: ["issueId", "status"], }, }, }, { type: "function", function: { name: "add_comment", description: "Post a comment on an issue. Use this to summarize your work, link outputs, ask follow-up questions to a human, or reply to a peer agent. Long comments are truncated at 50 KB.", parameters: { type: "object", properties: { issueId: { type: "string" }, body: { type: "string" }, mentions: { type: "array", items: { type: "string" }, description: "Optional user/agent UUIDs to @-mention." }, }, required: ["issueId", "body"], }, }, }, { type: "function", function: { name: "list_comments", description: "List the comments on an issue, oldest first.", parameters: { type: "object", properties: { issueId: { type: "string" }, limit: { type: "number" }, }, required: ["issueId"], }, }, }, { type: "function", function: { name: "create_sub_issue", description: "Decompose the current issue into one or more sub-issues. Use this when a task is too big for one heartbeat or when a multi-track plan needs to be tracked as separate work items. Sub-issues inherit parent links and labels.", parameters: { type: "object", properties: { parentId: { type: "string" }, title: { type: "string" }, body: { type: "string" }, assigneeId: { type: "string", description: "Defaults to yourself." }, }, required: ["parentId", "title"], }, }, }, { type: "function", function: { name: "hire_agent", description: "Propose hiring a new agent. This is APPROVAL-GATED — it creates an approval row in the company's board queue; the new agent materializes only after a human signs off. Use for new roles you discover mid-task (e.g. 'we need a QA agent now').", parameters: { type: "object", properties: { companyId: { type: "string" }, name: { type: "string" }, role: { type: "string" }, title: { type: "string" }, capabilities: { type: "string", description: "Plain-language description of what this agent will do." }, }, required: ["name", "role", "capabilities"], }, }, }, { type: "function", function: { name: "request_approval", description: "Open a board approval row for something risky (a code push, a spend over budget, an outbound communication). The board will see this in their Approvals panel and can approve or deny.", parameters: { type: "object", properties: { companyId: { type: "string" }, title: { type: "string" }, description: { type: "string" }, amountCents: { type: "number", description: "If the approval is tied to a spend, the cents." }, }, required: ["title", "description"], }, }, }, ]; export function listTools(): ToolDefinition[] { return toolDefs; } export type ToolName = (typeof toolDefs)[number]["function"]["name"]; export interface ToolContext { config: AdapterConfig; companyId: string; issueId: string; fetchImpl?: typeof fetch; /** When true, side-effect tools (hire_agent, request_approval) are routed through the approval flow. */ approvalGated: boolean; } /** Make a fetch with the right base URL + auth headers baked in. */ function makeClient(ctx: ToolContext) { const base = ctx.config.paperclipApiUrl.replace(/\/$/, ""); const headers = authHeadersFor(ctx.config); const f = ctx.fetchImpl ?? fetch; return async (path: string, init: RequestInit = {}): Promise => { const url = path.startsWith("http") ? path : `${base}${path}`; return f(url, { ...init, headers: { "Content-Type": "application/json", ...headers, ...(init.headers as Record | undefined), }, }); }; } /** * Dispatch a tool call to Paperclip. Returns a JSON-serializable value * for the model and records the result for the transcript. */ export async function callTool( name: string, rawArgs: string, ctx: ToolContext, ): Promise<{ output: JsonValue; isError: boolean }> { let args: JsonObject = {}; try { const parsed = JSON.parse(rawArgs || "{}"); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { args = parsed as JsonObject; } } catch { return { output: { error: "invalid_json", raw: rawArgs }, isError: true }; } const client = makeClient(ctx); try { switch (name) { case "get_issue": { const r = await client(`/api/issues/${args.issueId}`); return { output: await safeJson(r), isError: !r.ok }; } case "list_issues": { const qs = buildQuery({ status: args.status, assigneeId: args.assigneeId, parentId: args.parentId, label: args.label, limit: args.limit, }); const r = await client(`/api/companies/${ctx.companyId}/issues?${qs}`); return { output: await safeJson(r), isError: !r.ok }; } case "update_issue_status": { const r = await client(`/api/issues/${args.issueId}/status`, { method: "PATCH", body: JSON.stringify({ status: args.status, reason: args.reason }), }); if (!r.ok) return { output: await safeJson(r), isError: true }; // Post a status-comment if a reason was given. if (args.reason) { await client(`/api/issues/${args.issueId}/comments`, { method: "POST", body: JSON.stringify({ body: `_status → ${args.status}_\n\n${args.reason}` }), }); } return { output: { ok: true, status: args.status }, isError: false }; } case "add_comment": { const r = await client(`/api/issues/${args.issueId}/comments`, { method: "POST", body: JSON.stringify({ body: args.body, mentions: args.mentions ?? [] }), }); return { output: await safeJson(r), isError: !r.ok }; } case "list_comments": { const r = await client(`/api/issues/${args.issueId}/comments`); return { output: await safeJson(r), isError: !r.ok }; } case "create_sub_issue": { const r = await client(`/api/companies/${ctx.companyId}/issues`, { method: "POST", body: JSON.stringify({ title: args.title, body: args.body ?? "", parentId: args.parentId, assigneeId: args.assigneeId, }), }); return { output: await safeJson(r), isError: !r.ok }; } case "hire_agent": { if (ctx.approvalGated) { // Route through the approval flow so a human signs off. const r = await client(`/api/companies/${ctx.companyId}/approvals`, { method: "POST", body: JSON.stringify({ kind: "hire_agent", title: `Hire new agent: ${args.name}`, description: `Role: ${args.role}\nTitle: ${args.title ?? args.role}\n\nCapabilities: ${args.capabilities}`, payload: { name: args.name, role: args.role, title: args.title, capabilities: args.capabilities, }, }), }); return { output: { routed: "approval", approvalId: ((await safeJson(r)) as { id?: string } | null)?.id ?? null, message: "Approval request created. Will materialize after a human signs off.", }, isError: !r.ok, }; } // Non-gated path (rare — only in dev/test). const r = await client(`/api/companies/${ctx.companyId}/agents`, { method: "POST", body: JSON.stringify(args), }); return { output: await safeJson(r), isError: !r.ok }; } case "request_approval": { const r = await client(`/api/companies/${ctx.companyId}/approvals`, { method: "POST", body: JSON.stringify({ kind: "request_approval", title: args.title, description: args.description, amountCents: args.amountCents, }), }); return { output: await safeJson(r), isError: !r.ok }; } default: return { output: { error: "unknown_tool", name }, isError: true }; } } catch (err) { return { output: { error: String(err) }, isError: true }; } } async function safeJson(r: Response): Promise { try { return (await r.json()) as JsonValue; } catch { return { status: r.status, text: await r.text().catch(() => "") }; } } function buildQuery(obj: JsonObject): string { const parts: string[] = []; for (const [k, v] of Object.entries(obj)) { if (v === undefined || v === null) continue; parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); } return parts.join("&"); }