diff --git a/internal/agent/backend.go b/internal/agent/backend.go new file mode 100644 index 00000000..45ecca13 --- /dev/null +++ b/internal/agent/backend.go @@ -0,0 +1,39 @@ +package agent + +import ( + "context" + "time" + + "chat-agent-system/internal/config" +) + +type Request struct { + WorkDir string `json:"work_dir"` + Prompt string `json:"prompt"` + Instructions string `json:"instructions"` + Targets []string `json:"targets"` + Timeout time.Duration `json:"timeout"` +} + +type Result struct { + Success bool `json:"success"` + Patches map[string][]byte `json:"patches"` + Logs string `json:"logs"` + ErrorReason string `json:"error_reason"` +} + +type Backend interface { + Name() string + Execute(ctx context.Context, req Request) (*Result, error) +} + +func NewBackend(name string, cfg *config.Config) Backend { + switch name { + case "pi-agent": + return &PiAgentBackend{cfg: cfg} + case "cursor-cli": + return &CursorCLIBackend{cfg: cfg} + default: + return &StubBackend{} + } +} diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go new file mode 100644 index 00000000..3a4834b9 --- /dev/null +++ b/internal/agent/backend_test.go @@ -0,0 +1,42 @@ +package agent_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/config" +) + +func TestStubBackendExecution(t *testing.T) { + tmpDir := t.TempDir() + cfg := &config.Config{AgentBackend: "stub"} + + backend := agent.NewBackend("stub", cfg) + if backend.Name() != "stub" { + t.Errorf("expected stub backend, got %s", backend.Name()) + } + + req := agent.Request{ + WorkDir: tmpDir, + Prompt: "请编写用户初始需求收集表", + Targets: []string{"output.md"}, + Timeout: 5 * time.Second, + } + + res, err := backend.Execute(context.Background(), req) + if err != nil { + t.Fatalf("stub execute failed: %v", err) + } + if !res.Success { + t.Fatalf("expected success") + } + + outputPath := filepath.Join(tmpDir, "output.md") + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected output.md to exist: %v", err) + } +} diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go new file mode 100644 index 00000000..2edbc224 --- /dev/null +++ b/internal/agent/cursor.go @@ -0,0 +1,80 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "chat-agent-system/internal/config" +) + +type CursorCLIBackend struct { + cfg *config.Config +} + +func (c *CursorCLIBackend) Name() string { return "cursor-cli" } + +func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, error) { + if len(req.Targets) == 0 { + req.Targets = []string{"output.md"} + } + + payload, _ := json.Marshal(map[string]any{ + "workspace": req.WorkDir, + "api_key": c.cfg.CursorAPIKey, + "model": c.cfg.CursorModel, + "prompt": req.Prompt, + "targets": req.Targets, + }) + + bridgeBin := c.cfg.CursorBridgeBin + if !filepath.IsAbs(bridgeBin) { + wd, _ := os.Getwd() + bridgeBin = filepath.Join(wd, bridgeBin) + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = c.cfg.AgentTimeout + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, bridgeBin) + cmd.Dir = req.WorkDir + cmd.Stdin = bytes.NewReader(payload) + cmd.Env = append(os.Environ(), + "CURSOR_API_KEY="+c.cfg.CursorAPIKey, + "CURSOR_MODEL="+c.cfg.CursorModel, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return &Result{ + Success: false, + ErrorReason: fmt.Sprintf("cursor execution error: %v, stderr: %s", err, stderr.String()), + }, nil + } + + patches := make(map[string][]byte) + for _, t := range req.Targets { + p := filepath.Join(req.WorkDir, t) + if b, err := os.ReadFile(p); err == nil { + patches[t] = b + } + } + + return &Result{ + Success: true, + Patches: patches, + Logs: stdout.String(), + }, nil +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go new file mode 100644 index 00000000..752bfbdd --- /dev/null +++ b/internal/agent/piagent.go @@ -0,0 +1,81 @@ +package agent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + + "chat-agent-system/internal/config" +) + +type PiAgentBackend struct { + cfg *config.Config +} + +func (p *PiAgentBackend) Name() string { return "pi-agent" } + +func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, error) { + if len(req.Targets) == 0 { + req.Targets = []string{"output.md"} + } + + payload, _ := json.Marshal(map[string]any{ + "workspace": req.WorkDir, + "model": p.cfg.PiAgentModel, + "provider": p.cfg.PiAgentProvider, + "prompt": req.Prompt, + "targets": req.Targets, + }) + + bridgeBin := p.cfg.PiAgentBridgeBin + if !filepath.IsAbs(bridgeBin) { + wd, _ := os.Getwd() + bridgeBin = filepath.Join(wd, bridgeBin) + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = p.cfg.AgentTimeout + } + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(runCtx, bridgeBin) + cmd.Dir = req.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, + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return &Result{ + Success: false, + ErrorReason: fmt.Sprintf("pi execution error: %v, stderr: %s", err, stderr.String()), + }, nil + } + + patches := make(map[string][]byte) + for _, t := range req.Targets { + p := filepath.Join(req.WorkDir, t) + if b, err := os.ReadFile(p); err == nil { + patches[t] = b + } + } + + return &Result{ + Success: true, + Patches: patches, + Logs: stdout.String(), + }, nil +} diff --git a/internal/agent/stub.go b/internal/agent/stub.go new file mode 100644 index 00000000..054edd5a --- /dev/null +++ b/internal/agent/stub.go @@ -0,0 +1,29 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +type StubBackend struct{} + +func (s *StubBackend) Name() string { return "stub" } + +func (s *StubBackend) Execute(ctx context.Context, req Request) (*Result, error) { + patches := make(map[string][]byte) + for _, target := range req.Targets { + content := []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt)) + outPath := filepath.Join(req.WorkDir, target) + _ = os.MkdirAll(filepath.Dir(outPath), 0o755) + _ = os.WriteFile(outPath, content, 0o644) + patches[target] = content + } + + return &Result{ + Success: true, + Patches: patches, + Logs: "Stub agent execution completed.", + }, nil +} diff --git a/tools/cursor-bridge/index.mjs b/tools/cursor-bridge/index.mjs new file mode 100755 index 00000000..5f3bfb7d --- /dev/null +++ b/tools/cursor-bridge/index.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +/** + * Cursor SDK bridge (stdio JSON). + * + * Input (stdin): + * { + * "workspace": "/abs/path", + * "api_key": "...", + * "model": "auto", + * "prompt": "...", + * "targets": ["docs/RD-UN.md"] + * } + * + * Output (stdout, last line JSON): + * { + * "ok": true, + * "agent_id": "...", + * "run_id": "...", + * "patches": [{ "rel_path": "docs/RD-UN.md", "content": "..." }], + * "fallback_reason": "" + * } + */ +import { readFileSync, existsSync } from "node:fs"; +import { resolve, relative, isAbsolute } from "node:path"; +import { Agent, CursorAgentError } from "@cursor/sdk"; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function fail(reason, extra = {}) { + process.stdout.write( + JSON.stringify({ + ok: false, + patches: [], + fallback_reason: reason, + ...extra, + }) + "\n", + ); + process.exit(0); +} + +function sanitizeApiKey(raw) { + if (!raw) return ""; + let key = String(raw).trim(); + const commentIdx = key.indexOf(" #"); + if (commentIdx !== -1) { + key = key.slice(0, commentIdx).trim(); + } + const tabCommentIdx = key.indexOf("\t#"); + if (tabCommentIdx !== -1) { + key = key.slice(0, tabCommentIdx).trim(); + } + return key; +} + +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, "/"); +} + +async function main() { + let req; + try { + const raw = await readStdin(); + req = JSON.parse(raw); + } catch (e) { + fail("invalid_json: " + (e && e.message ? e.message : String(e))); + return; + } + + const workspace = req.workspace; + 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 targets = Array.isArray(req.targets) ? req.targets : []; + + if (!workspace || !existsSync(workspace)) { + fail("workspace_missing"); + return; + } + if (!apiKey) { + fail("api_key_missing"); + return; + } + if (!prompt.trim()) { + fail("prompt_empty"); + return; + } + + const allowed = new Set(); + for (const t of targets) { + const rel = safeRel(workspace, t); + if (rel) allowed.add(rel); + } + if (allowed.size === 0) { + fail("no_targets"); + return; + } + + let agentId = ""; + let runId = ""; + try { + const result = await Agent.prompt(prompt, { + apiKey, + model: { id: model }, + local: { + cwd: workspace, + settingSources: [], + }, + }); + agentId = result?.agentId || result?.agent_id || ""; + runId = result?.id || result?.runId || result?.run_id || ""; + if (result?.status === "error") { + fail("run_error", { agent_id: agentId, run_id: runId }); + return; + } + } catch (e) { + const rawMsg = + e instanceof CursorAgentError + ? e.message + : e && e.message + ? e.message + : String(e); + let category = "startup_or_run"; + const lower = rawMsg.toLowerCase(); + if ( + lower.includes("invalid user api key") || + lower.includes("unauthorized") || + lower.includes("401") || + lower.includes("authentication") + ) { + category = "cursor_auth_failed"; + } else if ( + lower.includes("timeout") || + lower.includes("timed out") || + lower.includes("deadline") + ) { + category = "cursor_timeout"; + } else if ( + lower.includes("enotfound") || + lower.includes("econnrefused") || + lower.includes("fetch failed") || + lower.includes("network") + ) { + category = "cursor_network_error"; + } else if (lower.includes("cannot use this model") || lower.includes("available models")) { + category = "cursor_model_unsupported"; + } + fail(category + ": " + rawMsg, { agent_id: agentId, run_id: runId }); + return; + } + + 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 }); + } + + if (patches.length === 0) { + fail("empty_patches", { agent_id: agentId, run_id: runId }); + return; + } + + process.stdout.write( + JSON.stringify({ + ok: true, + agent_id: agentId, + run_id: runId, + patches, + fallback_reason: "", + }) + "\n", + ); +} + +main().catch((e) => { + fail("unhandled: " + (e && e.message ? e.message : String(e))); +}); diff --git a/tools/pi-bridge/index.mjs b/tools/pi-bridge/index.mjs new file mode 100755 index 00000000..722335fd --- /dev/null +++ b/tools/pi-bridge/index.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * pi-coding-agent bridge (stdio JSON). + * + * Input (stdin): + * { + * "workspace": "/abs/path", + * "model": "claude-sonnet-4-5", + * "prompt": "...", + * "targets": ["docs/RD-UN.md"] + * } + * + * Output (stdout, last line JSON): 同 cursor-bridge / opencode-bridge 协议 + * + * 设计:与 cursor-bridge / opencode-bridge 完全相同的协议层。 + * + * 调用的 pi 命令: + * pi --mode rpc --no-session --provider

--model + * --append-system-prompt INSTRUCTIONS.md + * --tools read,write,edit --approve + * + * 工作目录:Go 端 cmd.Dir 设(pi 没有 --cwd flag;node 桥 spawn 时再覆盖一次) + * + * 完成信号: + * - agent_settled → 成功("最稳妥" 标志,自动重试/队列全走完才算) + * - agent_error → 失败 + * - turn_end → 单轮结束,不一定等于整体任务完成 + */ +import { spawn } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import { resolve, relative, isAbsolute } from "node:path"; + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function fail(reason, extra = {}, stderr = "") { + process.stdout.write( + JSON.stringify({ + ok: false, + patches: [], + fallback_reason: reason, + stderr: stderr ? stderr.slice(0, 400) : undefined, + ...extra, + }) + "\n", + ); + process.exit(0); +} + +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, "/"); +} + +async function main() { + let req; + try { + const raw = await readStdin(); + req = JSON.parse(raw); + } catch (e) { + fail("invalid_json: " + (e && e.message ? e.message : String(e))); + return; + } + + const workspace = req.workspace; + const model = req.model || process.env.PI_AGENT_MODEL || "claude-sonnet-4-5"; + const provider = req.provider || process.env.PI_AGENT_PROVIDER || "anthropic"; + const prompt = req.prompt || ""; + const targets = Array.isArray(req.targets) ? req.targets : []; + + if (!workspace || !existsSync(workspace)) { + fail("workspace_missing"); + return; + } + if (!prompt.trim()) { + fail("prompt_empty"); + return; + } + + const allowed = new Set(); + for (const t of targets) { + const rel = safeRel(workspace, t); + if (rel) allowed.add(rel); + } + if (allowed.size === 0) { + fail("no_targets"); + return; + } + + // 1. spawn pi --mode rpc + const piBin = process.env.PI_AGENT_BIN || "pi"; + const args = [ + "--mode", "rpc", + "--no-session", + "--provider", provider, + "--model", model, + ]; + + const sysPromptFile = process.env.PI_AGENT_SYSTEM_PROMPT_FILE || "INSTRUCTIONS.md"; + const absSysPrompt = resolve(workspace, sysPromptFile); + if (existsSync(absSysPrompt)) { + args.push("--append-system-prompt", sysPromptFile); + } + + args.push( + "--tools", "read,write,edit", + "--approve", + ); + + const env = { ...process.env }; + env.PI_AGENT_MODEL = model; + env.PI_AGENT_PROVIDER = provider; + + let agentId = ""; + let runId = ""; + let lastErr = ""; + let succeeded = false; + + try { + const child = spawn(piBin, args, { + cwd: workspace, + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + // 2. 写 JSONL prompt 命令到 stdin + const cmdId = "req-" + Date.now(); + child.stdin.write( + JSON.stringify({ + id: cmdId, + type: "prompt", + message: prompt, + }) + "\n", + ); + + // 3. 按行解析 ndjson 事件流 + let buf = ""; + child.stdout.on("data", (chunk) => { + buf += chunk.toString("utf8"); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.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; + // agent_settled 表示任务真正结束;关闭 stdin 让 pi 退出 + 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", () => resolveDone()); + child.on("error", (err) => { + lastErr = err && err.message ? err.message : String(err); + resolveDone(); + }); + }); + } catch (e) { + fail("startup_or_run: " + (e && e.message ? e.message : String(e))); + return; + } + + if (!succeeded) { + fail(lastErr || "agent_not_settled", { agent_id: agentId, run_id: runId }, lastErr); + return; + } + + // 4. 读 workspace 内目标文件 + 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 }); + } + + if (patches.length === 0) { + fail("empty_patches", { agent_id: agentId, run_id: runId }); + return; + } + + process.stdout.write( + JSON.stringify({ + ok: true, + agent_id: agentId, + run_id: runId, + patches, + fallback_reason: "", + }) + "\n", + ); +} + +main().catch((e) => { + fail("unhandled: " + (e && e.message ? e.message : String(e))); +});