feat(agent): add pi-agent container assets with dynamic MiniMax config and HTTP runner
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"]
|
||||
Executable
+92
@@ -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 <<EOF > "${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 <<EOF > "${PI_DIR}/settings.json"
|
||||
{
|
||||
"defaultProvider": "minimax",
|
||||
"defaultModel": "${MODEL_ID}"
|
||||
}
|
||||
EOF
|
||||
|
||||
# 6. 执行容器命令
|
||||
if [ "$#" -eq 0 ]; then
|
||||
exec node /app/runner.mjs
|
||||
else
|
||||
exec "$@"
|
||||
fi
|
||||
Executable
+295
@@ -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()}`);
|
||||
});
|
||||
Reference in New Issue
Block a user