/** * Server-side execution for the DeepSeek-via-Hermes adapter. * * Spawns `hermes chat -q "..." -Q -m --provider custom` with * HERMES_HOME pinned to a DeepSeek-configured profile so the same machine * can run other Hermes-based agents on different providers in parallel. * * The Hermes CLI loads model.base_url, model.key_env (DEEPSEEK_API_KEY), * and toolsets from /config.yaml + /.env. */ import { runChildProcess, buildPaperclipEnv, renderTemplate, ensureAbsoluteDirectory, } from "@paperclipai/adapter-utils/server-utils"; import { HERMES_CLI, DEFAULT_PROFILE_HOME, DEFAULT_MODEL, DEFAULT_PROVIDER, DEFAULT_TIMEOUT_SEC, DEFAULT_GRACE_SEC, SESSION_ID_REGEX, SESSION_ID_REGEX_LEGACY, TOKEN_USAGE_REGEX, COST_REGEX, } from "../shared/constants.js"; function cfgString(v) { return typeof v === "string" && v.length > 0 ? v : undefined; } function cfgNumber(v) { return typeof v === "number" ? v : undefined; } function cfgBoolean(v) { return typeof v === "boolean" ? v : undefined; } function cfgStringArray(v) { return Array.isArray(v) && v.every((i) => typeof i === "string") ? v : undefined; } const DEFAULT_PROMPT_TEMPLATE = `You are "{{agentName}}", an AI agent employee in a Paperclip-managed company powered by DeepSeek. IMPORTANT: Use the \`terminal\` tool with \`curl\` for ALL Paperclip API calls (web_extract and browser cannot access localhost). Your Paperclip identity: Agent ID: {{agentId}} Company ID: {{companyId}} API Base: {{paperclipApiUrl}} {{#taskId}} ## Assigned Task Issue ID: {{taskId}} Title: {{taskTitle}} {{taskBody}} ## Workflow 1. Work on the task using your tools. 2. When done, mark the issue completed: \`curl -s -X PATCH "{{paperclipApiUrl}}/issues/{{taskId}}" -H "Content-Type: application/json" -d '{"status":"done"}'\` 3. Post a completion comment summarizing what you did: \`curl -s -X POST "{{paperclipApiUrl}}/issues/{{taskId}}/comments" -H "Content-Type: application/json" -d '{"body":"DONE: "}'\` {{/taskId}} {{#commentId}} ## Comment on This Issue Someone commented. Read it: \`curl -s "{{paperclipApiUrl}}/issues/{{taskId}}/comments/{{commentId}}" | python3 -m json.tool\` Address the comment, POST a reply if needed, then continue working. {{/commentId}} {{#noTask}} ## Heartbeat Wake — Check for Work 1. List your open issues: \`curl -s "{{paperclipApiUrl}}/companies/{{companyId}}/issues?assigneeAgentId={{agentId}}"\` 2. Pick the highest priority and work on it. When done, follow steps 2-3 above. 3. If nothing to do, report briefly what you checked. {{/noTask}}`; function buildPrompt(ctx, config) { const template = cfgString(config.promptTemplate) || DEFAULT_PROMPT_TEMPLATE; const taskId = cfgString(ctx.context?.taskId); const taskTitle = cfgString(ctx.context?.taskTitle) || ""; const taskBody = cfgString(ctx.context?.taskBody) || ""; const commentId = cfgString(ctx.context?.commentId) || ""; const wakeReason = cfgString(ctx.context?.wakeReason) || ""; const agentName = ctx.agent?.name || "DeepSeek Agent"; const companyName = cfgString(ctx.context?.companyName) || ""; const projectName = cfgString(ctx.context?.projectName) || ""; let paperclipApiUrl = cfgString(config.paperclipApiUrl) || process.env.PAPERCLIP_API_URL || "http://127.0.0.1:3100/api"; if (!paperclipApiUrl.endsWith("/api")) { paperclipApiUrl = paperclipApiUrl.replace(/\/+$/, "") + "/api"; } const vars = { agentId: ctx.agent?.id || "", agentName, companyId: ctx.agent?.companyId || "", companyName, runId: ctx.runId || "", taskId: taskId || "", taskTitle, taskBody, commentId, wakeReason, projectName, paperclipApiUrl, }; let rendered = template; rendered = rendered.replace(/\{\{#taskId\}\}([\s\S]*?)\{\{\/taskId\}\}/g, taskId ? "$1" : ""); rendered = rendered.replace(/\{\{#noTask\}\}([\s\S]*?)\{\{\/noTask\}\}/g, taskId ? "" : "$1"); rendered = rendered.replace(/\{\{#commentId\}\}([\s\S]*?)\{\{\/commentId\}\}/g, commentId ? "$1" : ""); return renderTemplate(rendered, vars); } function cleanResponse(raw) { return raw .split("\n") .filter((line) => { const t = line.trim(); if (!t) return true; if (t.startsWith("[tool]") || t.startsWith("[hermes]") || t.startsWith("[paperclip]") || t.startsWith("[deepseek]")) return false; if (t.startsWith("session_id:")) return false; if (/^\[\d{4}-\d{2}-\d{2}T/.test(t)) return false; if (/^\[done\]\s*┊/.test(t)) return false; if (/^┊\s*[\p{Emoji_Presentation}]/u.test(t) && !/^┊\s*💬/.test(t)) return false; if (/^\p{Emoji_Presentation}\s*(Completed|Running|Error)?\s*$/u.test(t)) return false; return true; }) .map((line) => { let t = line.replace(/^[\s]*┊\s*💬\s*/, "").trim(); t = t.replace(/^\[done\]\s*/, "").trim(); return t; }) .join("\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function parseHermesOutput(stdout, stderr) { const combined = stdout + "\n" + stderr; const result = {}; const sessionMatch = stdout.match(SESSION_ID_REGEX); if (sessionMatch?.[1]) { result.sessionId = sessionMatch[1]; const sessionLineIdx = stdout.lastIndexOf("\nsession_id:"); if (sessionLineIdx > 0) { result.response = cleanResponse(stdout.slice(0, sessionLineIdx)); } } else { const legacyMatch = combined.match(SESSION_ID_REGEX_LEGACY); if (legacyMatch?.[1]) result.sessionId = legacyMatch[1]; const cleaned = cleanResponse(stdout); if (cleaned.length > 0) result.response = cleaned; } const usageMatch = combined.match(TOKEN_USAGE_REGEX); if (usageMatch) { result.usage = { inputTokens: parseInt(usageMatch[1], 10) || 0, outputTokens: parseInt(usageMatch[2], 10) || 0, }; } const costMatch = combined.match(COST_REGEX); if (costMatch?.[1]) result.costUsd = parseFloat(costMatch[1]); if (stderr.trim()) { const errorLines = stderr .split("\n") .filter((line) => /error|exception|traceback|failed/i.test(line)) .filter((line) => !/INFO|DEBUG|warn/i.test(line)); if (errorLines.length > 0) result.errorMessage = errorLines.slice(0, 5).join("\n"); } return result; } export async function execute(ctx) { const config = ctx.agent?.adapterConfig ?? {}; const hermesCmd = cfgString(config.hermesCommand) || HERMES_CLI; const model = cfgString(config.model) || DEFAULT_MODEL; const provider = cfgString(config.provider) || DEFAULT_PROVIDER; const profileHome = cfgString(config.hermesProfileHome) || DEFAULT_PROFILE_HOME; const timeoutSec = cfgNumber(config.timeoutSec) || DEFAULT_TIMEOUT_SEC; const graceSec = cfgNumber(config.graceSec) || DEFAULT_GRACE_SEC; const toolsets = cfgString(config.toolsets) || cfgStringArray(config.enabledToolsets)?.join(","); const extraArgs = cfgStringArray(config.extraArgs); const persistSession = cfgBoolean(config.persistSession) !== false; const worktreeMode = cfgBoolean(config.worktreeMode) === true; const checkpoints = cfgBoolean(config.checkpoints) === true; const useQuiet = cfgBoolean(config.quiet) !== false; const prompt = buildPrompt(ctx, config); const args = ["chat", "-q", prompt]; if (useQuiet) args.push("-Q"); if (model) args.push("-m", model); args.push("--provider", provider); if (toolsets) args.push("-t", toolsets); if (worktreeMode) args.push("-w"); if (checkpoints) args.push("--checkpoints"); if (cfgBoolean(config.verbose) === true) args.push("-v"); args.push("--source", "tool"); args.push("--yolo"); const prevSessionId = cfgString(ctx.runtime?.sessionParams?.sessionId); if (persistSession && prevSessionId) args.push("--resume", prevSessionId); if (extraArgs?.length) args.push(...extraArgs); // Pin Hermes to the DeepSeek profile by default. The agent can override // by setting adapter_config.hermesProfileHome or adapter_config.env.HERMES_HOME. const env = { ...process.env, ...buildPaperclipEnv(ctx.agent), HERMES_HOME: profileHome, }; if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId; const taskId = cfgString(ctx.context?.taskId); if (taskId) env.PAPERCLIP_TASK_ID = taskId; // Parity with hermes_local (paperclip-src/server/src/adapters/registry.ts:267): // inject the per-run agent auth token so the agent can call the Paperclip API. // Without this, every Paperclip API write from the running agent fails with 401. // // Resolve env from the runtime-resolved config (ctx.config.env contains plain // strings — Paperclip's secrets service unwraps {type:"plain"|"secret_ref", ...} // bindings before invocation in services/heartbeat.ts:5433-5437). // Fall back to agent.adapterConfig.env with manual unwrapping for older paths. function unwrapEnvValue(v) { if (typeof v === "string") return v; if (v && typeof v === "object" && !Array.isArray(v)) { if (v.type === "plain" && typeof v.value === "string") return v.value; } return undefined; // skip secret_ref / unknown types — let resolver handle them } const resolvedUserEnv = ctx.config && typeof ctx.config === "object" && ctx.config.env && typeof ctx.config.env === "object" && !Array.isArray(ctx.config.env) ? ctx.config.env : null; const rawUserEnv = typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) ? config.env : {}; // Prefer pre-resolved values from ctx.config.env when available; fall back to // unwrapping raw bindings from agent.adapterConfig.env. const flattenedUserEnv = {}; for (const [k, v] of Object.entries(rawUserEnv)) { const resolved = resolvedUserEnv && typeof resolvedUserEnv[k] === "string" ? resolvedUserEnv[k] : unwrapEnvValue(v); if (typeof resolved === "string") flattenedUserEnv[k] = resolved; } const userEnvApiKey = flattenedUserEnv.PAPERCLIP_API_KEY; const explicitApiKey = typeof userEnvApiKey === "string" && userEnvApiKey.trim().length > 0; if (ctx.authToken && !explicitApiKey) env.PAPERCLIP_API_KEY = ctx.authToken; // Apply unwrapped user env (may override HERMES_HOME, OPENAI_API_KEY, etc.). Object.assign(env, flattenedUserEnv); const cwd = cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || "."; try { await ensureAbsoluteDirectory(cwd); } catch { // non-fatal } await ctx.onLog( "stdout", `[deepseek] Starting Hermes (model=${model}, provider=${provider}, profileHome=${env.HERMES_HOME}, timeout=${timeoutSec}s)\n`, ); if (prevSessionId) { await ctx.onLog("stdout", `[deepseek] Resuming session: ${prevSessionId}\n`); } // Reclassify benign Hermes stderr lines as stdout so the UI doesn't paint them red. const wrappedOnLog = async (stream, chunk) => { if (stream === "stderr") { const trimmed = chunk.trimEnd(); const isBenign = /^\[?\d{4}[-/]\d{2}[-/]\d{2}T/.test(trimmed) || /^[A-Z]+:\s+(INFO|DEBUG|WARN|WARNING)\b/.test(trimmed) || /Successfully registered all tools/.test(trimmed) || /MCP [Ss]erver/.test(trimmed) || /tool registered successfully/.test(trimmed) || /Application initialized/.test(trimmed); if (isBenign) return ctx.onLog("stdout", chunk); } return ctx.onLog(stream, chunk); }; // Forward ctx.onSpawn so Paperclip persists processPid/processGroupId to the // heartbeat_runs row. Without it, the reaper cannot verify the child is alive // (run.processPid is null) and treats the run as orphaned during long quiet // phases (DeepSeek V4-Pro thinking can be silent for 60-90s per turn). const result = await runChildProcess(ctx.runId, hermesCmd, args, { cwd, env, timeoutSec, graceSec, onLog: wrappedOnLog, onSpawn: ctx.onSpawn, }); const parsed = parseHermesOutput(result.stdout || "", result.stderr || ""); await ctx.onLog( "stdout", `[deepseek] Exit code: ${result.exitCode ?? "null"}, timed out: ${result.timedOut}\n`, ); if (parsed.sessionId) { await ctx.onLog("stdout", `[deepseek] Session: ${parsed.sessionId}\n`); } const executionResult = { exitCode: result.exitCode, signal: result.signal, timedOut: result.timedOut, provider, model, }; if (parsed.errorMessage) executionResult.errorMessage = parsed.errorMessage; if (parsed.usage) executionResult.usage = parsed.usage; if (parsed.costUsd !== undefined) executionResult.costUsd = parsed.costUsd; if (parsed.response) executionResult.summary = parsed.response.slice(0, 2000); executionResult.resultJson = { result: parsed.response || "", session_id: parsed.sessionId || null, usage: parsed.usage || null, cost_usd: parsed.costUsd ?? null, }; if (persistSession && parsed.sessionId) { executionResult.sessionParams = { sessionId: parsed.sessionId }; executionResult.sessionDisplayId = parsed.sessionId.slice(0, 16); } return executionResult; }