feat(agent): add pi-native backend and drop cursor-cli

Keep the container pi-agent backend as the default while the in-process runtime is available beside it, and remove the unused cursor bridge.
This commit is contained in:
weichen
2026-09-21 14:52:53 +00:00
parent 022b914c66
commit 738bbe1579
12 changed files with 327 additions and 376 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ import (
func main() { func main() {
stageFlag := flag.String("stage", "", "执行成熟度阶段 (可选: V0.1_triage, V0.5_feasibility, V1.0_quoting)") stageFlag := flag.String("stage", "", "执行成熟度阶段 (可选: V0.1_triage, V0.5_feasibility, V1.0_quoting)")
taskKey := flag.String("task", "intake", "任务标识 (intake, research, cdo_quote 等)") taskKey := flag.String("task", "intake", "任务标识 (intake, research, cdo_quote 等)")
backend := flag.String("backend", "", "智能体后端 (留空则读取 .env 配置,可选: stub, pi-agent, cursor-cli)") backend := flag.String("backend", "", "智能体后端 (留空则读取 .env 配置,可选: stub, pi-agent, pi-native)")
project := flag.String("project", "医疗器械研发演示项目", "项目名称") project := flag.String("project", "医疗器械研发演示项目", "项目名称")
customer := flag.String("customer", "北京水木东方", "客户名称") customer := flag.String("customer", "北京水木东方", "客户名称")
device := flag.String("device", "医疗器械", "设备类型") device := flag.String("device", "医疗器械", "设备类型")
+1 -1
View File
@@ -19,7 +19,7 @@ func main() {
envPath := flag.String("env", ".env", "环境配置文件路径") envPath := flag.String("env", ".env", "环境配置文件路径")
port := flag.Int("port", 0, "HTTP 服务端口 (默认读取配置文件或 8080)") port := flag.Int("port", 0, "HTTP 服务端口 (默认读取配置文件或 8080)")
host := flag.String("host", "", "HTTP 监听主机 (默认读取配置文件或 127.0.0.1)") host := flag.String("host", "", "HTTP 监听主机 (默认读取配置文件或 127.0.0.1)")
backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, cursor-cli)") backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, pi-native)")
flag.Parse() flag.Parse()
cfg, err := config.Load(*envPath) cfg, err := config.Load(*envPath)
+2 -2
View File
@@ -42,8 +42,8 @@ func NewBackend(name string, cfg *config.Config) Backend {
switch name { switch name {
case "pi-agent": case "pi-agent":
inner = &PiAgentBackend{cfg: cfg} inner = &PiAgentBackend{cfg: cfg}
case "cursor-cli": case "pi-native":
inner = &CursorCLIBackend{cfg: cfg} inner = &PiNativeBackend{cfg: cfg}
default: default:
inner = &StubBackend{} inner = &StubBackend{}
} }
-114
View File
@@ -1,114 +0,0 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"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"}
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
payload, _ := json.Marshal(map[string]any{
"workspace": workDir,
"api_key": c.cfg.CursorAPIKey,
"model": c.cfg.CursorModel,
"prompt": req.Prompt,
"instructions": req.Instructions,
"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 = 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
}
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(workDir, t)
if b, err := os.ReadFile(p); err == nil {
patches[t] = b
}
}
return &Result{
Success: true,
Patches: patches,
Logs: stdout.String(),
}, nil
}
+154
View File
@@ -0,0 +1,154 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"chat-agent-system/internal/agentx"
"chat-agent-system/internal/agentx/llm"
"chat-agent-system/internal/agentx/session"
"chat-agent-system/internal/agentx/tools"
"chat-agent-system/internal/config"
)
// PiNativeBackend runs the in-process Go agent loop. The container pi-agent backend stays available.
type PiNativeBackend struct {
cfg *config.Config
stream llm.StreamFunc
}
func (p *PiNativeBackend) Name() string { return "pi-native" }
func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, error) {
if len(req.Targets) == 0 {
req.Targets = []string{"output.md"}
}
profile := strings.TrimSpace(req.ToolProfile)
if profile == "" {
profile = ToolProfileWorkspace
}
if profile != ToolProfileWorkspace && profile != ToolProfileReasoning {
return &Result{Success: false, ErrorReason: "unknown tool profile"}, nil
}
if p.stream == nil && (p.cfg == nil || strings.TrimSpace(p.cfg.AgentXAPIKey) == "") {
return &Result{Success: false, ErrorReason: "agentx api key is not configured"}, nil
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return &Result{Success: false, ErrorReason: err.Error()}, nil
}
if strings.TrimSpace(req.Instructions) != "" {
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
_ = os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(req.Instructions), 0o644)
}
if strings.TrimSpace(req.Prompt) != "" {
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644)
}
var reg *tools.Registry
if profile == ToolProfileWorkspace {
reg = tools.WorkspaceTools(workDir)
}
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
if err != nil {
return &Result{Success: false, ErrorReason: err.Error()}, nil
}
stream := p.stream
model := "MiniMax-M2.7-highspeed"
if p.cfg != nil && p.cfg.AgentXModel != "" {
model = p.cfg.AgentXModel
}
if stream == nil {
client := &llm.Client{BaseURL: p.cfg.AgentXAPIBase, APIKey: p.cfg.AgentXAPIKey}
stream = client.Stream
}
timeout := req.Timeout
if timeout <= 0 && p.cfg != nil {
timeout = p.cfg.AgentXTimeout
}
if timeout <= 0 && p.cfg != nil {
timeout = p.cfg.AgentTimeout
}
runCtx := ctx
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
}
defer cancel()
var logs strings.Builder
runRes, err := agentx.Run(runCtx, agentx.Config{
Model: model,
SystemPrompt: nativeSystemPrompt(req, profile),
Tools: reg,
Stream: stream,
Session: store,
OnEvent: func(ev agentx.Event) {
logs.WriteString(ev.Type)
if ev.Tool != "" {
logs.WriteString(" ")
logs.WriteString(ev.Tool)
}
if ev.Text != "" {
logs.WriteString(" ")
logs.WriteString(ev.Text)
}
logs.WriteByte('\n')
},
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
if err != nil {
return &Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
}
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
if profile == ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
for _, target := range req.Targets {
path := filepath.Join(workDir, filepath.FromSlash(target))
if b, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b))) > 0 {
continue
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(runRes.Text), 0o644)
}
}
if runRes.Err != nil {
reason := runRes.Err.Error()
res := &Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
if runRes.StopReason == llm.StopAborted {
res.Retryable = true
}
return res, nil
}
patches, matErr := MaterializeTargets(workDir, req.Targets, nil)
if matErr != nil {
return &Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil
}
return &Result{Success: true, Patches: patches, Logs: logs.String()}, nil
}
func nativeSystemPrompt(req Request, profile string) string {
var b strings.Builder
if strings.TrimSpace(req.Instructions) != "" {
b.WriteString(req.Instructions)
b.WriteString("\n\n")
}
b.WriteString("Deliverable files: ")
b.WriteString(strings.Join(req.Targets, ", "))
b.WriteString(".")
if profile == ToolProfileWorkspace {
b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.")
} else {
b.WriteString(" Reply with the deliverable text only. Tools are disabled.")
}
return b.String()
}
+83
View File
@@ -0,0 +1,83 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"chat-agent-system/internal/agentx/llm"
"chat-agent-system/internal/config"
)
func TestPiNativeWorkspaceWritesTarget(t *testing.T) {
dir := t.TempDir()
b := &PiNativeBackend{cfg: &config.Config{}, stream: scriptedNative(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "1", Name: "write_file", Arguments: `{"path":"output.md","content":"# hello"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
)}
res, err := b.Execute(context.Background(), Request{
WorkDir: dir,
Prompt: "write the doc",
Instructions: "be precise",
Targets: []string{"output.md"},
ToolProfile: ToolProfileWorkspace,
})
if err != nil {
t.Fatal(err)
}
if !res.Success || string(res.Patches["output.md"]) != "# hello" {
t.Fatalf("%#v", res)
}
if _, err := os.Stat(filepath.Join(dir, "execution.log")); err != nil {
t.Fatal(err)
}
}
func TestPiNativeReasoningMaterializesText(t *testing.T) {
dir := t.TempDir()
b := &PiNativeBackend{cfg: &config.Config{}, stream: scriptedNative(
llm.Message{Role: llm.RoleAssistant, Content: "reply body", StopReason: llm.StopEnd},
)}
res, err := b.Execute(context.Background(), Request{
WorkDir: dir,
Prompt: "say hello",
Targets: []string{"reply.md"},
ToolProfile: ToolProfileReasoning,
})
if err != nil {
t.Fatal(err)
}
if !res.Success || !strings.Contains(string(res.Patches["reply.md"]), "reply body") {
t.Fatalf("%#v", res)
}
}
func TestPiNativeRejectsUnknownProfileAndMissingKey(t *testing.T) {
b := &PiNativeBackend{cfg: &config.Config{}}
res, err := b.Execute(context.Background(), Request{WorkDir: t.TempDir(), ToolProfile: "shell"})
if err != nil || res.Success || !strings.Contains(res.ErrorReason, "unknown tool profile") {
t.Fatalf("%v %#v", err, res)
}
res, err = b.Execute(context.Background(), Request{WorkDir: t.TempDir(), ToolProfile: ToolProfileWorkspace})
if err != nil || res.Success || !strings.Contains(res.ErrorReason, "api key") {
t.Fatalf("%v %#v", err, res)
}
}
func scriptedNative(turns ...llm.Message) llm.StreamFunc {
i := 0
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 2)
go func() {
defer close(ch)
msg := turns[i]
i++
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
}()
return ch
}
}
+53 -23
View File
@@ -17,10 +17,12 @@ type Config struct {
HTTPPort int `json:"http_port"` HTTPPort int `json:"http_port"`
AgentBackend string `json:"agent_backend"` AgentBackend string `json:"agent_backend"`
AgentTimeout time.Duration `json:"agent_timeout"` AgentTimeout time.Duration `json:"agent_timeout"`
CursorAPIKey string `json:"cursor_api_key"` AgentXAPIBase string `json:"agentx_api_base"`
CursorModel string `json:"cursor_model"` AgentXAPIKey string `json:"agentx_api_key"`
CursorBin string `json:"cursor_bin"` AgentXModel string `json:"agentx_model"`
CursorBridgeBin string `json:"cursor_bridge_bin"` AgentXTimeout time.Duration `json:"agentx_timeout"`
WorkspaceTTL time.Duration `json:"workspace_ttl"`
WorkspaceQuotaBytes int64 `json:"workspace_quota_bytes"`
PiAgentBin string `json:"pi_agent_bin"` PiAgentBin string `json:"pi_agent_bin"`
PiAgentBridgeBin string `json:"pi_agent_bridge_bin"` PiAgentBridgeBin string `json:"pi_agent_bridge_bin"`
PiAgentProvider string `json:"pi_agent_provider"` PiAgentProvider string `json:"pi_agent_provider"`
@@ -94,9 +96,11 @@ func Load(path string) (*Config, error) {
HTTPPort: 8080, HTTPPort: 8080,
AgentBackend: "pi-agent", AgentBackend: "pi-agent",
AgentTimeout: 30 * time.Minute, AgentTimeout: 30 * time.Minute,
CursorModel: "auto", AgentXAPIBase: "https://api.minimaxi.com/v1",
CursorBin: "/usr/local/bin/cursor", AgentXModel: "MiniMax-M2.7-highspeed",
CursorBridgeBin: "tools/cursor-bridge/index.mjs", AgentXTimeout: 30 * time.Minute,
WorkspaceTTL: 7 * 24 * time.Hour,
WorkspaceQuotaBytes: 20 << 30,
PiAgentBin: "/opt/homebrew/bin/pi", PiAgentBin: "/opt/homebrew/bin/pi",
PiAgentBridgeBin: "tools/pi-bridge/index.mjs", PiAgentBridgeBin: "tools/pi-bridge/index.mjs",
PiAgentProvider: "minimax", PiAgentProvider: "minimax",
@@ -179,17 +183,33 @@ func Load(path string) (*Config, error) {
cfg.AgentTimeout = d cfg.AgentTimeout = d
} }
} }
if v := os.Getenv("CURSOR_API_KEY"); v != "" { if v := os.Getenv("AGENTX_API_BASE"); v != "" {
cfg.CursorAPIKey = v cfg.AgentXAPIBase = v
} else if v := os.Getenv("MINIMAX_ENDPOINT_URL"); v != "" {
cfg.AgentXAPIBase = v
} }
if v := os.Getenv("CURSOR_MODEL"); v != "" { if v := os.Getenv("AGENTX_API_KEY"); v != "" {
cfg.CursorModel = v cfg.AgentXAPIKey = v
} else if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
cfg.AgentXAPIKey = v
} }
if v := os.Getenv("CURSOR_BIN"); v != "" { if v := os.Getenv("AGENTX_MODEL"); v != "" {
cfg.CursorBin = v cfg.AgentXModel = v
}
if v := os.Getenv("AGENTX_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.AgentXTimeout = d
}
}
if v := os.Getenv("WORKSPACE_TTL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.WorkspaceTTL = d
}
}
if v := os.Getenv("WORKSPACE_QUOTA_BYTES"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
cfg.WorkspaceQuotaBytes = n
} }
if v := os.Getenv("CURSOR_BRIDGE_BIN"); v != "" {
cfg.CursorBridgeBin = v
} }
if v := os.Getenv("PI_AGENT_BIN"); v != "" { if v := os.Getenv("PI_AGENT_BIN"); v != "" {
cfg.PiAgentBin = v cfg.PiAgentBin = v
@@ -392,14 +412,24 @@ func applyEnvFile(cfg *Config, path string) error {
if d, err := time.ParseDuration(val); err == nil { if d, err := time.ParseDuration(val); err == nil {
cfg.AgentTimeout = d cfg.AgentTimeout = d
} }
case "CURSOR_API_KEY": case "AGENTX_API_BASE", "MINIMAX_ENDPOINT_URL":
cfg.CursorAPIKey = val cfg.AgentXAPIBase = val
case "CURSOR_MODEL": case "AGENTX_API_KEY", "MINIMAX_API_KEY":
cfg.CursorModel = val cfg.AgentXAPIKey = val
case "CURSOR_BIN": case "AGENTX_MODEL":
cfg.CursorBin = val cfg.AgentXModel = val
case "CURSOR_BRIDGE_BIN": case "AGENTX_TIMEOUT":
cfg.CursorBridgeBin = val if d, err := time.ParseDuration(val); err == nil {
cfg.AgentXTimeout = d
}
case "WORKSPACE_TTL":
if d, err := time.ParseDuration(val); err == nil {
cfg.WorkspaceTTL = d
}
case "WORKSPACE_QUOTA_BYTES":
if n, err := strconv.ParseInt(val, 10, 64); err == nil && n > 0 {
cfg.WorkspaceQuotaBytes = n
}
case "PI_AGENT_BIN": case "PI_AGENT_BIN":
cfg.PiAgentBin = val cfg.PiAgentBin = val
case "PI_AGENT_BRIDGE_BIN": case "PI_AGENT_BRIDGE_BIN":
+6 -6
View File
@@ -89,10 +89,10 @@ func TestConfigLoadDSNOverrides(t *testing.T) {
func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) { func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) {
t.Setenv("ENV", "dev") t.Setenv("ENV", "dev")
t.Setenv("AGENT_TIMEOUT", "47m") t.Setenv("AGENT_TIMEOUT", "47m")
t.Setenv("CURSOR_API_KEY", "cursor-test-key") t.Setenv("AGENTX_API_KEY", "agentx-test-key")
t.Setenv("CURSOR_MODEL", "cursor-test-model") t.Setenv("AGENTX_MODEL", "agentx-test-model")
t.Setenv("CURSOR_BIN", "/test/cursor") t.Setenv("AGENTX_API_BASE", "https://example.invalid/v1")
t.Setenv("CURSOR_BRIDGE_BIN", "/test/cursor-bridge.mjs") t.Setenv("AGENTX_TIMEOUT", "5m")
t.Setenv("PI_AGENT_BRIDGE_BIN", "/test/pi-bridge.mjs") t.Setenv("PI_AGENT_BRIDGE_BIN", "/test/pi-bridge.mjs")
t.Setenv("EMBEDDING_MODEL", "embedding-test-model") t.Setenv("EMBEDDING_MODEL", "embedding-test-model")
t.Setenv("RERANKER_MODEL", "reranker-test-model") t.Setenv("RERANKER_MODEL", "reranker-test-model")
@@ -104,8 +104,8 @@ func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) {
if cfg.AgentTimeout != 47*time.Minute { if cfg.AgentTimeout != 47*time.Minute {
t.Fatalf("expected environment AgentTimeout 47m, got %v", cfg.AgentTimeout) t.Fatalf("expected environment AgentTimeout 47m, got %v", cfg.AgentTimeout)
} }
if cfg.CursorAPIKey != "cursor-test-key" || cfg.CursorModel != "cursor-test-model" || cfg.CursorBin != "/test/cursor" || cfg.CursorBridgeBin != "/test/cursor-bridge.mjs" { if cfg.AgentXAPIKey != "agentx-test-key" || cfg.AgentXModel != "agentx-test-model" || cfg.AgentXAPIBase != "https://example.invalid/v1" || cfg.AgentXTimeout != 5*time.Minute {
t.Fatalf("cursor environment overrides were not applied") t.Fatalf("agentx environment overrides were not applied: %+v", cfg.AgentXAPIKey)
} }
if cfg.PiAgentBridgeBin != "/test/pi-bridge.mjs" { if cfg.PiAgentBridgeBin != "/test/pi-bridge.mjs" {
t.Fatalf("pi bridge environment override was not applied") t.Fatalf("pi bridge environment override was not applied")
+1 -1
View File
@@ -14,7 +14,7 @@ var (
var allowedBackends = map[string]struct{}{ var allowedBackends = map[string]struct{}{
"stub": {}, "stub": {},
"pi-agent": {}, "pi-agent": {},
"cursor": {}, "pi-native": {},
} }
func isProductionEnv(env string) bool { func isProductionEnv(env string) bool {
+7
View File
@@ -61,6 +61,13 @@ func TestResolveBackend_StripsUnauthorized(t *testing.T) {
if err != nil || got != "pi-agent" { if err != nil || got != "pi-agent" {
t.Fatalf("got %q %v", got, err) t.Fatalf("got %q %v", got, err)
} }
if _, err := ResolveBackend("cursor", "pi-agent"); err == nil {
t.Fatal("cursor backend must stay unauthorized")
}
got, err = ResolveBackend("pi-native", "pi-agent")
if err != nil || got != "pi-native" {
t.Fatalf("pi-native %q %v", got, err)
}
} }
func TestResolveBackendInEnv_RejectsStubInMain(t *testing.T) { func TestResolveBackendInEnv_RejectsStubInMain(t *testing.T) {
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env node
/**
* Cursor SDK bridge (stdio JSON).
*
* Input (stdin):
* {
* "workspace": "/abs/path",
* "api_key": "...",
* "model": "auto",
* "prompt": "...",
* "instructions": "...",
* "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 instructions = req.instructions || "";
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;
}
const fullPrompt = instructions ? `${instructions}\n\n---\n\n${prompt}` : prompt;
let agentId = "";
let runId = "";
try {
const result = await Agent.prompt(fullPrompt, {
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)));
});
-17
View File
@@ -1,17 +0,0 @@
{
"name": "cursor-bridge",
"version": "1.0.0",
"private": true,
"description": "stdio JSON bridge: Cursor local Agent drafts document patches for agent-system",
"type": "module",
"bin": {
"cursor-bridge": "./index.mjs"
},
"main": "index.mjs",
"engines": {
"node": ">=20"
},
"dependencies": {
"@cursor/sdk": "1.0.31"
}
}