feat(agent): implement cursor-cli, pi-agent, and stub backend adapters

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:18 +00:00
committed by weichen
co-authored by Cursor
parent 9bcf458529
commit 6ee1c5b6c7
7 changed files with 680 additions and 0 deletions
+39
View File
@@ -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{}
}
}
+42
View File
@@ -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)
}
}
+80
View File
@@ -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
}
+81
View File
@@ -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
}
+29
View File
@@ -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
}
+188
View File
@@ -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)));
});
+221
View File
@@ -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 <p> --model <m>
* --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)));
});