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:
@@ -24,7 +24,7 @@ import (
|
||||
func main() {
|
||||
stageFlag := flag.String("stage", "", "执行成熟度阶段 (可选: V0.1_triage, V0.5_feasibility, V1.0_quoting)")
|
||||
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", "医疗器械研发演示项目", "项目名称")
|
||||
customer := flag.String("customer", "北京水木东方", "客户名称")
|
||||
device := flag.String("device", "医疗器械", "设备类型")
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ func main() {
|
||||
envPath := flag.String("env", ".env", "环境配置文件路径")
|
||||
port := flag.Int("port", 0, "HTTP 服务端口 (默认读取配置文件或 8080)")
|
||||
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()
|
||||
|
||||
cfg, err := config.Load(*envPath)
|
||||
|
||||
@@ -42,8 +42,8 @@ func NewBackend(name string, cfg *config.Config) Backend {
|
||||
switch name {
|
||||
case "pi-agent":
|
||||
inner = &PiAgentBackend{cfg: cfg}
|
||||
case "cursor-cli":
|
||||
inner = &CursorCLIBackend{cfg: cfg}
|
||||
case "pi-native":
|
||||
inner = &PiNativeBackend{cfg: cfg}
|
||||
default:
|
||||
inner = &StubBackend{}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -17,10 +17,12 @@ type Config struct {
|
||||
HTTPPort int `json:"http_port"`
|
||||
AgentBackend string `json:"agent_backend"`
|
||||
AgentTimeout time.Duration `json:"agent_timeout"`
|
||||
CursorAPIKey string `json:"cursor_api_key"`
|
||||
CursorModel string `json:"cursor_model"`
|
||||
CursorBin string `json:"cursor_bin"`
|
||||
CursorBridgeBin string `json:"cursor_bridge_bin"`
|
||||
AgentXAPIBase string `json:"agentx_api_base"`
|
||||
AgentXAPIKey string `json:"agentx_api_key"`
|
||||
AgentXModel string `json:"agentx_model"`
|
||||
AgentXTimeout time.Duration `json:"agentx_timeout"`
|
||||
WorkspaceTTL time.Duration `json:"workspace_ttl"`
|
||||
WorkspaceQuotaBytes int64 `json:"workspace_quota_bytes"`
|
||||
PiAgentBin string `json:"pi_agent_bin"`
|
||||
PiAgentBridgeBin string `json:"pi_agent_bridge_bin"`
|
||||
PiAgentProvider string `json:"pi_agent_provider"`
|
||||
@@ -94,9 +96,11 @@ func Load(path string) (*Config, error) {
|
||||
HTTPPort: 8080,
|
||||
AgentBackend: "pi-agent",
|
||||
AgentTimeout: 30 * time.Minute,
|
||||
CursorModel: "auto",
|
||||
CursorBin: "/usr/local/bin/cursor",
|
||||
CursorBridgeBin: "tools/cursor-bridge/index.mjs",
|
||||
AgentXAPIBase: "https://api.minimaxi.com/v1",
|
||||
AgentXModel: "MiniMax-M2.7-highspeed",
|
||||
AgentXTimeout: 30 * time.Minute,
|
||||
WorkspaceTTL: 7 * 24 * time.Hour,
|
||||
WorkspaceQuotaBytes: 20 << 30,
|
||||
PiAgentBin: "/opt/homebrew/bin/pi",
|
||||
PiAgentBridgeBin: "tools/pi-bridge/index.mjs",
|
||||
PiAgentProvider: "minimax",
|
||||
@@ -179,17 +183,33 @@ func Load(path string) (*Config, error) {
|
||||
cfg.AgentTimeout = d
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("CURSOR_API_KEY"); v != "" {
|
||||
cfg.CursorAPIKey = v
|
||||
if v := os.Getenv("AGENTX_API_BASE"); v != "" {
|
||||
cfg.AgentXAPIBase = v
|
||||
} else if v := os.Getenv("MINIMAX_ENDPOINT_URL"); v != "" {
|
||||
cfg.AgentXAPIBase = v
|
||||
}
|
||||
if v := os.Getenv("CURSOR_MODEL"); v != "" {
|
||||
cfg.CursorModel = v
|
||||
if v := os.Getenv("AGENTX_API_KEY"); v != "" {
|
||||
cfg.AgentXAPIKey = v
|
||||
} else if v := os.Getenv("MINIMAX_API_KEY"); v != "" {
|
||||
cfg.AgentXAPIKey = v
|
||||
}
|
||||
if v := os.Getenv("CURSOR_BIN"); v != "" {
|
||||
cfg.CursorBin = v
|
||||
if v := os.Getenv("AGENTX_MODEL"); 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 != "" {
|
||||
cfg.PiAgentBin = v
|
||||
@@ -392,14 +412,24 @@ func applyEnvFile(cfg *Config, path string) error {
|
||||
if d, err := time.ParseDuration(val); err == nil {
|
||||
cfg.AgentTimeout = d
|
||||
}
|
||||
case "CURSOR_API_KEY":
|
||||
cfg.CursorAPIKey = val
|
||||
case "CURSOR_MODEL":
|
||||
cfg.CursorModel = val
|
||||
case "CURSOR_BIN":
|
||||
cfg.CursorBin = val
|
||||
case "CURSOR_BRIDGE_BIN":
|
||||
cfg.CursorBridgeBin = val
|
||||
case "AGENTX_API_BASE", "MINIMAX_ENDPOINT_URL":
|
||||
cfg.AgentXAPIBase = val
|
||||
case "AGENTX_API_KEY", "MINIMAX_API_KEY":
|
||||
cfg.AgentXAPIKey = val
|
||||
case "AGENTX_MODEL":
|
||||
cfg.AgentXModel = val
|
||||
case "AGENTX_TIMEOUT":
|
||||
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":
|
||||
cfg.PiAgentBin = val
|
||||
case "PI_AGENT_BRIDGE_BIN":
|
||||
|
||||
@@ -89,10 +89,10 @@ func TestConfigLoadDSNOverrides(t *testing.T) {
|
||||
func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) {
|
||||
t.Setenv("ENV", "dev")
|
||||
t.Setenv("AGENT_TIMEOUT", "47m")
|
||||
t.Setenv("CURSOR_API_KEY", "cursor-test-key")
|
||||
t.Setenv("CURSOR_MODEL", "cursor-test-model")
|
||||
t.Setenv("CURSOR_BIN", "/test/cursor")
|
||||
t.Setenv("CURSOR_BRIDGE_BIN", "/test/cursor-bridge.mjs")
|
||||
t.Setenv("AGENTX_API_KEY", "agentx-test-key")
|
||||
t.Setenv("AGENTX_MODEL", "agentx-test-model")
|
||||
t.Setenv("AGENTX_API_BASE", "https://example.invalid/v1")
|
||||
t.Setenv("AGENTX_TIMEOUT", "5m")
|
||||
t.Setenv("PI_AGENT_BRIDGE_BIN", "/test/pi-bridge.mjs")
|
||||
t.Setenv("EMBEDDING_MODEL", "embedding-test-model")
|
||||
t.Setenv("RERANKER_MODEL", "reranker-test-model")
|
||||
@@ -104,8 +104,8 @@ func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) {
|
||||
if cfg.AgentTimeout != 47*time.Minute {
|
||||
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" {
|
||||
t.Fatalf("cursor environment overrides were not applied")
|
||||
if cfg.AgentXAPIKey != "agentx-test-key" || cfg.AgentXModel != "agentx-test-model" || cfg.AgentXAPIBase != "https://example.invalid/v1" || cfg.AgentXTimeout != 5*time.Minute {
|
||||
t.Fatalf("agentx environment overrides were not applied: %+v", cfg.AgentXAPIKey)
|
||||
}
|
||||
if cfg.PiAgentBridgeBin != "/test/pi-bridge.mjs" {
|
||||
t.Fatalf("pi bridge environment override was not applied")
|
||||
|
||||
@@ -14,7 +14,7 @@ var (
|
||||
var allowedBackends = map[string]struct{}{
|
||||
"stub": {},
|
||||
"pi-agent": {},
|
||||
"cursor": {},
|
||||
"pi-native": {},
|
||||
}
|
||||
|
||||
func isProductionEnv(env string) bool {
|
||||
|
||||
@@ -61,6 +61,13 @@ func TestResolveBackend_StripsUnauthorized(t *testing.T) {
|
||||
if err != nil || got != "pi-agent" {
|
||||
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) {
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user