From 260e1a6b8a5f7d0f11533621bd2fad4a124254a7 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:00:40 +0800 Subject: [PATCH 1/9] fix(all): resolve whole-branch review findings on Excel labels, persona forwarding, and error handling Co-authored-by: Cursor --- cmd/bizdev-cli/main.go | 4 ++++ go.mod | 3 ++- go.sum | 10 ++++++++++ internal/agent/cursor.go | 11 ++++++----- internal/agent/piagent.go | 12 +++++++----- internal/bizdev/service.go | 34 +++++++++++++++++++++++++--------- internal/bizdev/types.go | 2 ++ internal/quote/quote.go | 13 +++++++++++++ internal/quote/quote_test.go | 24 ++++++++++++++++++++++++ internal/template/render.go | 7 +++++++ tools/cursor-bridge/index.mjs | 6 +++++- 11 files changed, 105 insertions(+), 21 deletions(-) diff --git a/cmd/bizdev-cli/main.go b/cmd/bizdev-cli/main.go index f43e52f2..fc6d4624 100644 --- a/cmd/bizdev-cli/main.go +++ b/cmd/bizdev-cli/main.go @@ -41,6 +41,10 @@ func main() { 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) diff --git a/go.mod b/go.mod index a2995d40..5b7456b8 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 44cf10f8..3ea667a0 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go index 2edbc224..809c52f0 100644 --- a/internal/agent/cursor.go +++ b/internal/agent/cursor.go @@ -24,11 +24,12 @@ func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, e } 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": req.WorkDir, + "api_key": c.cfg.CursorAPIKey, + "model": c.cfg.CursorModel, + "prompt": req.Prompt, + "instructions": req.Instructions, + "targets": req.Targets, }) bridgeBin := c.cfg.CursorBridgeBin diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 752bfbdd..0ee03b61 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -24,11 +24,12 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err } payload, _ := json.Marshal(map[string]any{ - "workspace": req.WorkDir, - "model": p.cfg.PiAgentModel, - "provider": p.cfg.PiAgentProvider, - "prompt": req.Prompt, - "targets": req.Targets, + "workspace": req.WorkDir, + "model": p.cfg.PiAgentModel, + "provider": p.cfg.PiAgentProvider, + "prompt": req.Prompt, + "instructions": req.Instructions, + "targets": req.Targets, }) bridgeBin := p.cfg.PiAgentBridgeBin @@ -51,6 +52,7 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err "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 diff --git a/internal/bizdev/service.go b/internal/bizdev/service.go index 6e372094..bacbdd04 100644 --- a/internal/bizdev/service.go +++ b/internal/bizdev/service.go @@ -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 } diff --git a/internal/bizdev/types.go b/internal/bizdev/types.go index f27dff77..aec6f4ab 100644 --- a/internal/bizdev/types.go +++ b/internal/bizdev/types.go @@ -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 { diff --git a/internal/quote/quote.go b/internal/quote/quote.go index 2f8e91c5..60711131 100644 --- a/internal/quote/quote.go +++ b/internal/quote/quote.go @@ -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) diff --git a/internal/quote/quote_test.go b/internal/quote/quote_test.go index 1c7e6101..d6ae43c6 100644 --- a/internal/quote/quote_test.go +++ b/internal/quote/quote_test.go @@ -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", "全阶段综合报价") } diff --git a/internal/template/render.go b/internal/template/render.go index 297449b9..420876b4 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -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 } diff --git a/tools/cursor-bridge/index.mjs b/tools/cursor-bridge/index.mjs index 5f3bfb7d..2ac0fff8 100755 --- a/tools/cursor-bridge/index.mjs +++ b/tools/cursor-bridge/index.mjs @@ -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: { From 1208c740306dcffad6fe288e7f740a21bfbbeb93 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:15:16 +0800 Subject: [PATCH 2/9] fix(agent): pass absolute workDir and parse bridge JSON response Co-authored-by: Cursor --- .env.example | 12 ++++++------ internal/agent/cursor.go | 39 ++++++++++++++++++++++++++++++++++++--- internal/agent/piagent.go | 39 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 1b770665..2d6f3fb4 100644 --- a/.env.example +++ b/.env.example @@ -28,12 +28,12 @@ CURSOR_BRIDGE_BIN=tools/cursor-bridge/index.mjs 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= +# pi 底层 provider (如 cc-switch-mini-max, anthropic, cc-switch-kimi-for-coding 等) +PI_AGENT_PROVIDER=cc-switch-mini-max +# pi 所用模型标识 (如 MiniMax-M3, MiniMax-M2.7, claude-3-7-sonnet 等) +PI_AGENT_MODEL=MiniMax-M3 +# pi 运行时的额外环境变量或 token (若通过 ~/.pi/agent/models.json 配置则可留空) +MINIMAX_API_KEY= # --- 5. 法规知识库配置 (当前复用已有 agent-regkb Docker 容器) --- KB_DB_HOST=127.0.0.1 diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go index 809c52f0..da78d50c 100644 --- a/internal/agent/cursor.go +++ b/internal/agent/cursor.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "chat-agent-system/internal/config" ) @@ -23,8 +24,14 @@ 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, + "workspace": workDir, "api_key": c.cfg.CursorAPIKey, "model": c.cfg.CursorModel, "prompt": req.Prompt, @@ -46,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, @@ -65,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 } diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 0ee03b61..22d17255 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "chat-agent-system/internal/config" ) @@ -23,8 +24,14 @@ 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, + "workspace": workDir, "model": p.cfg.PiAgentModel, "provider": p.cfg.PiAgentProvider, "prompt": req.Prompt, @@ -46,7 +53,7 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err defer cancel() 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, @@ -67,9 +74,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 } From b78c9f507186733d0a50e406c1db7e2cf3421919 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:31:23 +0800 Subject: [PATCH 3/9] feat(agent): add Tavily search extension with API key pool and auto-rotation Co-authored-by: Cursor --- deploy/pi-agent/extensions/tavily-search.mjs | 300 +++++++++++++++++++ tools/pi-extensions/tavily-search.mjs | 2 + 2 files changed, 302 insertions(+) create mode 100644 deploy/pi-agent/extensions/tavily-search.mjs create mode 100644 tools/pi-extensions/tavily-search.mjs diff --git a/deploy/pi-agent/extensions/tavily-search.mjs b/deploy/pi-agent/extensions/tavily-search.mjs new file mode 100644 index 00000000..7b0834ae --- /dev/null +++ b/deploy/pi-agent/extensions/tavily-search.mjs @@ -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} [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} action + * @returns {Promise} + */ + 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} + */ +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." }], + }; + }, + }); +} diff --git a/tools/pi-extensions/tavily-search.mjs b/tools/pi-extensions/tavily-search.mjs new file mode 100644 index 00000000..05f788c1 --- /dev/null +++ b/tools/pi-extensions/tavily-search.mjs @@ -0,0 +1,2 @@ +export * from "../../deploy/pi-agent/extensions/tavily-search.mjs"; +export { default } from "../../deploy/pi-agent/extensions/tavily-search.mjs"; From 61f9b6edb44ab4ead8f102df9be5815009fa211f Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:31:27 +0800 Subject: [PATCH 4/9] feat(agent): add pi-agent container assets with dynamic MiniMax config and HTTP runner Co-authored-by: Cursor --- deploy/pi-agent/Dockerfile | 44 +++++ deploy/pi-agent/entrypoint.sh | 92 +++++++++++ deploy/pi-agent/runner.mjs | 295 ++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 deploy/pi-agent/Dockerfile create mode 100755 deploy/pi-agent/entrypoint.sh create mode 100755 deploy/pi-agent/runner.mjs diff --git a/deploy/pi-agent/Dockerfile b/deploy/pi-agent/Dockerfile new file mode 100644 index 00000000..b3638fad --- /dev/null +++ b/deploy/pi-agent/Dockerfile @@ -0,0 +1,44 @@ +# ============================================================================= +# Pi Agent Container (MiniMax-M3 + Tavily Key Pool + Runner Service) +# Requires Node.js 22+ for node:fs globSync support +# ============================================================================= +FROM node:22-bookworm-slim + +# 安装系统运行时依赖 (bash, curl, git, ca-certificates) +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + bash; \ + rm -rf /var/lib/apt/lists/* + +# 全局安装 pi-coding-agent CLI +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 --omit=dev + +# 验证 pi 安装 +RUN pi --version + +WORKDIR /app + +# 复制 runner、扩展工具以及入口脚本 +COPY deploy/pi-agent/extensions/ /app/extensions/ +COPY deploy/pi-agent/runner.mjs /app/runner.mjs +COPY deploy/pi-agent/entrypoint.sh /app/entrypoint.sh + +RUN chmod +x /app/entrypoint.sh /app/runner.mjs + +# 默认环境变量配置 (敏感密钥通过运行时 -e 或 docker-compose 传入) +ENV PORT=18766 \ + DATA_DIR=/app/data/workspaces \ + PI_CODING_AGENT_DIR=/root/.pi/agent \ + MINIMAX_ENDPOINT_URL=https://api.minimaxi.com/v1 \ + MINIMAX_MODEL_ID=MiniMax-M3 \ + MINIMAX_API_TYPE=openai-compatible \ + MINIMAX_PROVIDER=minimax + +EXPOSE 18766 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "/app/runner.mjs"] diff --git a/deploy/pi-agent/entrypoint.sh b/deploy/pi-agent/entrypoint.sh new file mode 100755 index 00000000..e007edd0 --- /dev/null +++ b/deploy/pi-agent/entrypoint.sh @@ -0,0 +1,92 @@ +#!/bin/bash +set -e + +# ============================================================================= +# Pi Agent Container Entrypoint +# Dynamically configures MiniMax provider (4 core parameters) & Tavily search extension +# ============================================================================= + +# 1. 核心参数配置与规整 +# 1) ENDPOINT URL (默认: https://api.minimaxi.com/v1) +ENDPOINT_URL="${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1}" + +# 2) API TOKEN (用户自行设定) +API_TOKEN="${MINIMAX_API_KEY:-${MINIMAX_API_TOKEN:-}}" + +# 3) MODEL ID (默认: MiniMax-M3) +MODEL_ID="${MINIMAX_MODEL_ID:-MiniMax-M3}" + +# 4) API 类型 (用户设定 openai-compatible,规整映射至 pi 的 openai-completions) +RAW_API_TYPE="${MINIMAX_API_TYPE:-openai-compatible}" +if [ "$RAW_API_TYPE" = "openai-compatible" ] || [ "$RAW_API_TYPE" = "openai" ]; then + API_TYPE="openai-completions" +else + API_TYPE="$RAW_API_TYPE" +fi + +echo "=================================================================" +echo " 🚀 Starting Pi-Agent Container" +echo " - ENDPOINT URL : ${ENDPOINT_URL}" +echo " - MODEL ID : ${MODEL_ID}" +echo " - API TYPE : ${API_TYPE} (from ${RAW_API_TYPE})" +if [ -z "$API_TOKEN" ]; then + echo " - API TOKEN : [WARNING] MINIMAX_API_KEY is not set yet!" +else + MASKED_KEY="${API_TOKEN:0:4}...${API_TOKEN: -4}" + echo " - API TOKEN : configured (${MASKED_KEY})" +fi +if [ -n "$TAVILY_API_KEYS" ] || [ -n "$TAVILY_API_KEY" ]; then + echo " - TAVILY SEARCH: configured (API Key Pool active)" +else + echo " - TAVILY SEARCH: not configured (set TAVILY_API_KEYS to enable)" +fi +echo "=================================================================" + +# 2. 准备 Pi Agent 运行时目录结构 +PI_DIR="${PI_CODING_AGENT_DIR:-/root/.pi/agent}" +mkdir -p "${PI_DIR}/extensions" +mkdir -p "${DATA_DIR:-/app/data/workspaces}" + +# 3. 注入 Tavily Web Search 扩展 +if [ -f "/app/extensions/tavily-search.mjs" ]; then + cp -f "/app/extensions/tavily-search.mjs" "${PI_DIR}/extensions/tavily-search.mjs" +fi + +# 4. 动态生成 models.json +cat < "${PI_DIR}/models.json" +{ + "providers": { + "minimax": { + "name": "MiniMax", + "baseUrl": "${ENDPOINT_URL}", + "api": "${API_TYPE}", + "apiKey": "${API_TOKEN}", + "models": [ + { + "id": "${MODEL_ID}", + "name": "${MODEL_ID}", + "reasoning": true, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 131072 + } + ] + } + } +} +EOF + +# 5. 生成 settings.json 设置默认 Provider 和 Model +cat < "${PI_DIR}/settings.json" +{ + "defaultProvider": "minimax", + "defaultModel": "${MODEL_ID}" +} +EOF + +# 6. 执行容器命令 +if [ "$#" -eq 0 ]; then + exec node /app/runner.mjs +else + exec "$@" +fi diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs new file mode 100755 index 00000000..b69201b9 --- /dev/null +++ b/deploy/pi-agent/runner.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node +/** + * Pi Agent Container HTTP Runner Server + * Listens for task run requests, executes pi agent in RPC mode within workspace, + * and returns generated artifact patches. + */ + +import http from "node:http"; +import { spawn } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { resolve, relative, isAbsolute } from "node:path"; +import { TavilyKeyPool, searchTavily, formatTavilyResults } from "./extensions/tavily-search.mjs"; + +const PORT = parseInt(process.env.PORT || process.env.PI_AGENT_RUNNER_PORT || "18766", 10); +const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces"; +const DEFAULT_MODEL = process.env.MINIMAX_MODEL_ID || "MiniMax-M3"; +const DEFAULT_PROVIDER = process.env.MINIMAX_PROVIDER || "minimax"; +const tavilyPool = TavilyKeyPool.fromEnv(); + +function safeRel(workspace, target) { + const abs = isAbsolute(target) ? target : resolve(workspace, target); + const rel = relative(workspace, abs); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) { + return null; + } + return rel.replace(/\\/g, "/"); +} + +function resolveWorkspace(rawPath) { + if (!rawPath) return DATA_DIR; + if (existsSync(rawPath)) return rawPath; + + // If path contains 'workspaces/', extract the relative part + const wsIndex = rawPath.indexOf("workspaces/"); + if (wsIndex !== -1) { + const sub = rawPath.slice(wsIndex + "workspaces/".length); + return resolve(DATA_DIR, sub); + } + + // If relative path + if (!isAbsolute(rawPath)) { + return resolve(DATA_DIR, rawPath); + } + + return rawPath; +} + +function parseJsonBody(req) { + return new Promise((resolvePromise, rejectPromise) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolvePromise({}); + return; + } + try { + resolvePromise(JSON.parse(raw)); + } catch (err) { + rejectPromise(new Error("Invalid JSON: " + err.message)); + } + }); + req.on("error", rejectPromise); + }); +} + +function sendJson(res, statusCode, data) { + res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(data, null, 2) + "\n"); +} + +async function handleRun(body) { + const rawWs = body.workspace || ""; + const workspace = resolveWorkspace(rawWs); + const model = body.model || DEFAULT_MODEL; + const provider = body.provider || DEFAULT_PROVIDER; + const prompt = body.prompt || ""; + const instructions = body.instructions || ""; + const targets = Array.isArray(body.targets) ? body.targets : []; + const timeoutMs = Number(body.timeout_ms) || 30 * 60 * 1000; + + if (!existsSync(workspace)) { + mkdirSync(workspace, { recursive: true }); + } + + if (!prompt.trim()) { + return { ok: false, fallback_reason: "prompt_empty", patches: [] }; + } + + const allowed = new Set(); + for (const t of targets) { + const rel = safeRel(workspace, t); + if (rel) allowed.add(rel); + } + + // 1. Write persona instructions into workspace + if (instructions.trim()) { + try { + writeFileSync(resolve(workspace, "AGENTS.md"), instructions, "utf8"); + writeFileSync(resolve(workspace, "INSTRUCTIONS.md"), instructions, "utf8"); + } catch (e) { + console.warn("[runner] Failed to write instructions to workspace:", e); + } + } + + // 2. Build pi arguments + const piBin = process.env.PI_AGENT_BIN || "pi"; + const args = [ + "--mode", "rpc", + "--no-session", + "--provider", provider, + "--model", model, + ]; + + const sysPromptFile = resolve(workspace, "INSTRUCTIONS.md"); + if (existsSync(sysPromptFile)) { + args.push("--append-system-prompt", "INSTRUCTIONS.md"); + } + + args.push( + "--tools", "read,write,edit,tavily_search,web_search", + "--approve", + ); + + const env = { ...process.env }; + env.PI_AGENT_MODEL = model; + env.PI_AGENT_PROVIDER = provider; + env.PI_AGENT_SYSTEM_PROMPT_FILE = "AGENTS.md"; + + let agentId = ""; + let runId = ""; + let lastErr = ""; + let succeeded = false; + + console.log(`[runner] Starting pi run in workspace: ${workspace}, model: ${model}`); + + const child = spawn(piBin, args, { + cwd: workspace, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + const timer = setTimeout(() => { + lastErr = `Execution timed out after ${timeoutMs}ms`; + child.kill("SIGKILL"); + }, timeoutMs); + + const cmdId = "req-" + Date.now(); + child.stdin.write( + JSON.stringify({ + id: cmdId, + type: "prompt", + message: instructions ? `[Persona & System Instructions]:\n${instructions}\n\n[User Task]:\n${prompt}` : prompt, + }) + "\n", + ); + + let stdoutBuf = ""; + child.stdout.on("data", (chunk) => { + stdoutBuf += chunk.toString("utf8"); + let nl; + while ((nl = stdoutBuf.indexOf("\n")) >= 0) { + const line = stdoutBuf.slice(0, nl).trim(); + stdoutBuf = stdoutBuf.slice(nl + 1); + if (!line) continue; + let ev; + try { ev = JSON.parse(line); } catch { continue; } + if (!ev || typeof ev !== "object") continue; + + const t = ev.type || ""; + if (t === "session" && ev.id) agentId = ev.id; + if (t === "agent_settled") { + succeeded = true; + child.stdin.end(); + } + if (t === "agent_error") { + lastErr = ev.error?.message || ev.message || "agent_error"; + } + } + }); + + let stderrBuf = ""; + child.stderr.on("data", (chunk) => { + stderrBuf += chunk.toString("utf8"); + lastErr = (stderrBuf + " " + lastErr).slice(-400); + }); + + await new Promise((resolveDone) => { + child.on("close", () => { + clearTimeout(timer); + resolveDone(); + }); + child.on("error", (err) => { + clearTimeout(timer); + lastErr = err && err.message ? err.message : String(err); + resolveDone(); + }); + }); + + if (!succeeded) { + return { + ok: false, + agent_id: agentId, + run_id: runId, + fallback_reason: lastErr || "agent_not_settled", + stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, + patches: [], + }; + } + + // 3. Read generated files + const patches = []; + for (const rel of allowed) { + const abs = resolve(workspace, rel); + if (!existsSync(abs)) continue; + const content = readFileSync(abs, "utf8"); + patches.push({ rel_path: rel, content }); + } + + return { + ok: true, + agent_id: agentId, + run_id: runId, + patches, + fallback_reason: "", + }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const method = req.method.toUpperCase(); + + // 1. Healthcheck + if (method === "GET" && (url.pathname === "/health" || url.pathname === "/")) { + sendJson(res, 200, { + ok: true, + service: "shuimu-pi-agent-runner", + endpoint_url: process.env.MINIMAX_ENDPOINT_URL || "https://api.minimaxi.com/v1", + model_id: DEFAULT_MODEL, + api: process.env.MINIMAX_API_TYPE || "openai-completions", + has_token: Boolean(process.env.MINIMAX_API_KEY || process.env.MINIMAX_API_TOKEN), + tavily: { + active: tavilyPool.size() > 0, + keys_count: tavilyPool.size(), + }, + data_dir: DATA_DIR, + uptime: process.uptime(), + }); + return; + } + + // 2. Direct search test endpoint + if (method === "POST" && url.pathname === "/v1/search") { + try { + const body = await parseJsonBody(req); + const query = (body.query || "").trim(); + if (!query) { + sendJson(res, 400, { error: "Missing query parameter" }); + return; + } + const data = await tavilyPool.executeWithPool(async (apiKey) => { + return await searchTavily(apiKey, query, { + search_depth: body.search_depth, + max_results: body.max_results, + }); + }); + const markdown = formatTavilyResults(data, query); + sendJson(res, 200, { ok: true, data, markdown }); + } catch (err) { + sendJson(res, 500, { ok: false, error: err.message }); + } + return; + } + + // 3. Agent Task Run Endpoint + if (method === "POST" && url.pathname === "/v1/run") { + try { + const body = await parseJsonBody(req); + const result = await handleRun(body); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 500, { ok: false, error: err.message, patches: [] }); + } + return; + } + + sendJson(res, 404, { error: "Not Found", path: url.pathname }); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[pi-agent-runner] Listening on 0.0.0.0:${PORT}`); + console.log(`[pi-agent-runner] Endpoint: ${process.env.MINIMAX_ENDPOINT_URL || "https://api.minimaxi.com/v1"}`); + console.log(`[pi-agent-runner] Model: ${DEFAULT_MODEL}, Provider: ${DEFAULT_PROVIDER}`); + console.log(`[pi-agent-runner] Tavily API keys pool size: ${tavilyPool.size()}`); +}); From b36a9ee1085a0c17b77e066154007e1e6d6a5d6f Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:31:31 +0800 Subject: [PATCH 5/9] feat(agent): support PI_AGENT_RUNNER_URL remote execution in PiAgentBackend Co-authored-by: Cursor --- internal/agent/piagent.go | 89 +++++++++++++++++++++++++++++++++++++++ internal/config/config.go | 3 ++ 2 files changed, 92 insertions(+) diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 22d17255..e0203f7b 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -5,10 +5,12 @@ import ( "context" "encoding/json" "fmt" + "net/http" "os" "os/exec" "path/filepath" "strings" + "time" "chat-agent-system/internal/config" ) @@ -52,6 +54,10 @@ 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 = workDir cmd.Stdin = bytes.NewReader(payload) @@ -114,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 +} diff --git a/internal/config/config.go b/internal/config/config.go index fd9447fa..01d7473a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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": From aad4a0daee798be8f0c649d40ffbee27682e93a0 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:31:35 +0800 Subject: [PATCH 6/9] test(agent): add Tavily pool rotation and PiAgent remote runner tests Co-authored-by: Cursor --- deploy/pi-agent/tests/tavily-test.mjs | 132 ++++++++++++++++++++++++++ internal/agent/backend_test.go | 61 ++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 deploy/pi-agent/tests/tavily-test.mjs diff --git a/deploy/pi-agent/tests/tavily-test.mjs b/deploy/pi-agent/tests/tavily-test.mjs new file mode 100644 index 00000000..61f77db4 --- /dev/null +++ b/deploy/pi-agent/tests/tavily-test.mjs @@ -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("=========================================="); diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go index 3a4834b9..ffaced36 100644 --- a/internal/agent/backend_test.go +++ b/internal/agent/backend_test.go @@ -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)) + } +} From 34fcbe6ff31cb6b6caa80fbc289c039aca246f37 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:31:39 +0800 Subject: [PATCH 7/9] chore(docker): integrate pi-agent service in dev, test, and main compose files Co-authored-by: Cursor --- docker-compose.dev.yml | 30 ++++++++++++++++++++++++++++++ docker-compose.main.yml | 30 ++++++++++++++++++++++++++++++ docker-compose.test.yml | 30 ++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8aee114b..bb47cfcf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -45,6 +45,35 @@ 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 + 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 +90,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}" diff --git a/docker-compose.main.yml b/docker-compose.main.yml index 84b3b1f5..dcf26f45 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -45,6 +45,35 @@ 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 + 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 +90,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}" diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 92a25e38..682d2b23 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -45,6 +45,35 @@ 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 + 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 +90,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}" From 3ffcab4502ca0cc88c956c86597ce19a3b333415 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:31:45 +0800 Subject: [PATCH 8/9] docs(env): update environment configuration template with MiniMax and Tavily pool Co-authored-by: Cursor --- .env.example | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 2d6f3fb4..b0974f78 100644 --- a/.env.example +++ b/.env.example @@ -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 (如 cc-switch-mini-max, anthropic, cc-switch-kimi-for-coding 等) -PI_AGENT_PROVIDER=cc-switch-mini-max -# pi 所用模型标识 (如 MiniMax-M3, MiniMax-M2.7, claude-3-7-sonnet 等) -PI_AGENT_MODEL=MiniMax-M3 -# pi 运行时的额外环境变量或 token (若通过 ~/.pi/agent/models.json 配置则可留空) +# --- 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= From 4c35888a4e399f0e91f5348a9ec45b62831dbe58 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Mon, 7 Sep 2026 17:51:50 +0800 Subject: [PATCH 9/9] feat(agent): ensure clean container and isolate workspace artifacts strictly to host volume Co-authored-by: Cursor --- cmd/bizdev-cli/main.go | 30 ++++++++++++++-- deploy/pi-agent/entrypoint.sh | 7 ++-- deploy/pi-agent/runner.mjs | 64 +++++++++++++++++++++++++++++------ docker-compose.dev.yml | 3 ++ docker-compose.main.yml | 3 ++ docker-compose.test.yml | 3 ++ 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/cmd/bizdev-cli/main.go b/cmd/bizdev-cli/main.go index fc6d4624..18fde0f6 100644 --- a/cmd/bizdev-cli/main.go +++ b/cmd/bizdev-cli/main.go @@ -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,16 +29,32 @@ 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) @@ -47,7 +67,11 @@ func main() { 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") diff --git a/deploy/pi-agent/entrypoint.sh b/deploy/pi-agent/entrypoint.sh index e007edd0..d91b6fe6 100755 --- a/deploy/pi-agent/entrypoint.sh +++ b/deploy/pi-agent/entrypoint.sh @@ -76,11 +76,14 @@ cat < "${PI_DIR}/models.json" } EOF -# 5. 生成 settings.json 设置默认 Provider 和 Model +# 5. 生成 settings.json 设置默认 Provider、Model 与全局扩展 cat < "${PI_DIR}/settings.json" { "defaultProvider": "minimax", - "defaultModel": "${MODEL_ID}" + "defaultModel": "${MODEL_ID}", + "extensions": [ + "/app/extensions/tavily-search.mjs" + ] } EOF diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index b69201b9..08265feb 100755 --- a/deploy/pi-agent/runner.mjs +++ b/deploy/pi-agent/runner.mjs @@ -28,21 +28,25 @@ function safeRel(workspace, target) { function resolveWorkspace(rawPath) { if (!rawPath) return DATA_DIR; - if (existsSync(rawPath)) return rawPath; // If path contains 'workspaces/', extract the relative part const wsIndex = rawPath.indexOf("workspaces/"); + let sub = ""; if (wsIndex !== -1) { - const sub = rawPath.slice(wsIndex + "workspaces/".length); - return resolve(DATA_DIR, sub); + sub = rawPath.slice(wsIndex + "workspaces/".length); + } else if (rawPath.startsWith(DATA_DIR)) { + sub = relative(DATA_DIR, rawPath); + } else { + sub = rawPath.replace(/^[/\\]+/, ""); } - // If relative path - if (!isAbsolute(rawPath)) { - return resolve(DATA_DIR, rawPath); + // 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 rawPath; + return target; } function parseJsonBody(req) { @@ -94,7 +98,7 @@ async function handleRun(body) { if (rel) allowed.add(rel); } - // 1. Write persona instructions into workspace + // 1. Write persona instructions and task into workspace if (instructions.trim()) { try { writeFileSync(resolve(workspace, "AGENTS.md"), instructions, "utf8"); @@ -104,10 +108,19 @@ async function handleRun(body) { } } + 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, @@ -156,8 +169,11 @@ async function handleRun(body) { ); let stdoutBuf = ""; + let fullStdout = ""; child.stdout.on("data", (chunk) => { - stdoutBuf += chunk.toString("utf8"); + const text = chunk.toString("utf8"); + stdoutBuf += text; + fullStdout += text; let nl; while ((nl = stdoutBuf.indexOf("\n")) >= 0) { const line = stdoutBuf.slice(0, nl).trim(); @@ -197,6 +213,23 @@ async function handleRun(body) { }); }); + // 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, @@ -208,7 +241,7 @@ async function handleRun(body) { }; } - // 3. Read generated files + // 3. Read generated files and deliverables const patches = []; for (const rel of allowed) { const abs = resolve(workspace, rel); @@ -217,6 +250,15 @@ async function handleRun(body) { 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, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bb47cfcf..bc6ed967 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -65,6 +65,9 @@ services: - "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 diff --git a/docker-compose.main.yml b/docker-compose.main.yml index dcf26f45..872c212e 100644 --- a/docker-compose.main.yml +++ b/docker-compose.main.yml @@ -65,6 +65,9 @@ services: - "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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 682d2b23..6e392c9f 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -65,6 +65,9 @@ services: - "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