diff --git a/deploy/pi-agent/Dockerfile b/deploy/pi-agent/Dockerfile new file mode 100644 index 00000000..b3638fad --- /dev/null +++ b/deploy/pi-agent/Dockerfile @@ -0,0 +1,44 @@ +# ============================================================================= +# Pi Agent Container (MiniMax-M3 + Tavily Key Pool + Runner Service) +# Requires Node.js 22+ for node:fs globSync support +# ============================================================================= +FROM node:22-bookworm-slim + +# 安装系统运行时依赖 (bash, curl, git, ca-certificates) +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + bash; \ + rm -rf /var/lib/apt/lists/* + +# 全局安装 pi-coding-agent CLI +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 --omit=dev + +# 验证 pi 安装 +RUN pi --version + +WORKDIR /app + +# 复制 runner、扩展工具以及入口脚本 +COPY deploy/pi-agent/extensions/ /app/extensions/ +COPY deploy/pi-agent/runner.mjs /app/runner.mjs +COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh + +RUN chmod +x /app/entrypoint.sh /app/runner.mjs + +# 默认环境变量配置 (敏感密钥通过运行时 -e 或 docker-compose 传入) +ENV PORT=18766 \ + DATA_DIR=/app/data/workspaces \ + PI_CODING_AGENT_DIR=/root/.pi/agent \ + MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1 \ + MINIMAX_MODEL_ID=MiniMax-M3 \ + MINIMAX_API_TYPE=openai-compatible \ + MINIMAX_PROVIDER=minimax + +EXPOSE 18766 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "/app/runner.mjs"] diff --git a/deploy/pi-agent/entrypoint.sh b/deploy/pi-agent/entrypoint.sh new file mode 100755 index 00000000..e007edd0 --- /dev/null +++ b/deploy/pi-agent/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +# ============================================================================= +# Pi Agent Container Entrypoint +# Dynamically configures MiniMax provider (4 core parameters) & Tavily search extension +# ============================================================================= + +# 1. 核心参数配置与规整 +# 1) ENDPOINT URL (默认: https://api.minimaxi.com/v1) +ENDPOINT_URL="${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}" + +# 2) API TOKEN (用户自行设定) +API_TOKEN="${MINIMAX_API_KEY:-${MINIMAX_API_TOKEN:-}}" + +# 3) MODEL ID (默认: MiniMax-M3) +MODEL_ID="${MINIMAX_MODEL_ID:-MiniMax-M3}" + +# 4) API 类型 (用户设定 openai-compatible,规整映射至 pi 的 openai-completions) +RAW_API_TYPE="${MINIMAX_API_TYPE:-openai-compatible}" +if [ "$RAW_API_TYPE" = "openai-compatible" ] || [ "$RAW_API_TYPE" = "openai" ]; then + API_TYPE="openai-completions" +else + API_TYPE="$RAW_API_TYPE" +fi + +echo "=================================================================" +echo " 🚀 Starting Pi-Agent Container" +echo " - ENDPOINT URL : ${ENDPOINT_URL}" +echo " - MODEL ID : ${MODEL_ID}" +echo " - API TYPE : ${API_TYPE} (from ${RAW_API_TYPE})" +if [ -z "$API_TOKEN" ]; then + echo " - API TOKEN : [WARNING] MINIMAX_API_KEY is not set yet!" +else + MASKED_KEY="${API_TOKEN:0:4}...${API_TOKEN: -4}" + echo " - API TOKEN : configured (${MASKED_KEY})" +fi +if [ -n "$TAVILY_API_KEYS" ] || [ -n "$TAVILY_API_KEY" ]; then + echo " - TAVILY SEARCH: configured (API Key Pool active)" +else + echo " - TAVILY SEARCH: not configured (set TAVILY_API_KEYS to enable)" +fi +echo "=================================================================" + +# 2. 准备 Pi Agent 运行时目录结构 +PI_DIR="${PI_CODING_AGENT_DIR:-/root/.pi/agent}" +mkdir -p "${PI_DIR}/extensions" +mkdir -p "${DATA_DIR:-/app/data/workspaces}" + +# 3. 注入 Tavily Web Search 扩展 +if [ -f "/app/extensions/tavily-search.mjs" ]; then + cp -f "/app/extensions/tavily-search.mjs" "${PI_DIR}/extensions/tavily-search.mjs" +fi + +# 4. 动态生成 models.json +cat < "${PI_DIR}/models.json" +{ + "providers": { + "minimax": { + "name": "MiniMax", + "baseUrl": "${ENDPOINT_URL}", + "api": "${API_TYPE}", + "apiKey": "${API_TOKEN}", + "models": [ + { + "id": "${MODEL_ID}", + "name": "${MODEL_ID}", + "reasoning": true, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 131072 + } + ] + } + } +} +EOF + +# 5. 生成 settings.json 设置默认 Provider 和 Model +cat < "${PI_DIR}/settings.json" +{ + "defaultProvider": "minimax", + "defaultModel": "${MODEL_ID}" +} +EOF + +# 6. 执行容器命令 +if [ "$#" -eq 0 ]; then + exec node /app/runner.mjs +else + exec "$@" +fi diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs new file mode 100755 index 00000000..b69201b9 --- /dev/null +++ b/deploy/pi-agent/runner.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node +/** + * Pi Agent Container HTTP Runner Server + * Listens for task run requests, executes pi agent in RPC mode within workspace, + * and returns generated artifact patches. + */ + +import http from "node:http"; +import { spawn } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { resolve, relative, isAbsolute } from "node:path"; +import { TavilyKeyPool, searchTavily, formatTavilyResults } from "./extensions/tavily-search.mjs"; + +const PORT = parseInt(process.env.PORT || process.env.PI_AGENT_RUNNER_PORT || "18766", 10); +const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces"; +const DEFAULT_MODEL = process.env.MINIMAX_MODEL_ID || "MiniMax-M3"; +const DEFAULT_PROVIDER = process.env.MINIMAX_PROVIDER || "minimax"; +const tavilyPool = TavilyKeyPool.fromEnv(); + +function safeRel(workspace, target) { + const abs = isAbsolute(target) ? target : resolve(workspace, target); + const rel = relative(workspace, abs); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + return null; + } + return rel.replace(/\\/g, "/"); +} + +function resolveWorkspace(rawPath) { + if (!rawPath) return DATA_DIR; + if (existsSync(rawPath)) return rawPath; + + // If path contains 'workspaces/', extract the relative part + const wsIndex = rawPath.indexOf("workspaces/"); + if (wsIndex !== -1) { + const sub = rawPath.slice(wsIndex + "workspaces/".length); + return resolve(DATA_DIR, sub); + } + + // If relative path + if (!isAbsolute(rawPath)) { + return resolve(DATA_DIR, rawPath); + } + + return rawPath; +} + +function parseJsonBody(req) { + return new Promise((resolvePromise, rejectPromise) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolvePromise({}); + return; + } + try { + resolvePromise(JSON.parse(raw)); + } catch (err) { + rejectPromise(new Error("Invalid JSON: " + err.message)); + } + }); + req.on("error", rejectPromise); + }); +} + +function sendJson(res, statusCode, data) { + res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data, null, 2) + "\n"); +} + +async function handleRun(body) { + const rawWs = body.workspace || ""; + const workspace = resolveWorkspace(rawWs); + const model = body.model || DEFAULT_MODEL; + const provider = body.provider || DEFAULT_PROVIDER; + const prompt = body.prompt || ""; + const instructions = body.instructions || ""; + const targets = Array.isArray(body.targets) ? body.targets : []; + const timeoutMs = Number(body.timeout_ms) || 30 * 60 * 1000; + + if (!existsSync(workspace)) { + mkdirSync(workspace, { recursive: true }); + } + + if (!prompt.trim()) { + return { ok: false, fallback_reason: "prompt_empty", patches: [] }; + } + + const allowed = new Set(); + for (const t of targets) { + const rel = safeRel(workspace, t); + if (rel) allowed.add(rel); + } + + // 1. Write persona instructions into workspace + if (instructions.trim()) { + try { + writeFileSync(resolve(workspace, "AGENTS.md"), instructions, "utf8"); + writeFileSync(resolve(workspace, "INSTRUCTIONS.md"), instructions, "utf8"); + } catch (e) { + console.warn("[runner] Failed to write instructions to workspace:", e); + } + } + + // 2. Build pi arguments + const piBin = process.env.PI_AGENT_BIN || "pi"; + const args = [ + "--mode", "rpc", + "--no-session", + "--provider", provider, + "--model", model, + ]; + + const sysPromptFile = resolve(workspace, "INSTRUCTIONS.md"); + if (existsSync(sysPromptFile)) { + args.push("--append-system-prompt", "INSTRUCTIONS.md"); + } + + args.push( + "--tools", "read,write,edit,tavily_search,web_search", + "--approve", + ); + + const env = { ...process.env }; + env.PI_AGENT_MODEL = model; + env.PI_AGENT_PROVIDER = provider; + env.PI_AGENT_SYSTEM_PROMPT_FILE = "AGENTS.md"; + + let agentId = ""; + let runId = ""; + let lastErr = ""; + let succeeded = false; + + console.log(`[runner] Starting pi run in workspace: ${workspace}, model: ${model}`); + + const child = spawn(piBin, args, { + cwd: workspace, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + const timer = setTimeout(() => { + lastErr = `Execution timed out after ${timeoutMs}ms`; + child.kill("SIGKILL"); + }, timeoutMs); + + const cmdId = "req-" + Date.now(); + child.stdin.write( + JSON.stringify({ + id: cmdId, + type: "prompt", + message: instructions ? `[Persona & System Instructions]:\n${instructions}\n\n[User Task]:\n${prompt}` : prompt, + }) + "\n", + ); + + let stdoutBuf = ""; + child.stdout.on("data", (chunk) => { + stdoutBuf += chunk.toString("utf8"); + let nl; + while ((nl = stdoutBuf.indexOf("\n")) >= 0) { + const line = stdoutBuf.slice(0, nl).trim(); + stdoutBuf = stdoutBuf.slice(nl + 1); + if (!line) continue; + let ev; + try { ev = JSON.parse(line); } catch { continue; } + if (!ev || typeof ev !== "object") continue; + + const t = ev.type || ""; + if (t === "session" && ev.id) agentId = ev.id; + if (t === "agent_settled") { + succeeded = true; + child.stdin.end(); + } + if (t === "agent_error") { + lastErr = ev.error?.message || ev.message || "agent_error"; + } + } + }); + + let stderrBuf = ""; + child.stderr.on("data", (chunk) => { + stderrBuf += chunk.toString("utf8"); + lastErr = (stderrBuf + " " + lastErr).slice(-400); + }); + + await new Promise((resolveDone) => { + child.on("close", () => { + clearTimeout(timer); + resolveDone(); + }); + child.on("error", (err) => { + clearTimeout(timer); + lastErr = err && err.message ? err.message : String(err); + resolveDone(); + }); + }); + + if (!succeeded) { + return { + ok: false, + agent_id: agentId, + run_id: runId, + fallback_reason: lastErr || "agent_not_settled", + stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, + patches: [], + }; + } + + // 3. Read generated files + const patches = []; + for (const rel of allowed) { + const abs = resolve(workspace, rel); + if (!existsSync(abs)) continue; + const content = readFileSync(abs, "utf8"); + patches.push({ rel_path: rel, content }); + } + + return { + ok: true, + agent_id: agentId, + run_id: runId, + patches, + fallback_reason: "", + }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const method = req.method.toUpperCase(); + + // 1. Healthcheck + if (method === "GET" && (url.pathname === "/health" || url.pathname === "/")) { + sendJson(res, 200, { + ok: true, + service: "shuimu-pi-agent-runner", + endpoint_url: process.env.MINIMAX_ENDPOINT_URL || "https://api.minimaxi.com/v1", + model_id: DEFAULT_MODEL, + api: process.env.MINIMAX_API_TYPE || "openai-completions", + has_token: Boolean(process.env.MINIMAX_API_KEY || process.env.MINIMAX_API_TOKEN), + tavily: { + active: tavilyPool.size() > 0, + keys_count: tavilyPool.size(), + }, + data_dir: DATA_DIR, + uptime: process.uptime(), + }); + return; + } + + // 2. Direct search test endpoint + if (method === "POST" && url.pathname === "/v1/search") { + try { + const body = await parseJsonBody(req); + const query = (body.query || "").trim(); + if (!query) { + sendJson(res, 400, { error: "Missing query parameter" }); + return; + } + const data = await tavilyPool.executeWithPool(async (apiKey) => { + return await searchTavily(apiKey, query, { + search_depth: body.search_depth, + max_results: body.max_results, + }); + }); + const markdown = formatTavilyResults(data, query); + sendJson(res, 200, { ok: true, data, markdown }); + } catch (err) { + sendJson(res, 500, { ok: false, error: err.message }); + } + return; + } + + // 3. Agent Task Run Endpoint + if (method === "POST" && url.pathname === "/v1/run") { + try { + const body = await parseJsonBody(req); + const result = await handleRun(body); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 500, { ok: false, error: err.message, patches: [] }); + } + return; + } + + sendJson(res, 404, { error: "Not Found", path: url.pathname }); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[pi-agent-runner] Listening on 0.0.0.0:${PORT}`); + console.log(`[pi-agent-runner] Endpoint: ${process.env.MINIMAX_ENDPOINT_URL || "https://api.minimaxi.com/v1"}`); + console.log(`[pi-agent-runner] Model: ${DEFAULT_MODEL}, Provider: ${DEFAULT_PROVIDER}`); + console.log(`[pi-agent-runner] Tavily API keys pool size: ${tavilyPool.size()}`); +});