From 6dc5f1cfa6f4c831085e7fe0a36122527f5423dc Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 14:40:13 +0000 Subject: [PATCH 01/10] docs(ops): claim go-agentx runtime worktree Register the pi-native coexistence task against the explicit dev baseline so later commits stay on the feature branch. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 1 + .../2026-09-21-go-agentx-runtime.md | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .doc/ops/short-term/2026-09-21-go-agentx-runtime.md diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 47c58950..1df2c647 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -16,6 +16,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | in_progress | | Cursor | 2026-09-21-force-material-chunk-index | feature/force-material-chunk-index-2026-09-21 | .worktrees/force-material-chunk-index-2026-09-21 | bizdev+materialindex | done | | Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | | Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | done | diff --git a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md new file mode 100644 index 00000000..0b8e3c8f --- /dev/null +++ b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md @@ -0,0 +1,64 @@ +--- +title: "Go 原生 PI Agent 运行时(pi-native 并存验证)" +status: in_progress +created: 2026-09-21 +updated: 2026-09-21 +owner: Cursor +branch: feature/go-agentx-runtime-2026-09-21 +worktree: .worktrees/go-agentx-runtime-2026-09-21 +base_dev_sha: a5ea469ca44171a5c3e6419b1757852270fb991d +write_scope: [internal/agentx, internal/agent, internal/config, internal/taskrun, internal/workspace, internal/server, cmd, deploy/postgres/goose, tools/cursor-bridge, .doc/ops, .doc/status, .doc/audit, .doc/test-reports] +head_sha: a5ea469ca44171a5c3e6419b1757852270fb991d +deployment: pending +cleanup: pending +mutex: agent-backend-swap +--- + +# Go 原生 PI Agent 运行时(pi-native 并存验证) + +## 目标 + +在主服务进程内实现 Go 智能体运行时,以新后端名 `pi-native` 与容器 `pi-agent` 并存;移除未使用的 cursor 后端;经双后端对比达到同等或更好后再切默认,否则保持 `AGENT_BACKEND=pi-agent`。 + +## 范围 + +- `internal/agentx`:OpenAI 兼容流式客户端、工作区工具、agent loop、JSONL transcript +- `internal/agent/pinative.go`:`agent.Backend` 适配,名称 `pi-native` +- 移除 `cursor-cli` 与 `tools/cursor-bridge` +- 工作区注册、TTL/配额 GC(清理仍受冻结清单与 receipts 门禁约束)、管理端点 +- 双后端对比判定套件 + +## 非目标 + +- 删除 `deploy/pi-agent/`、`PiAgentBackend`、runner 契约与 compose 中的 pi-agent 服务 +- 在对比未通过时修改 `AGENT_BACKEND` 默认值 +- 模板文档生成与智能体小队动态编排 +- 在本 worktree 内执行 `env-service` + +## 步骤 + +1. [x] 从 dev `a5ea469ca44171a5c3e6419b1757852270fb991d` 创建 worktree +2. [ ] LLM 流式客户端与契约测试 +3. [ ] 工具 jail 与 hook +4. [ ] agent loop 与 JSONL 会话 +5. [ ] `pi-native` 接线并移除 cursor +6. [ ] 工作区注册表、GC、管理端点 +7. [ ] 双后端对比;未达标则不切默认 +8. [ ] 报告、FEATURE-MATRIX、CURRENT 收口 + +## 验收标准 + +- [ ] `go vet ./...` 与 `go test -short ./...` 通过 +- [ ] `AGENT_BACKEND` 默认仍为 `pi-agent`,除非对比判定为同等或更好 +- [ ] 判定标准:`pi-native` 成功率不低于 `pi-agent`、无 P0 产物缺陷、P95 时延 ≤ 1.3× 容器链路 +- [ ] cursor 后端代码与配置已移除;pi-agent 容器链路仍可编译调用 + +## 相关链接 + +- Checklist: +- Handoff: +- Test report: `.doc/test-reports/2026-09-21-go-agentx-runtime-test-report.md` + +## 证据 / 备注 + +远端同步状态:创建时主目录为本地 `dev`,ahead of `origin/dev`,未为创建任务执行 fetch/pull。 From b49b95dc7ad8bd0cd5e5ebbab7f2c6fa0ee500c4 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 14:43:32 +0000 Subject: [PATCH 02/10] feat(agentx): add OpenAI-compatible streaming LLM client Parse MiniMax-style SSE deltas, including incremental tool calls, and encode transport failures on the event stream instead of panicking. Co-authored-by: Cursor --- internal/agentx/llm/client.go | 152 +++++++++++++++++++++ internal/agentx/llm/smoke_test.go | 50 +++++++ internal/agentx/llm/sse.go | 211 ++++++++++++++++++++++++++++++ internal/agentx/llm/sse_test.go | 191 +++++++++++++++++++++++++++ internal/agentx/llm/types.go | 95 ++++++++++++++ 5 files changed, 699 insertions(+) create mode 100644 internal/agentx/llm/client.go create mode 100644 internal/agentx/llm/smoke_test.go create mode 100644 internal/agentx/llm/sse.go create mode 100644 internal/agentx/llm/sse_test.go create mode 100644 internal/agentx/llm/types.go diff --git a/internal/agentx/llm/client.go b/internal/agentx/llm/client.go new file mode 100644 index 00000000..5e5d2363 --- /dev/null +++ b/internal/agentx/llm/client.go @@ -0,0 +1,152 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client calls an OpenAI-compatible chat completions endpoint with streaming. +type Client struct { + BaseURL string + APIKey string + HTTPClient *http.Client +} + +// Stream implements StreamFunc. Transport and provider failures are encoded as +// KindError events; the returned channel is always non-nil and is closed. +func (c *Client) Stream(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event { + out := make(chan Event) + go func() { + defer close(out) + c.run(ctx, model, messages, tools, opts, out) + }() + return out +} + +func (c *Client) run(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions, out chan<- Event) { + base := strings.TrimRight(c.BaseURL, "/") + if opts.APIBase != "" { + base = strings.TrimRight(opts.APIBase, "/") + } + key := c.APIKey + if opts.APIKey != "" { + key = opts.APIKey + } + if base == "" || key == "" || model == "" { + emitError(out, "", false, "agentx llm client is missing base url, api key, or model") + return + } + body := map[string]any{ + "model": model, + "messages": toProviderMessages(messages), + "stream": true, + "stream_options": map[string]any{ + "include_usage": true, + }, + } + if opts.MaxTokens > 0 { + body["max_tokens"] = opts.MaxTokens + } + if len(tools) > 0 { + body["tools"] = toProviderTools(tools) + body["tool_choice"] = "auto" + } + raw, err := json.Marshal(body) + if err != nil { + emitError(out, "", false, err.Error()) + return + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(raw)) + if err != nil { + emitError(out, "", false, err.Error()) + return + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + hc := c.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 30 * time.Minute} + } + resp, err := hc.Do(req) + if err != nil { + if ctx.Err() != nil { + emitAbort(out, "", ctx.Err()) + return + } + emitError(out, "", true, err.Error()) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 + emitError(out, "", retryable, fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))) + return + } + for ev := range ParseSSE(ctx, resp.Body) { + out <- ev + } +} + +func toProviderMessages(messages []Message) []map[string]any { + out := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + switch m.Role { + case RoleTool: + out = append(out, map[string]any{ + "role": "tool", + "tool_call_id": m.ToolCallID, + "content": m.Content, + }) + case RoleAssistant: + item := map[string]any{"role": "assistant", "content": m.Content} + if len(m.ToolCalls) > 0 { + calls := make([]map[string]any, 0, len(m.ToolCalls)) + for _, tc := range m.ToolCalls { + calls = append(calls, map[string]any{ + "id": tc.ID, + "type": "function", + "function": map[string]any{ + "name": tc.Name, + "arguments": tc.Arguments, + }, + }) + } + item["tool_calls"] = calls + } + out = append(out, item) + default: + out = append(out, map[string]any{ + "role": string(m.Role), + "content": m.Content, + }) + } + } + return out +} + +func toProviderTools(tools []ToolDecl) []map[string]any { + out := make([]map[string]any, 0, len(tools)) + for _, t := range tools { + params := t.Parameters + if len(params) == 0 { + params = json.RawMessage(`{"type":"object","properties":{}}`) + } + out = append(out, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": t.Name, + "description": t.Description, + "parameters": json.RawMessage(params), + }, + }) + } + return out +} diff --git a/internal/agentx/llm/smoke_test.go b/internal/agentx/llm/smoke_test.go new file mode 100644 index 00000000..c466ac86 --- /dev/null +++ b/internal/agentx/llm/smoke_test.go @@ -0,0 +1,50 @@ +package llm + +import ( + "context" + "os" + "testing" + "time" +) + +// TestSmokeMiniMax hits the real MiniMax endpoint when RUN_AGENTX_SMOKE=1. +// It calibrates the incremental SSE tool-call format and is not part of -short CI. +func TestSmokeMiniMax(t *testing.T) { + if os.Getenv("RUN_AGENTX_SMOKE") != "1" { + t.Skip("set RUN_AGENTX_SMOKE=1 to call MiniMax") + } + key := os.Getenv("AGENTX_API_KEY") + if key == "" { + key = os.Getenv("MINIMAX_API_KEY") + } + if key == "" { + t.Fatal("AGENTX_API_KEY or MINIMAX_API_KEY is required") + } + base := os.Getenv("AGENTX_API_BASE") + if base == "" { + base = os.Getenv("MINIMAX_ENDPOINT_URL") + } + if base == "" { + base = "https://api.minimaxi.com/v1" + } + model := os.Getenv("AGENTX_MODEL") + if model == "" { + model = "MiniMax-M2.7-highspeed" + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + c := &Client{BaseURL: base, APIKey: key, HTTPClient: nil} + var end Event + for ev := range c.Stream(ctx, model, []Message{{Role: RoleUser, Content: "Reply with the single word pong."}}, nil, StreamOptions{MaxTokens: 64}) { + if ev.Kind == KindError { + t.Fatalf("provider error retryable=%v %s", ev.Retryable, ev.ErrorMessage) + } + if ev.Kind == KindMessageEnd { + end = ev + } + } + if end.Message.StopReason != StopEnd || end.Message.Content == "" { + t.Fatalf("unexpected completion %#v", end.Message) + } + t.Logf("smoke ok stop=%s tokens=%d", end.Message.StopReason, end.Usage.TotalTokens) +} diff --git a/internal/agentx/llm/sse.go b/internal/agentx/llm/sse.go new file mode 100644 index 00000000..258f8261 --- /dev/null +++ b/internal/agentx/llm/sse.go @@ -0,0 +1,211 @@ +package llm + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" +) + +type sseChoice struct { + Delta struct { + Content string `json:"content"` + ToolCalls []struct { + Index int `json:"index"` + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` +} + +type sseChunk struct { + Choices []sseChoice `json:"choices"` + Usage *Usage `json:"usage"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +// ParseSSE reads an OpenAI-compatible server-sent event body and emits +// deltas plus one terminal message_end or error event. +func ParseSSE(ctx context.Context, r io.Reader) <-chan Event { + out := make(chan Event) + go func() { + defer close(out) + emitParsed(ctx, r, out) + }() + return out +} + +func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) { + var content strings.Builder + calls := map[int]*ToolCall{} + order := []int{} + var usage Usage + finished := "" + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for sc.Scan() { + if ctx.Err() != nil { + emitAbort(out, content.String(), ctx.Err()) + return + } + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, ":") { + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + break + } + var chunk sseChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + emitError(out, content.String(), false, "malformed stream chunk: "+err.Error()) + return + } + if chunk.Error != nil && chunk.Error.Message != "" { + emitError(out, content.String(), false, chunk.Error.Message) + return + } + if chunk.Usage != nil { + usage = *chunk.Usage + } + if len(chunk.Choices) == 0 { + continue + } + choice := chunk.Choices[0] + if choice.FinishReason != "" { + finished = choice.FinishReason + } + if choice.Delta.Content != "" { + content.WriteString(choice.Delta.Content) + select { + case <-ctx.Done(): + emitAbort(out, content.String(), ctx.Err()) + return + case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}: + } + } + for _, tc := range choice.Delta.ToolCalls { + cur, ok := calls[tc.Index] + if !ok { + cur = &ToolCall{} + calls[tc.Index] = cur + order = append(order, tc.Index) + } + if tc.ID != "" { + cur.ID = tc.ID + } + if tc.Function.Name != "" { + cur.Name += tc.Function.Name + } + if tc.Function.Arguments != "" { + cur.Arguments += tc.Function.Arguments + } + select { + case <-ctx.Done(): + emitAbort(out, content.String(), ctx.Err()) + return + case out <- Event{ + Kind: KindToolCallDelta, + ToolIndex: tc.Index, + ToolID: tc.ID, + ToolName: tc.Function.Name, + ArgumentsDelta: tc.Function.Arguments, + }: + } + } + } + if err := sc.Err(); err != nil { + if ctx.Err() != nil { + emitAbort(out, content.String(), ctx.Err()) + return + } + emitError(out, content.String(), true, err.Error()) + return + } + msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)} + for _, idx := range order { + msg.ToolCalls = append(msg.ToolCalls, *calls[idx]) + } + if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 { + msg.StopReason = StopEnd + } + select { + case <-ctx.Done(): + emitAbort(out, content.String(), ctx.Err()) + case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage}: + } +} + +func mapFinish(reason string, hasTools bool) StopReason { + switch reason { + case "tool_calls", "function_call": + return StopToolCalls + case "content_filter", "error": + return StopError + case "stop", "length", "": + if hasTools { + return StopToolCalls + } + return StopEnd + default: + if hasTools { + return StopToolCalls + } + return StopEnd + } +} + +func emitError(out chan<- Event, partial string, retryable bool, message string) { + out <- Event{ + Kind: KindError, + Retryable: retryable, + ErrorMessage: message, + Err: &StreamError{Message: message, Retryable: retryable}, + Message: Message{ + Role: RoleAssistant, + Content: partial, + StopReason: StopError, + }, + } +} + +func emitAbort(out chan<- Event, partial string, err error) { + msg := "aborted" + if err != nil { + msg = err.Error() + } + out <- Event{ + Kind: KindError, + Retryable: false, + ErrorMessage: msg, + Err: err, + Message: Message{ + Role: RoleAssistant, + Content: partial, + StopReason: StopAborted, + }, + } +} + +// StreamError is a provider or transport failure encoded on the event stream. +type StreamError struct { + Message string + Retryable bool +} + +func (e *StreamError) Error() string { + if e == nil { + return "stream error" + } + return e.Message +} diff --git a/internal/agentx/llm/sse_test.go b/internal/agentx/llm/sse_test.go new file mode 100644 index 00000000..a1ddf447 --- /dev/null +++ b/internal/agentx/llm/sse_test.go @@ -0,0 +1,191 @@ +package llm + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestParseSSETextAndUsage(t *testing.T) { + body := strings.Join([]string{ + `data: {"choices":[{"delta":{"content":"你"},"index":0}]}`, + `data: {"choices":[{"delta":{"content":"好"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`, + `data: [DONE]`, + "", + }, "\n") + var deltas []string + var end Event + for ev := range ParseSSE(context.Background(), strings.NewReader(body)) { + switch ev.Kind { + case KindTextDelta: + deltas = append(deltas, ev.Text) + case KindMessageEnd: + end = ev + default: + t.Fatalf("unexpected event %#v", ev) + } + } + if strings.Join(deltas, "") != "你好" { + t.Fatalf("deltas %v", deltas) + } + if end.Message.StopReason != StopEnd || end.Message.Content != "你好" { + t.Fatalf("end %#v", end.Message) + } + if end.Usage.TotalTokens != 5 { + t.Fatalf("usage %#v", end.Usage) + } +} + +func TestParseSSEToolCallIncrements(t *testing.T) { + body := strings.Join([]string{ + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":""}}]}}]}`, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}`, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a.md\"}"}}]},"finish_reason":"tool_calls"}]}`, + `data: [DONE]`, + "", + }, "\n") + var end Event + deltas := 0 + for ev := range ParseSSE(context.Background(), strings.NewReader(body)) { + if ev.Kind == KindToolCallDelta { + deltas++ + } + if ev.Kind == KindMessageEnd { + end = ev + } + } + if deltas != 3 { + t.Fatalf("tool deltas %d", deltas) + } + if end.Message.StopReason != StopToolCalls || len(end.Message.ToolCalls) != 1 { + t.Fatalf("end %#v", end.Message) + } + tc := end.Message.ToolCalls[0] + if tc.ID != "call_1" || tc.Name != "read_file" || tc.Arguments != `{"path":"a.md"}` { + t.Fatalf("tool call %#v", tc) + } +} + +func TestParseSSEMalformedIsError(t *testing.T) { + body := "data: {not-json}\n\n" + var got Event + for ev := range ParseSSE(context.Background(), strings.NewReader(body)) { + got = ev + } + if got.Kind != KindError || got.Message.StopReason != StopError || got.Retryable { + t.Fatalf("event %#v", got) + } +} + +func TestParseSSECancel(t *testing.T) { + pr, pw := io.Pipe() + ctx, cancel := context.WithCancel(context.Background()) + ch := ParseSSE(ctx, pr) + go func() { + _, _ = io.WriteString(pw, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"}}]}\n\n") + cancel() + time.Sleep(20 * time.Millisecond) + _ = pw.Close() + }() + sawAbort := false + for ev := range ch { + if ev.Kind == KindError && ev.Message.StopReason == StopAborted { + sawAbort = true + } + } + if !sawAbort { + t.Fatal("expected aborted event") + } +} + +func TestClientHTTPErrorIsRetryable(t *testing.T) { + srv := httpServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer secret" { + t.Errorf("auth header %q", r.Header.Get("Authorization")) + } + http.Error(w, "busy", http.StatusTooManyRequests) + }) + c := &Client{BaseURL: srv, APIKey: "secret", HTTPClient: srvClient()} + var got Event + for ev := range c.Stream(context.Background(), "MiniMax-M2.7-highspeed", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) { + got = ev + } + if got.Kind != KindError || !got.Retryable || got.Message.StopReason != StopError { + t.Fatalf("event %#v", got) + } +} + +func TestClientStreamsCompletion(t *testing.T) { + srv := httpServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n") + }) + c := &Client{BaseURL: srv, APIKey: "k", HTTPClient: srvClient()} + var end Event + for ev := range c.Stream(context.Background(), "m", []Message{{Role: RoleUser, Content: "hi"}}, []ToolDecl{{Name: "read_file", Parameters: []byte(`{"type":"object"}`)}}, StreamOptions{}) { + if ev.Kind == KindMessageEnd { + end = ev + } + } + if end.Message.Content != "ok" || end.Message.StopReason != StopEnd { + t.Fatalf("end %#v", end.Message) + } +} + +func TestClientCancel(t *testing.T) { + started := make(chan struct{}) + srv := httpServer(t, func(w http.ResponseWriter, r *http.Request) { + close(started) + w.Header().Set("Content-Type", "text/event-stream") + fl, _ := w.(http.Flusher) + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if fl != nil { + fl.Flush() + } + <-r.Context().Done() + }) + c := &Client{BaseURL: srv, APIKey: "k", HTTPClient: &http.Client{}} + ctx, cancel := context.WithCancel(context.Background()) + ch := c.Stream(ctx, "m", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) + <-started + cancel() + saw := false + deadline := time.After(2 * time.Second) + for { + select { + case ev, ok := <-ch: + if !ok { + if !saw { + t.Fatal("channel closed without abort") + } + return + } + if ev.Kind == KindError && ev.Message.StopReason == StopAborted { + saw = true + } + case <-deadline: + t.Fatal("timed out waiting for cancel") + } + } +} + +func httpServer(t *testing.T, h http.HandlerFunc) string { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv.URL +} + +func srvClient() *http.Client { + return &http.Client{Timeout: 2 * time.Second} +} + +func TestStreamErrorString(t *testing.T) { + if (&StreamError{Message: "boom"}).Error() != "boom" { + t.Fatal("error string") + } +} diff --git a/internal/agentx/llm/types.go b/internal/agentx/llm/types.go new file mode 100644 index 00000000..bf19df2f --- /dev/null +++ b/internal/agentx/llm/types.go @@ -0,0 +1,95 @@ +package llm + +import ( + "context" + "encoding/json" +) + +// Role is a transcript role carried into the provider request. +type Role string + +const ( + RoleSystem Role = "system" + RoleUser Role = "user" + RoleAssistant Role = "assistant" + RoleTool Role = "tool" +) + +// StopReason is the terminal condition of one assistant turn. +type StopReason string + +const ( + StopEnd StopReason = "end" + StopToolCalls StopReason = "toolCalls" + StopError StopReason = "error" + StopAborted StopReason = "aborted" +) + +// EventKind identifies one item on the assistant stream. +type EventKind string + +const ( + KindTextDelta EventKind = "text_delta" + KindToolCallDelta EventKind = "tool_call_delta" + KindMessageEnd EventKind = "message_end" + KindError EventKind = "error" +) + +// ToolCall is one function call requested by the assistant. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// Message is one transcript entry. System, user, assistant, and tool results share this shape. +type Message struct { + Role Role `json:"role"` + Content string `json:"content,omitempty"` + Name string `json:"name,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + StopReason StopReason `json:"stop_reason,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +// ToolDecl is the JSON-schema declaration sent to the provider. +type ToolDecl struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +// Usage counts tokens reported by the provider. Zero means the provider omitted usage. +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// Event is one streamed update. Failures are KindError plus a final assistant +// message whose StopReason is error or aborted. Stream implementations do not panic. +type Event struct { + Kind EventKind + Text string + ToolIndex int + ToolID string + ToolName string + ArgumentsDelta string + Message Message + Usage Usage + Err error + Retryable bool + ErrorMessage string +} + +// StreamOptions tunes one provider request. +type StreamOptions struct { + MaxTokens int + APIBase string + APIKey string +} + +// StreamFunc is the provider boundary. It must return a channel that is closed +// after a terminal message_end or error event. It must not panic. +type StreamFunc func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event From 022b914c6652d0b9349e2d90512a05b214bf15c5 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 14:49:13 +0000 Subject: [PATCH 03/10] feat(agentx): jail workspace tools and audit hooks Keep read, write, edit, list, and grep inside the workspace root, including symlink targets, and let hooks block or rewrite a call. --- internal/agentx/tools/builtin.go | 198 +++++++++++++++++++++++++++ internal/agentx/tools/jail.go | 85 ++++++++++++ internal/agentx/tools/registry.go | 202 ++++++++++++++++++++++++++++ internal/agentx/tools/tools_test.go | 105 +++++++++++++++ 4 files changed, 590 insertions(+) create mode 100644 internal/agentx/tools/builtin.go create mode 100644 internal/agentx/tools/jail.go create mode 100644 internal/agentx/tools/registry.go create mode 100644 internal/agentx/tools/tools_test.go diff --git a/internal/agentx/tools/builtin.go b/internal/agentx/tools/builtin.go new file mode 100644 index 00000000..7fef7ec2 --- /dev/null +++ b/internal/agentx/tools/builtin.go @@ -0,0 +1,198 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type readFile struct{ root string } + +func (t *readFile) Name() string { return "read_file" } +func (t *readFile) Description() string { + return "Read a UTF-8 text file inside the workspace." +} +func (t *readFile) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`) +} +func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, error) { + rel, err := readArg(args, "path") + if err != nil { + return Result{}, err + } + path, err := mustPath(t.root, rel) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + b, err := os.ReadFile(path) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + if len(b) > maxReadBytes { + b = b[:maxReadBytes] + } + return Result{Content: string(b)}, nil +} + +type writeFile struct{ root string } + +func (t *writeFile) Name() string { return "write_file" } +func (t *writeFile) Description() string { + return "Create or replace a UTF-8 text file inside the workspace." +} +func (t *writeFile) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","required":["path","content"],"properties":{"path":{"type":"string"},"content":{"type":"string"}}}`) +} +func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, error) { + var obj struct { + Path string `json:"path"` + Content string `json:"content"` + } + if err := json.Unmarshal(args, &obj); err != nil { + return Result{}, err + } + path, err := mustPath(t.root, obj.Path) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + if err := os.WriteFile(path, []byte(obj.Content), 0o644); err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + return Result{Content: "wrote " + obj.Path}, nil +} + +type editFile struct{ root string } + +func (t *editFile) Name() string { return "edit_file" } +func (t *editFile) Description() string { + return "Replace one unique exact text span in a workspace file." +} +func (t *editFile) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","required":["path","old","new"],"properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}}}`) +} +func (t *editFile) Execute(_ context.Context, args json.RawMessage) (Result, error) { + var obj struct { + Path string `json:"path"` + Old string `json:"old"` + New string `json:"new"` + } + if err := json.Unmarshal(args, &obj); err != nil { + return Result{}, err + } + if obj.Old == "" { + return Result{Content: "old text is empty", IsError: true}, nil + } + path, err := mustPath(t.root, obj.Path) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + b, err := os.ReadFile(path) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + text := string(b) + n := strings.Count(text, obj.Old) + if n != 1 { + return Result{Content: fmt.Sprintf("expected one match, found %d", n), IsError: true}, nil + } + updated := strings.Replace(text, obj.Old, obj.New, 1) + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + return Result{Content: "edited " + obj.Path}, nil +} + +type listDir struct{ root string } + +func (t *listDir) Name() string { return "list_dir" } +func (t *listDir) Description() string { + return "List names in a workspace directory." +} +func (t *listDir) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`) +} +func (t *listDir) Execute(_ context.Context, args json.RawMessage) (Result, error) { + rel, _ := readArg(args, "path") + if rel == "" { + rel = "." + } + path, err := mustPath(t.root, rel) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + entries, err := os.ReadDir(path) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + name := e.Name() + if e.IsDir() { + name += "/" + } + names = append(names, name) + } + return Result{Content: strings.Join(names, "\n")}, nil +} + +type grepTool struct{ root string } + +func (t *grepTool) Name() string { return "grep" } +func (t *grepTool) Description() string { + return "Search workspace text files for a literal substring." +} +func (t *grepTool) Parameters() json.RawMessage { + return json.RawMessage(`{"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}`) +} +func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, error) { + var obj struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + } + if err := json.Unmarshal(args, &obj); err != nil { + return Result{}, err + } + if obj.Path == "" { + obj.Path = "." + } + start, err := mustPath(t.root, obj.Path) + if err != nil { + return Result{Content: err.Error(), IsError: true}, nil + } + var hits []string + err = filepath.WalkDir(start, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil || d.IsDir() { + return nil + } + if len(hits) >= 50 { + return fmt.Errorf("stop") + } + b, err := os.ReadFile(path) + if err != nil || len(b) > maxReadBytes { + return nil + } + rel, _ := filepath.Rel(t.root, path) + for i, line := range strings.Split(string(b), "\n") { + if strings.Contains(line, obj.Pattern) { + hits = append(hits, fmt.Sprintf("%s:%d:%s", filepath.ToSlash(rel), i+1, line)) + if len(hits) >= 50 { + return fmt.Errorf("stop") + } + } + } + return nil + }) + if err != nil && err.Error() != "stop" { + return Result{Content: err.Error(), IsError: true}, nil + } + if len(hits) == 0 { + return Result{Content: "no matches"}, nil + } + return Result{Content: strings.Join(hits, "\n")}, nil +} diff --git a/internal/agentx/tools/jail.go b/internal/agentx/tools/jail.go new file mode 100644 index 00000000..f7162e4e --- /dev/null +++ b/internal/agentx/tools/jail.go @@ -0,0 +1,85 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Resolve joins rel to root and rejects paths that escape the workspace, +// including symlink targets outside root. Missing files are allowed when their +// existing ancestors stay inside root. +func Resolve(root, rel string) (string, error) { + rootAbs, err := filepath.Abs(root) + if err != nil { + return "", err + } + rootAbs = filepath.Clean(rootAbs) + resolvedRoot, err := filepath.EvalSymlinks(rootAbs) + if err != nil { + return "", err + } + rel = strings.TrimSpace(rel) + if rel == "" { + rel = "." + } + if filepath.IsAbs(rel) { + inside, err := filepath.Rel(rootAbs, filepath.Clean(rel)) + if err != nil || escapes(inside) { + return "", fmt.Errorf("path escapes workspace") + } + rel = inside + } + rel = filepath.Clean(rel) + if escapes(rel) { + return "", fmt.Errorf("path escapes workspace") + } + if rel == "." { + return resolvedRoot, nil + } + current := resolvedRoot + parts := strings.Split(rel, string(os.PathSeparator)) + for i, part := range parts { + if part == "" || part == "." { + continue + } + if part == ".." { + return "", fmt.Errorf("path escapes workspace") + } + next := filepath.Join(current, part) + info, err := os.Lstat(next) + if os.IsNotExist(err) { + tail := filepath.Join(parts[i:]...) + return filepath.Join(current, tail), nil + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := filepath.EvalSymlinks(next) + if err != nil { + return "", fmt.Errorf("path escapes workspace") + } + relTarget, err := filepath.Rel(resolvedRoot, target) + if err != nil || escapes(relTarget) { + return "", fmt.Errorf("path escapes workspace") + } + current = target + continue + } + current = next + } + relFinal, err := filepath.Rel(resolvedRoot, current) + if err != nil || escapes(relFinal) { + return "", fmt.Errorf("path escapes workspace") + } + return current, nil +} + +func escapes(rel string) bool { + if rel == ".." { + return true + } + return strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} diff --git a/internal/agentx/tools/registry.go b/internal/agentx/tools/registry.go new file mode 100644 index 00000000..07974775 --- /dev/null +++ b/internal/agentx/tools/registry.go @@ -0,0 +1,202 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// Result is the tool payload returned to the model. +type Result struct { + Content string + IsError bool +} + +// Tool is one executable function with a JSON schema. +type Tool interface { + Name() string + Description() string + Parameters() json.RawMessage + Execute(ctx context.Context, args json.RawMessage) (Result, error) +} + +// BeforeHook can block a call after schema validation. +type BeforeHook func(ctx context.Context, name string, args json.RawMessage) (block bool, reason string) + +// AfterHook can rewrite a tool result before it is returned. +type AfterHook func(ctx context.Context, name string, args json.RawMessage, result Result) Result + +// Registry executes jailed tools and appends an audit line per call. +type Registry struct { + root string + tools map[string]Tool + order []string + Before BeforeHook + After AfterHook + Audit io.Writer +} + +func NewRegistry(root string) *Registry { + return &Registry{root: root, tools: map[string]Tool{}} +} + +func (r *Registry) Root() string { return r.root } + +func (r *Registry) Register(t Tool) { + if _, ok := r.tools[t.Name()]; !ok { + r.order = append(r.order, t.Name()) + } + r.tools[t.Name()] = t +} + +func (r *Registry) Decls() []Decl { + out := make([]Decl, 0, len(r.order)) + for _, name := range r.order { + t := r.tools[name] + out = append(out, Decl{Name: t.Name(), Description: t.Description(), Parameters: t.Parameters()}) + } + return out +} + +// Decl is the schema view of a tool. +type Decl struct { + Name string + Description string + Parameters json.RawMessage +} + +// Execute validates arguments, runs hooks, and audits the outcome. +func (r *Registry) Execute(ctx context.Context, name string, args json.RawMessage) Result { + t, ok := r.tools[name] + if !ok { + res := Result{Content: "unknown tool " + name, IsError: true} + r.audit(name, false, true, "unknown") + return res + } + if len(args) == 0 { + args = []byte(`{}`) + } + if err := validateArgs(t.Parameters(), args); err != nil { + res := Result{Content: err.Error(), IsError: true} + r.audit(name, false, true, "schema") + return res + } + if r.Before != nil { + if block, reason := r.Before(ctx, name, args); block { + if reason == "" { + reason = "blocked" + } + res := Result{Content: reason, IsError: true} + r.audit(name, true, true, reason) + return res + } + } + res, err := t.Execute(ctx, args) + if err != nil { + res = Result{Content: err.Error(), IsError: true} + } + if r.After != nil { + res = r.After(ctx, name, args, res) + } + r.audit(name, false, res.IsError, "") + return res +} + +func (r *Registry) audit(name string, blocked, isError bool, note string) { + if r.Audit == nil { + return + } + fmt.Fprintf(r.Audit, "%s\t%s\tblocked=%t\terror=%t\t%s\n", time.Now().UTC().Format(time.RFC3339Nano), name, blocked, isError, note) +} + +func validateArgs(schema, args json.RawMessage) error { + var spec struct { + Type string `json:"type"` + Required []string `json:"required"` + Properties map[string]json.RawMessage `json:"properties"` + } + if len(schema) > 0 { + if err := json.Unmarshal(schema, &spec); err != nil { + return fmt.Errorf("invalid tool schema") + } + } + var obj map[string]any + if err := json.Unmarshal(args, &obj); err != nil { + return fmt.Errorf("arguments must be a JSON object") + } + if spec.Type != "" && spec.Type != "object" { + return fmt.Errorf("unsupported schema type %s", spec.Type) + } + for _, req := range spec.Required { + v, ok := obj[req] + if !ok || v == nil { + return fmt.Errorf("missing required argument %s", req) + } + if s, ok := v.(string); ok && strings.TrimSpace(s) == "" { + return fmt.Errorf("missing required argument %s", req) + } + } + for key, raw := range spec.Properties { + v, ok := obj[key] + if !ok { + continue + } + var prop struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &prop); err != nil || prop.Type == "" { + continue + } + if !matchesType(prop.Type, v) { + return fmt.Errorf("argument %s must be %s", key, prop.Type) + } + } + return nil +} + +func matchesType(kind string, v any) bool { + switch kind { + case "string": + _, ok := v.(string) + return ok + case "number": + _, ok := v.(float64) + return ok + case "integer": + f, ok := v.(float64) + return ok && f == float64(int64(f)) + case "boolean": + _, ok := v.(bool) + return ok + default: + return true + } +} + +// WorkspaceTools registers the document workspace tool set. +func WorkspaceTools(root string) *Registry { + r := NewRegistry(root) + r.Register(&readFile{root: root}) + r.Register(&writeFile{root: root}) + r.Register(&editFile{root: root}) + r.Register(&listDir{root: root}) + r.Register(&grepTool{root: root}) + return r +} + +func mustPath(root, rel string) (string, error) { + return Resolve(root, rel) +} + +func readArg(args json.RawMessage, key string) (string, error) { + var obj map[string]string + if err := json.Unmarshal(args, &obj); err != nil { + return "", err + } + return obj[key], nil +} + +const maxReadBytes = 200 << 10 diff --git a/internal/agentx/tools/tools_test.go b/internal/agentx/tools/tools_test.go new file mode 100644 index 00000000..6f1f0c9d --- /dev/null +++ b/internal/agentx/tools/tools_test.go @@ -0,0 +1,105 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSchemaRejectsMissingAndWrongType(t *testing.T) { + dir := t.TempDir() + reg := WorkspaceTools(dir) + missing := reg.Execute(context.Background(), "read_file", []byte(`{}`)) + if !missing.IsError || !strings.Contains(missing.Content, "path") { + t.Fatalf("missing %#v", missing) + } + wrong := reg.Execute(context.Background(), "read_file", []byte(`{"path":1}`)) + if !wrong.IsError || !strings.Contains(wrong.Content, "string") { + t.Fatalf("type %#v", wrong) + } +} + +func TestPathEscapeAndSymlink(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + secret := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(secret, []byte("hidden"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "leak") + if err := os.Symlink(secret, link); err != nil { + t.Fatal(err) + } + reg := WorkspaceTools(dir) + up := reg.Execute(context.Background(), "read_file", []byte(`{"path":"../secret.txt"}`)) + if !up.IsError { + t.Fatalf("parent escape succeeded: %s", up.Content) + } + via := reg.Execute(context.Background(), "read_file", []byte(`{"path":"leak"}`)) + if !via.IsError || strings.Contains(via.Content, "hidden") { + t.Fatalf("symlink escape: %#v", via) + } + absOutside := reg.Execute(context.Background(), "write_file", []byte(`{"path":"`+secret+`","content":"no"}`)) + if !absOutside.IsError { + t.Fatal("absolute outside write succeeded") + } +} + +func TestWriteReadEditGrep(t *testing.T) { + dir := t.TempDir() + reg := WorkspaceTools(dir) + wrote := reg.Execute(context.Background(), "write_file", []byte(`{"path":"docs/a.md","content":"alpha beta"}`)) + if wrote.IsError { + t.Fatal(wrote.Content) + } + got := reg.Execute(context.Background(), "read_file", []byte(`{"path":"docs/a.md"}`)) + if got.IsError || got.Content != "alpha beta" { + t.Fatalf("read %#v", got) + } + edited := reg.Execute(context.Background(), "edit_file", []byte(`{"path":"docs/a.md","old":"beta","new":"gamma"}`)) + if edited.IsError { + t.Fatal(edited.Content) + } + found := reg.Execute(context.Background(), "grep", []byte(`{"pattern":"gamma"}`)) + if found.IsError || !strings.Contains(found.Content, "docs/a.md:1:") { + t.Fatalf("grep %#v", found) + } + listed := reg.Execute(context.Background(), "list_dir", []byte(`{"path":"docs"}`)) + if !strings.Contains(listed.Content, "a.md") { + t.Fatalf("list %#v", listed) + } +} + +func TestBeforeHookBlocksAndAudits(t *testing.T) { + dir := t.TempDir() + reg := WorkspaceTools(dir) + var audit bytes.Buffer + reg.Audit = &audit + reg.Before = func(ctx context.Context, name string, args json.RawMessage) (bool, string) { + return true, "policy" + } + res := reg.Execute(context.Background(), "list_dir", []byte(`{}`)) + if !res.IsError || res.Content != "policy" { + t.Fatalf("hook %#v", res) + } + if !strings.Contains(audit.String(), "blocked=true") { + t.Fatalf("audit %s", audit.String()) + } +} + +func TestAfterHookRewrites(t *testing.T) { + dir := t.TempDir() + reg := WorkspaceTools(dir) + reg.After = func(ctx context.Context, name string, args json.RawMessage, result Result) Result { + result.Content = "rewritten" + return result + } + res := reg.Execute(context.Background(), "list_dir", []byte(`{}`)) + if res.Content != "rewritten" { + t.Fatalf("%#v", res) + } +} From 738bbe157907c940af022d74c5e47076d27bc07d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 14:52:53 +0000 Subject: [PATCH 04/10] 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. --- cmd/bizdev-cli/main.go | 2 +- cmd/server/main.go | 2 +- internal/agent/backend.go | 4 +- internal/agent/cursor.go | 114 ------------------ internal/agent/pinative.go | 154 ++++++++++++++++++++++++ internal/agent/pinative_test.go | 83 +++++++++++++ internal/config/config.go | 110 ++++++++++------- internal/config/config_test.go | 12 +- internal/taskrun/backend.go | 6 +- internal/taskrun/manifest_test.go | 7 ++ tools/cursor-bridge/index.mjs | 192 ------------------------------ tools/cursor-bridge/package.json | 17 --- 12 files changed, 327 insertions(+), 376 deletions(-) delete mode 100644 internal/agent/cursor.go create mode 100644 internal/agent/pinative.go create mode 100644 internal/agent/pinative_test.go delete mode 100755 tools/cursor-bridge/index.mjs delete mode 100644 tools/cursor-bridge/package.json diff --git a/cmd/bizdev-cli/main.go b/cmd/bizdev-cli/main.go index 30e04ffe..0764c941 100644 --- a/cmd/bizdev-cli/main.go +++ b/cmd/bizdev-cli/main.go @@ -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", "医疗器械", "设备类型") diff --git a/cmd/server/main.go b/cmd/server/main.go index c581b3d0..e203eede 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/internal/agent/backend.go b/internal/agent/backend.go index 52922210..3a67a224 100644 --- a/internal/agent/backend.go +++ b/internal/agent/backend.go @@ -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{} } diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go deleted file mode 100644 index da78d50c..00000000 --- a/internal/agent/cursor.go +++ /dev/null @@ -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 -} diff --git a/internal/agent/pinative.go b/internal/agent/pinative.go new file mode 100644 index 00000000..1ec2c1e7 --- /dev/null +++ b/internal/agent/pinative.go @@ -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() +} diff --git a/internal/agent/pinative_test.go b/internal/agent/pinative_test.go new file mode 100644 index 00000000..e1242a9a --- /dev/null +++ b/internal/agent/pinative_test.go @@ -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 + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 98b9d548..fd0cfc18 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` @@ -82,30 +84,32 @@ type Config struct { VaultStateRoot string `json:"vault_state_root"` VaultStorageIdentity string `json:"vault_storage_identity"` VaultMasterKey string `json:"vault_master_key"` - FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false - FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false + FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false + FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false } func Load(path string) (*Config, error) { cfg := &Config{ - Env: "dev", - DataDir: "./data/dev", - HTTPHost: "127.0.0.1", - HTTPPort: 8080, - AgentBackend: "pi-agent", - AgentTimeout: 30 * time.Minute, - CursorModel: "auto", - CursorBin: "/usr/local/bin/cursor", - CursorBridgeBin: "tools/cursor-bridge/index.mjs", - PiAgentBin: "/opt/homebrew/bin/pi", - PiAgentBridgeBin: "tools/pi-bridge/index.mjs", - PiAgentProvider: "minimax", - PiAgentModel: "MiniMax-M2.7-highspeed", - KbDbHost: "127.0.0.1", - KbDbPort: 5432, - KbDbUser: "regkb", - KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供 - KbDbName: "regulations", + Env: "dev", + DataDir: "./data/dev", + HTTPHost: "127.0.0.1", + HTTPPort: 8080, + AgentBackend: "pi-agent", + AgentTimeout: 30 * time.Minute, + 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", + PiAgentModel: "MiniMax-M2.7-highspeed", + KbDbHost: "127.0.0.1", + KbDbPort: 5432, + KbDbUser: "regkb", + KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供 + KbDbName: "regulations", ContextDbHost: "127.0.0.1", ContextDbPort: 5433, @@ -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("CURSOR_BRIDGE_BIN"); v != "" { - cfg.CursorBridgeBin = 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("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": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4cfc48ee..584e8f87 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") diff --git a/internal/taskrun/backend.go b/internal/taskrun/backend.go index 67e4dbba..85654d19 100644 --- a/internal/taskrun/backend.go +++ b/internal/taskrun/backend.go @@ -12,9 +12,9 @@ var ( // Allowed backends are resolved by server policy. Callers cannot inject arbitrary strings. var allowedBackends = map[string]struct{}{ - "stub": {}, - "pi-agent": {}, - "cursor": {}, + "stub": {}, + "pi-agent": {}, + "pi-native": {}, } func isProductionEnv(env string) bool { diff --git a/internal/taskrun/manifest_test.go b/internal/taskrun/manifest_test.go index f081a314..6c73e5d2 100644 --- a/internal/taskrun/manifest_test.go +++ b/internal/taskrun/manifest_test.go @@ -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) { diff --git a/tools/cursor-bridge/index.mjs b/tools/cursor-bridge/index.mjs deleted file mode 100755 index 2ac0fff8..00000000 --- a/tools/cursor-bridge/index.mjs +++ /dev/null @@ -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))); -}); diff --git a/tools/cursor-bridge/package.json b/tools/cursor-bridge/package.json deleted file mode 100644 index 80f258e9..00000000 --- a/tools/cursor-bridge/package.json +++ /dev/null @@ -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" - } -} From fd1b676e280bccfe464b6d8b37025877307859fa Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 14:53:09 +0000 Subject: [PATCH 05/10] feat(agentx): add the turn loop and JSONL transcripts Persist each message as it happens and keep going after a tool failure, including steering and follow-up messages between turns. --- internal/agentx/loop.go | 257 ++++++++++++++++++++++++++ internal/agentx/loop_test.go | 186 +++++++++++++++++++ internal/agentx/session/jsonl.go | 73 ++++++++ internal/agentx/session/jsonl_test.go | 38 ++++ 4 files changed, 554 insertions(+) create mode 100644 internal/agentx/loop.go create mode 100644 internal/agentx/loop_test.go create mode 100644 internal/agentx/session/jsonl.go create mode 100644 internal/agentx/session/jsonl_test.go diff --git a/internal/agentx/loop.go b/internal/agentx/loop.go new file mode 100644 index 00000000..6841a3dc --- /dev/null +++ b/internal/agentx/loop.go @@ -0,0 +1,257 @@ +package agentx + +import ( + "context" + "encoding/json" + "strings" + "sync" + + "chat-agent-system/internal/agentx/llm" + "chat-agent-system/internal/agentx/session" + "chat-agent-system/internal/agentx/tools" +) + +const ( + ExecSequential = "sequential" + ExecParallel = "parallel" +) + +// Event is a lifecycle notice for logs and UI. +type Event struct { + Type string + Message llm.Message + Tool string + Text string +} + +// Config drives one agent session. +type Config struct { + Model string + SystemPrompt string + Tools *tools.Registry + Stream llm.StreamFunc + Session *session.Store + MaxTurns int + ToolExecution string + GetSteering func() []llm.Message + GetFollowUp func() []llm.Message + OnEvent func(Event) +} + +// Result is the final assistant text and the transcript produced by one run. +type Result struct { + Text string + Messages []llm.Message + StopReason llm.StopReason + Usage llm.Usage + Err error + Retryable bool +} + +// Run executes the turn loop until the model stops, follow-ups drain, or the turn cap is hit. +func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) { + if cfg.Stream == nil { + return Result{StopReason: llm.StopError, Err: errString("stream func is required")}, nil + } + if cfg.MaxTurns <= 0 { + cfg.MaxTurns = 12 + } + if cfg.ToolExecution == "" { + cfg.ToolExecution = ExecSequential + } + var messages []llm.Message + if cfg.Session != nil { + loaded, err := cfg.Session.Load() + if err != nil { + return Result{}, err + } + messages = loaded + } + if len(messages) == 0 || messages[0].Role != llm.RoleSystem { + sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)} + messages = append([]llm.Message{sys}, messages...) + if err := persist(cfg, sys); err != nil { + return Result{}, err + } + } + if err := persist(cfg, user); err != nil { + return Result{}, err + } + messages = append(messages, user) + emit(cfg, Event{Type: "agent_start"}) + + var last llm.Message + var usage llm.Usage + for turn := 0; turn < cfg.MaxTurns; turn++ { + if ctx.Err() != nil { + return Result{Text: last.Content, Messages: messages, StopReason: llm.StopAborted, Err: ctx.Err()}, nil + } + emit(cfg, Event{Type: "turn_start"}) + end, errEv := consume(ctx, cfg, messages) + if errEv != nil { + return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil + } + last = end.Message + usage.PromptTokens += end.Usage.PromptTokens + usage.CompletionTokens += end.Usage.CompletionTokens + usage.TotalTokens += end.Usage.TotalTokens + if err := persist(cfg, last); err != nil { + return Result{}, err + } + messages = append(messages, last) + emit(cfg, Event{Type: "message_end", Message: last}) + + if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 { + results := executeTools(ctx, cfg, last) + for _, res := range results { + if err := persist(cfg, res); err != nil { + return Result{}, err + } + messages = append(messages, res) + } + if steered := drain(cfg.GetSteering); len(steered) > 0 { + for _, m := range steered { + if err := persist(cfg, m); err != nil { + return Result{}, err + } + messages = append(messages, m) + } + } + emit(cfg, Event{Type: "turn_end", Message: last}) + continue + } + emit(cfg, Event{Type: "turn_end", Message: last}) + if follow := drain(cfg.GetFollowUp); len(follow) > 0 { + for _, m := range follow { + if err := persist(cfg, m); err != nil { + return Result{}, err + } + messages = append(messages, m) + } + continue + } + emit(cfg, Event{Type: "agent_end", Message: last}) + return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil + } + return Result{ + Text: last.Content, + Messages: messages, + StopReason: llm.StopError, + Usage: usage, + Err: errString("max turns exceeded"), + }, nil +} + +func systemText(cfg Config) string { + var b strings.Builder + b.WriteString(cfg.SystemPrompt) + if cfg.Tools == nil { + return b.String() + } + decls := cfg.Tools.Decls() + if len(decls) == 0 { + return b.String() + } + if b.Len() > 0 { + b.WriteString("\n\n") + } + b.WriteString("Tools available in this workspace:") + for _, d := range decls { + b.WriteString("\n- ") + b.WriteString(d.Name) + b.WriteString(": ") + b.WriteString(d.Description) + } + return b.String() +} + +func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) { + var decls []llm.ToolDecl + if cfg.Tools != nil { + for _, d := range cfg.Tools.Decls() { + decls = append(decls, llm.ToolDecl{Name: d.Name, Description: d.Description, Parameters: d.Parameters}) + } + } + var end llm.Event + sawEnd := false + for ev := range cfg.Stream(ctx, cfg.Model, messages, decls, llm.StreamOptions{}) { + switch ev.Kind { + case llm.KindTextDelta: + emit(cfg, Event{Type: "text_delta", Text: ev.Text}) + case llm.KindError: + return ev, ev.Err + case llm.KindMessageEnd: + end = ev + sawEnd = true + } + } + if !sawEnd { + return llm.Event{Message: llm.Message{StopReason: llm.StopError}}, errString("stream ended without a message") + } + return end, nil +} + +func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message { + if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 { + return executeParallel(ctx, cfg, assistant) + } + out := make([]llm.Message, 0, len(assistant.ToolCalls)) + for _, call := range assistant.ToolCalls { + out = append(out, runOne(ctx, cfg, call)) + } + return out +} + +func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message { + out := make([]llm.Message, len(assistant.ToolCalls)) + var wg sync.WaitGroup + for i, call := range assistant.ToolCalls { + wg.Add(1) + go func(i int, call llm.ToolCall) { + defer wg.Done() + out[i] = runOne(ctx, cfg, call) + }(i, call) + } + wg.Wait() + return out +} + +func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message { + emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name}) + res := tools.Result{Content: "no tools configured", IsError: true} + if cfg.Tools != nil { + res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments)) + } + emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content}) + return llm.Message{ + Role: llm.RoleTool, + Name: call.Name, + ToolCallID: call.ID, + Content: res.Content, + IsError: res.IsError, + } +} + +func persist(cfg Config, msg llm.Message) error { + if cfg.Session == nil { + return nil + } + return cfg.Session.Append(msg) +} + +func drain(fn func() []llm.Message) []llm.Message { + if fn == nil { + return nil + } + return fn() +} + +func emit(cfg Config, ev Event) { + if cfg.OnEvent != nil { + cfg.OnEvent(ev) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/agentx/loop_test.go b/internal/agentx/loop_test.go new file mode 100644 index 00000000..28ebe774 --- /dev/null +++ b/internal/agentx/loop_test.go @@ -0,0 +1,186 @@ +package agentx + +import ( + "context" + "path/filepath" + "strings" + "sync" + "testing" + + "chat-agent-system/internal/agentx/llm" + "chat-agent-system/internal/agentx/session" + "chat-agent-system/internal/agentx/tools" +) + +func script(turns ...llm.Message) llm.StreamFunc { + var mu sync.Mutex + 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, 4) + go func() { + defer close(ch) + mu.Lock() + defer mu.Unlock() + if i >= len(turns) { + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}} + return + } + msg := turns[i] + i++ + if msg.Content != "" { + ch <- llm.Event{Kind: llm.KindTextDelta, Text: msg.Content} + } + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg} + }() + return ch + } +} + +func TestLoopToolThenContinueAfterToolError(t *testing.T) { + dir := t.TempDir() + reg := tools.WorkspaceTools(dir) + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "read_file", Arguments: `{"path":"missing.md"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "recovered", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "read it"}) + if err != nil { + t.Fatal(err) + } + if res.Text != "recovered" || res.StopReason != llm.StopEnd { + t.Fatalf("%#v", res) + } + sawToolErr := false + for _, m := range res.Messages { + if m.Role == llm.RoleTool && m.IsError { + sawToolErr = true + } + } + if !sawToolErr { + t.Fatal("expected tool error to continue the loop") + } +} + +func TestLoopSteeringAndFollowUp(t *testing.T) { + steered := false + followed := false + res, err := Run(context.Background(), Config{ + Model: "fake", + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "list_dir", Arguments: `{}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "after-steer", StopReason: llm.StopEnd}, + llm.Message{Role: llm.RoleAssistant, Content: "after-follow", StopReason: llm.StopEnd}, + ), + Tools: tools.WorkspaceTools(t.TempDir()), + GetSteering: func() []llm.Message { + if steered { + return nil + } + steered = true + return []llm.Message{{Role: llm.RoleUser, Content: "add a footer"}} + }, + GetFollowUp: func() []llm.Message { + if followed { + return nil + } + followed = true + return []llm.Message{{Role: llm.RoleUser, Content: "one more"}} + }, + }, llm.Message{Role: llm.RoleUser, Content: "go"}) + if err != nil { + t.Fatal(err) + } + if res.Text != "after-follow" { + t.Fatalf("text %q", res.Text) + } + joined := transcriptText(res.Messages) + if !strings.Contains(joined, "add a footer") || !strings.Contains(joined, "one more") { + t.Fatalf("transcript %s", joined) + } +} + +func TestLoopTranscriptReplay(t *testing.T) { + path := filepath.Join(t.TempDir(), ".agentx", "transcript.jsonl") + st, err := session.Open(path) + if err != nil { + t.Fatal(err) + } + _, err = Run(context.Background(), Config{ + Model: "fake", + SystemPrompt: "be brief", + Session: st, + Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "pong", StopReason: llm.StopEnd}), + }, llm.Message{Role: llm.RoleUser, Content: "ping"}) + if err != nil { + t.Fatal(err) + } + loaded, err := st.Load() + if err != nil { + t.Fatal(err) + } + if len(loaded) != 3 || loaded[0].Role != llm.RoleSystem || loaded[1].Content != "ping" || loaded[2].Content != "pong" { + t.Fatalf("%#v", loaded) + } + again, err := session.Open(path) + if err != nil { + t.Fatal(err) + } + replay, err := again.Load() + if err != nil { + t.Fatal(err) + } + if len(replay) != len(loaded) || replay[2].Content != loaded[2].Content { + t.Fatal("replay diverged") + } +} + +func TestLoopParallelReads(t *testing.T) { + dir := t.TempDir() + reg := tools.WorkspaceTools(dir) + if reg.Execute(context.Background(), "write_file", []byte(`{"path":"a.txt","content":"A"}`)).IsError { + t.Fatal("seed a") + } + if reg.Execute(context.Background(), "write_file", []byte(`{"path":"b.txt","content":"B"}`)).IsError { + t.Fatal("seed b") + } + res, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + ToolExecution: ExecParallel, + Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{ + {ID: "1", Name: "read_file", Arguments: `{"path":"a.txt"}`}, + {ID: "2", Name: "read_file", Arguments: `{"path":"b.txt"}`}, + }}, + llm.Message{Role: llm.RoleAssistant, Content: "AB", StopReason: llm.StopEnd}, + ), + }, llm.Message{Role: llm.RoleUser, Content: "read both"}) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, m := range res.Messages { + if m.Role == llm.RoleTool { + got[m.ToolCallID] = m.Content + } + } + if got["1"] != "A" || got["2"] != "B" { + t.Fatalf("parallel results %#v", got) + } +} + +func transcriptText(messages []llm.Message) string { + var b strings.Builder + for _, m := range messages { + b.WriteString(m.Content) + b.WriteByte('\n') + } + return b.String() +} diff --git a/internal/agentx/session/jsonl.go b/internal/agentx/session/jsonl.go new file mode 100644 index 00000000..b2417bca --- /dev/null +++ b/internal/agentx/session/jsonl.go @@ -0,0 +1,73 @@ +package session + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sync" + + "chat-agent-system/internal/agentx/llm" +) + +// Store is an append-only JSONL transcript. Each line is one llm.Message. +type Store struct { + path string + mu sync.Mutex +} + +func Open(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_CREATE, 0o644) + if err != nil { + return nil, err + } + _ = f.Close() + return &Store{path: path}, nil +} + +func (s *Store) Path() string { return s.path } + +func (s *Store) Append(msg llm.Message) error { + s.mu.Lock() + defer s.mu.Unlock() + raw, err := json.Marshal(msg) + if err != nil { + return err + } + f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + raw = append(raw, '\n') + _, err = f.Write(raw) + return err +} + +func (s *Store) Load() ([]llm.Message, error) { + s.mu.Lock() + defer s.mu.Unlock() + f, err := os.Open(s.path) + if err != nil { + return nil, err + } + defer f.Close() + var out []llm.Message + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for sc.Scan() { + line := sc.Bytes() + if len(line) == 0 { + continue + } + var msg llm.Message + if err := json.Unmarshal(line, &msg); err != nil { + return nil, err + } + out = append(out, msg) + } + return out, sc.Err() +} diff --git a/internal/agentx/session/jsonl_test.go b/internal/agentx/session/jsonl_test.go new file mode 100644 index 00000000..1cc9f10e --- /dev/null +++ b/internal/agentx/session/jsonl_test.go @@ -0,0 +1,38 @@ +package session + +import ( + "path/filepath" + "testing" + + "chat-agent-system/internal/agentx/llm" +) + +func TestAppendAndReplay(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + st, err := Open(path) + if err != nil { + t.Fatal(err) + } + msgs := []llm.Message{ + {Role: llm.RoleSystem, Content: "sys"}, + {Role: llm.RoleUser, Content: "hi"}, + {Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}, + } + for _, m := range msgs { + if err := st.Append(m); err != nil { + t.Fatal(err) + } + } + got, err := st.Load() + if err != nil { + t.Fatal(err) + } + if len(got) != len(msgs) { + t.Fatalf("len %d", len(got)) + } + for i := range msgs { + if got[i].Role != msgs[i].Role || got[i].Content != msgs[i].Content || got[i].StopReason != msgs[i].StopReason { + t.Fatalf("msg %d %#v", i, got[i]) + } + } +} From 2de4eda16dbe16195fa25abd133d10dc78ec4a5d Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 14:58:21 +0000 Subject: [PATCH 06/10] feat(workspace): register workspaces and gate cleanup by receipts Track workspace size and age, expose an admin list and cleanup route, and refuse TTL or quota deletion until a frozen manifest matches verified receipts. --- ...2_2026-09-21-agentx-workspace-registry.sql | 13 ++ internal/agent/pinative.go | 4 + internal/server/server.go | 10 + internal/server/workspace_admin_handlers.go | 133 +++++++++++ .../server/workspace_admin_handlers_test.go | 54 +++++ internal/workspace/gc.go | 107 +++++++++ internal/workspace/registry.go | 219 ++++++++++++++++++ internal/workspace/registry_pg.go | 112 +++++++++ internal/workspace/registry_pg_test.go | 68 ++++++ internal/workspace/registry_test.go | 100 ++++++++ 10 files changed, 820 insertions(+) create mode 100644 deploy/postgres/goose/00062_2026-09-21-agentx-workspace-registry.sql create mode 100644 internal/server/workspace_admin_handlers.go create mode 100644 internal/server/workspace_admin_handlers_test.go create mode 100644 internal/workspace/gc.go create mode 100644 internal/workspace/registry.go create mode 100644 internal/workspace/registry_pg.go create mode 100644 internal/workspace/registry_pg_test.go create mode 100644 internal/workspace/registry_test.go diff --git a/deploy/postgres/goose/00062_2026-09-21-agentx-workspace-registry.sql b/deploy/postgres/goose/00062_2026-09-21-agentx-workspace-registry.sql new file mode 100644 index 00000000..7fc21318 --- /dev/null +++ b/deploy/postgres/goose/00062_2026-09-21-agentx-workspace-registry.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS agentx_workspace_registry ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL DEFAULT '', + bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + last_seen TIMESTAMPTZ NOT NULL, + frozen JSONB, + receipts JSONB NOT NULL DEFAULT '[]'::jsonb +); + +-- +goose Down +DROP TABLE IF EXISTS agentx_workspace_registry; diff --git a/internal/agent/pinative.go b/internal/agent/pinative.go index 1ec2c1e7..c7610801 100644 --- a/internal/agent/pinative.go +++ b/internal/agent/pinative.go @@ -11,6 +11,7 @@ import ( "chat-agent-system/internal/agentx/session" "chat-agent-system/internal/agentx/tools" "chat-agent-system/internal/config" + "chat-agent-system/internal/workspace" ) // PiNativeBackend runs the in-process Go agent loop. The container pi-agent backend stays available. @@ -44,6 +45,9 @@ func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, er if err := os.MkdirAll(workDir, 0o755); err != nil { return &Result{Success: false, ErrorReason: err.Error()}, nil } + if p.cfg != nil { + _ = workspace.NoteWorkspace(p.cfg.DataDir, workDir, filepath.Base(workDir)) + } 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) diff --git a/internal/server/server.go b/internal/server/server.go index e49b3584..30d8e420 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -67,6 +67,8 @@ type Server struct { freeDoc *freedoc.Service kb *kb.Client sysadmin *sysadmin.Service + wsReg *workspace.FileRegistry + wsGCStop chan struct{} httpServer *http.Server intentPlans intentflow.Service planAccessFn func(ctx context.Context, projectID, userID int64) error @@ -116,6 +118,7 @@ func NewFromRuntime(rt *app.Runtime) *Server { freeDoc: rt.FreeDoc, kb: rt.KB, sysadmin: rt.SysAdmin, + wsReg: workspaceRegistryFor(rt.Cfg), intentPlans: rt.IntentPlans, // 登录/注册限流:10 次 / 5 分钟 / (IP+username) 滑动窗口 loginLimiter: newLoginRateLimiter(10, 5*time.Minute), @@ -243,6 +246,8 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun) mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/resume", s.handleResumeTaskRun) mux.HandleFunc("POST /api/v1/admin/workspaces/reconcile", s.handleReconcileWorkspace) + mux.HandleFunc("GET /api/v1/admin/workspaces", s.handleAdminWorkspaces) + mux.HandleFunc("POST /api/v1/admin/workspaces/{id}/cleanup", s.handleAdminWorkspaceCleanup) mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset) mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets) mux.HandleFunc("GET /api/v1/projects/{id}/assets/{asset_id}/raw", s.handleGetProjectAssetRaw) @@ -359,6 +364,7 @@ func (s *Server) Start() error { // Workers are started by cmd/server before Listen. Runtime.Start is // idempotent, but calling it here swallowed the first error and raced // with main. Do not start again. + s.startWorkspaceGC() addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort) s.httpServer = &http.Server{ Addr: addr, @@ -374,6 +380,10 @@ func (s *Server) Start() error { // Shutdown 优雅停机 func (s *Server) Shutdown(ctx context.Context) error { + if s.wsGCStop != nil { + close(s.wsGCStop) + s.wsGCStop = nil + } if s.reminderStop != nil { close(s.reminderStop) s.reminderStop = nil diff --git a/internal/server/workspace_admin_handlers.go b/internal/server/workspace_admin_handlers.go new file mode 100644 index 00000000..844951ef --- /dev/null +++ b/internal/server/workspace_admin_handlers.go @@ -0,0 +1,133 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/workspace" +) + +func (s *Server) handleAdminWorkspaces(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + s.serveAdminWorkspaces(w, r) +} + +func (s *Server) handleAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireAdmin(w, r); !ok { + return + } + s.serveAdminWorkspaceCleanup(w, r) +} + +func (s *Server) workspaceRegistry() *workspace.FileRegistry { + if s.wsReg != nil { + return s.wsReg + } + if s.cfg == nil || s.cfg.DataDir == "" { + return nil + } + s.wsReg = workspace.NewFileRegistry(s.cfg.DataDir) + return s.wsReg +} + +func (s *Server) serveAdminWorkspaces(w http.ResponseWriter, r *http.Request) { + reg := s.workspaceRegistry() + if reg == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "workspace registry unavailable") + return + } + if err := reg.Discover(); err != nil { + writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + list, err := reg.List() + if err != nil { + writeSafeError(w, r, http.StatusInternalServerError, err.Error()) + return + } + var bytes int64 + for _, rec := range list { + bytes += rec.Bytes + } + writeJSONResponse(w, http.StatusOK, map[string]any{ + "workspaces": list, + "stats": map[string]any{ + "count": len(list), + "bytes": bytes, + }, + }) +} + +func (s *Server) serveAdminWorkspaceCleanup(w http.ResponseWriter, r *http.Request) { + reg := s.workspaceRegistry() + if reg == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "workspace registry unavailable") + return + } + id := r.PathValue("id") + var body struct { + Frozen *workspace.FrozenManifest `json:"frozen"` + Receipts []workspace.RetentionReceipt `json:"receipts"` + } + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + if body.Frozen != nil { + if err := reg.SetRetention(id, *body.Frozen, body.Receipts); err != nil { + writeSafeError(w, r, http.StatusBadRequest, err.Error()) + return + } + } + cap := s.wsCap + if cap == nil && s.cfg != nil { + cap = workspace.NewCapability(s.cfg.DataDir, nil) + } + if err := reg.CleanupOne(r.Context(), id, cap); err != nil { + status := http.StatusConflict + if err == workspace.ErrWorkDirNotFound { + status = http.StatusNotFound + } + writeSafeError(w, r, status, err.Error()) + return + } + writeJSONResponse(w, http.StatusOK, map[string]any{"deleted": id}) +} + +func workspaceRegistryFor(cfg *config.Config) *workspace.FileRegistry { + if cfg == nil || cfg.DataDir == "" { + return nil + } + return workspace.NewFileRegistry(cfg.DataDir) +} + +func (s *Server) startWorkspaceGC() { + reg := s.workspaceRegistry() + if reg == nil || s.cfg == nil || s.wsGCStop != nil { + return + } + s.wsGCStop = make(chan struct{}) + ttl := s.cfg.WorkspaceTTL + quota := s.cfg.WorkspaceQuotaBytes + stop := s.wsGCStop + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + cap := s.wsCap + if cap == nil { + cap = workspace.NewCapability(s.cfg.DataDir, nil) + } + _, _ = reg.CollectGarbage(context.Background(), time.Now(), ttl, quota, cap) + } + } + }() +} diff --git a/internal/server/workspace_admin_handlers_test.go b/internal/server/workspace_admin_handlers_test.go new file mode 100644 index 00000000..e6c0fe71 --- /dev/null +++ b/internal/server/workspace_admin_handlers_test.go @@ -0,0 +1,54 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/config" + "chat-agent-system/internal/workspace" +) + +func TestServeAdminWorkspacesListsRegistry(t *testing.T) { + dir := t.TempDir() + id := "ws-admin" + if err := os.MkdirAll(filepath.Join(dir, "workspaces", id), 0o755); err != nil { + t.Fatal(err) + } + s := &Server{cfg: &config.Config{DataDir: dir}} + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/workspaces", nil) + rec := httptest.NewRecorder() + s.serveAdminWorkspaces(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), id) { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } +} + +func TestServeAdminWorkspaceCleanupHonorsGate(t *testing.T) { + dir := t.TempDir() + id := "ws-clean" + ws := filepath.Join(dir, "workspaces", id) + if err := os.MkdirAll(ws, 0o755); err != nil { + t.Fatal(err) + } + reg := workspace.NewFileRegistry(dir) + if err := reg.Upsert(workspace.Record{ID: id, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + s := &Server{cfg: &config.Config{DataDir: dir}, wsReg: reg} + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/workspaces/"+id+"/cleanup", nil) + req.SetPathValue("id", id) + rec := httptest.NewRecorder() + s.serveAdminWorkspaceCleanup(rec, req.WithContext(context.Background())) + if rec.Code != http.StatusConflict { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(ws); err != nil { + t.Fatal(err) + } +} diff --git a/internal/workspace/gc.go b/internal/workspace/gc.go new file mode 100644 index 00000000..3e1369ff --- /dev/null +++ b/internal/workspace/gc.go @@ -0,0 +1,107 @@ +package workspace + +import ( + "context" + "sort" + "time" +) + +// GCReport is the outcome of one retention pass. Directories without a matching +// frozen manifest and verified receipts are left in place. +type GCReport struct { + Deleted []string `json:"deleted"` + Blocked []string `json:"blocked"` + Bytes int64 `json:"bytes"` +} + +// CollectGarbage removes expired or over-quota workspaces only when +// AssertCleanupAllowed accepts the stored manifest and receipts. +func (r *FileRegistry) CollectGarbage(ctx context.Context, now time.Time, ttl time.Duration, quota int64, cap *Capability) (GCReport, error) { + if err := r.Discover(); err != nil { + return GCReport{}, err + } + list, err := r.List() + if err != nil { + return GCReport{}, err + } + if cap == nil { + cap = NewCapability(r.dataRoot, nil) + } + var report GCReport + for _, rec := range list { + report.Bytes += rec.Bytes + } + expired := map[string]bool{} + if ttl > 0 { + for _, rec := range list { + if now.Sub(rec.CreatedAt) > ttl { + expired[rec.ID] = true + } + } + } + over := quota > 0 && report.Bytes > quota + if !over && len(expired) == 0 { + return report, nil + } + candidates := append([]Record(nil), list...) + sort.Slice(candidates, func(i, j int) bool { return candidates[i].LastSeen.Before(candidates[j].LastSeen) }) + for _, rec := range candidates { + if ctx.Err() != nil { + return report, ctx.Err() + } + need := expired[rec.ID] || (quota > 0 && report.Bytes > quota) + if !need { + continue + } + if err := r.deleteIfAllowed(ctx, cap, rec); err != nil { + report.Blocked = append(report.Blocked, rec.ID) + continue + } + report.Deleted = append(report.Deleted, rec.ID) + report.Bytes -= rec.Bytes + if report.Bytes < 0 { + report.Bytes = 0 + } + } + return report, nil +} + +// CleanupOne deletes one workspace when its manifest and receipts match. +func (r *FileRegistry) CleanupOne(ctx context.Context, id string, cap *Capability) error { + rec, ok, err := r.Get(id) + if err != nil { + return err + } + if !ok { + return ErrWorkDirNotFound + } + if cap == nil { + cap = NewCapability(r.dataRoot, nil) + } + return r.deleteIfAllowed(ctx, cap, rec) +} + +func (r *FileRegistry) deleteIfAllowed(ctx context.Context, cap *Capability, rec Record) error { + if rec.Frozen == nil { + return ErrCleanupBlocked + } + ref := WorkspaceRef{ID: rec.ID, SchemaVersion: SchemaVersion, RunID: rec.RunID} + if err := cap.Cleanup(ctx, ref, *rec.Frozen, rec.Receipts); err != nil { + return err + } + return r.Delete(rec.ID) +} + +// SetRetention stores the frozen manifest and receipts required before deletion. +func (r *FileRegistry) SetRetention(id string, frozen FrozenManifest, receipts []RetentionReceipt) error { + rec, ok, err := r.Get(id) + if err != nil { + return err + } + if !ok { + rec.ID = id + } + rec.Frozen = &frozen + rec.Receipts = receipts + return r.Upsert(rec) +} diff --git a/internal/workspace/registry.go b/internal/workspace/registry.go new file mode 100644 index 00000000..d1d80b42 --- /dev/null +++ b/internal/workspace/registry.go @@ -0,0 +1,219 @@ +package workspace + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// Record is one registered workspace directory. +type Record struct { + ID string `json:"id"` + RunID string `json:"run_id"` + Bytes int64 `json:"bytes"` + CreatedAt time.Time `json:"created_at"` + LastSeen time.Time `json:"last_seen"` + Frozen *FrozenManifest `json:"frozen,omitempty"` + Receipts []RetentionReceipt `json:"receipts,omitempty"` +} + +// FileRegistry stores workspace records next to the environment data directory. +type FileRegistry struct { + dataRoot string + path string + mu sync.Mutex +} + +func NewFileRegistry(dataRoot string) *FileRegistry { + return &FileRegistry{ + dataRoot: dataRoot, + path: filepath.Join(dataRoot, "workspace-registry.json"), + } +} + +func (r *FileRegistry) Upsert(rec Record) error { + if err := validateID(rec.ID); err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.load() + if err != nil { + return err + } + now := time.Now().UTC() + if rec.CreatedAt.IsZero() { + if prev, ok := all[rec.ID]; ok && !prev.CreatedAt.IsZero() { + rec.CreatedAt = prev.CreatedAt + } else { + rec.CreatedAt = now + } + } + if rec.LastSeen.IsZero() { + rec.LastSeen = now + } + if rec.Bytes == 0 { + rec.Bytes = dirBytes(filepath.Join(r.dataRoot, "workspaces", rec.ID)) + } + all[rec.ID] = rec + return r.save(all) +} + +func (r *FileRegistry) Get(id string) (Record, bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.load() + if err != nil { + return Record{}, false, err + } + rec, ok := all[id] + return rec, ok, nil +} + +func (r *FileRegistry) List() ([]Record, error) { + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.load() + if err != nil { + return nil, err + } + out := make([]Record, 0, len(all)) + for _, rec := range all { + out = append(out, rec) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (r *FileRegistry) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + all, err := r.load() + if err != nil { + return err + } + delete(all, id) + return r.save(all) +} + +// Discover registers workspace directories that are not yet in the registry. +func (r *FileRegistry) Discover() error { + root := filepath.Join(r.dataRoot, "workspaces") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if err := validateID(e.Name()); err != nil { + continue + } + info, err := e.Info() + if err != nil { + continue + } + _, ok, err := r.Get(e.Name()) + if err != nil { + return err + } + if ok { + continue + } + mod := info.ModTime().UTC() + if err := r.Upsert(Record{ID: e.Name(), CreatedAt: mod, LastSeen: mod, Bytes: dirBytes(filepath.Join(root, e.Name()))}); err != nil { + return err + } + } + return nil +} + +// NoteWorkspace registers a directory when it lives under dataRoot/workspaces/. +func NoteWorkspace(dataRoot, workDir, runID string) error { + if dataRoot == "" || workDir == "" { + return nil + } + root, err := filepath.Abs(filepath.Join(dataRoot, "workspaces")) + if err != nil { + return err + } + abs, err := filepath.Abs(workDir) + if err != nil { + return err + } + rel, err := filepath.Rel(root, abs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return nil + } + id := strings.Split(rel, string(os.PathSeparator))[0] + return NewFileRegistry(dataRoot).Upsert(Record{ID: id, RunID: runID}) +} + +func (r *FileRegistry) load() (map[string]Record, error) { + b, err := os.ReadFile(r.path) + if os.IsNotExist(err) { + return map[string]Record{}, nil + } + if err != nil { + return nil, err + } + if len(strings.TrimSpace(string(b))) == 0 { + return map[string]Record{}, nil + } + var list []Record + if err := json.Unmarshal(b, &list); err != nil { + return nil, err + } + out := make(map[string]Record, len(list)) + for _, rec := range list { + out[rec.ID] = rec + } + return out, nil +} + +func (r *FileRegistry) save(all map[string]Record) error { + list := make([]Record, 0, len(all)) + for _, rec := range all { + list = append(list, rec) + } + sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) + raw, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(r.path), 0o755); err != nil { + return err + } + tmp := r.path + ".tmp" + if err := os.WriteFile(tmp, append(raw, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, r.path) +} + +func validateID(id string) error { + if id == "" || strings.Contains(id, "/") || strings.Contains(id, "..") || id == "." { + return ErrInvalidKey + } + return nil +} + +func dirBytes(root string) int64 { + var n int64 + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + n += info.Size() + return nil + }) + return n +} diff --git a/internal/workspace/registry_pg.go b/internal/workspace/registry_pg.go new file mode 100644 index 00000000..a64bdaa1 --- /dev/null +++ b/internal/workspace/registry_pg.go @@ -0,0 +1,112 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// RegistryDDL is the table used by PGRegistry. Goose migration 00062 is the deploy path. +const RegistryDDL = ` +CREATE TABLE IF NOT EXISTS agentx_workspace_registry ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL DEFAULT '', + bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + last_seen TIMESTAMPTZ NOT NULL, + frozen JSONB, + receipts JSONB NOT NULL DEFAULT '[]'::jsonb +)` + +// PGRegistry persists workspace records in Postgres. +type PGRegistry struct { + pool *pgxpool.Pool +} + +func NewPGRegistry(pool *pgxpool.Pool) *PGRegistry { + return &PGRegistry{pool: pool} +} + +func (p *PGRegistry) Ensure(ctx context.Context) error { + _, err := p.pool.Exec(ctx, RegistryDDL) + return err +} + +func (p *PGRegistry) Upsert(ctx context.Context, rec Record) error { + if err := validateID(rec.ID); err != nil { + return err + } + if rec.CreatedAt.IsZero() { + rec.CreatedAt = time.Now().UTC() + } + if rec.LastSeen.IsZero() { + rec.LastSeen = rec.CreatedAt + } + frozen, err := json.Marshal(rec.Frozen) + if err != nil { + return err + } + if rec.Frozen == nil { + frozen = []byte("null") + } + receipts, err := json.Marshal(rec.Receipts) + if err != nil { + return err + } + if rec.Receipts == nil { + receipts = []byte("[]") + } + _, err = p.pool.Exec(ctx, ` + INSERT INTO agentx_workspace_registry (id, run_id, bytes, created_at, last_seen, frozen, receipts) + VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb) + ON CONFLICT (id) DO UPDATE SET + run_id=EXCLUDED.run_id, bytes=EXCLUDED.bytes, last_seen=EXCLUDED.last_seen, + frozen=EXCLUDED.frozen, receipts=EXCLUDED.receipts`, + rec.ID, rec.RunID, rec.Bytes, rec.CreatedAt, rec.LastSeen, frozen, receipts) + return err +} + +func (p *PGRegistry) Get(ctx context.Context, id string) (Record, bool, error) { + row := p.pool.QueryRow(ctx, `SELECT id, run_id, bytes, created_at, last_seen, frozen, receipts FROM agentx_workspace_registry WHERE id=$1`, id) + rec, err := scanRecord(row) + if err == pgx.ErrNoRows { + return Record{}, false, nil + } + if err != nil { + return Record{}, false, err + } + return rec, true, nil +} + +func scanRecord(row pgx.Row) (Record, error) { + var rec Record + var frozen, receipts []byte + if err := row.Scan(&rec.ID, &rec.RunID, &rec.Bytes, &rec.CreatedAt, &rec.LastSeen, &frozen, &receipts); err != nil { + return Record{}, err + } + if len(frozen) > 0 && string(frozen) != "null" { + rec.Frozen = &FrozenManifest{} + if err := json.Unmarshal(frozen, rec.Frozen); err != nil { + return Record{}, err + } + } + if len(receipts) > 0 { + if err := json.Unmarshal(receipts, &rec.Receipts); err != nil { + return Record{}, err + } + } + return rec, nil +} + +// TouchSchema creates an isolated schema for tests and points the pool at it. +func TouchSchema(ctx context.Context, pool *pgxpool.Pool, schema string) error { + if _, err := pool.Exec(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema)); err != nil { + return err + } + _, err := pool.Exec(ctx, fmt.Sprintf("SET search_path TO %s", schema)) + return err +} diff --git a/internal/workspace/registry_pg_test.go b/internal/workspace/registry_pg_test.go new file mode 100644 index 00000000..7ce94fe5 --- /dev/null +++ b/internal/workspace/registry_pg_test.go @@ -0,0 +1,68 @@ +package workspace + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestPGRegistryRoundTrip(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("set TEST_DATABASE_URL to run the workspace registry postgres test") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + schema := fmt.Sprintf("agentx_reg_%d", time.Now().UnixNano()) + boot, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + defer boot.Close() + if _, err := boot.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cctx, ccancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ccancel() + _, _ = boot.Exec(cctx, "DROP SCHEMA "+schema+" CASCADE") + }) + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + t.Fatal(err) + } + cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + _, err := conn.Exec(ctx, "SET search_path TO "+schema) + return err + } + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + reg := NewPGRegistry(pool) + if err := reg.Ensure(ctx); err != nil { + t.Fatal(err) + } + frozen, receipts := sampleFrozen("ws-pg") + rec := Record{ + ID: "ws-pg", RunID: "run-pg", Bytes: 12, + CreatedAt: time.Now().UTC().Add(-time.Hour), + Frozen: &frozen, Receipts: receipts, + } + if err := reg.Upsert(ctx, rec); err != nil { + t.Fatal(err) + } + got, ok, err := reg.Get(ctx, "ws-pg") + if err != nil || !ok { + t.Fatalf("get %#v %v %v", got, ok, err) + } + if got.RunID != "run-pg" || got.Frozen == nil || got.Frozen.SHA256 != "abc" || len(got.Receipts) != 1 || !got.Receipts[0].Verified { + t.Fatalf("round trip %#v", got) + } +} diff --git a/internal/workspace/registry_test.go b/internal/workspace/registry_test.go new file mode 100644 index 00000000..c88d8e5a --- /dev/null +++ b/internal/workspace/registry_test.go @@ -0,0 +1,100 @@ +package workspace + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func sampleFrozen(id string) (FrozenManifest, []RetentionReceipt) { + frozen := FrozenManifest{ + SHA256: "abc", + StorageIdentity: "disk", + WorkspaceID: id, + Members: []FrozenMember{{LogicalID: "doc", Digest: "d1", Kind: "document"}}, + } + receipts := []RetentionReceipt{{LogicalID: "doc", Digest: "d1", StorageIdentity: "disk", Verified: true}} + return frozen, receipts +} + +func TestGCDeletesExpiredWhenReceiptsMatch(t *testing.T) { + root := t.TempDir() + id := "ws-old" + dir := filepath.Join(root, "workspaces", id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "output.md"), []byte("kept-until-gc"), 0o644); err != nil { + t.Fatal(err) + } + reg := NewFileRegistry(root) + frozen, receipts := sampleFrozen(id) + if err := reg.Upsert(Record{ID: id, CreatedAt: time.Now().Add(-8 * 24 * time.Hour), LastSeen: time.Now().Add(-8 * 24 * time.Hour)}); err != nil { + t.Fatal(err) + } + if err := reg.SetRetention(id, frozen, receipts); err != nil { + t.Fatal(err) + } + report, err := reg.CollectGarbage(context.Background(), time.Now(), 7*24*time.Hour, 0, nil) + if err != nil { + t.Fatal(err) + } + if len(report.Deleted) != 1 || report.Deleted[0] != id { + t.Fatalf("report %#v", report) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("workspace directory still exists") + } +} + +func TestGCBlocksWithoutReceipts(t *testing.T) { + root := t.TempDir() + id := "ws-blocked" + dir := filepath.Join(root, "workspaces", id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + reg := NewFileRegistry(root) + if err := reg.Upsert(Record{ID: id, CreatedAt: time.Now().Add(-30 * 24 * time.Hour)}); err != nil { + t.Fatal(err) + } + report, err := reg.CollectGarbage(context.Background(), time.Now(), time.Hour, 1, nil) + if err != nil { + t.Fatal(err) + } + if len(report.Deleted) != 0 || len(report.Blocked) != 1 { + t.Fatalf("report %#v", report) + } + if _, err := os.Stat(dir); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverAndNote(t *testing.T) { + root := t.TempDir() + id := "ws-scan" + if err := os.MkdirAll(filepath.Join(root, "workspaces", id), 0o755); err != nil { + t.Fatal(err) + } + reg := NewFileRegistry(root) + if err := reg.Discover(); err != nil { + t.Fatal(err) + } + rec, ok, err := reg.Get(id) + if err != nil || !ok || rec.ID != id { + t.Fatalf("discovered %#v %v %v", rec, ok, err) + } + nested := filepath.Join(root, "workspaces", "ws-note", "tasks") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := NoteWorkspace(root, nested, "run-1"); err != nil { + t.Fatal(err) + } + noted, ok, err := reg.Get("ws-note") + if err != nil || !ok || noted.RunID != "run-1" { + t.Fatalf("noted %#v %v", noted, err) + } +} From 174e59e6926b843c7b0698e4bac40b1cb72a0697 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 15:11:29 +0000 Subject: [PATCH 07/10] test(agentx): compare pi-native with the container backend Record token usage and nearest-rank latency so the replacement gate matches the written bar. --- .doc/system-design/module-boundaries.yaml | 30 +++-- internal/agent/backend.go | 3 + internal/agent/pinative.go | 12 +- internal/agentx/abtest/judge.go | 140 ++++++++++++++++++++++ internal/agentx/abtest/judge_test.go | 58 +++++++++ internal/agentx/abtest/live_test.go | 119 ++++++++++++++++++ 6 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 internal/agentx/abtest/judge.go create mode 100644 internal/agentx/abtest/judge_test.go create mode 100644 internal/agentx/abtest/live_test.go diff --git a/.doc/system-design/module-boundaries.yaml b/.doc/system-design/module-boundaries.yaml index 4de7eccf..96951f4f 100644 --- a/.doc/system-design/module-boundaries.yaml +++ b/.doc/system-design/module-boundaries.yaml @@ -4,7 +4,21 @@ imports: agent: + - agentx + - agentx/llm + - agentx/session + - agentx/tools - config + - workspace + agentx: + - agentx/llm + - agentx/session + - agentx/tools + agentx/abtest: [] + agentx/llm: [] + agentx/session: + - agentx/llm + agentx/tools: [] auth: - config authz: @@ -491,14 +505,6 @@ exceptions: reason: workflow terminate cancels task_runs; U8 owns run finalization in the same UoW milestone: U8 expires: "2026-10-26" - - kind: path - package: agent - file: internal/agent/cursor.go - symbol: output.md - owner: agent - reason: runner still names the deliverable file; U5 replaces this with WorkspaceRef - milestone: U5 - expires: "2026-10-12" - kind: path package: agent file: internal/agent/piagent.go @@ -507,6 +513,14 @@ exceptions: reason: PI runner contract still uses output.md; U5 capability must hide the literal milestone: U5 expires: "2026-10-12" + - kind: path + package: agent + file: internal/agent/pinative.go + symbol: output.md + owner: agent + reason: pi-native uses the same default deliverable name as the container runner + milestone: U5 + expires: "2026-10-12" - kind: path package: bizdev file: internal/bizdev/service.go diff --git a/internal/agent/backend.go b/internal/agent/backend.go index 3a67a224..20e39d97 100644 --- a/internal/agent/backend.go +++ b/internal/agent/backend.go @@ -30,6 +30,9 @@ type Result struct { // Retryable marks a provider-transient failure that may receive a // fresh step invocation. Permanent missing deliverables stay false. Retryable bool `json:"retryable,omitempty"` + // PromptTokens and CompletionTokens are set by pi-native when the provider reports usage. + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` } type Backend interface { diff --git a/internal/agent/pinative.go b/internal/agent/pinative.go index c7610801..73aae059 100644 --- a/internal/agent/pinative.go +++ b/internal/agent/pinative.go @@ -20,6 +20,10 @@ type PiNativeBackend struct { stream llm.StreamFunc } +func NewPiNativeBackend(cfg *config.Config) *PiNativeBackend { + return &PiNativeBackend{cfg: cfg} +} + func (p *PiNativeBackend) Name() string { return "pi-native" } func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, error) { @@ -137,7 +141,13 @@ func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, er 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 + return &Result{ + Success: true, + Patches: patches, + Logs: logs.String(), + PromptTokens: runRes.Usage.PromptTokens, + CompletionTokens: runRes.Usage.CompletionTokens, + }, nil } func nativeSystemPrompt(req Request, profile string) string { diff --git a/internal/agentx/abtest/judge.go b/internal/agentx/abtest/judge.go new file mode 100644 index 00000000..ae7cc826 --- /dev/null +++ b/internal/agentx/abtest/judge.go @@ -0,0 +1,140 @@ +package abtest + +import ( + "math" + "sort" + "time" +) + +// Case is one comparable task. Both backends receive the same prompt and targets. +type Case struct { + ID string + Kind string + Prompt string + Instructions string + ToolProfile string + Targets []string +} + +// Outcome is one backend's result for one case. +type Outcome struct { + Backend string + CaseID string + Kind string + Success bool + P0Defect bool + Duration time.Duration + PromptTokens int + CompletionTokens int + TargetsPresent int + TargetsExpected int + ErrorClass string +} + +// Verdict is the replacement gate. Pass means pi-native is at least as good as pi-agent. +type Verdict struct { + Pass bool + NativeSuccess float64 + RemoteSuccess float64 + NativeP0 int + RemoteP95 time.Duration + NativeP95 time.Duration + Reasons []string +} + +const latencyFactor = 1.3 + +// Suite is the fixed, replayable comparison set. +func Suite() []Case { + return []Case{ + {ID: "doc-brief", Kind: "doc", ToolProfile: "workspace", Targets: []string{"brief.md"}, + Instructions: "Write a short Chinese project brief.", + Prompt: "根据以下信息写一份项目简介,写入 brief.md:项目名「水木试点」,目标是把检验报告整理成可检索档案。"}, + {ID: "doc-outline", Kind: "doc", ToolProfile: "workspace", Targets: []string{"outline.md"}, + Instructions: "Write a markdown outline with three headings.", + Prompt: "为「试剂冷链运输方案」写三级大纲,写入 outline.md。"}, + {ID: "dialogue-hello", Kind: "dialogue", ToolProfile: "reasoning", Targets: []string{"reply.md"}, + Instructions: "你是项目助手,用一两句中文回答。", + Prompt: "你好,今天先确认项目范围。"}, + {ID: "dialogue-status", Kind: "dialogue", ToolProfile: "reasoning", Targets: []string{"reply.md"}, + Instructions: "你是项目助手,用一两句中文回答。", + Prompt: "当前阶段还在需求收集,请告诉我下一步该准备什么。"}, + {ID: "reason-risk", Kind: "reasoning", ToolProfile: "reasoning", Targets: []string{"reply.md"}, + Instructions: "只输出结论,不要调用工具。", + Prompt: "用一句话说明:缺少样本量时,为什么不能把试点结果写成确定结论。"}, + {ID: "reason-scope", Kind: "reasoning", ToolProfile: "reasoning", Targets: []string{"reply.md"}, + Instructions: "只输出结论,不要调用工具。", + Prompt: "用一句话区分「项目简介」和「检验方案」各自要回答的问题。"}, + } +} + +// Judge applies the replacement bar: success rate not below the container backend, +// no P0 defects on pi-native, and P95 latency at most 1.3× the container backend. +func Judge(native, remote []Outcome) Verdict { + v := Verdict{Pass: true} + if len(native) == 0 || len(remote) == 0 { + v.Pass = false + v.Reasons = append(v.Reasons, "both backends need completed outcomes") + return v + } + v.NativeSuccess = successRate(native) + v.RemoteSuccess = successRate(remote) + if v.NativeSuccess < v.RemoteSuccess { + v.Pass = false + v.Reasons = append(v.Reasons, "pi-native success rate is below pi-agent") + } + for _, o := range native { + if o.P0Defect { + v.NativeP0++ + } + } + if v.NativeP0 > 0 { + v.Pass = false + v.Reasons = append(v.Reasons, "pi-native has a P0 deliverable defect") + } + v.NativeP95 = percentile(durations(native), 0.95) + v.RemoteP95 = percentile(durations(remote), 0.95) + if v.RemoteP95 > 0 && float64(v.NativeP95) > latencyFactor*float64(v.RemoteP95) { + v.Pass = false + v.Reasons = append(v.Reasons, "pi-native P95 latency exceeds 1.3x pi-agent") + } + return v +} + +func successRate(list []Outcome) float64 { + if len(list) == 0 { + return 0 + } + ok := 0 + for _, o := range list { + if o.Success && !o.P0Defect && o.TargetsPresent >= o.TargetsExpected && o.TargetsExpected > 0 { + ok++ + } + } + return float64(ok) / float64(len(list)) +} + +func durations(list []Outcome) []time.Duration { + out := make([]time.Duration, 0, len(list)) + for _, o := range list { + if o.Success { + out = append(out, o.Duration) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + rank := int(math.Ceil(p*float64(len(sorted)))) - 1 + if rank < 0 { + rank = 0 + } + if rank >= len(sorted) { + rank = len(sorted) - 1 + } + return sorted[rank] +} diff --git a/internal/agentx/abtest/judge_test.go b/internal/agentx/abtest/judge_test.go new file mode 100644 index 00000000..6b7b3464 --- /dev/null +++ b/internal/agentx/abtest/judge_test.go @@ -0,0 +1,58 @@ +package abtest + +import ( + "testing" + "time" +) + +func TestJudgePassWhenNativeMatches(t *testing.T) { + native := []Outcome{ + {Backend: "pi-native", Success: true, Duration: 100 * time.Millisecond, TargetsPresent: 1, TargetsExpected: 1}, + {Backend: "pi-native", Success: true, Duration: 120 * time.Millisecond, TargetsPresent: 1, TargetsExpected: 1}, + } + remote := []Outcome{ + {Backend: "pi-agent", Success: true, Duration: 110 * time.Millisecond, TargetsPresent: 1, TargetsExpected: 1}, + {Backend: "pi-agent", Success: true, Duration: 130 * time.Millisecond, TargetsPresent: 1, TargetsExpected: 1}, + } + v := Judge(native, remote) + if !v.Pass { + t.Fatalf("reasons %v", v.Reasons) + } +} + +func TestJudgeFailsOnSuccessP0AndLatency(t *testing.T) { + native := []Outcome{{Success: true, P0Defect: true, Duration: 2 * time.Second, TargetsPresent: 1, TargetsExpected: 1}} + remote := []Outcome{{Success: true, Duration: 100 * time.Millisecond, TargetsPresent: 1, TargetsExpected: 1}} + v := Judge(native, remote) + if v.Pass { + t.Fatal("expected fail") + } + if v.NativeP0 != 1 { + t.Fatalf("p0 %d", v.NativeP0) + } + if len(v.Reasons) < 2 { + t.Fatalf("reasons %v", v.Reasons) + } +} + +func TestPercentileUsesUpperRank(t *testing.T) { + got := percentile([]time.Duration{5 * time.Second, 15 * time.Second}, 0.95) + if got != 15*time.Second { + t.Fatalf("p95 %s", got) + } +} + +func TestSuiteCoversThreeKinds(t *testing.T) { + kinds := map[string]int{} + for _, c := range Suite() { + kinds[c.Kind]++ + if c.Prompt == "" || len(c.Targets) == 0 || c.ToolProfile == "" { + t.Fatalf("incomplete case %#v", c) + } + } + for _, kind := range []string{"doc", "dialogue", "reasoning"} { + if kinds[kind] < 2 { + t.Fatalf("kind %s count %d", kind, kinds[kind]) + } + } +} diff --git a/internal/agentx/abtest/live_test.go b/internal/agentx/abtest/live_test.go new file mode 100644 index 00000000..c57e0a79 --- /dev/null +++ b/internal/agentx/abtest/live_test.go @@ -0,0 +1,119 @@ +package abtest + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/config" +) + +// TestLiveDualBackend runs the fixed suite on pi-native and the container pi-agent. +// It is the replacement gate and stays skipped unless RUN_AGENTX_AB=1. +func TestLiveDualBackend(t *testing.T) { + if os.Getenv("RUN_AGENTX_AB") != "1" { + t.Skip("set RUN_AGENTX_AB=1 to compare pi-native with the container pi-agent") + } + cfg := &config.Config{ + AgentXAPIBase: envFirst("AGENTX_API_BASE", "MINIMAX_ENDPOINT_URL", "https://api.minimaxi.com/v1"), + AgentXAPIKey: envFirst("AGENTX_API_KEY", "MINIMAX_API_KEY", ""), + AgentXModel: "MiniMax-M2.7-highspeed", + AgentTimeout: 3 * time.Minute, + PiAgentRunnerURL: os.Getenv("PI_AGENT_RUNNER_URL"), + PiAgentRunnerAuthToken: envFirst("PI_AGENT_RUNNER_AUTH_TOKEN", "RUNNER_AUTH_TOKEN", ""), + PiAgentModel: "MiniMax-M2.7-highspeed", + PiAgentProvider: "minimax", + } + if cfg.AgentXAPIKey == "" { + t.Fatal("AGENTX_API_KEY or MINIMAX_API_KEY is required") + } + if cfg.PiAgentRunnerURL == "" { + t.Fatal("PI_AGENT_RUNNER_URL is required so the container backend can be compared") + } + native := agent.NewPiNativeBackend(cfg) + remote := agent.NewBackend("pi-agent", cfg) + root := t.TempDir() + var nativeOut, remoteOut []Outcome + for _, c := range Suite() { + nativeOut = append(nativeOut, runCase(t, root, "pi-native", native, c)) + remoteOut = append(remoteOut, runCase(t, root, "pi-agent", remote, c)) + } + v := Judge(nativeOut, remoteOut) + t.Logf("native_success=%.2f remote_success=%.2f native_p95=%s remote_p95=%s p0=%d", + v.NativeSuccess, v.RemoteSuccess, v.NativeP95, v.RemoteP95, v.NativeP0) + for _, o := range append(nativeOut, remoteOut...) { + t.Logf("%s %s success=%t p0=%t targets=%d/%d dur=%s err=%s tokens=%d", + o.Backend, o.CaseID, o.Success, o.P0Defect, o.TargetsPresent, o.TargetsExpected, o.Duration, o.ErrorClass, o.PromptTokens+o.CompletionTokens) + } + if !v.Pass { + t.Fatalf("replacement gate failed: %v", v.Reasons) + } +} + +func runCase(t *testing.T, root, name string, backend agent.Backend, c Case) Outcome { + t.Helper() + dir := filepath.Join(root, name, c.ID) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + res, err := backend.Execute(ctx, agent.Request{ + WorkDir: dir, + Prompt: c.Prompt, + Instructions: c.Instructions, + Targets: c.Targets, + ToolProfile: c.ToolProfile, + Timeout: 3 * time.Minute, + }) + out := Outcome{ + Backend: name, + CaseID: c.ID, + Kind: c.Kind, + Duration: time.Since(start), + TargetsExpected: len(c.Targets), + } + if err != nil { + out.ErrorClass = err.Error() + out.P0Defect = true + return out + } + if res == nil { + out.ErrorClass = "nil result" + out.P0Defect = true + return out + } + out.Success = res.Success + out.ErrorClass = res.ErrorReason + out.PromptTokens = res.PromptTokens + out.CompletionTokens = res.CompletionTokens + for _, target := range c.Targets { + if len(res.Patches[target]) > 0 { + out.TargetsPresent++ + } + } + if res.Success && out.TargetsPresent < out.TargetsExpected { + out.P0Defect = true + } + return out +} + +func envFirst(keys ...string) string { + fallback := "" + if len(keys) > 0 { + fallback = keys[len(keys)-1] + } + for _, key := range keys[:len(keys)-1] { + if key == "" { + continue + } + if v := os.Getenv(key); v != "" { + return v + } + } + return fallback +} From 5d77e9cf4600bbec298c639da1fa368bef12f6f0 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 15:15:39 +0000 Subject: [PATCH 08/10] docs(test): record the pi-native replacement gate Keep the default backend on pi-agent because the live P95 comparison missed the 1.3x bar. --- ...026-09-21-go-agentx-runtime-latency-gap.md | 39 +++++ .doc/ops/CURRENT.md | 8 +- .../2026-09-21-go-agentx-runtime.md | 28 ++-- .doc/status/FEATURE-MATRIX.md | 2 +- ...026-09-21-go-agentx-runtime-test-report.md | 99 ++++++++++++ .../go-agentx-runtime/ab-live-round1.txt | 19 +++ .../assets/go-agentx-runtime/ab-live.txt | 19 +++ .../assets/go-agentx-runtime/short-test.txt | 142 ++++++++++++++++++ .../assets/go-agentx-runtime/vet.txt | 1 + 9 files changed, 339 insertions(+), 18 deletions(-) create mode 100644 .doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md create mode 100644 .doc/test-reports/2026-09-21-go-agentx-runtime-test-report.md create mode 100644 .doc/test-reports/assets/go-agentx-runtime/ab-live-round1.txt create mode 100644 .doc/test-reports/assets/go-agentx-runtime/ab-live.txt create mode 100644 .doc/test-reports/assets/go-agentx-runtime/short-test.txt create mode 100644 .doc/test-reports/assets/go-agentx-runtime/vet.txt diff --git a/.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md b/.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md new file mode 100644 index 00000000..328bcda7 --- /dev/null +++ b/.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md @@ -0,0 +1,39 @@ +--- +title: "pi-native 对比未过 P95 门槛" +status: planned +created: 2026-09-21 +updated: 2026-09-21 +severity: medium +--- + +# pi-native 对比未过 P95 门槛 + +## 现象 + +真实 MiniMax-M2.7-highspeed 双后端对比中,`pi-native` 六例全部写出目标文件,容器 `pi-agent` 只有两例文档任务成功。按成功样本计算的 P95 仍是 `pi-native` 16.616s、容器 6.926s,比值为 2.40,超过 1.3 倍替换门槛。因此 dev 默认后端保持 `pi-agent`。 + +## 根因分析 + +- 时延样本只统计成功且无 P0 的用例。容器 `reasoning` 画像使用 `--no-tools`,不会把助手正文落成目标文件,四例返回 `MISSING_TARGET`,不进入 P95。容器 P95 只由两例较快的文档任务构成。 +- `pi-native` 的 P95 落在 `doc-outline`(16.616s)。同一提示在容器上是 6.926s。文档任务本身慢于 1.3 倍,不是分位数算法造成的。 +- 第一轮对比把分位索引截断成 0,两例成功时取了更快的那例,已作废。本结论使用 nearest-rank(`ceil`)后的第二轮日志。 + +## 严重度 + +`medium`。运行时可以继续在分支上迭代,当前默认链路未切换,容器后端仍可回退。 + +## 关联整改 + +- 任务:`.doc/ops/short-term/2026-09-21-go-agentx-runtime.md` +- 报告:`.doc/test-reports/2026-09-21-go-agentx-runtime-test-report.md` +- 后续:缩短 `pi-native` 文档轮次时延,或把门槛改成按同一成功用例配对比较后重新实测。未重新通过前不改 `AGENT_BACKEND`。 + +## 证据 + +- `.doc/test-reports/assets/go-agentx-runtime/ab-live.txt` +- `.doc/test-reports/assets/go-agentx-runtime/ab-live-round1.txt`(作废的截断分位) + +## 开放问题 + +- 容器结果不回传 token,本轮容器用量为 0,无法比较 token。 +- `reasoning` 画像的成功定义是否应要求容器也把正文写入目标文件。当前判定仍按两边各自的 `Result.Success` 与目标文件是否出现。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 1df2c647..4c5bb9a6 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,7 +1,7 @@ # 当前焦点 **更新**: 2026-09-21 -**production_ready**: yes(门禁 B1–B4 已签;dev schema **61**,test/main 仍为 **58**。P0 热修 done;rdev 种子热修 done,见 [报告](../test-reports/2026-09-20-rdev-seed-hotfix-test-report.md)) +**production_ready**: yes(门禁 B1–B4 已签;dev 库 goose **62**,`/health` schema_version=62,test/main 仍为 **58**。62 来自本机短测试执行的 `agentx_workspace_registry`,迁移文件仍在 `feature/go-agentx-runtime-2026-09-21`,运行中的 dev 未 rebuild,`agent_backend=pi-agent`。P0 热修 done;rdev 种子热修 done,见 [报告](../test-reports/2026-09-20-rdev-seed-hotfix-test-report.md)) ## 焦点 @@ -10,13 +10,13 @@ 3. **上传解析提示 / 去重 / CoW 收口**:MinerU 解析状态轮询闭环(pending→running→done)、重复文件「已复用」提示、失败原因+重新解析、CAS 不可变 CoW 契约测试,已合入 `dev`(代码 `99905571`、证据 `74587ccf`、收口 `a0464eb5`)。见 [报告](../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md)。test/main 未晋升。前序任务执行状态卡收口见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)(含二轮修复:completed 步骤 label 冻结为"正在…(NN%)"的收口归一化 + dev 历史回填,已合入 `dev@4b303247` 并真实 PI 验收)。 4. **MinerU 真实 PDF 二轮验收**:用户反馈「PDF 内容未被理解」由两因叠加——(A) 首轮在 worktree 内跑 `env-service dev rebuild` 令挂载漂到 worktree `data/dev`,真实历史 blob 全部读不到;(B) Agent 轻量 API 硬限 20 页,28 页真实文件失败。首轮结论作废。二轮从**主目录** rebuild 修复挂载,`internal/mineru` 在 `-30003` 时按 20 页窗分块解析并重编全局页锚;`ReadRefBytes` 失败改为 `slog.Warn`。已合入 `dev@362b68a0`。真实 `bd_intake`(项目 6532)`completed`,工作区 `materials_text` 注入 29152 字节中文正文。见 [报告](../test-reports/2026-09-21-mineru-doc-parsing-test-report.md) 第八节。 5. **材料切块不再看体积**:项目 6579 `doc_proj6579_bd_intake` 已确认候选 535,正式正文不再含「二进制压缩流」。成功解析的材料一律写入 `material_chunks`,≤60KB 仍全文注入。见 [报告](../test-reports/2026-09-21-force-material-chunk-index-test-report.md)。已合入 `dev` 并从主目录 rebuild。test/main 未晋升。 -6. **红线**:禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`;**合入 dev 后必须回主目录跑 `env-service.sh dev rebuild`,禁止任何 worktree 内运行 `env-service`**(脚本 ROOT_DIR 取自脚本所在目录树,会带走 bind mount,导致 `/app/data` 漂到 worktree 空目录)。 +6. **红线**:`pi-native` 对比未过 P95 门槛,默认 `AGENT_BACKEND` 保持 `pi-agent`,未合入、未部署。禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`;**合入 dev 后必须回主目录跑 `env-service.sh dev rebuild`,禁止任何 worktree 内运行 `env-service`**(脚本 ROOT_DIR 取自脚本所在目录树,会带走 bind mount,导致 `/app/data` 漂到 worktree 空目录)。 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | in_progress | +| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | blocked | | Cursor | 2026-09-21-force-material-chunk-index | feature/force-material-chunk-index-2026-09-21 | .worktrees/force-material-chunk-index-2026-09-21 | bizdev+materialindex | done | | Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | | Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | done | @@ -200,7 +200,7 @@ | 环境 | 端口 | schema | 状态 | |------|------|--------|------| -| dev | 8080 | **61** | healthy(mineru `00061`;intent-confirm 真实 PI PASS;`/health` schema_version=61;`pi-agent`;events `:8088`) | +| dev | 8080 | **62** | healthy(`/health` schema_version=62,`agent_backend=pi-agent`;62 为未合入迁移的空表;events `:8088`) | | test | 8081 | **58** | healthy(`rc-v1.0.0-beta.7`;rdev v1 active;`published_by` 回落到用户 4;events `:8089`) | | main | 8000 | **58** | healthy(P0 Wave3 rebuild 已追平 schema;分支树对齐仍记 backlog;口令已轮换;events `:8090`) | diff --git a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md index 0b8e3c8f..b5c4fc84 100644 --- a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md +++ b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md @@ -1,6 +1,6 @@ --- title: "Go 原生 PI Agent 运行时(pi-native 并存验证)" -status: in_progress +status: blocked created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -8,7 +8,7 @@ branch: feature/go-agentx-runtime-2026-09-21 worktree: .worktrees/go-agentx-runtime-2026-09-21 base_dev_sha: a5ea469ca44171a5c3e6419b1757852270fb991d write_scope: [internal/agentx, internal/agent, internal/config, internal/taskrun, internal/workspace, internal/server, cmd, deploy/postgres/goose, tools/cursor-bridge, .doc/ops, .doc/status, .doc/audit, .doc/test-reports] -head_sha: a5ea469ca44171a5c3e6419b1757852270fb991d +head_sha: 174e59e6 deployment: pending cleanup: pending mutex: agent-backend-swap @@ -38,20 +38,20 @@ mutex: agent-backend-swap ## 步骤 1. [x] 从 dev `a5ea469ca44171a5c3e6419b1757852270fb991d` 创建 worktree -2. [ ] LLM 流式客户端与契约测试 -3. [ ] 工具 jail 与 hook -4. [ ] agent loop 与 JSONL 会话 -5. [ ] `pi-native` 接线并移除 cursor -6. [ ] 工作区注册表、GC、管理端点 -7. [ ] 双后端对比;未达标则不切默认 -8. [ ] 报告、FEATURE-MATRIX、CURRENT 收口 +2. [x] LLM 流式客户端与契约测试 +3. [x] 工具 jail 与 hook +4. [x] agent loop 与 JSONL 会话 +5. [x] `pi-native` 接线并移除 cursor +6. [x] 工作区注册表、GC、管理端点 +7. [x] 双后端对比;未达标则不切默认 +8. [x] 报告、FEATURE-MATRIX、CURRENT 收口 ## 验收标准 -- [ ] `go vet ./...` 与 `go test -short ./...` 通过 -- [ ] `AGENT_BACKEND` 默认仍为 `pi-agent`,除非对比判定为同等或更好 -- [ ] 判定标准:`pi-native` 成功率不低于 `pi-agent`、无 P0 产物缺陷、P95 时延 ≤ 1.3× 容器链路 -- [ ] cursor 后端代码与配置已移除;pi-agent 容器链路仍可编译调用 +- [ ] `go vet ./...` 通过;`go test -short ./...` 未全绿(见报告第六节,失败在既有 import 与共享 dev 库) +- [x] `AGENT_BACKEND` 默认仍为 `pi-agent`(对比未判定为同等或更好) +- [x] 判定标准已执行:成功率 1.00≥0.33、P0=0、P95 16.616s > 1.3×6.926s,不切换 +- [x] cursor 后端代码与配置已移除;pi-agent 容器链路仍保留 ## 相关链接 @@ -62,3 +62,5 @@ mutex: agent-backend-swap ## 证据 / 备注 远端同步状态:创建时主目录为本地 `dev`,ahead of `origin/dev`,未为创建任务执行 fetch/pull。 + +对比未过 P95 门槛,状态 `blocked`,默认后端不改。差距见 `.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md`。`deployment=pending`,`cleanup=pending`(未合入,worktree 保留)。短测试曾对 `127.0.0.1:5433` 执行 goose,dev schema 现为 62,表 `agentx_workspace_registry` 已存在;迁移文件仍只在本分支。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index dac15a56..6bb0a651 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -66,7 +66,7 @@ | 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md) | 已合入 dev。常驻「需求与影响」面板已退役,新待复核与下游影响改走已有项目会话。dev schema 59。test/main 未升。阶段推进确认门禁仍在。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/`TaskRunCard` 竖向进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363);[TaskRunCard](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | -| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` | +| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1;分支并存 pi-native,默认未切 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md);[pi-native 对比](../test-reports/2026-09-21-go-agentx-runtime-test-report.md) | 容器链路仍是默认。pi-native 真实双跑成功率 1.00、容器 0.33、P0 为 0,P95 16.6s 对 6.9s,未过 1.3 倍,不切 AGENT_BACKEND。cursor-cli 已从该分支移除,容器服务保留 | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 dev/test schema **48**、main **47** | — | `infra` | | CI / E2E | PR CI、PG 集成、治理 PG、已签真实 PI 门禁;**nightly** schedule/`workflow_dispatch` + infra/product 分类 receipt | done | 加固(阻止**下一次**晋升,不自动否定已发布) | [BUG-08](../test-reports/2026-09-16-ci-governance-pg-bug08-test-report.md);[PI 三工作流](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md);[W1 green](../test-reports/2026-09-17-w1-ci-e2e-green-nightly-test-report.md) + [receipt `6e2476f0…`](../assets/promotion-receipts/6e2476f0f2e408e111fe06ca7c31a38294a340b3.json) | **before**:partial(FullNone product_fail / secrets 文案过时)→ **after**:secrets 已配;duplicate goose 00048 已清;FullNone+U8 nightly `verdict=pass` | `ci` | | Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | done | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md) | **关闭**:same-image pin(`candidate-images-*` + `PINNED_IMAGE_MODE` + promote `--require-digests`)。**声明**:单机 retag/digest pin;远程 registry 推送另立任务 | `infra` | diff --git a/.doc/test-reports/2026-09-21-go-agentx-runtime-test-report.md b/.doc/test-reports/2026-09-21-go-agentx-runtime-test-report.md new file mode 100644 index 00000000..07dbadf1 --- /dev/null +++ b/.doc/test-reports/2026-09-21-go-agentx-runtime-test-report.md @@ -0,0 +1,99 @@ +# Go 原生 PI Agent 运行时 测试报告 + +## 一、 测试背景 (Background) + +主服务需要在进程内提供 `pi-native` 后端,与容器 `pi-agent` 并存。cursor 后端已从本分支移除。默认后端只有在真实双跑达到同等或更好之后才允许切到 `pi-native`。容器链路保留为回退。 + +## 二、 测试目标 (Test Objectives) + +- `pi-native` 能对真实 MiniMax-M2.7-highspeed 完成文档、对话、推理三类任务,并写出目标文件。 +- 与容器 `pi-agent` 使用同一套提示、同一模型、同一工具画像。 +- 替换门槛:`pi-native` 成功率不低于容器、无 P0 产物缺陷、成功样本 P95 ≤ 容器 P95 的 1.3 倍。 +- `go vet ./...` 通过;短测试中本任务新增包通过。 + +## 三、 测试方案与环境 (Test Plan & Environment) + +- OS:Linux。Go 模块 `chat-agent-system`。模型:MiniMax-M2.7-highspeed。两端都走真实接口,不使用 stub。 +- 容器 runner:`http://127.0.0.1:18865`(`shuimu-pi-agent-dev`)。`pi-native` 由主进程内 `internal/agentx` 直连 MiniMax OpenAI 兼容流式接口。 +- 套件:`internal/agentx/abtest`,6 例(文档 2、对话 2、推理 2)。每例超时 3 分钟,工作区互相隔离。 +- 入口:`RUN_AGENTX_AB=1 go test ./internal/agentx/abtest/ -run TestLiveDualBackend`。 +- 第一轮分位数用截断索引,两例成功时取了更快的样本,该轮不作判定。判定使用第二轮 nearest-rank。 + +## 四、 预期结果 (Expected Results) + +- `workspace` 画像写出目标文件;`reasoning` 画像不调用工具。`pi-native` 在推理画像把助手正文写入空目标。容器 `--no-tools` 只收集磁盘上已有文件,缺文件则 `MISSING_TARGET`。 +- 判定通过才允许把 dev 的 `AGENT_BACKEND` 改为 `pi-native`。未通过则默认保持 `pi-agent`。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 用例矩阵(第二轮,2026-09-21) + +| 后端 | 用例 | 成功 | 目标 | 耗时 | token | 错误 | +| --- | --- | --- | --- | --- | --- | --- | +| pi-native | doc-brief | true | 1/1 | 8.216s | 1765 | | +| pi-native | doc-outline | true | 1/1 | 16.616s | 2946 | | +| pi-native | dialogue-hello | true | 1/1 | 3.001s | 159 | | +| pi-native | dialogue-status | true | 1/1 | 2.657s | 180 | | +| pi-native | reason-risk | true | 1/1 | 2.403s | 145 | | +| pi-native | reason-scope | true | 1/1 | 3.180s | 142 | | +| pi-agent | doc-brief | true | 1/1 | 5.974s | 0 | | +| pi-agent | doc-outline | true | 1/1 | 6.926s | 0 | | +| pi-agent | dialogue-hello | false | 0/1 | 4.727s | 0 | MISSING_TARGET | +| pi-agent | dialogue-status | false | 0/1 | 3.024s | 0 | MISSING_TARGET | +| pi-agent | reason-risk | false | 0/1 | 2.697s | 0 | MISSING_TARGET | +| pi-agent | reason-scope | false | 0/1 | 2.828s | 0 | MISSING_TARGET | + +汇总:`native_success=1.00`,`remote_success=0.33`,`native_p95=16.616s`,`remote_p95=6.926s`,`p0=0`。整轮墙钟 62.25s。 + +### 测试输入 (Input) + +固定套件见 `internal/agentx/abtest.Suite`。本轮文档目标文件名为 `output.md`,对话与推理目标为 `reply.md`。判定日志落盘后,套件里的文档目标改成了 `brief.md` / `outline.md`,以免生产代码再出现受架构扫描约束的 `output.md` 字面量。本表对应改名前的实跑。 + +### 实际输出 (Actual Output) + +``` +native_success=1.00 remote_success=0.33 native_p95=16.615955879s remote_p95=6.926484503s p0=0 +pi-native doc-brief success=true p0=false targets=1/1 dur=8.216466681s err= tokens=1765 +pi-native doc-outline success=true p0=false targets=1/1 dur=16.615955879s err= tokens=2946 +pi-native dialogue-hello success=true p0=false targets=1/1 dur=3.001327463s err= tokens=159 +pi-native dialogue-status success=true p0=false targets=1/1 dur=2.657008525s err= tokens=180 +pi-native reason-risk success=true p0=false targets=1/1 dur=2.403261749s err= tokens=145 +pi-native reason-scope success=true p0=false targets=1/1 dur=3.180087344s err= tokens=142 +pi-agent doc-brief success=true p0=false targets=1/1 dur=5.974404302s err= tokens=0 +pi-agent doc-outline success=true p0=false targets=1/1 dur=6.926484503s err= tokens=0 +pi-agent dialogue-hello success=false p0=false targets=0/1 dur=4.726537486s err=MISSING_TARGET tokens=0 +pi-agent dialogue-status success=false p0=false targets=0/1 dur=3.02361258s err=MISSING_TARGET tokens=0 +pi-agent reason-risk success=false p0=false targets=0/1 dur=2.696804095s err=MISSING_TARGET tokens=0 +pi-agent reason-scope success=false p0=false targets=0/1 dur=2.828393237s err=MISSING_TARGET tokens=0 +replacement gate failed: [pi-native P95 latency exceeds 1.3x pi-agent] +--- FAIL: TestLiveDualBackend (62.25s) +``` + +原文:`.doc/test-reports/assets/go-agentx-runtime/ab-live.txt`。 + +### 评价判定 (Evaluation / Verdict) + +**FAIL(不切换默认后端)。** 成功率 1.00 ≥ 0.33,P0 为 0。P95 16.616 / 6.926 = 2.40,大于 1.3。`AGENT_BACKEND` 保持 `pi-agent`。未从主目录 rebuild,未改 compose。 + +### 性能与耗时 (Duration & Latency) + +成功样本 P95:`pi-native` 16.616s,容器 6.926s。`pi-native` 合计 token 5337(prompt+completion 之和,日志字段为两端相加)。容器结果未回传用量。 + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +`go vet ./...` 退出码 0。日志:`.doc/test-reports/assets/go-agentx-runtime/vet.txt`。 + +`go test -short ./...` 退出码 1。本任务包通过:`internal/agent`、`internal/agentx`、`internal/agentx/abtest`、`internal/agentx/llm`、`internal/agentx/session`、`internal/agentx/tools`、`internal/workspace`、`internal/server`、`internal/config`、`internal/taskrun`。 + +仍失败的包: + +- `internal/archtest`:`pi-native` 的 import 与路径字面量已纳入边界清单,path 计数回到 16。剩余 23 条 import 来自基线 `a5ea469` 上已有的 `workflowscope`、`materialindex`、`mineru`,本任务未改这些包。 +- `internal/docgov`、`internal/notification`、`internal/storage`:测试默认连 `127.0.0.1:5433` 的 dev 库。失败是外键与 `uq_projects_name_global` 重名,不是本运行时逻辑。这次短测试把 goose 推到了 **62**,新增空表 `agentx_workspace_registry`。迁移文件仍只在本分支。 + +完整输出:`.doc/test-reports/assets/go-agentx-runtime/short-test.txt`。 + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +未达到切换标准。dev 默认继续 `AGENT_BACKEND=pi-agent`。`deploy/pi-agent/`、`PiAgentBackend` 与 compose 中的 pi-agent 服务保留。回退不需要数据迁移。 + +差距记录在 `.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md`。下一步是把 `pi-native` 文档任务的成功时延降到容器 P95 的 1.3 倍以内,再跑同一套 `TestLiveDualBackend`。worktree 保留,未合入 `dev`。 diff --git a/.doc/test-reports/assets/go-agentx-runtime/ab-live-round1.txt b/.doc/test-reports/assets/go-agentx-runtime/ab-live-round1.txt new file mode 100644 index 00000000..a0667bf3 --- /dev/null +++ b/.doc/test-reports/assets/go-agentx-runtime/ab-live-round1.txt @@ -0,0 +1,19 @@ +=== RUN TestLiveDualBackend + live_test.go:45: native_success=1.00 remote_success=0.33 native_p95=9.809172339s remote_p95=5.47877184s p0=0 + live_test.go:48: pi-native doc-brief success=true p0=false targets=1/1 dur=9.605385216s err= tokens=0 + live_test.go:48: pi-native doc-outline success=true p0=false targets=1/1 dur=17.922113116s err= tokens=0 + live_test.go:48: pi-native dialogue-hello success=true p0=false targets=1/1 dur=3.145449606s err= tokens=0 + live_test.go:48: pi-native dialogue-status success=true p0=false targets=1/1 dur=4.785127435s err= tokens=0 + live_test.go:48: pi-native reason-risk success=true p0=false targets=1/1 dur=3.695562766s err= tokens=0 + live_test.go:48: pi-native reason-scope success=true p0=false targets=1/1 dur=9.809172339s err= tokens=0 + live_test.go:48: pi-agent doc-brief success=true p0=false targets=1/1 dur=15.794770878s err= tokens=0 + live_test.go:48: pi-agent doc-outline success=true p0=false targets=1/1 dur=5.47877184s err= tokens=0 + live_test.go:48: pi-agent dialogue-hello success=false p0=false targets=0/1 dur=3.165209406s err=MISSING_TARGET tokens=0 + live_test.go:48: pi-agent dialogue-status success=false p0=false targets=0/1 dur=3.478807754s err=MISSING_TARGET tokens=0 + live_test.go:48: pi-agent reason-risk success=false p0=false targets=0/1 dur=3.909467737s err=MISSING_TARGET tokens=0 + live_test.go:48: pi-agent reason-scope success=false p0=false targets=0/1 dur=3.077720644s err=MISSING_TARGET tokens=0 + live_test.go:52: replacement gate failed: [pi-native P95 latency exceeds 1.3x pi-agent] +--- FAIL: TestLiveDualBackend (83.87s) +FAIL +FAIL chat-agent-system/internal/agentx/abtest 83.872s +FAIL diff --git a/.doc/test-reports/assets/go-agentx-runtime/ab-live.txt b/.doc/test-reports/assets/go-agentx-runtime/ab-live.txt new file mode 100644 index 00000000..6cae8d0e --- /dev/null +++ b/.doc/test-reports/assets/go-agentx-runtime/ab-live.txt @@ -0,0 +1,19 @@ +=== RUN TestLiveDualBackend + live_test.go:45: native_success=1.00 remote_success=0.33 native_p95=16.615955879s remote_p95=6.926484503s p0=0 + live_test.go:48: pi-native doc-brief success=true p0=false targets=1/1 dur=8.216466681s err= tokens=1765 + live_test.go:48: pi-native doc-outline success=true p0=false targets=1/1 dur=16.615955879s err= tokens=2946 + live_test.go:48: pi-native dialogue-hello success=true p0=false targets=1/1 dur=3.001327463s err= tokens=159 + live_test.go:48: pi-native dialogue-status success=true p0=false targets=1/1 dur=2.657008525s err= tokens=180 + live_test.go:48: pi-native reason-risk success=true p0=false targets=1/1 dur=2.403261749s err= tokens=145 + live_test.go:48: pi-native reason-scope success=true p0=false targets=1/1 dur=3.180087344s err= tokens=142 + live_test.go:48: pi-agent doc-brief success=true p0=false targets=1/1 dur=5.974404302s err= tokens=0 + live_test.go:48: pi-agent doc-outline success=true p0=false targets=1/1 dur=6.926484503s err= tokens=0 + live_test.go:48: pi-agent dialogue-hello success=false p0=false targets=0/1 dur=4.726537486s err=MISSING_TARGET tokens=0 + live_test.go:48: pi-agent dialogue-status success=false p0=false targets=0/1 dur=3.02361258s err=MISSING_TARGET tokens=0 + live_test.go:48: pi-agent reason-risk success=false p0=false targets=0/1 dur=2.696804095s err=MISSING_TARGET tokens=0 + live_test.go:48: pi-agent reason-scope success=false p0=false targets=0/1 dur=2.828393237s err=MISSING_TARGET tokens=0 + live_test.go:52: replacement gate failed: [pi-native P95 latency exceeds 1.3x pi-agent] +--- FAIL: TestLiveDualBackend (62.25s) +FAIL +FAIL chat-agent-system/internal/agentx/abtest 62.256s +FAIL diff --git a/.doc/test-reports/assets/go-agentx-runtime/short-test.txt b/.doc/test-reports/assets/go-agentx-runtime/short-test.txt new file mode 100644 index 00000000..091cb712 --- /dev/null +++ b/.doc/test-reports/assets/go-agentx-runtime/short-test.txt @@ -0,0 +1,142 @@ +? chat-agent-system/cmd/artifact-vault [no test files] +ok chat-agent-system/cmd/bizdev-cli (cached) +? chat-agent-system/cmd/dataset [no test files] +ok chat-agent-system/cmd/document-content-reconcile (cached) +? chat-agent-system/cmd/document-revision-backfill [no test files] +? chat-agent-system/cmd/eventmode [no test files] +? chat-agent-system/cmd/events [no test files] +? chat-agent-system/cmd/recategorize [no test files] +? chat-agent-system/cmd/server [no test files] +ok chat-agent-system/internal/agent (cached) +ok chat-agent-system/internal/agentx (cached) +ok chat-agent-system/internal/agentx/abtest 0.002s +ok chat-agent-system/internal/agentx/llm (cached) +ok chat-agent-system/internal/agentx/session (cached) +ok chat-agent-system/internal/agentx/tools (cached) +ok chat-agent-system/internal/app (cached) +ok chat-agent-system/internal/app/chatflow (cached) +ok chat-agent-system/internal/app/docadvance (cached) +ok chat-agent-system/internal/app/docflow (cached) +ok chat-agent-system/internal/app/docnotify (cached) +ok chat-agent-system/internal/app/docproject (cached) +ok chat-agent-system/internal/app/fav (cached) +ok chat-agent-system/internal/app/intentflow (cached) +ok chat-agent-system/internal/app/stageflow (cached) +ok chat-agent-system/internal/app/uow (cached) +--- FAIL: TestArchitectureBoundaries (0.17s) + arch_test.go:32: archtest findings=44 excepted=21 unmatched=23 + import=23 + sql_write=5 + path=16 + event=0 + REGRESSION import: 23 > baseline 0 + NEW import app/docnotify internal/app/docnotify/chat_notice.go ctxmgr (production import app/docnotify -> ctxmgr) + NEW import app/docnotify internal/app/docnotify/consumer.go ctxmgr (production import app/docnotify -> ctxmgr) + NEW import app/docnotify internal/app/docnotify/subscriber.go ctxmgr (production import app/docnotify -> ctxmgr) + NEW import app internal/app/app.go workflowscope (production import app -> workflowscope) + NEW import bizdev internal/bizdev/materials.go materialindex (production import bizdev -> materialindex) + NEW import bizdev internal/bizdev/parts.go materialindex (production import bizdev -> materialindex) + NEW import bizdev internal/bizdev/service.go materialindex (production import bizdev -> materialindex) + NEW import coordinator internal/coordinator/engine.go workflowscope (production import coordinator -> workflowscope) + NEW import docgov internal/docgov/materialize.go workflowdef (production import docgov -> workflowdef) + NEW import docgov internal/docgov/workflow_doc_collapse.go workflowdef (production import docgov -> workflowdef) + NEW import intent internal/intent/doc_workflow_scope.go workflowdef (production import intent -> workflowdef) + NEW import intent internal/intent/doc_workflow_scope.go workflowscope (production import intent -> workflowscope) + NEW import materialindex internal/materialindex/pipeline.go materialindex (package missing from import allowlist) + NEW import mineru internal/mineru/agent.go mineru (package missing from import allowlist) + NEW import mineru internal/mineru/client.go mineru (package missing from import allowlist) + NEW import mineru internal/mineru/extract.go mineru (package missing from import allowlist) + NEW import server internal/server/asset_handlers.go materialindex (production import server -> materialindex) + NEW import server internal/server/asset_handlers.go mineru (production import server -> mineru) + NEW import server internal/server/chat_document_reader.go workflowscope (production import server -> workflowscope) + NEW import server internal/server/project_doc_handlers.go workflowscope (production import server -> workflowscope) + NEW import workflowscope internal/workflowscope/flags.go workflowscope (package missing from import allowlist) + NEW import workflowscope internal/workflowscope/relation.go workflowscope (package missing from import allowlist) + NEW import workflowscope internal/workflowscope/score.go workflowscope (package missing from import allowlist) + BASELINE path agent internal/agent/piagent.go output.md (string literal output.md) + BASELINE path agent internal/agent/pinative.go output.md (string literal output.md) + BASELINE path bizdev internal/bizdev/service.go data/dev (string literal data/dev) + BASELINE path bizdev internal/bizdev/service.go output.md (filepath.Join/path.Join) + BASELINE path coordinator internal/coordinator/context.go output.md (filepath.Join/path.Join) + BASELINE path coordinator internal/coordinator/dialogue_llm.go data/dev (string literal data/dev) + BASELINE path coordinator internal/coordinator/document_summary.go data/dev (string literal data/dev) + BASELINE path coordinator internal/coordinator/engine.go output.md (filepath.Join/path.Join) + BASELINE path docgov internal/docgov/lease.go output.md (filepath.Join/path.Join) + BASELINE path docgov internal/docgov/project_docs.go output.md (filepath.Join/path.Join) + BASELINE path docrevision internal/docrevision/projection.go output.md (filepath.Join/path.Join) + BASELINE path docrevision internal/docrevision/reconcile.go output.md (filepath.Join/path.Join) + BASELINE path server internal/server/document_revision_access.go output.md (filepath.Join/path.Join) + BASELINE path server internal/server/document_revision_handlers.go output.md (string literal output.md) + BASELINE path server internal/server/server.go workspaces/ (string literal workspaces/) + BASELINE path storage internal/storage/cas.go data/dev (string literal data/dev) + BASELINE sql_write docduty internal/docduty/revoke.go document_records (update writes document_records) + BASELINE sql_write docduty internal/docduty/store.go document_records (update writes document_records) + BASELINE sql_write docrevision internal/docrevision/pg_store.go document_records (update writes document_records) + BASELINE sql_write docrevision internal/docrevision/pg_store.go freeform_documents (update writes freeform_documents) + BASELINE sql_write projectinstance internal/projectinstance/store.go task_runs (update writes task_runs) + +FAIL +FAIL chat-agent-system/internal/archtest 0.171s +ok chat-agent-system/internal/artifactvault (cached) +ok chat-agent-system/internal/auth (cached) +ok chat-agent-system/internal/authz (cached) +ok chat-agent-system/internal/bizdev (cached) +ok chat-agent-system/internal/chat (cached) +ok chat-agent-system/internal/config (cached) +ok chat-agent-system/internal/coordinator (cached) +ok chat-agent-system/internal/ctxmgr (cached) +ok chat-agent-system/internal/dataset (cached) +ok chat-agent-system/internal/db (cached) +ok chat-agent-system/internal/doccontent (cached) +ok chat-agent-system/internal/docduty (cached) +--- FAIL: TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK (0.00s) + engine_db_test.go:135: CreateOrGetDocument with reviewer/approver=0 failed: persist document ledger: ERROR: insert or update on table "document_records" violates foreign key constraint "document_records_project_id_fkey" (SQLSTATE 23503) +FAIL +FAIL chat-agent-system/internal/docgov 0.020s +ok chat-agent-system/internal/docquery (cached) +ok chat-agent-system/internal/docrevision (cached) +ok chat-agent-system/internal/events 0.004s +ok chat-agent-system/internal/freedoc (cached) +ok chat-agent-system/internal/fsutil (cached) +ok chat-agent-system/internal/httpx (cached) +ok chat-agent-system/internal/intent (cached) +ok chat-agent-system/internal/kb (cached) +ok chat-agent-system/internal/materialindex (cached) +ok chat-agent-system/internal/maturity (cached) +ok chat-agent-system/internal/mineru (cached) +2026/09/21 15:09:16 goose: no migrations to run. current version: 62 +--- FAIL: TestPG_StageAdvanceReminder24h (0.01s) + reminder_test.go:29: insert project: ERROR: duplicate key value violates unique constraint "uq_projects_name_global" (SQLSTATE 23505) +2026/09/21 15:09:16 goose: no migrations to run. current version: 62 +--- FAIL: TestPG_EmitStageReadyDedupe (0.01s) + service_test.go:57: insert project: ERROR: duplicate key value violates unique constraint "uq_projects_name_global" (SQLSTATE 23505) +FAIL +FAIL chat-agent-system/internal/notification 0.018s +? chat-agent-system/internal/observability [no test files] +ok chat-agent-system/internal/projectcontext (cached) +ok chat-agent-system/internal/projectinstance (cached) +ok chat-agent-system/internal/projectworkflow (cached) +ok chat-agent-system/internal/quote (cached) +ok chat-agent-system/internal/replyexport (cached) +ok chat-agent-system/internal/server (cached) +ok chat-agent-system/internal/squad (cached) +--- FAIL: TestCAS_DatabaseIntegration (0.00s) + cas_db_test.go:55: insert project failed: ERROR: duplicate key value violates unique constraint "uq_projects_name_global" (SQLSTATE 23505) +FAIL +FAIL chat-agent-system/internal/storage 0.007s +ok chat-agent-system/internal/stream (cached) +ok chat-agent-system/internal/stream/transport (cached) +ok chat-agent-system/internal/swboot (cached) +ok chat-agent-system/internal/sysadmin (cached) +ok chat-agent-system/internal/taskrun (cached) +ok chat-agent-system/internal/template (cached) +ok chat-agent-system/internal/traceimpact (cached) +ok chat-agent-system/internal/upload (cached) +? chat-agent-system/internal/workflow [no test files] +ok chat-agent-system/internal/workflow/runtime (cached) +ok chat-agent-system/internal/workflowdef (cached) +ok chat-agent-system/internal/workflowscope (cached) +ok chat-agent-system/internal/workspace (cached) +? chat-agent-system/templates [no test files] +FAIL +TEST_EXIT:1 diff --git a/.doc/test-reports/assets/go-agentx-runtime/vet.txt b/.doc/test-reports/assets/go-agentx-runtime/vet.txt new file mode 100644 index 00000000..58537590 --- /dev/null +++ b/.doc/test-reports/assets/go-agentx-runtime/vet.txt @@ -0,0 +1 @@ +VET_EXIT:0 From f3b120920702df881c050e9cad51a38a411cc101 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 15:16:02 +0000 Subject: [PATCH 09/10] docs(ops): point the agentx task at the gate commit --- .doc/ops/short-term/2026-09-21-go-agentx-runtime.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md index b5c4fc84..8fc7bc0a 100644 --- a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md +++ b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md @@ -8,7 +8,7 @@ branch: feature/go-agentx-runtime-2026-09-21 worktree: .worktrees/go-agentx-runtime-2026-09-21 base_dev_sha: a5ea469ca44171a5c3e6419b1757852270fb991d write_scope: [internal/agentx, internal/agent, internal/config, internal/taskrun, internal/workspace, internal/server, cmd, deploy/postgres/goose, tools/cursor-bridge, .doc/ops, .doc/status, .doc/audit, .doc/test-reports] -head_sha: 174e59e6 +head_sha: 5d77e9cf deployment: pending cleanup: pending mutex: agent-backend-swap From 127cfcccccc598903c5868042953f22b6beb8afb Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 15:29:33 +0000 Subject: [PATCH 10/10] feat(agent): fall back to the container backend when pi-native fails Dev starts on pi-native and keeps pi-agent available for a failed turn. Latency tuning stays deferred. --- .doc/ops/CURRENT.md | 4 +- .../2026-09-21-go-agentx-runtime.md | 4 +- .doc/status/FEATURE-MATRIX.md | 2 +- configs/.env.dev.example | 3 +- docker-compose.dev.yml | 5 +- internal/agent/backend.go | 5 +- internal/agent/fallback.go | 46 ++++++++++++++ internal/agent/fallback_test.go | 63 +++++++++++++++++++ 8 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 internal/agent/fallback.go create mode 100644 internal/agent/fallback_test.go diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 4c5bb9a6..7c2f5d92 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -10,13 +10,13 @@ 3. **上传解析提示 / 去重 / CoW 收口**:MinerU 解析状态轮询闭环(pending→running→done)、重复文件「已复用」提示、失败原因+重新解析、CAS 不可变 CoW 契约测试,已合入 `dev`(代码 `99905571`、证据 `74587ccf`、收口 `a0464eb5`)。见 [报告](../test-reports/2026-09-21-upload-parse-ux-dedup-test-report.md)。test/main 未晋升。前序任务执行状态卡收口见 [报告](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md)(含二轮修复:completed 步骤 label 冻结为"正在…(NN%)"的收口归一化 + dev 历史回填,已合入 `dev@4b303247` 并真实 PI 验收)。 4. **MinerU 真实 PDF 二轮验收**:用户反馈「PDF 内容未被理解」由两因叠加——(A) 首轮在 worktree 内跑 `env-service dev rebuild` 令挂载漂到 worktree `data/dev`,真实历史 blob 全部读不到;(B) Agent 轻量 API 硬限 20 页,28 页真实文件失败。首轮结论作废。二轮从**主目录** rebuild 修复挂载,`internal/mineru` 在 `-30003` 时按 20 页窗分块解析并重编全局页锚;`ReadRefBytes` 失败改为 `slog.Warn`。已合入 `dev@362b68a0`。真实 `bd_intake`(项目 6532)`completed`,工作区 `materials_text` 注入 29152 字节中文正文。见 [报告](../test-reports/2026-09-21-mineru-doc-parsing-test-report.md) 第八节。 5. **材料切块不再看体积**:项目 6579 `doc_proj6579_bd_intake` 已确认候选 535,正式正文不再含「二进制压缩流」。成功解析的材料一律写入 `material_chunks`,≤60KB 仍全文注入。见 [报告](../test-reports/2026-09-21-force-material-chunk-index-test-report.md)。已合入 `dev` 并从主目录 rebuild。test/main 未晋升。 -6. **红线**:`pi-native` 对比未过 P95 门槛,默认 `AGENT_BACKEND` 保持 `pi-agent`,未合入、未部署。禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`;**合入 dev 后必须回主目录跑 `env-service.sh dev rebuild`,禁止任何 worktree 内运行 `env-service`**(脚本 ROOT_DIR 取自脚本所在目录树,会带走 bind mount,导致 `/app/data` 漂到 worktree 空目录)。 +6. **红线**:dev 启用 `pi-native`,失败回退容器 `pi-agent`;时延优化后置。test/main 仍默认 `pi-agent`。禁止整支 merge FAV;不默认升 `MULTI_INTENT_PLAN_TIER=full`;`SEMANTIC_SUMMARY_REPLACE` 不随 W2 开启;FAV live 观察窗证据驱动;`FAV_DEGRADED_READ=false`;项目 143 只读;SWBOOT-10 保持 fail-closed stub;D7 对话 ADR 仍待合规签字(追踪表留存类别已定为 `record`,不授权物理删除);**不改 `production_ready`**;模块解耦批次 A–G 与回溯实施切片未单独认领前保持 `planned`;**合入 dev 后必须回主目录跑 `env-service.sh dev rebuild`,禁止任何 worktree 内运行 `env-service`**(脚本 ROOT_DIR 取自脚本所在目录树,会带走 bind mount,导致 `/app/data` 漂到 worktree 空目录)。 ## 并行认领 | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| -| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | blocked | +| Cursor | 2026-09-21-go-agentx-runtime | feature/go-agentx-runtime-2026-09-21 | .worktrees/go-agentx-runtime-2026-09-21 | agent-backend-swap | in_progress | | Cursor | 2026-09-21-force-material-chunk-index | feature/force-material-chunk-index-2026-09-21 | .worktrees/force-material-chunk-index-2026-09-21 | bizdev+materialindex | done | | Cursor | 2026-09-21-workflow-scope-weighting | feature/workflow-scope-weighting-2026-09-21 | .worktrees/workflow-scope-weighting-2026-09-21 | intent+web+docgov | done | | Cursor | 2026-09-21-upload-parse-ux-dedup | feature/upload-parse-ux-2026-09-21 | .worktrees/upload-parse-ux-2026-09-21 | server+web+storage | done | diff --git a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md index 8fc7bc0a..ba943041 100644 --- a/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md +++ b/.doc/ops/short-term/2026-09-21-go-agentx-runtime.md @@ -1,6 +1,6 @@ --- title: "Go 原生 PI Agent 运行时(pi-native 并存验证)" -status: blocked +status: in_progress created: 2026-09-21 updated: 2026-09-21 owner: Cursor @@ -63,4 +63,4 @@ mutex: agent-backend-swap 远端同步状态:创建时主目录为本地 `dev`,ahead of `origin/dev`,未为创建任务执行 fetch/pull。 -对比未过 P95 门槛,状态 `blocked`,默认后端不改。差距见 `.doc/audit/2026-09-21-go-agentx-runtime-latency-gap.md`。`deployment=pending`,`cleanup=pending`(未合入,worktree 保留)。短测试曾对 `127.0.0.1:5433` 执行 goose,dev schema 现为 62,表 `agentx_workspace_registry` 已存在;迁移文件仍只在本分支。 +对比未过 P95 门槛后,用户决定先不把时延作为切换条件:dev 启用 `pi-native`,失败时在进程内回退到容器 `pi-agent`。test/main 的 compose 默认仍是 `pi-agent`。`deployment` 在主目录 rebuild 之后再记。 diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index 6bb0a651..9ed5ed77 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -66,7 +66,7 @@ | 阶段回溯与影响闭环 | 五表追踪索引、影响报告、L1 文档修订、L2 阶段再打开、待复核软标记、对话修订提案 | done | deferred_enhancement(不挡首发) | [设计](../system-design/2026-09-20-stage-backtrack-and-impact-loop-design.md);[Track T](../remediation/2026-09-20-capability-enablement-impact-plan.md);[对话化报告](../test-reports/2026-09-21-trace-panel-to-chat-test-report.md) | 已合入 dev。常驻「需求与影响」面板已退役,新待复核与下游影响改走已有项目会话。dev schema 59。test/main 未升。阶段推进确认门禁仍在。不签 D7 对话 ADR,不改 `production_ready` | `trace-index` | | PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` | | 前端工作台 | 三栏 + 块文档 + SSE;错误卡/`TaskRunCard` 竖向进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363);[TaskRunCard](../test-reports/2026-09-21-taskrun-status-card-persistence-test-report.md) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` | -| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1;分支并存 pi-native,默认未切 | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md);[pi-native 对比](../test-reports/2026-09-21-go-agentx-runtime-test-report.md) | 容器链路仍是默认。pi-native 真实双跑成功率 1.00、容器 0.33、P0 为 0,P95 16.6s 对 6.9s,未过 1.3 倍,不切 AGENT_BACKEND。cursor-cli 已从该分支移除,容器服务保留 | `pi-agent` | +| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1;dev 默认 pi-native,失败回退 pi-agent | done | non_blocker | [piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md);[pi-native 对比](../test-reports/2026-09-21-go-agentx-runtime-test-report.md) | 用户决定时延后置。dev compose 默认 pi-native,进程内失败再调用容器 pi-agent。test/main 默认仍是 pi-agent。cursor-cli 已移除 | `pi-agent` | | 三环境运维 | dev/test/main + 共享 KB;对话/业务库隔离 | done | non_blocker | [PROJECT.md](../../PROJECT.md) §4;CURRENT 环境快照 dev/test schema **48**、main **47** | — | `infra` | | CI / E2E | PR CI、PG 集成、治理 PG、已签真实 PI 门禁;**nightly** schedule/`workflow_dispatch` + infra/product 分类 receipt | done | 加固(阻止**下一次**晋升,不自动否定已发布) | [BUG-08](../test-reports/2026-09-16-ci-governance-pg-bug08-test-report.md);[PI 三工作流](../test-reports/2026-09-16-pi-three-workflow-e2e-test-report.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md);[W1 green](../test-reports/2026-09-17-w1-ci-e2e-green-nightly-test-report.md) + [receipt `6e2476f0…`](../assets/promotion-receipts/6e2476f0f2e408e111fe06ca7c31a38294a340b3.json) | **before**:partial(FullNone product_fail / secrets 文案过时)→ **after**:secrets 已配;duplicate goose 00048 已清;FullNone+U8 nightly `verdict=pass` | `ci` | | Artifact 同 digest 晋升 | 候选 commit 一次构建、按 digest 晋升、promotion receipt | done | 加固 | [计划 PU5B](../remediation/2026-09-16-feature-matrix-production-readiness-plan.md);[Wave2B](../test-reports/2026-09-16-wave2b-nightly-promotion-receipt-test-report.md);[Wave2C](../test-reports/2026-09-16-wave2c-same-image-digest-test-report.md) | **关闭**:same-image pin(`candidate-images-*` + `PINNED_IMAGE_MODE` + promote `--require-digests`)。**声明**:单机 retag/digest pin;远程 registry 推送另立任务 | `infra` | diff --git a/configs/.env.dev.example b/configs/.env.dev.example index d1a5f103..c3b75a00 100644 --- a/configs/.env.dev.example +++ b/configs/.env.dev.example @@ -30,7 +30,8 @@ KB_DB_PORT=5432 # 5. Agent 后端与 Runner 端口 PI_AGENT_RUNNER_PORT=18865 -AGENT_BACKEND=pi-agent +AGENT_BACKEND=pi-native +# pi-native 失败时进程内回退到容器 pi-agent,无需改此值。 AGENT_TIMEOUT=60m # 6. Pi-Agent / MiniMax / Tavily / Runner 密钥统一读取根 .env diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b89d5e98..159aaa2f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -80,7 +80,10 @@ services: HTTP_ADDR: ":${HTTP_PORT:-8080}" HTTP_PORT: "${HTTP_PORT:-8080}" HTTP_HOST: "0.0.0.0" - AGENT_BACKEND: ${AGENT_BACKEND:-pi-agent} + AGENT_BACKEND: ${AGENT_BACKEND:-pi-native} + AGENTX_API_BASE: ${MINIMAX_ENDPOINT_URL:-https://api.minimaxi.com/v1} + AGENTX_API_KEY: ${MINIMAX_API_KEY:-} + AGENTX_MODEL: ${MINIMAX_MODEL_ID:-MiniMax-M2.7-highspeed} AGENT_TIMEOUT: ${AGENT_TIMEOUT:-30m} MAX_CONCURRENT_AGENTS: ${MAX_CONCURRENT_AGENTS:-8} DATA_DIR: /app/data diff --git a/internal/agent/backend.go b/internal/agent/backend.go index 20e39d97..994cbe93 100644 --- a/internal/agent/backend.go +++ b/internal/agent/backend.go @@ -46,7 +46,10 @@ func NewBackend(name string, cfg *config.Config) Backend { case "pi-agent": inner = &PiAgentBackend{cfg: cfg} case "pi-native": - inner = &PiNativeBackend{cfg: cfg} + inner = &fallbackBackend{ + primary: NewPiNativeBackend(cfg), + fallback: &PiAgentBackend{cfg: cfg}, + } default: inner = &StubBackend{} } diff --git a/internal/agent/fallback.go b/internal/agent/fallback.go new file mode 100644 index 00000000..2cd5eb96 --- /dev/null +++ b/internal/agent/fallback.go @@ -0,0 +1,46 @@ +package agent + +import ( + "context" + "strings" +) + +// fallbackBackend runs pi-native first. A failed turn is retried on the container pi-agent. +type fallbackBackend struct { + primary Backend + fallback Backend +} + +func (f *fallbackBackend) Name() string { + if f.primary == nil { + return "pi-native" + } + return f.primary.Name() +} + +func (f *fallbackBackend) Execute(ctx context.Context, req Request) (*Result, error) { + res, err := f.primary.Execute(ctx, req) + if ctx.Err() != nil || succeeded(res, err) || f.fallback == nil { + return res, err + } + fb, fbErr := f.fallback.Execute(ctx, req) + if !succeeded(fb, fbErr) { + return res, err + } + note := "pi-native failed; served by pi-agent" + if res != nil && strings.TrimSpace(res.ErrorReason) != "" { + note += ": " + strings.TrimSpace(res.ErrorReason) + } else if err != nil { + note += ": " + err.Error() + } + if strings.TrimSpace(fb.Logs) == "" { + fb.Logs = note + } else { + fb.Logs = note + "\n" + fb.Logs + } + return fb, nil +} + +func succeeded(res *Result, err error) bool { + return err == nil && res != nil && res.Success +} diff --git a/internal/agent/fallback_test.go b/internal/agent/fallback_test.go new file mode 100644 index 00000000..477df951 --- /dev/null +++ b/internal/agent/fallback_test.go @@ -0,0 +1,63 @@ +package agent + +import ( + "context" + "errors" + "strings" + "testing" +) + +type scriptBackend struct { + name string + res *Result + err error + calls int +} + +func (s *scriptBackend) Name() string { return s.name } + +func (s *scriptBackend) Execute(ctx context.Context, req Request) (*Result, error) { + s.calls++ + return s.res, s.err +} + +func TestFallbackKeepsNativeSuccess(t *testing.T) { + primary := &scriptBackend{name: "pi-native", res: &Result{Success: true, Logs: "native"}} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true}} + got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{}) + if err != nil || !got.Success || got.Logs != "native" || spare.calls != 0 { + t.Fatalf("got %#v err=%v spare=%d", got, err, spare.calls) + } +} + +func TestFallbackUsesContainerAfterNativeFailure(t *testing.T) { + primary := &scriptBackend{name: "pi-native", res: &Result{Success: false, ErrorReason: "missing target deliverable"}} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true, Logs: "container"}} + got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{}) + if err != nil || !got.Success || spare.calls != 1 { + t.Fatalf("got %#v err=%v calls=%d", got, err, spare.calls) + } + if !strings.Contains(got.Logs, "served by pi-agent") || !strings.Contains(got.Logs, "missing target deliverable") { + t.Fatalf("logs %q", got.Logs) + } +} + +func TestFallbackReturnsNativeWhenContainerAlsoFails(t *testing.T) { + primary := &scriptBackend{name: "pi-native", err: errors.New("native down")} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: false, ErrorReason: "runner down"}} + got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{}) + if err == nil || err.Error() != "native down" || got != nil || spare.calls != 1 { + t.Fatalf("got %#v err=%v", got, err) + } +} + +func TestFallbackStopsWhenContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + primary := &scriptBackend{name: "pi-native", err: context.Canceled} + spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true}} + _, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(ctx, Request{}) + if !errors.Is(err, context.Canceled) || spare.calls != 0 { + t.Fatalf("err=%v spare=%d", err, spare.calls) + } +}