merge: feature/agent-infra — pi-agent Tavily and docker
This commit is contained in:
+34
-14
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
+95
@@ -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." }],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Executable
+337
@@ -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()}`);
|
||||
});
|
||||
@@ -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("==========================================");
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", "全阶段综合报价")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "../../deploy/pi-agent/extensions/tavily-search.mjs";
|
||||
export { default } from "../../deploy/pi-agent/extensions/tavily-search.mjs";
|
||||
Reference in New Issue
Block a user