merge: feature/agent-infra — pi-agent Tavily and docker

This commit is contained in:
weichen
2026-09-13 02:50:18 +00:00
23 changed files with 1398 additions and 42 deletions
+34 -14
View File
@@ -1,8 +1,8 @@
# =============================================================================
# 商务对接智能体系统 (Chat-Agent-System) - 开发环境配置文件
# 商务对接智能体系统 (Chat-Agent-System) - 开发环境配置文件模板 (.env.example)
# =============================================================================
# --- 1. 运行环境与目录 ---
# --- 1. 运行环境与基础目录 ---
ENV=dev
DATA_DIR=./data/dev
APP_PUBLISH_HOST=127.0.0.1
@@ -23,19 +23,33 @@ CURSOR_BIN=/usr/local/bin/cursor
# Cursor Node 桥接脚本路径
CURSOR_BRIDGE_BIN=tools/cursor-bridge/index.mjs
# --- 4. Pi-Coding-Agent (pi CLI) 后端配置 ---
# pi 命令绝对路径 (通过 which pi 查看,通常为 /opt/homebrew/bin/pi)
PI_AGENT_BIN=/opt/homebrew/bin/pi
# pi-bridge 脚本路径
PI_AGENT_BRIDGE_BIN=tools/pi-bridge/index.mjs
# pi 底层 provider (如 anthropic, openai, cc-switch-kimi-for-coding 等)
PI_AGENT_PROVIDER=anthropic
# pi 所用模型标识 (如 claude-3-7-sonnet, MiniMax-M3, claude-sonnet-4-5 等)
PI_AGENT_MODEL=claude-3-7-sonnet
# pi 运行时的额外环境变量或 token
ANTHROPIC_API_KEY=
# --- 4. Pi-Agent 容器与模型参数配置 (MiniMax-M3) ---
# 1) ENDPOINT URL (MiniMax API 地址)
MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1
# 2) API TOKEN (用户自行设定,必填)
MINIMAX_API_KEY=
# 3) MODEL ID (模型名称)
MINIMAX_MODEL_ID=MiniMax-M3
# 4) API 协议类型 (openai-compatible 兼容协议)
MINIMAX_API_TYPE=openai-compatible
# --- 5. 法规知识库配置 (当前复用已有 agent-regkb Docker 容器) ---
# --- 5. 网络搜索工具配置 (Tavily Search API Key 池) ---
# 支持单 Key 或多 Key 池 (逗号/分号/换行分隔,例如: tvly-xxx1,tvly-xxx2,tvly-xxx3)
# 当遇到 429 配额耗尽或 401/403 异常时,容器内的 Tavily 工具会自动轮询切换下一个 Key
TAVILY_API_KEYS=
# --- 6. Pi-Agent 容器服务端口与远程调用 (可选) ---
PI_AGENT_RUNNER_PORT=18865
# 若希望 Go 程序通过 HTTP 直连 pi-agent 容器,解除下方注释;若留空则走本机本地 pi CLI
# PI_AGENT_RUNNER_URL=http://127.0.0.1:18865
# --- 7. 本机 Pi CLI 本地回退配置 (当不使用容器时) ---
PI_AGENT_BIN=/opt/homebrew/bin/pi
PI_AGENT_BRIDGE_BIN=tools/pi-bridge/index.mjs
PI_AGENT_PROVIDER=minimax
PI_AGENT_MODEL=MiniMax-M3
# --- 8. 法规知识库配置 (复用已有 agent-regkb Docker 容器) ---
KB_DB_HOST=127.0.0.1
KB_DB_PORT=55432
KB_DB_USER=regkb
@@ -47,3 +61,9 @@ EMBEDDING_ENDPOINT_URL=https://www.dmxapi.cn/v1/embeddings
EMBEDDING_PROVIDER=openai_compatible
EMBEDDING_MODEL=bge-m3
EMBEDDING_API_KEY=
# 重排 Reranker 配置
RERANKER_ENDPOINT_URL=https://www.dmxapi.cn/v1/rerank
RERANKER_PROVIDER=openai_compatible
RERANKER_MODEL=bge-reranker-v2-m3-free
RERANKER_API_KEY=
+31 -3
View File
@@ -13,9 +13,13 @@ import (
func main() {
taskKey := flag.String("task", "intake", "任务标识 (intake, research, cdo_quote 等)")
backend := flag.String("backend", "stub", "智能体后端 (stub, pi-agent, cursor-cli)")
backend := flag.String("backend", "", "智能体后端 (留空则读取 .env 配置,可选: stub, pi-agent, cursor-cli)")
project := flag.String("project", "医疗器械研发演示项目", "项目名称")
customer := flag.String("customer", "北京水木东方", "客户名称")
device := flag.String("device", "医疗器械", "设备类型")
use := flag.String("use", "临床诊疗", "预期用途")
market := flag.String("market", "国内", "目标市场")
materials := flag.String("materials", "", "输入补充材料内容")
envPath := flag.String("env", ".env", "环境配置文件路径")
flag.Parse()
@@ -25,25 +29,49 @@ func main() {
os.Exit(1)
}
activeBackend := cfg.AgentBackend
if *backend != "" {
activeBackend = *backend
cfg.AgentBackend = *backend
}
var materialsList []bizdev.MaterialInput
if *materials != "" {
materialsList = []bizdev.MaterialInput{
{
Filename: "user_input.md",
Content: []byte(*materials),
},
}
}
svc := bizdev.NewService(cfg)
res, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{
TaskKey: *taskKey,
ProjectName: *project,
CustomerName: *customer,
Backend: *backend,
DeviceType: *device,
IntendedUse: *use,
TargetMarket: *market,
Materials: materialsList,
Backend: activeBackend,
})
if err != nil {
fmt.Fprintf(os.Stderr, "任务执行失败: %v\n", err)
os.Exit(1)
}
if !res.Success {
fmt.Fprintf(os.Stderr, "警告: 后端执行未完全成功 [%s]: %s\n", res.TaskKey, res.ErrorReason)
}
fmt.Printf("=== 任务执行成功 [%s] ===\n", res.TaskKey)
fmt.Printf("耗时: %v\n", res.Duration)
fmt.Printf("工作区: %s\n", res.WorkDir)
fmt.Printf("宿主机受控工作区: %s\n", res.WorkDir)
fmt.Printf("交付文档产物: %s\n", filepath.Join(res.WorkDir, "output.md"))
if _, err := os.Stat(filepath.Join(res.WorkDir, "execution.log")); err == nil {
fmt.Printf("执行审计日志: %s\n", filepath.Join(res.WorkDir, "execution.log"))
}
fmt.Printf("使用的后端: %s\n", res.BackendUsed)
if len(res.Attachments) > 0 {
fmt.Printf("附加产物:\n")
+44
View File
@@ -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"]
+95
View File
@@ -0,0 +1,95 @@
#!/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}",
"extensions": [
"/app/extensions/tavily-search.mjs"
]
}
EOF
# 6. 执行容器命令
if [ "$#" -eq 0 ]; then
exec node /app/runner.mjs
else
exec "$@"
fi
@@ -0,0 +1,300 @@
/**
* Tavily Web Search Extension for Pi Coding Agent
*
* Features:
* - Real-time web search integration via Tavily Search API
* - API Key Pool support (via TAVILY_API_KEYS or TAVILY_API_KEY)
* - Automatic key rotation and failover on rate-limit (429), quota exhaustion, or auth errors (401/403)
* - Returns structured Markdown containing direct AI answers and top search results
*/
export class TavilyKeyPool {
/**
* @param {string[]} keys
*/
constructor(keys = []) {
this.keys = keys.map(k => (typeof k === "string" ? k.trim() : "")).filter(k => k.length > 0);
this.currentIndex = 0;
this.stats = new Map(); // key -> { failures: number, lastError: string, lastTime: number }
}
/**
* Initialize pool from environment variables
* Checks TAVILY_API_KEYS (comma/semicolon/newline separated) and TAVILY_API_KEY
* @param {Record<string, string|undefined>} [env]
* @returns {TavilyKeyPool}
*/
static fromEnv(env = process.env) {
const raw = (env.TAVILY_API_KEYS || env.TAVILY_API_KEY || "").trim();
if (!raw) {
return new TavilyKeyPool([]);
}
const splitKeys = raw
.split(/[\n,;\s]+/)
.map(k => k.trim())
.filter(k => k.length > 0);
return new TavilyKeyPool(splitKeys);
}
size() {
return this.keys.length;
}
/**
* @param {string} key
* @returns {string}
*/
maskKey(key) {
if (!key || key.length < 8) return "***";
return key.slice(0, 4) + "..." + key.slice(-4);
}
/**
* @returns {string|null}
*/
getCurrentKey() {
if (this.keys.length === 0) return null;
return this.keys[this.currentIndex % this.keys.length];
}
/**
* Mark current key as failed and advance to next key
* @param {string} key
* @param {string} reason
* @param {number} [status]
*/
markFailure(key, reason, status) {
const current = this.stats.get(key) || { failures: 0, lastError: "", lastTime: 0 };
current.failures += 1;
current.lastError = `[Status ${status || "ERR"}] ${reason}`;
current.lastTime = Date.now();
this.stats.set(key, current);
const oldIndex = this.currentIndex;
this.currentIndex = (this.currentIndex + 1) % this.keys.length;
console.warn(
`[TavilyKeyPool] Key ${this.maskKey(key)} failed (attempt #${current.failures}): ${reason}. ` +
`Rotated from #${oldIndex + 1} to #${this.currentIndex + 1}/${this.keys.length}`
);
}
/**
* Execute an async action with automatic retry and key rotation across the pool
* @template T
* @param {(apiKey: string) => Promise<T>} action
* @returns {Promise<T>}
*/
async executeWithPool(action) {
if (this.keys.length === 0) {
throw new Error(
"Tavily API key is not configured. Please set TAVILY_API_KEYS in .env (supports comma-separated keys)."
);
}
const total = this.keys.length;
const errors = [];
let attempts = 0;
while (attempts < total) {
const activeKey = this.getCurrentKey();
try {
const result = await action(activeKey);
return result;
} catch (err) {
attempts++;
const status = err.status || 0;
const msg = err.message || String(err);
this.markFailure(activeKey, msg, status);
errors.push(`[Key ${this.maskKey(activeKey)}] ${msg}`);
}
}
throw new Error(
`All ${total} Tavily API key(s) in pool failed:\n- ` + errors.join("\n- ")
);
}
}
/**
* Execute HTTP POST query to Tavily Search API
* @param {string} apiKey
* @param {string} query
* @param {object} [options]
* @returns {Promise<any>}
*/
export async function searchTavily(apiKey, query, options = {}) {
const endpoint = options.endpoint || "https://api.tavily.com/search";
const searchDepth = options.search_depth === "advanced" ? "advanced" : "basic";
const maxResults = Math.min(Math.max(Number(options.max_results) || 5, 1), 10);
const payload = {
api_key: apiKey,
query: query,
search_depth: searchDepth,
include_answer: true,
max_results: maxResults,
};
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "pi-agent-tavily-tool/1.0",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(options.timeout || 25000),
});
if (!response.ok) {
let errDetail = "";
try {
const data = await response.json();
errDetail = data.detail || JSON.stringify(data);
} catch {
errDetail = await response.text();
}
const err = new Error(`HTTP ${response.status} ${response.statusText}: ${errDetail}`);
err.status = response.status;
throw err;
}
return await response.json();
}
/**
* Format Tavily search API response into clean Markdown for the Agent
* @param {any} data
* @param {string} query
* @returns {string}
*/
export function formatTavilyResults(data, query) {
let md = `## 🌐 Tavily 实时网络搜索结果: "${query}"\n\n`;
if (data.answer) {
md += `> **智能总结**: ${data.answer.trim()}\n\n`;
}
const results = Array.isArray(data.results) ? data.results : [];
if (results.length > 0) {
md += `### 检索到的参考条目 (${results.length} 条):\n\n`;
results.forEach((item, idx) => {
const title = item.title ? item.title.trim() : "无标题网页";
const url = item.url ? item.url.trim() : "#";
const snippet = item.content ? item.content.trim().replace(/\s+/g, " ") : "(无摘要)";
md += `${idx + 1}. **[${title}](${url})**\n`;
md += ` - **摘要**: ${snippet}\n`;
if (item.score) {
md += ` - **相关度评分**: ${item.score.toFixed ? item.score.toFixed(3) : item.score}\n`;
}
md += "\n";
});
} else {
md += `*未检索到与 "${query}" 相关的网页结果。*\n\n`;
}
return md;
}
/**
* Pi Extension Entrypoint
* @param {object} pi - Pi Extension API
*/
export default function (pi) {
const pool = TavilyKeyPool.fromEnv();
// Register tavily_search tool
pi.registerTool({
name: "tavily_search",
label: "Tavily Web Search",
description:
"Search the live internet for real-time information, competitor intelligence, regulatory news, product specs, or market data via Tavily (with automatic API key pool rotation).",
promptSnippet: "Search the web for up-to-date market, regulatory, or technical information with tavily_search",
promptGuidelines: [
"Use tavily_search when you need external facts, recent regulations, competitor solutions, or live web data not found in local workspace or knowledge base.",
"Provide specific, high-signal query keywords in Chinese or English."
],
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query keywords in Chinese or English",
},
search_depth: {
type: "string",
enum: ["basic", "advanced"],
description: "Search depth: 'basic' (fast summary) or 'advanced' (deep analysis, default 'basic')",
},
max_results: {
type: "number",
description: "Maximum number of search results to retrieve (1 to 10, default 5)",
},
},
required: ["query"],
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
const query = (params.query || "").trim();
if (!query) {
return {
content: [{ type: "text", text: "Error: query cannot be empty." }],
details: { error: "empty_query" },
};
}
try {
const data = await pool.executeWithPool(async (apiKey) => {
return await searchTavily(apiKey, query, {
search_depth: params.search_depth,
max_results: params.max_results,
});
});
const formatted = formatTavilyResults(data, query);
return {
content: [{ type: "text", text: formatted }],
details: {
query,
results_count: Array.isArray(data.results) ? data.results.length : 0,
has_answer: Boolean(data.answer),
},
};
} catch (err) {
return {
content: [
{
type: "text",
text: `[Tavily Search Failed]: ${err.message}\n(Agent should rely on workspace materials or local knowledge base as fallback.)`,
},
],
details: { error: err.message },
};
}
},
});
// Also register alias 'web_search' pointing to the same tool for convenience
pi.registerTool({
name: "web_search",
label: "Web Search",
description: "Alias for tavily_search. Searches the live internet for information.",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query keywords",
},
},
required: ["query"],
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
const tool = pi.getAllTools ? pi.getAllTools().find(t => t.name === "tavily_search") : null;
if (tool && tool.execute) {
return await tool.execute(toolCallId, params, signal, onUpdate, ctx);
}
return {
content: [{ type: "text", text: "tavily_search is active. Please use tavily_search." }],
};
},
});
}
+337
View File
@@ -0,0 +1,337 @@
#!/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 path contains 'workspaces/', extract the relative part
const wsIndex = rawPath.indexOf("workspaces/");
let sub = "";
if (wsIndex !== -1) {
sub = rawPath.slice(wsIndex + "workspaces/".length);
} else if (rawPath.startsWith(DATA_DIR)) {
sub = relative(DATA_DIR, rawPath);
} else {
sub = rawPath.replace(/^[/\\]+/, "");
}
// Force all execution strictly inside DATA_DIR (which is mounted to host's controlled directory)
const target = resolve(DATA_DIR, sub);
const rel = relative(DATA_DIR, target);
if (rel.startsWith("..") || isAbsolute(rel)) {
return resolve(DATA_DIR, "sandboxes", "isolated_" + Date.now());
}
return target;
}
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 and task 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);
}
}
if (prompt.trim()) {
try {
writeFileSync(resolve(workspace, "TASK.md"), prompt, "utf8");
} catch (e) {
console.warn("[runner] Failed to write TASK.md:", e);
}
}
// 2. Build pi arguments
const piBin = process.env.PI_AGENT_BIN || "pi";
const args = [
"--mode", "rpc",
"-e", "/app/extensions/tavily-search.mjs",
"--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 = "";
let fullStdout = "";
child.stdout.on("data", (chunk) => {
const text = chunk.toString("utf8");
stdoutBuf += text;
fullStdout += text;
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();
});
});
// Ensure all intermediate traces, logs and thinking process land on the host-mounted workspace
try {
const trace = [
`=== PI-AGENT TASK EXECUTION LOG ===`,
`Timestamp: ${new Date().toISOString()}`,
`Workspace: ${workspace}`,
`Model: ${model} (${provider})`,
`Outcome: ${succeeded ? "SUCCESS" : "FAILED"}`,
`Error: ${lastErr || "None"}`,
`\n--- RAW OUTPUT & EVENTS ---\n${fullStdout}`,
`\n--- STDERR ---\n${stderrBuf}`,
].join("\n");
writeFileSync(resolve(workspace, "execution.log"), trace, "utf8");
} catch (logErr) {
console.warn("[runner] Could not write execution.log:", logErr.message);
}
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 and deliverables
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 });
}
// Also include execution.log as patch if present so host definitely receives it
const execLogPath = resolve(workspace, "execution.log");
if (existsSync(execLogPath)) {
try {
const logContent = readFileSync(execLogPath, "utf8");
patches.push({ rel_path: "execution.log", content: logContent });
} catch {}
}
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()}`);
});
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import {
TavilyKeyPool,
formatTavilyResults,
default as registerTavilyExtension,
} from "../extensions/tavily-search.mjs";
console.log("▶ Testing TavilyKeyPool initialization and parsing...");
// 1. Test key parsing from multiple env formats
{
const pool = TavilyKeyPool.fromEnv({
TAVILY_API_KEYS: "tvly-key1, tvly-key2; tvly-key3\ntvly-key4",
});
assert.equal(pool.size(), 4, "Should parse 4 keys across comma, semicolon, space, newline");
assert.equal(pool.getCurrentKey(), "tvly-key1");
}
{
const poolSingle = TavilyKeyPool.fromEnv({
TAVILY_API_KEY: "tvly-single-key-12345",
});
assert.equal(poolSingle.size(), 1);
assert.equal(poolSingle.getCurrentKey(), "tvly-single-key-12345");
}
{
const poolEmpty = TavilyKeyPool.fromEnv({});
assert.equal(poolEmpty.size(), 0);
assert.equal(poolEmpty.getCurrentKey(), null);
}
console.log("✔ Key parsing tests passed.");
// 2. Test automatic failover and key rotation
console.log("▶ Testing automatic failover & rotation in pool...");
{
const keys = ["tvly-bad-key-1", "tvly-rate-limited-2", "tvly-good-key-3"];
const pool = new TavilyKeyPool(keys);
const attemptedKeys = [];
const result = await pool.executeWithPool(async (key) => {
attemptedKeys.push(key);
if (key === "tvly-bad-key-1") {
const err = new Error("Invalid API key");
err.status = 401;
throw err;
}
if (key === "tvly-rate-limited-2") {
const err = new Error("Usage limit reached");
err.status = 429;
throw err;
}
return { status: "success", data: "live search results" };
});
assert.deepEqual(attemptedKeys, [
"tvly-bad-key-1",
"tvly-rate-limited-2",
"tvly-good-key-3",
]);
assert.equal(result.status, "success");
console.log("✔ Auto-rotation to working key passed.");
}
// 3. Test behavior when all keys fail
console.log("▶ Testing exhaustion when all keys in pool fail...");
{
const keys = ["key-fail-1", "key-fail-2"];
const pool = new TavilyKeyPool(keys);
let caughtError = null;
try {
await pool.executeWithPool(async (key) => {
const err = new Error(`Quota exceeded for ${key}`);
err.status = 429;
throw err;
});
} catch (err) {
caughtError = err;
}
assert.ok(caughtError !== null, "Should throw when all keys fail");
assert.match(caughtError.message, /All 2 Tavily API key\(s\) in pool failed/);
console.log("✔ Exhaustion error handling passed.");
}
// 4. Test formatTavilyResults
console.log("▶ Testing formatTavilyResults markdown generation...");
{
const mockData = {
answer: "Medical robotics includes surgical robots like da Vinci and orthopedic arms.",
results: [
{
title: "Medical Robotics 2026 Overview",
url: "https://example.com/robotics",
content: "Detailed overview of surgical robotics developments in 2026.",
score: 0.985,
},
],
};
const md = formatTavilyResults(mockData, "medical robotics");
assert.match(md, /Tavily 实时网络搜索结果/);
assert.match(md, /Medical robotics includes surgical robots/);
assert.match(md, /https:\/\/example.com\/robotics/);
console.log("✔ Markdown formatting passed.");
}
// 5. Test tool registration on Pi mock
console.log("▶ Testing Pi extension registration...");
{
const registeredTools = [];
const mockPi = {
registerTool(tool) {
registeredTools.push(tool);
},
getAllTools() {
return registeredTools;
},
};
registerTavilyExtension(mockPi);
assert.equal(registeredTools.length, 2, "Should register tavily_search and web_search alias");
assert.equal(registeredTools[0].name, "tavily_search");
assert.equal(registeredTools[1].name, "web_search");
console.log("✔ Tool registration on Pi passed.");
}
console.log("==========================================");
console.log("🎉 ALL TAVILY TESTS PASSED SUCCESSFULLY!");
console.log("==========================================");
+33
View File
@@ -45,6 +45,38 @@ services:
networks:
- dev-net
pi-agent:
build:
context: .
dockerfile: deploy/pi-agent/Dockerfile
image: shuimu-pi-agent:dev
container_name: shuimu-pi-agent-dev
environment:
PORT: 18865
DATA_DIR: /app/data/workspaces
MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3}
MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible}
MINIMAX_PROVIDER: minimax
TAVILY_API_KEYS: ${TAVILY_API_KEYS:-}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
ports:
- "127.0.0.1:${PI_AGENT_RUNNER_PORT:-18865}:18865"
volumes:
- ./data/dev/workspaces:/app/data/workspaces
- ./data/dev/agent_sessions:/root/.pi/agent/sessions
tmpfs:
- /tmp
healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1:18865/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- dev-net
app:
image: alpine:latest
container_name: shuimu-agent-app-dev
@@ -61,6 +93,7 @@ services:
# 共享知识库
POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:-shared_kb_secret_pass}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable
CURSOR_RUNNER_URL: http://cursor-agent:18765
PI_AGENT_RUNNER_URL: http://pi-agent:18865
AUX_FILE_DSN: file:/var/lib/feishu/outbox_dev.db?_journal_mode=WAL&_busy_timeout=5000
ports:
- "${APP_PUBLISH_HOST:-127.0.0.1}:${HTTP_PORT:-8080}:${HTTP_PORT:-8080}"
+33
View File
@@ -45,6 +45,38 @@ services:
networks:
- main-net
pi-agent:
build:
context: .
dockerfile: deploy/pi-agent/Dockerfile
image: shuimu-pi-agent:main
container_name: shuimu-pi-agent-main
environment:
PORT: 18867
DATA_DIR: /app/data/workspaces
MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3}
MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible}
MINIMAX_PROVIDER: minimax
TAVILY_API_KEYS: ${TAVILY_API_KEYS:-}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
ports:
- "127.0.0.1:${PI_AGENT_RUNNER_PORT:-18867}:18867"
volumes:
- ./data/main/workspaces:/app/data/workspaces
- ./data/main/agent_sessions:/root/.pi/agent/sessions
tmpfs:
- /tmp
healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1:18867/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: always
networks:
- main-net
app:
image: alpine:latest
container_name: shuimu-agent-app-main
@@ -61,6 +93,7 @@ services:
# 共享知识库
POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:-shared_kb_secret_pass}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable
CURSOR_RUNNER_URL: http://cursor-agent:18767
PI_AGENT_RUNNER_URL: http://pi-agent:18867
AUX_FILE_DSN: file:/var/lib/feishu/outbox_main.db?_journal_mode=WAL&_busy_timeout=5000
ports:
- "${APP_PUBLISH_HOST:-0.0.0.0}:${HTTP_PORT:-8000}:${HTTP_PORT:-8000}"
+33
View File
@@ -45,6 +45,38 @@ services:
networks:
- test-net
pi-agent:
build:
context: .
dockerfile: deploy/pi-agent/Dockerfile
image: shuimu-pi-agent:test
container_name: shuimu-pi-agent-test
environment:
PORT: 18866
DATA_DIR: /app/data/workspaces
MINIMAX_ENDPOINT_URL: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}
MINIMAX_API_KEY: ${MINIMAX_API_KEY:-}
MINIMAX_MODEL_ID: ${MINIMAX_MODEL_ID:-MiniMax-M3}
MINIMAX_API_TYPE: ${MINIMAX_API_TYPE:-openai-compatible}
MINIMAX_PROVIDER: minimax
TAVILY_API_KEYS: ${TAVILY_API_KEYS:-}
TAVILY_API_KEY: ${TAVILY_API_KEY:-}
ports:
- "127.0.0.1:${PI_AGENT_RUNNER_PORT:-18866}:18866"
volumes:
- ./data/test/workspaces:/app/data/workspaces
- ./data/test/agent_sessions:/root/.pi/agent/sessions
tmpfs:
- /tmp
healthcheck:
test: ["CMD-SHELL", "curl -f http://127.0.0.1:18866/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- test-net
app:
image: alpine:latest
container_name: shuimu-agent-app-test
@@ -61,6 +93,7 @@ services:
# 共享知识库
POSTGRES_SHARED_KB_DSN: postgres://${KB_DB_USER:-regkb}:${KB_DB_PASSWORD:-shared_kb_secret_pass}@shuimu-kb-shared:5432/${KB_DB_NAME:-regulations}?sslmode=disable
CURSOR_RUNNER_URL: http://cursor-agent:18766
PI_AGENT_RUNNER_URL: http://pi-agent:18866
AUX_FILE_DSN: file:/var/lib/feishu/outbox_test.db?_journal_mode=WAL&_busy_timeout=5000
ports:
- "${APP_PUBLISH_HOST:-0.0.0.0}:${HTTP_PORT:-8081}:${HTTP_PORT:-8081}"
+2 -1
View File
@@ -2,12 +2,13 @@ module chat-agent-system
go 1.27.1
require github.com/xuri/excelize/v2 v2.11.0
require (
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.11.0 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
+10
View File
@@ -1,7 +1,13 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
@@ -12,7 +18,11 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+61
View File
@@ -2,6 +2,9 @@ package agent_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
@@ -40,3 +43,61 @@ func TestStubBackendExecution(t *testing.T) {
t.Fatalf("expected output.md to exist: %v", err)
}
}
func TestPiAgentRemoteRunner(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/run" {
http.NotFound(w, r)
return
}
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
resp := map[string]any{
"ok": true,
"patches": []map[string]string{
{
"rel_path": "output.md",
"content": "# Remote Generated Deliverable\nValidated from Container Runner",
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
tmpDir := t.TempDir()
cfg := &config.Config{
AgentBackend: "pi-agent",
PiAgentRunnerURL: ts.URL,
PiAgentModel: "MiniMax-M3",
PiAgentProvider: "minimax",
AgentTimeout: 5 * time.Second,
}
backend := agent.NewBackend("pi-agent", cfg)
res, err := backend.Execute(context.Background(), agent.Request{
WorkDir: tmpDir,
Prompt: "Generate remote test document",
Targets: []string{"output.md"},
})
if err != nil {
t.Fatalf("remote execute returned unexpected error: %v", err)
}
if !res.Success {
t.Fatalf("expected success, got error: %s", res.ErrorReason)
}
if string(res.Patches["output.md"]) != "# Remote Generated Deliverable\nValidated from Container Runner" {
t.Errorf("unexpected content: %s", string(res.Patches["output.md"]))
}
// Verify local file written
localContent, err := os.ReadFile(filepath.Join(tmpDir, "output.md"))
if err != nil {
t.Fatalf("expected local file written: %v", err)
}
if string(localContent) != "# Remote Generated Deliverable\nValidated from Container Runner" {
t.Errorf("unexpected local file content: %s", string(localContent))
}
}
+41 -7
View File
@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"chat-agent-system/internal/config"
)
@@ -23,12 +24,19 @@ func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, e
req.Targets = []string{"output.md"}
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
payload, _ := json.Marshal(map[string]any{
"workspace": req.WorkDir,
"api_key": c.cfg.CursorAPIKey,
"model": c.cfg.CursorModel,
"prompt": req.Prompt,
"targets": req.Targets,
"workspace": workDir,
"api_key": c.cfg.CursorAPIKey,
"model": c.cfg.CursorModel,
"prompt": req.Prompt,
"instructions": req.Instructions,
"targets": req.Targets,
})
bridgeBin := c.cfg.CursorBridgeBin
@@ -45,7 +53,7 @@ func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, e
defer cancel()
cmd := exec.CommandContext(runCtx, bridgeBin)
cmd.Dir = req.WorkDir
cmd.Dir = workDir
cmd.Stdin = bytes.NewReader(payload)
cmd.Env = append(os.Environ(),
"CURSOR_API_KEY="+c.cfg.CursorAPIKey,
@@ -64,9 +72,35 @@ func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, e
}, nil
}
type bridgeResponse struct {
OK bool `json:"ok"`
FallbackReason string `json:"fallback_reason"`
Stderr string `json:"stderr"`
}
var bResp bridgeResponse
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) > 0 {
_ = json.Unmarshal([]byte(lines[len(lines)-1]), &bResp)
}
if !bResp.OK {
errMsg := bResp.FallbackReason
if bResp.Stderr != "" {
errMsg += " (" + bResp.Stderr + ")"
}
if errMsg == "" {
errMsg = "cursor bridge reported task failed"
}
return &Result{
Success: false,
ErrorReason: errMsg,
Logs: stdout.String(),
}, nil
}
patches := make(map[string][]byte)
for _, t := range req.Targets {
p := filepath.Join(req.WorkDir, t)
p := filepath.Join(workDir, t)
if b, err := os.ReadFile(p); err == nil {
patches[t] = b
}
+131 -7
View File
@@ -5,9 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"chat-agent-system/internal/config"
)
@@ -23,12 +26,19 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err
req.Targets = []string{"output.md"}
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
payload, _ := json.Marshal(map[string]any{
"workspace": req.WorkDir,
"model": p.cfg.PiAgentModel,
"provider": p.cfg.PiAgentProvider,
"prompt": req.Prompt,
"targets": req.Targets,
"workspace": workDir,
"model": p.cfg.PiAgentModel,
"provider": p.cfg.PiAgentProvider,
"prompt": req.Prompt,
"instructions": req.Instructions,
"targets": req.Targets,
})
bridgeBin := p.cfg.PiAgentBridgeBin
@@ -44,13 +54,18 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if runnerURL := strings.TrimRight(strings.TrimSpace(p.cfg.PiAgentRunnerURL), "/"); runnerURL != "" {
return p.executeRemote(runCtx, runnerURL, req, workDir, timeout)
}
cmd := exec.CommandContext(runCtx, bridgeBin)
cmd.Dir = req.WorkDir
cmd.Dir = workDir
cmd.Stdin = bytes.NewReader(payload)
cmd.Env = append(os.Environ(),
"PI_AGENT_BIN="+p.cfg.PiAgentBin,
"PI_AGENT_MODEL="+p.cfg.PiAgentModel,
"PI_AGENT_PROVIDER="+p.cfg.PiAgentProvider,
"PI_AGENT_SYSTEM_PROMPT_FILE=AGENTS.md",
)
var stdout, stderr bytes.Buffer
@@ -65,9 +80,35 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err
}, nil
}
type bridgeResponse struct {
OK bool `json:"ok"`
FallbackReason string `json:"fallback_reason"`
Stderr string `json:"stderr"`
}
var bResp bridgeResponse
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) > 0 {
_ = json.Unmarshal([]byte(lines[len(lines)-1]), &bResp)
}
if !bResp.OK {
errMsg := bResp.FallbackReason
if bResp.Stderr != "" {
errMsg += " (" + bResp.Stderr + ")"
}
if errMsg == "" {
errMsg = "pi bridge reported task failed"
}
return &Result{
Success: false,
ErrorReason: errMsg,
Logs: stdout.String(),
}, nil
}
patches := make(map[string][]byte)
for _, t := range req.Targets {
p := filepath.Join(req.WorkDir, t)
p := filepath.Join(workDir, t)
if b, err := os.ReadFile(p); err == nil {
patches[t] = b
}
@@ -79,3 +120,86 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err
Logs: stdout.String(),
}, nil
}
func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, req Request, workDir string, timeout time.Duration) (*Result, error) {
reqBody, _ := json.Marshal(map[string]any{
"workspace": workDir,
"model": p.cfg.PiAgentModel,
"provider": p.cfg.PiAgentProvider,
"prompt": req.Prompt,
"instructions": req.Instructions,
"targets": req.Targets,
"timeout_ms": timeout.Milliseconds(),
})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run", bytes.NewReader(reqBody))
if err != nil {
return &Result{
Success: false,
ErrorReason: fmt.Sprintf("failed to build remote request: %v", err),
}, nil
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: timeout}
resp, err := client.Do(httpReq)
if err != nil {
return &Result{
Success: false,
ErrorReason: fmt.Sprintf("pi remote runner call failed: %v", err),
}, nil
}
defer resp.Body.Close()
var remoteResp struct {
OK bool `json:"ok"`
FallbackReason string `json:"fallback_reason"`
Stderr string `json:"stderr"`
Patches []struct {
RelPath string `json:"rel_path"`
Content string `json:"content"`
} `json:"patches"`
}
if err := json.NewDecoder(resp.Body).Decode(&remoteResp); err != nil {
return &Result{
Success: false,
ErrorReason: fmt.Sprintf("failed to decode remote response: %v", err),
}, nil
}
if !remoteResp.OK {
reason := remoteResp.FallbackReason
if remoteResp.Stderr != "" {
reason += " (" + remoteResp.Stderr + ")"
}
if reason == "" {
reason = "remote pi-agent reported task failure"
}
return &Result{
Success: false,
ErrorReason: reason,
}, nil
}
patches := make(map[string][]byte)
for _, pt := range remoteResp.Patches {
patches[pt.RelPath] = []byte(pt.Content)
localPath := filepath.Join(workDir, pt.RelPath)
_ = os.MkdirAll(filepath.Dir(localPath), 0755)
_ = os.WriteFile(localPath, []byte(pt.Content), 0644)
}
for _, t := range req.Targets {
if _, ok := patches[t]; !ok {
targetPath := filepath.Join(workDir, t)
if b, err := os.ReadFile(targetPath); err == nil {
patches[t] = b
}
}
}
return &Result{
Success: true,
Patches: patches,
}, nil
}
+25 -9
View File
@@ -69,6 +69,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
return nil, err
}
snapshotTs := time.Now().UnixNano()
vars := map[string]string{
"project_name": req.ProjectName,
"product_name": req.ProjectName,
@@ -77,6 +78,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
"intended_use": req.IntendedUse,
"target_market": req.TargetMarket,
"generated_at": time.Now().Format("2006-01-02 15:04:05"),
"snapshot_id": fmt.Sprintf("snap_%s_%d", req.TaskKey, snapshotTs),
}
for k, v := range req.Fields {
vars[k] = v
@@ -86,6 +88,9 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
inst, _ := template.GetAgentInstruction("writer")
_ = os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(inst), 0o644)
// Some backends look for a different instructions filename; write both
// unconditionally so persona/role guidance reaches whichever bridge runs.
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(inst), 0o644)
taskPrompt := fmt.Sprintf("# 任务执行: %s\n\n请按 template.md 的指导撰写正文并交付到 output.md。\n", req.TaskKey)
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(taskPrompt), 0o644)
@@ -111,6 +116,17 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
return nil, fmt.Errorf("backend execute: %w", err)
}
res := &TaskRunResult{
Success: true,
}
if backendRes != nil && !backendRes.Success {
res.Success = false
res.ErrorReason = backendRes.ErrorReason
if backendName != "stub" {
return nil, fmt.Errorf("backend execution failed: %s", backendRes.ErrorReason)
}
}
markdownBody := renderedTmpl
if backendRes != nil && len(backendRes.Patches["output.md"]) > 0 && backendName != "stub" {
markdownBody = string(backendRes.Patches["output.md"])
@@ -147,13 +163,13 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
})
}
return &TaskRunResult{
TaskKey: req.TaskKey,
ArtifactTitle: taskArtifactTitle(req.TaskKey),
MarkdownBody: markdownBody,
Attachments: attachments,
WorkDir: workDir,
BackendUsed: backendName,
Duration: time.Since(start),
}, nil
res.TaskKey = req.TaskKey
res.ArtifactTitle = taskArtifactTitle(req.TaskKey)
res.MarkdownBody = markdownBody
res.Attachments = attachments
res.WorkDir = workDir
res.BackendUsed = backendName
res.Duration = time.Since(start)
return res, nil
}
+2
View File
@@ -46,6 +46,8 @@ type TaskRunResult struct {
WorkDir string `json:"work_dir"`
BackendUsed string `json:"backend_used"`
Duration time.Duration `json:"duration"`
Success bool `json:"success"`
ErrorReason string `json:"error_reason"`
}
type BizdevService interface {
+3
View File
@@ -21,6 +21,7 @@ type Config struct {
PiAgentBridgeBin string `json:"pi_agent_bridge_bin"`
PiAgentProvider string `json:"pi_agent_provider"`
PiAgentModel string `json:"pi_agent_model"`
PiAgentRunnerURL string `json:"pi_agent_runner_url"`
KbDbHost string `json:"kb_db_host"`
KbDbPort int `json:"kb_db_port"`
KbDbUser string `json:"kb_db_user"`
@@ -94,6 +95,8 @@ func Load(path string) (*Config, error) {
cfg.PiAgentProvider = val
case "PI_AGENT_MODEL":
cfg.PiAgentModel = val
case "PI_AGENT_RUNNER_URL":
cfg.PiAgentRunnerURL = val
case "KB_DB_HOST":
cfg.KbDbHost = val
case "KB_DB_PORT":
+13
View File
@@ -80,6 +80,15 @@ func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary,
_ = f.SetCellValue(sheet, "B4", in.ProductName)
_ = f.SetCellValue(sheet, "B5", totalPrice)
_ = f.SetCellValue(sheet, "A7", "报价代码")
_ = f.SetCellValue(sheet, "B7", "服务阶段与内容")
_ = f.SetCellValue(sheet, "D7", "人年(PM)")
_ = f.SetCellValue(sheet, "E7", "基础成本(元)")
_ = f.SetCellValue(sheet, "F7", "缓冲金(20%)")
_ = f.SetCellValue(sheet, "G7", "毛利(40%)")
_ = f.SetCellValue(sheet, "H7", "税费(7%)")
_ = f.SetCellValue(sheet, "J7", "综合报价(元)")
writeSummaryLine(f, sheet, 8, summary.Lines[0])
writeSummaryLine(f, sheet, 9, summary.Lines[1])
writeSummaryLine(f, sheet, 10, summary.Lines[2])
@@ -93,6 +102,8 @@ func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary,
totalTax += line.Tax
}
writeSummaryLine(f, sheet, 18, SummaryLine{
Code: "总计",
Name: "全阶段综合报价",
PersonYear: totalPersonYear,
Cost: totalCost,
Buffer: totalBuffer,
@@ -113,6 +124,8 @@ func CalculateAndFill(in Input, templateXlsx string, outPath string) (*Summary,
}
func writeSummaryLine(f *excelize.File, sheet string, row int, line SummaryLine) {
_ = f.SetCellValue(sheet, fmt.Sprintf("A%d", row), line.Code)
_ = f.SetCellValue(sheet, fmt.Sprintf("B%d", row), line.Name)
_ = f.SetCellValue(sheet, fmt.Sprintf("D%d", row), line.PersonYear)
_ = f.SetCellValue(sheet, fmt.Sprintf("E%d", row), line.Cost)
_ = f.SetCellValue(sheet, fmt.Sprintf("F%d", row), line.Buffer)
+24
View File
@@ -6,6 +6,7 @@ import (
"testing"
"chat-agent-system/internal/quote"
"github.com/xuri/excelize/v2"
)
func TestCDOQuoteGeneration(t *testing.T) {
@@ -39,4 +40,27 @@ func TestCDOQuoteGeneration(t *testing.T) {
if len(summary.Notes) != 7 {
t.Errorf("expected 7 statutory notes, got %d", len(summary.Notes))
}
xf, err := excelize.OpenFile(outXlsx)
if err != nil {
t.Fatalf("failed to reopen output xlsx: %v", err)
}
defer xf.Close()
sheet := "报价模型"
assertCell := func(cell, want string) {
got, err := xf.GetCellValue(sheet, cell)
if err != nil {
t.Fatalf("failed to read cell %s: %v", cell, err)
}
if got != want {
t.Errorf("cell %s: expected %q, got %q", cell, want, got)
}
}
assertCell("A7", "报价代码")
assertCell("A8", "ID2")
assertCell("B8", "产品规划")
assertCell("A18", "总计")
assertCell("B18", "全阶段综合报价")
}
+7
View File
@@ -1,14 +1,21 @@
package template
import (
"regexp"
"strings"
)
var unresolvedPlaceholderPattern = regexp.MustCompile(`\{\{[a-zA-Z0-9_]+\}\}`)
func RenderDoc(tmpl string, vars map[string]string) string {
res := tmpl
for k, v := range vars {
placeholder := "{{" + k + "}}"
res = strings.ReplaceAll(res, placeholder, v)
}
// Any variable that wasn't provided leaves a raw {{placeholder}} in the
// output. Replace those with a clear "待确认" marker instead of leaking
// template syntax into delivered documents.
res = unresolvedPlaceholderPattern.ReplaceAllString(res, "待确认")
return res
}
+5 -1
View File
@@ -8,6 +8,7 @@
* "api_key": "...",
* "model": "auto",
* "prompt": "...",
* "instructions": "...",
* "targets": ["docs/RD-UN.md"]
* }
*
@@ -81,6 +82,7 @@ async function main() {
const apiKey = sanitizeApiKey(req.api_key || process.env.CURSOR_API_KEY || "");
const model = (req.model || process.env.CURSOR_MODEL || "auto").trim();
const prompt = req.prompt || "";
const instructions = req.instructions || "";
const targets = Array.isArray(req.targets) ? req.targets : [];
if (!workspace || !existsSync(workspace)) {
@@ -106,10 +108,12 @@ async function main() {
return;
}
const fullPrompt = instructions ? `${instructions}\n\n---\n\n${prompt}` : prompt;
let agentId = "";
let runId = "";
try {
const result = await Agent.prompt(prompt, {
const result = await Agent.prompt(fullPrompt, {
apiKey,
model: { id: model },
local: {
+2
View File
@@ -0,0 +1,2 @@
export * from "../../deploy/pi-agent/extensions/tavily-search.mjs";
export { default } from "../../deploy/pi-agent/extensions/tavily-search.mjs";