/** * STANDALONE entry point for Paperclip's install sandbox. * * This file: * - Has NO static imports of any project file * - Has NO re-exports * - Defines `createServerAdapter` directly and exports it as a named function * - Returns a ServerAdapterModule that matches the openrouter shape * exactly: type, execute, testEnvironment, sessionCodec, models, * agentConfigurationDoc (plus the standard optional ones) * - Implements everything synchronously where possible, with * process.env fallback to make heartbeats work even if the * external project files can't be loaded * * Why this approach: the previous 0.1.x versions all re-exported * `createServerAdapter` from another file or had top-level imports * that could fail at module-load time. The Paperclip install * sandbox wraps any module-load error as the generic "does not * export createServerAdapter" message. With this file, the import * chain is zero: just one function, one return object, no other * project modules touched. * * The full implementation (execute loop, tools, etc.) lives in * `src/index.ts` and `src/execute.ts`. The Paperclip runtime will * call into `execute(ctx)` here, which uses process.env for config * and an in-process HTTP client (no cross-file imports needed). */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult, AdapterExecutionContext, AdapterExecutionResult, AdapterModel, AdapterSkillContext, AdapterSkillSnapshot, ServerAdapterModule, HireApprovedPayload, HireApprovedHookResult, } from "@paperclipai/adapter-utils"; /* ------------------------------------------------------------------ */ /* Synchronous helpers (no cross-file imports) */ /* ------------------------------------------------------------------ */ const SCHEMA_VERSION = 1; function asString(v: unknown, fallback = ""): string { return typeof v === "string" ? v.trim() : fallback; } function asNumber(v: unknown, fallback: number): number { const n = Number(v); return Number.isFinite(n) ? n : fallback; } function asBool(v: unknown, fallback: boolean): boolean { if (typeof v === "boolean") return v; if (typeof v === "string") return v === "1" || v.toLowerCase() === "true"; return fallback; } function readConfig() { return { apiKey: process.env.MINIMAX_API_KEY ?? "", baseUrl: process.env.MINIMAX_BASE_URL ?? "https://api.minimax.chat/v1", model: process.env.MINIMAX_MODEL ?? "MiniMax-M3", paperclipApiKey: process.env.PAPERCLIP_API_KEY ?? "", paperclipApiUrl: process.env.PAPERCLIP_API_URL ?? "http://localhost:3100", maxTurns: asNumber(process.env.MAX_TURNS, 8), maxToolCallsPerTurn: asNumber(process.env.MAX_TOOL_CALLS_PER_TURN, 8), priceInputPer1M: asNumber(process.env.MINIMAX_PRICE_INPUT_PER_1M, 1), priceOutputPer1M: asNumber(process.env.MINIMAX_PRICE_OUTPUT_PER_1M, 3), skillsDir: process.env.MINIMAX_SKILLS_DIR ?? "", workspacePath: process.env.MINIMAX_WORKSPACE_PATH ?? "", approvalGated: asBool(process.env.MINIMAX_APPROVAL_GATED, true), }; } /* ------------------------------------------------------------------ */ /* Minimal HTTP client (uses global fetch — Node 18+) */ /* ------------------------------------------------------------------ */ async function callMiniMax( cfg: ReturnType, messages: any[], tools: any[], ): Promise { const res = await fetch(`${cfg.baseUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}`, }, body: JSON.stringify({ model: cfg.model, messages, tools, tool_choice: "auto", temperature: 0.7, max_tokens: 4096, }), }); if (!res.ok) { const text = await res.text(); throw new Error(`minimax ${res.status}: ${text.slice(0, 500)}`); } return (await res.json()) as any; } async function callPaperclip( cfg: ReturnType, path: string, init: RequestInit = {}, ): Promise { const url = path.startsWith("http") ? path : `${cfg.paperclipApiUrl}${path}`; const headers: Record = { "Content-Type": "application/json", ...((init.headers as Record) ?? {}), }; if (cfg.paperclipApiKey) { headers["Authorization"] = cfg.paperclipApiKey.startsWith("eyJ") ? `Bearer ${cfg.paperclipApiKey}` : `Bearer ${cfg.paperclipApiKey}`; } return fetch(url, { ...init, headers: { ...headers, ...(init.headers as any) } }); } /* ------------------------------------------------------------------ */ /* Tool definitions (8 Paperclip tools) */ /* ------------------------------------------------------------------ */ const TOOLS: any[] = [ { type: "function", function: { name: "get_issue", description: "Fetch a Paperclip issue by id or identifier (e.g. 'CRE-42').", parameters: { type: "object", properties: { issueId: { type: "string" } }, required: ["issueId"] }, }, }, { type: "function", function: { name: "list_issues", description: "List issues scoped to the current company.", parameters: { type: "object", properties: { status: { type: "string" }, assigneeId: { type: "string" }, parentId: { type: "string" }, label: { type: "string" }, limit: { type: "number" }, }, }, }, }, { type: "function", function: { name: "update_issue_status", description: "Move an issue to a new status.", parameters: { type: "object", properties: { issueId: { type: "string" }, status: { type: "string", enum: ["open", "in_progress", "done", "blocked", "cancelled"] }, reason: { type: "string" }, }, required: ["issueId", "status"], }, }, }, { type: "function", function: { name: "add_comment", description: "Post a comment on an issue.", parameters: { type: "object", properties: { issueId: { type: "string" }, body: { type: "string" }, mentions: { type: "array" } }, required: ["issueId", "body"], }, }, }, { type: "function", function: { name: "list_comments", description: "List comments on an issue.", 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 sub-issues.", parameters: { type: "object", properties: { parentId: { type: "string" }, title: { type: "string" }, body: { type: "string" }, assigneeId: { type: "string" }, }, required: ["parentId", "title"], }, }, }, { type: "function", function: { name: "hire_agent", description: "Propose hiring a new agent (approval-gated).", parameters: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, title: { type: "string" }, capabilities: { type: "string" } }, required: ["name", "role", "capabilities"], }, }, }, { type: "function", function: { name: "request_approval", description: "Open a board approval row.", parameters: { type: "object", properties: { title: { type: "string" }, description: { type: "string" }, amountCents: { type: "number" } }, required: ["title", "description"], }, }, }, ]; /* ------------------------------------------------------------------ */ /* Tool dispatch */ /* ------------------------------------------------------------------ */ async function dispatchTool( name: string, args: any, cfg: ReturnType, ctx: AdapterExecutionContext, ): Promise<{ output: any; isError: boolean }> { try { let r: Response; let result: any = null; switch (name) { case "get_issue": r = await callPaperclip(cfg, `/api/issues/${args.issueId}`); try { result = await r.json(); } catch { result = { status: r.status, text: "" }; } return { output: result, isError: !r.ok }; case "list_issues": { const q = new URLSearchParams(); for (const k of ["status", "assigneeId", "parentId", "label", "limit"]) { if (args[k] !== undefined && args[k] !== null) q.set(k, String(args[k])); } r = await callPaperclip(cfg, `/api/companies/${ctx.agent.companyId}/issues?${q}`); try { result = await r.json(); } catch { result = {}; } return { output: result, isError: !r.ok }; } case "update_issue_status": { r = await callPaperclip(cfg, `/api/issues/${args.issueId}/status`, { method: "PATCH", body: JSON.stringify({ status: args.status, reason: args.reason }), }); if (!r.ok) return { output: { error: "update_failed" }, isError: true }; if (args.reason) { await callPaperclip(cfg, `/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": r = await callPaperclip(cfg, `/api/issues/${args.issueId}/comments`, { method: "POST", body: JSON.stringify({ body: args.body, mentions: args.mentions ?? [] }), }); try { result = await r.json(); } catch { result = {}; } return { output: result, isError: !r.ok }; case "list_comments": r = await callPaperclip(cfg, `/api/issues/${args.issueId}/comments`); try { result = await r.json(); } catch { result = {}; } return { output: result, isError: !r.ok }; case "create_sub_issue": r = await callPaperclip(cfg, `/api/companies/${ctx.agent.companyId}/issues`, { method: "POST", body: JSON.stringify({ title: args.title, body: args.body ?? "", parentId: args.parentId, assigneeId: args.assigneeId, }), }); try { result = await r.json(); } catch { result = {}; } return { output: result, isError: !r.ok }; case "hire_agent": if (cfg.approvalGated) { r = await callPaperclip(cfg, `/api/companies/${ctx.agent.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: args, }), }); return { output: { routed: "approval", approvalId: null }, isError: !r.ok }; } r = await callPaperclip(cfg, `/api/companies/${ctx.agent.companyId}/agents`, { method: "POST", body: JSON.stringify(args), }); try { result = await r.json(); } catch { result = {}; } return { output: result, isError: !r.ok }; case "request_approval": r = await callPaperclip(cfg, `/api/companies/${ctx.agent.companyId}/approvals`, { method: "POST", body: JSON.stringify({ kind: "request_approval", title: args.title, description: args.description, amountCents: args.amountCents, }), }); try { result = await r.json(); } catch { result = {}; } return { output: result, isError: !r.ok }; default: return { output: { error: "unknown_tool", name }, isError: true }; } } catch (e) { return { output: { error: e instanceof Error ? e.message : String(e) }, isError: true }; } } /* ------------------------------------------------------------------ */ /* Execute — the tool loop */ /* ------------------------------------------------------------------ */ async function execute(ctx: AdapterExecutionContext): Promise { const cfg = readConfig(); if (!cfg.apiKey) { return { exitCode: 1, signal: null, timedOut: false, errorMessage: "MINIMAX_API_KEY not set", errorCode: "missing_api_key", summary: "missing_api_key", }; } const issue = (ctx.context as any)?.issue ?? {}; const issueId = String(issue.id ?? (ctx.context as any)?.issueId ?? "unknown"); const issueTitle = String(issue.title ?? ""); const issueStatus = String(issue.status ?? ""); const issueIdentifier = issue.identifier ? String(issue.identifier) : null; const previousParams = (ctx.runtime?.sessionParams as Record | null) ?? null; const session: { schema: number; header: { sessionId: string; model: string; messageCount: number }; messages: any[]; } = previousParams && previousParams.schema === SCHEMA_VERSION ? (previousParams as any) : { schema: SCHEMA_VERSION, header: { sessionId: `minimax-${Date.now()}`, model: cfg.model, messageCount: 0 }, messages: [], }; const systemMessage = { role: "system", content: `You are ${ctx.agent.name}, a ${ctx.agent.adapterType || "agent"} in Paperclip. Issue: ${issueIdentifier ?? issueId} - "${issueTitle}". Status: ${issueStatus}. Tools available: ${TOOLS.map((t) => t.function.name).join(", ")}. When done, post a comment and set status to done. If blocked 3x, stop and report.`, }; const userMessage = { role: "user", content: `Begin work on ${issueIdentifier ?? issueId} - "${issueTitle}".`, }; session.messages.push(systemMessage, userMessage); session.header.messageCount = session.messages.length; const allUsages: any[] = []; let totalInput = 0; let totalOutput = 0; let finalContent = ""; let failed = false; let errorMessage: string | null = null; const recentCalls: string[] = []; for (let turn = 1; turn <= cfg.maxTurns; turn++) { let resp: any; try { resp = await callMiniMax(cfg, session.messages, TOOLS); } catch (e) { failed = true; errorMessage = `minimax: ${e instanceof Error ? e.message : String(e)}`; break; } const choice = resp.choices?.[0]; const message = choice?.message ?? {}; allUsages.push(resp.usage); totalInput += resp.usage?.prompt_tokens ?? 0; totalOutput += resp.usage?.completion_tokens ?? 0; if (message.content) finalContent = String(message.content); await ctx.onLog("stdout", `[turn ${turn}] < ${String(message.content ?? "").slice(0, 2000)}\n`); const toolCalls = (message.tool_calls ?? []) as Array<{ id: string; function: { name: string; arguments: string }; }>; if (toolCalls.length === 0) break; session.messages.push({ role: "assistant", content: message.content ?? null, tool_calls: toolCalls, }); for (const tc of toolCalls) { const sig = `${tc.function.name}:${tc.function.arguments}`; recentCalls.push(sig); if (recentCalls.length > 6) recentCalls.shift(); if ( recentCalls.length >= 3 && recentCalls.slice(-3).every((s) => s === sig) ) { failed = true; errorMessage = `repeat_call_loop_break: ${tc.function.name}`; break; } } if (failed) break; for (const tc of toolCalls) { let args: any = {}; try { args = JSON.parse(tc.function.arguments || "{}"); } catch { /* */ } await ctx.onLog("stdout", `[turn ${turn}] • ${tc.function.name} ${JSON.stringify(args).slice(0, 500)}\n`); const { output, isError } = await dispatchTool(tc.function.name, args, cfg, ctx); await ctx.onLog("stdout", `[turn ${turn}] ${isError ? "✗" : "✓"} ${JSON.stringify(output).slice(0, 1000)}\n`); session.messages.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: JSON.stringify(output), }); } session.header.messageCount = session.messages.length; } const costUsd = (totalInput / 1_000_000) * cfg.priceInputPer1M + (totalOutput / 1_000_000) * cfg.priceOutputPer1M; return { exitCode: failed ? 1 : 0, signal: null, timedOut: false, errorMessage: errorMessage, errorCode: failed ? "adapter_failed" : null, errorFamily: failed ? "transient_upstream" : null, usage: { inputTokens: totalInput, outputTokens: totalOutput }, sessionParams: session, sessionDisplayId: session.header.sessionId.slice(0, 8), provider: "minimax", biller: "minimax", model: cfg.model, billingType: "api", costUsd: Math.round(costUsd * 10_000) / 10_000, summary: finalContent.slice(0, 4000) || (failed ? `failed: ${errorMessage}` : "completed"), resultJson: { usage: allUsages }, }; } /* ------------------------------------------------------------------ */ /* Environment test */ /* ------------------------------------------------------------------ */ async function testEnvironment(ctx: AdapterEnvironmentTestContext): Promise { const cfg = readConfig(); const checks: any[] = []; if (!cfg.apiKey) { checks.push({ code: "missing_api_key", level: "error", message: "MINIMAX_API_KEY not set", hint: "Set MINIMAX_API_KEY in agent adapterConfig or host environment.", }); } else { checks.push({ code: "api_key_present", level: "info", message: "MINIMAX_API_KEY is set" }); } try { const res = await fetch(`${cfg.baseUrl}/models`, { headers: { Authorization: `Bearer ${cfg.apiKey}` }, }); checks.push( res.ok ? { code: "minimax_connect", level: "info", message: `connected to ${cfg.baseUrl}` } : { code: "minimax_connect", level: "error", message: `minimax returned ${res.status}` }, ); } catch (e) { checks.push({ code: "minimax_connect", level: "error", message: e instanceof Error ? e.message : String(e), }); } const status = checks.some((c) => c.level === "error") ? "fail" : checks.some((c) => c.level === "warn") ? "warn" : "pass"; return { adapterType: "minimax", status, checks, testedAt: new Date().toISOString(), }; } /* ------------------------------------------------------------------ */ /* Skills — no cross-file imports, just empty snapshots */ /* ------------------------------------------------------------------ */ async function listSkills(_ctx: AdapterSkillContext): Promise { return { adapterType: "minimax", supported: true, mode: "persistent", desiredSkills: [], entries: [], warnings: [], }; } async function syncSkills(ctx: AdapterSkillContext): Promise { return listSkills(ctx); } /* ------------------------------------------------------------------ */ /* Session codec — minimal shape Paperclip expects */ /* ------------------------------------------------------------------ */ const sessionCodec = { serialize: (p: Record | null): Record | null => p ?? null, deserialize: (r: unknown): Record | null => { if (!r || typeof r !== "object" || Array.isArray(r)) return null; const o = r as Record; if (o.schema !== SCHEMA_VERSION) return null; return o; }, getDisplayId: (p: Record | null): string | null => { const h = (p as any)?.header; return typeof h?.sessionId === "string" ? h.sessionId.slice(0, 8) : null; }, }; /* ------------------------------------------------------------------ */ /* Models catalog */ /* ------------------------------------------------------------------ */ const MODELS: AdapterModel[] = [ { id: "MiniMax-M3", label: "MiniMax M3 (latest, default)" }, { id: "MiniMax-M3:free", label: "MiniMax M3 (free tier)" }, { id: "MiniMax-M2.7", label: "MiniMax M2.7" }, { id: "MiniMax-M2.5", label: "MiniMax M2.5" }, { id: "MiniMax-M2", label: "MiniMax M2 (earliest supported)" }, ]; /* ------------------------------------------------------------------ */ /* Agent configuration doc */ /* ------------------------------------------------------------------ */ const AGENT_DOC = `# MiniMax Adapter (community) Adapter: \`minimax\` — pure-HTTP TypeScript, no CLI subprocess. Backed by the [MiniMax](https://MiniMax.ai) M2/M3 family via MiniMax's OpenAI-compatible chat completions API. ## Required - **apiKey**: MiniMax API key (or set \`MINIMAX_API_KEY\` env var) ## Core - **baseUrl** (default \`https://api.minimax.chat/v1\`) - **model** (default \`MiniMax-M3\`; also \`MiniMax-M3:free\`, \`MiniMax-M2.7\`, etc.) - **maxTurns** (default 8): tool-loop iterations per heartbeat - **maxToolCallsPerTurn** (default 8): parallel tool calls per turn - **priceInputPer1M** / **priceOutputPer1M** (default $1 / $3) - **approvalGated** (default true): route side-effect tools through \`/api/companies/:id/approvals\` ## Built-in tools \`get_issue\`, \`list_issues\`, \`update_issue_status\`, \`add_comment\`, \`list_comments\`, \`create_sub_issue\`, \`hire_agent\` (approval-gated), \`request_approval\` (approval-gated). ## Loop break 3x identical consecutive tool calls → heartbeat fails with \`errorMessage: "repeat_call_loop_break: "\`. Source: `; /* ------------------------------------------------------------------ */ /* The exported function */ /* ------------------------------------------------------------------ */ export function createServerAdapter(): ServerAdapterModule { return { type: "minimax", execute, testEnvironment, listSkills, syncSkills, sessionCodec, supportsLocalAgentJwt: true, models: MODELS, listModels: async () => MODELS, agentConfigurationDoc: AGENT_DOC, onHireApproved: async (_p: HireApprovedPayload, _c: Record): Promise => ({ ok: true, }), }; }