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() {
|
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
@@ -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)
|
||||||
|
|||||||
@@ -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{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
-40
@@ -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"`
|
||||||
@@ -82,30 +84,32 @@ type Config struct {
|
|||||||
VaultStateRoot string `json:"vault_state_root"`
|
VaultStateRoot string `json:"vault_state_root"`
|
||||||
VaultStorageIdentity string `json:"vault_storage_identity"`
|
VaultStorageIdentity string `json:"vault_storage_identity"`
|
||||||
VaultMasterKey string `json:"vault_master_key"`
|
VaultMasterKey string `json:"vault_master_key"`
|
||||||
FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false
|
FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false
|
||||||
FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false
|
FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Env: "dev",
|
Env: "dev",
|
||||||
DataDir: "./data/dev",
|
DataDir: "./data/dev",
|
||||||
HTTPHost: "127.0.0.1",
|
HTTPHost: "127.0.0.1",
|
||||||
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,
|
||||||
PiAgentBin: "/opt/homebrew/bin/pi",
|
WorkspaceTTL: 7 * 24 * time.Hour,
|
||||||
PiAgentBridgeBin: "tools/pi-bridge/index.mjs",
|
WorkspaceQuotaBytes: 20 << 30,
|
||||||
PiAgentProvider: "minimax",
|
PiAgentBin: "/opt/homebrew/bin/pi",
|
||||||
PiAgentModel: "MiniMax-M2.7-highspeed",
|
PiAgentBridgeBin: "tools/pi-bridge/index.mjs",
|
||||||
KbDbHost: "127.0.0.1",
|
PiAgentProvider: "minimax",
|
||||||
KbDbPort: 5432,
|
PiAgentModel: "MiniMax-M2.7-highspeed",
|
||||||
KbDbUser: "regkb",
|
KbDbHost: "127.0.0.1",
|
||||||
KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供
|
KbDbPort: 5432,
|
||||||
KbDbName: "regulations",
|
KbDbUser: "regkb",
|
||||||
|
KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供
|
||||||
|
KbDbName: "regulations",
|
||||||
|
|
||||||
ContextDbHost: "127.0.0.1",
|
ContextDbHost: "127.0.0.1",
|
||||||
ContextDbPort: 5433,
|
ContextDbPort: 5433,
|
||||||
@@ -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("CURSOR_BRIDGE_BIN"); v != "" {
|
if v := os.Getenv("AGENTX_TIMEOUT"); v != "" {
|
||||||
cfg.CursorBridgeBin = 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("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":
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ var (
|
|||||||
|
|
||||||
// Allowed backends are resolved by server policy. Callers cannot inject arbitrary strings.
|
// Allowed backends are resolved by server policy. Callers cannot inject arbitrary strings.
|
||||||
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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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