commit fedf671a487d28e66a9d30a3de9c6aa2fb0e0f15 Author: weichen Date: Tue Sep 29 12:29:04 2026 +0000 Import the current agent-native module as a fresh history. Co-authored-by: Cursor diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..a084d4c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + vet-and-test: + runs-on: nightly + steps: + - uses: actions/checkout@v4 + - run: go vet ./... && go test -count=1 ./... + docker-checkout-hostgateway: + runs-on: ubuntu-latest + options: --add-host gitea:host-gateway + env: + GITHUB_SERVER_URL: http://gitea:3000 + steps: + - uses: actions/checkout@v4 + - run: test -f go.mod && echo "docker-mode checkout via host-gateway OK" diff --git a/README.md b/README.md new file mode 100644 index 0000000..04c208c --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Agent-Native + +可复用的 Agent 执行基础:自研执行内核(runtime)、模型/工具能力接入、外部 Agent +适配器(现有 PI Runner 等)与共用契约验收(conformance)。 + +## 仓库分层(U7a 首切片:仅 contract + conformance) + +| 层 | 职责 | 依赖边界 | +|---|---|---| +| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置 | +| runtime | agentx 演进的多轮循环、工具调度、模型调用、执行会话(U7b 迁入) | 实现 contract;不导入 Core/Squad | +| adapters | PI Runner transport / typed-result-v1(U7b 迁入);native:自研内核 runtime → exec.Backend 适配(规范后端名 agent-native,主仓 pinative 的通用化副本;含会话毒化轮转——resume-unsafe 失败后自动换新会话) | 显式映射外部差异,不伪造缺失能力 | +| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 | + +规范后端名:`agent-native`(`pi-native` 为兼容别名,由 Core 侧注册表解析)。 + +## 关键语义(详见 contract 包 doc.go) + +- ExecutionID 一次逻辑执行唯一;重试与受控 fallback 复用。 +- Outcome 四态:Succeeded/Failed/Canceled/Unknown;Unknown 不静默降级。 +- 失败副作用显式分类(none/possible);possible 禁止无条件重放。 +- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。 +- Workspace 路径授权 ≠ OS 沙箱。 + +## 消费方式(首期) + +外层组合(Agent-System 主仓 gitlink)固定精确 SHA,Go 相邻 replace 引用: + +``` +replace agent-native => ../agent-native +``` + +构建须 `GOWORK=off`;不通过 go get 路由依赖(实例 URL 带端口,见主仓方案 KTD3)。 + +## CI + +`.gitea/workflows/ci.yml`:go vet + go test(host runner)。 + +## 分层现状(U7b 本地提取,2026-09-29) + +| 包 | 内容 | 来源 | +|---|---|---| +| contract | 四态执行契约(v0.1.0 已发布) | U7a 原生 | +| conformance | 行为验收套件 + fake | U7a 原生 | +| exec | 冻结执行端口:Backend/Request/Result/ProcessEvent、步骤重试/失败分类、交付物物化、Stub、并发限流 | 主仓 internal/agent 对应文件冻结副本(测试随迁,PI 远程 runner 测试留主仓) | +| runtime | agentx 多轮循环/llm/session/tools(含全部单测) | 主仓 internal/agentx 派生 | +| adapters/pitransport | typed-result-v1 编解码、远程结果解析、Runner 搜索、鉴权头(含 Node 摘要交叉验证测试) | 主仓 internal/agent 传输件冻结副本 | + +依赖方向:squad → agent-native(exec);agent-native 不依赖 squad/Core。 +主仓 internal/agentx 与 internal/agent 对应文件仍是权威;主仓变更后按 +sync 流程重派生(各包 sync 说明见包内注释)。 diff --git a/adapters/native/backend.go b/adapters/native/backend.go new file mode 100644 index 0000000..e421940 --- /dev/null +++ b/adapters/native/backend.go @@ -0,0 +1,247 @@ +// Package native 把 agent-native 自研执行内核(runtime)适配为 exec.Backend, +// 规范后端名 "agent-native"。来源:主仓 internal/agent/pinative.go 的通用化 +// 副本(去除产品 config/workspace 依赖);主仓文件仍是产品路径权威, +// 本文件为模块原生新增,语义变更需两侧同步评审。 +package native + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "agent-native/exec" + "agent-native/runtime" + "agent-native/runtime/llm" + "agent-native/runtime/session" + "agent-native/runtime/tools" +) + +// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。 +type Options struct { + APIBase string + APIKey string + Model string + MaxTurns int + Timeout time.Duration + // Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。 + Stream llm.StreamFunc +} + +// Backend 以自研内核实现 exec.Backend。 +type Backend struct { + opts Options + // lastFailure 记录各工作目录上次失败原因,用于会话毒化检测 + // (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。 + mu sync.Mutex + lastFailure map[string]string +} + +// New 创建 native 后端。 +func New(opts Options) *Backend { + return &Backend{opts: opts, lastFailure: map[string]string{}} +} + +// Name 返回规范后端名。 +func (b *Backend) Name() string { return "agent-native" } + +// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。 +func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { + if len(req.Targets) == 0 { + req.Targets = []string{"output.md"} + } + profile := strings.TrimSpace(req.ToolProfile) + if profile == "" { + profile = exec.ToolProfileWorkspace + } + if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning { + return &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil + } + if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") { + return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil + } + if strings.TrimSpace(b.opts.Model) == "" { + return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, 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 &exec.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 == exec.ToolProfileWorkspace { + reg = tools.WorkspaceTools(workDir) + } + // 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档, + // 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。 + b.rotateIfPoisoned(workDir) + store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl")) + if err != nil { + return &exec.Result{Success: false, ErrorReason: err.Error()}, nil + } + stream := b.opts.Stream + if stream == nil { + client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey} + stream = client.Stream + } + + timeout := req.Timeout + if timeout <= 0 { + timeout = b.opts.Timeout + } + runCtx := ctx + cancel := func() {} + if timeout > 0 { + runCtx, cancel = context.WithTimeout(ctx, timeout) + } + defer cancel() + + var logs strings.Builder + onProcess := req.OnEvent + runRes, err := b.run(runCtx, reg, store, stream, profile, req, &logs, onProcess) + if err != nil { + return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil + } + _ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644) + + // reasoning 档无工具:把最终文本写入仍为空的目标文件。 + if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" { + for _, target := range req.Targets { + path := filepath.Join(workDir, filepath.FromSlash(target)) + if b2, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b2))) > 0 { + continue + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(runRes.Text), 0o644) + } + } + + if runRes.Err != nil { + reason := runRes.Err.Error() + b.mu.Lock() + b.lastFailure[workDir] = reason + b.mu.Unlock() + res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable} + if runRes.StopReason == llm.StopAborted { + res.Retryable = true + } + return res, nil + } + b.mu.Lock() + delete(b.lastFailure, workDir) + b.mu.Unlock() + + patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil) + if matErr != nil { + return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil + } + return &exec.Result{ + Success: true, + Patches: patches, + Logs: logs.String(), + PromptTokens: runRes.Usage.PromptTokens, + CompletionTokens: runRes.Usage.CompletionTokens, + }, nil +} + +// run 驱动 runtime 循环并桥接过程事件(语义对齐主仓 pinative)。 +func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.Store, + stream llm.StreamFunc, profile string, req exec.Request, logs *strings.Builder, + onProcess func(exec.ProcessEvent)) (runtime.Result, error) { + + runRes, err := runtime.Run(ctx, runtime.Config{ + Model: b.opts.Model, + SystemPrompt: systemPrompt(req, profile), + Tools: reg, + Stream: stream, + Session: store, + MaxTurns: b.opts.MaxTurns, + OnEvent: func(ev runtime.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') + if onProcess != nil { + onProcess(exec.ProcessEvent{ + Type: ev.Type, + Text: ev.Text, + Tool: ev.Tool, + Detail: ev.Detail, + }) + } + }, + }, llm.Message{Role: llm.RoleUser, Content: req.Prompt}) + return runRes, err +} + +// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/ +// 迭代上限/非法请求——重试必须换新会话而非续跑)。 +var resumeUnsafeMarkers = []string{ + "context_overflow", "context length", "context window", "too long", + "iteration_limit", "max turns", "turn cap", + "invalid_request", "invalid request", "bad request", "400", +} + +// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。 +func (b *Backend) rotateIfPoisoned(workDir string) { + b.mu.Lock() + reason := strings.ToLower(b.lastFailure[workDir]) + b.mu.Unlock() + if reason == "" { + return + } + unsafe := false + for _, m := range resumeUnsafeMarkers { + if strings.Contains(reason, m) { + unsafe = true + break + } + } + if !unsafe { + return + } + path := filepath.Join(workDir, ".agentx", "transcript.jsonl") + if _, err := os.Stat(path); err != nil { + return + } + _ = os.Rename(path, filepath.Join(workDir, ".agentx", + "transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl")) +} + +// systemPrompt 组装系统提示:角色指令 + 交付物与工具档位约束。 +func systemPrompt(req exec.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 == exec.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/adapters/native/backend_test.go b/adapters/native/backend_test.go new file mode 100644 index 0000000..1c9c43e --- /dev/null +++ b/adapters/native/backend_test.go @@ -0,0 +1,122 @@ +package native + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "agent-native/exec" + "agent-native/runtime/llm" +) + +// script 按序回放假助手轮次(对齐 runtime/loop_test 的伪造方式)。 +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 TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) { + dir := t.TempDir() + b := New(Options{Model: "fake", Stream: script( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"# 交付\n\n工具写入的交付物内容。","reason":"产出交付物"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd}, + )}) + var events []exec.ProcessEvent + res, err := b.Execute(context.Background(), exec.Request{ + WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色", + Targets: []string{"output.md"}, OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) }, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success { + t.Fatalf("want success, got %+v", res) + } + data, err := os.ReadFile(filepath.Join(dir, "output.md")) + if err != nil || !strings.Contains(string(data), "交付物内容") { + t.Fatalf("deliverable missing: %v %q", err, data) + } + if res.Patches["output.md"] == nil { + t.Fatal("patches missing output.md") + } + for _, f := range []string{"TASK.md", "AGENTS.md", "INSTRUCTIONS.md", "execution.log"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Errorf("workspace file missing: %s", f) + } + } + sawTool := false + for _, ev := range events { + if ev.Type == "tool_execution_start" && ev.Tool == "write_file" { + sawTool = true + } + } + if !sawTool { + t.Errorf("tool process events not forwarded: %+v", events) + } +} + +func TestBackendReasoningProfileWritesText(t *testing.T) { + dir := t.TempDir() + b := New(Options{Model: "fake", Stream: script( + llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd}, + )}) + res, err := b.Execute(context.Background(), exec.Request{ + WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning, + Targets: []string{"notes.md"}, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success { + t.Fatalf("want success, got %+v", res) + } + data, _ := os.ReadFile(filepath.Join(dir, "notes.md")) + if !strings.Contains(string(data), "纯文本交付内容") { + t.Fatalf("reasoning text not written: %q", data) + } +} + +func TestBackendFailClosedWithoutConfig(t *testing.T) { + b := New(Options{}) + res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}}) + if err != nil { + t.Fatal(err) + } + if res.Success || !strings.Contains(res.ErrorReason, "requires") { + t.Fatalf("want fail-closed config error, got %+v", res) + } + res2, _ := New(Options{APIBase: "http://x", APIKey: "k"}).Execute(context.Background(), + exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}}) + if res2.Success || !strings.Contains(res2.ErrorReason, "model") { + t.Fatalf("want model required error, got %+v", res2) + } + res3, _ := New(Options{Stream: script()}).Execute(context.Background(), + exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"}) + if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") { + t.Fatalf("want profile error, got %+v", res3) + } +} diff --git a/adapters/native/rotation_test.go b/adapters/native/rotation_test.go new file mode 100644 index 0000000..d9801f8 --- /dev/null +++ b/adapters/native/rotation_test.go @@ -0,0 +1,110 @@ +package native + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "agent-native/exec" + "agent-native/runtime/llm" +) + +// failThenSucceed 首次流返回 context overflow 错误,之后正常结束。 +func failThenSucceed(failMsg string) llm.StreamFunc { + var mu sync.Mutex + failed := false + 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) + mu.Lock() + first := !failed + failed = true + mu.Unlock() + if first { + ch <- llm.Event{Kind: llm.KindError, Err: errors.New(failMsg), ErrorMessage: failMsg} + return + } + ch <- llm.Event{Kind: llm.KindTextDelta, Text: "恢复后的正文输出。"} + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "恢复后的正文输出。", StopReason: llm.StopEnd}} + }() + return ch + } +} + +func TestSessionRotationAfterPoisonedFailure(t *testing.T) { + dir := t.TempDir() + b := New(Options{Model: "fake", Stream: failThenSucceed("maximum context length exceeded (context_overflow)")}) + + // 第一次执行:失败(上下文溢出),留下毒化会话。 + res1, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"})) + if err != nil { + t.Fatal(err) + } + if res1.Success || !strings.Contains(res1.ErrorReason, "context") { + t.Fatalf("first execute should fail with overflow: %+v", res1) + } + // 手工模拟毒化会话存在(runtime 假流没写 transcript,用 session.Open 布置)。 + transcript := filepath.Join(dir, ".agentx", "transcript.jsonl") + if err := os.MkdirAll(filepath.Dir(transcript), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcript, []byte(`{"role":"user","content":"poisoned"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + // 第二次执行:毒化检测应轮转旧会话并成功(reasoning 档直写文本; + // workspace 档需模型主动调写盘工具,假流不模拟工具调用)。 + res2, err := b.Execute(context.Background(), exec.Request{ + WorkDir: dir, Prompt: "p", Targets: []string{"output.md"}, + ToolProfile: exec.ToolProfileReasoning, + }) + if err != nil { + t.Fatal(err) + } + if !res2.Success { + t.Fatalf("second execute should succeed on fresh session: %+v", res2) + } + // 旧会话归档保留(审计),新会话文件存在。 + entries, _ := os.ReadDir(filepath.Join(dir, ".agentx")) + rotated, fresh := false, false + for _, e := range entries { + if strings.HasPrefix(e.Name(), "transcript.poisoned.") { + rotated = true + } + if e.Name() == "transcript.jsonl" { + fresh = true + } + } + if !rotated || !fresh { + t.Fatalf("rotation missing: rotated=%v fresh=%v entries=%v", rotated, fresh, entries) + } +} + +func TestNoRotationOnRetryableNetworkFailure(t *testing.T) { + dir := t.TempDir() + b := New(Options{Model: "fake", Stream: failThenSucceed("connection reset by peer (provider_network)")}) + _, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"})) + transcript := filepath.Join(dir, ".agentx", "transcript.jsonl") + _ = os.MkdirAll(filepath.Dir(transcript), 0o755) + _ = os.WriteFile(transcript, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644) + + _, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"})) + if err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(filepath.Join(dir, ".agentx")) + for _, e := range entries { + if strings.HasPrefix(e.Name(), "transcript.poisoned.") { + t.Fatal("network failure must not rotate session (resume-safe)") + } + } +} + +func execReq(dir string, targets []string) exec.Request { + return exec.Request{WorkDir: dir, Prompt: "p", Targets: targets} +} diff --git a/adapters/pitransport/pi_runner_search.go b/adapters/pitransport/pi_runner_search.go new file mode 100644 index 0000000..3a3549c --- /dev/null +++ b/adapters/pitransport/pi_runner_search.go @@ -0,0 +1,96 @@ +package pitransport + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const piRunnerSearchTimeout = 20 * time.Second + +// PiRunnerSearcher 通过 pi-agent runner 的 /v1/search 做 Tavily 检索。 +type PiRunnerSearcher struct { + URL string + Token string + Client *http.Client +} + +// Search 调用 runner /v1/search,返回 markdown 结果。 +func (s *PiRunnerSearcher) Search(ctx context.Context, query string) (string, error) { + if s == nil { + return "", fmt.Errorf("pi runner searcher is nil") + } + base := strings.TrimRight(strings.TrimSpace(s.URL), "/") + q := strings.TrimSpace(query) + if base == "" { + return "", fmt.Errorf("pi runner url empty") + } + if q == "" { + return "", fmt.Errorf("search query empty") + } + + body, err := json.Marshal(map[string]any{ + "query": q, + "max_results": 5, + }) + if err != nil { + return "", err + } + + reqCtx, cancel := context.WithTimeout(ctx, piRunnerSearchTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodPost, base+"/v1/search", bytes.NewReader(body)) + if err != nil { + return "", err + } + httpReq.Header.Set("Content-Type", "application/json") + ApplyRunnerAuth(httpReq, s.Token) + + client := s.Client + if client == nil { + client = &http.Client{Timeout: piRunnerSearchTimeout} + } + resp, err := client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + + limited, err := io.ReadAll(io.LimitReader(resp.Body, MaxResultBodyBytes+1)) + if err != nil { + return "", err + } + if len(limited) > MaxResultBodyBytes { + return "", fmt.Errorf("search response too large") + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("search http %d", resp.StatusCode) + } + + var parsed struct { + OK bool `json:"ok"` + Markdown string `json:"markdown"` + Error string `json:"error"` + } + if err := json.Unmarshal(limited, &parsed); err != nil { + return "", fmt.Errorf("decode search response: %w", err) + } + if !parsed.OK { + msg := strings.TrimSpace(parsed.Error) + if msg == "" { + msg = "search failed" + } + return "", fmt.Errorf("%s", msg) + } + md := strings.TrimSpace(parsed.Markdown) + if md == "" { + return "", fmt.Errorf("empty search markdown") + } + return md, nil +} diff --git a/adapters/pitransport/remote_result.go b/adapters/pitransport/remote_result.go new file mode 100644 index 0000000..be48ce7 --- /dev/null +++ b/adapters/pitransport/remote_result.go @@ -0,0 +1,108 @@ +package pitransport + +import ( + "encoding/json" + "fmt" + "strings" +) + +type remoteRunWire struct { + OK bool `json:"ok"` + RunID string `json:"run_id"` + FallbackReason string `json:"fallback_reason"` + Retryable bool `json:"retryable"` + RunnerProtocol string `json:"runner_protocol"` + Capabilities *struct { + TypedResult bool `json:"typed_result"` + } `json:"capabilities"` + ResultV1 *ResultV1 `json:"result_v1"` + Patches []struct { + RelPath string `json:"rel_path"` + Content string `json:"content"` + } `json:"patches"` +} + +type decodedRemote struct { + OK bool + Retryable bool + Reason string + Incoming map[string][]byte + Typed *ResultV1 + Legacy bool +} + +func claimsTypedCapability(w remoteRunWire) bool { + if w.Capabilities != nil && w.Capabilities.TypedResult { + return true + } + return strings.EqualFold(strings.TrimSpace(w.RunnerProtocol), RunnerProtocolTypedV1) +} + +func isLegacyHandshake(w remoteRunWire) bool { + return strings.EqualFold(strings.TrimSpace(w.RunnerProtocol), RunnerProtocolLegacy) +} + +func DecodeRemoteRun(body []byte, expectedRunID string, typedRequested bool) (*decodedRemote, error) { + if len(body) == 0 { + return nil, fmt.Errorf("%w: empty body", ErrTypedResult) + } + var w remoteRunWire + if err := json.Unmarshal(body, &w); err != nil { + return nil, fmt.Errorf("failed to decode remote response") + } + incoming := make(map[string][]byte, len(w.Patches)) + for _, pt := range w.Patches { + if len(pt.Content) > MaxResultMemberBytes { + return nil, fmt.Errorf("%w: member size", ErrTypedResult) + } + incoming[pt.RelPath] = []byte(pt.Content) + } + + out := &decodedRemote{OK: w.OK, Incoming: incoming} + + if w.ResultV1 != nil { + if err := w.ResultV1.Validate(expectedRunID, incoming); err != nil { + return nil, err + } + out.Typed = w.ResultV1 + out.Retryable = TypedRetryable(*w.ResultV1) + if w.ResultV1.Outcome == "error" { + out.OK = false + out.Reason = w.ResultV1.ErrorCode + } + return out, nil + } + + // typed-present is handled above. Missing typed payload: + // handshake downgrade — claiming typed capability without result_v1 — fail closed. + if typedRequested && (claimsTypedCapability(w) || !isLegacyHandshake(w)) { + return nil, fmt.Errorf("%w: missing result_v1", ErrTypedResult) + } + + out.Legacy = true + out.Retryable = w.Retryable + out.Reason = safeLegacyReason(w.FallbackReason) + return out, nil +} + +func safeLegacyReason(reason string) string { + r := strings.TrimSpace(reason) + switch { + case r == "": + return "remote pi-agent reported task failure" + case strings.EqualFold(r, "prompt_empty"): + return "PROMPT_EMPTY" + case strings.Contains(strings.ToLower(r), "unauthorized"): + return "UNAUTHORIZED" + case strings.Contains(strings.ToLower(r), "timeout"): + return "TIMEOUT" + default: + if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") { + return "RUN_FAILED" + } + if len(r) > 64 { + return "RUN_FAILED" + } + return r + } +} diff --git a/adapters/pitransport/resultv1.go b/adapters/pitransport/resultv1.go new file mode 100644 index 0000000..3085e44 --- /dev/null +++ b/adapters/pitransport/resultv1.go @@ -0,0 +1,178 @@ +package pitransport + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "strings" +) + +const ( + ResultV1SchemaVersion = 1 + MaxResultMembers = 32 + MaxResultMemberBytes = 2 << 20 + MaxResultBodyBytes = 8 << 20 + RunnerProtocolLegacy = "legacy" + RunnerProtocolTypedV1 = "typed-v1" +) + +var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected") + +var allowedErrorCodes = map[string]struct{}{ + "PROMPT_EMPTY": {}, + "UNAUTHORIZED": {}, + "TIMEOUT": {}, + "CANCELLED": {}, + "TRANSIENT": {}, + "MISSING_TARGET": {}, + "RUN_FAILED": {}, +} + +var retryableErrorCodes = map[string]struct{}{ + "TIMEOUT": {}, + "CANCELLED": {}, + "TRANSIENT": {}, +} + +type ResultV1Member struct { + Kind string `json:"kind"` + Path string `json:"path"` + Digest string `json:"digest"` + Size int `json:"size"` +} + +type ResultV1 struct { + SchemaVersion int `json:"schema_version"` + RunID string `json:"run_id"` + Outcome string `json:"outcome"` + Retryable bool `json:"retryable"` + ErrorCode string `json:"error_code,omitempty"` + Members []ResultV1Member `json:"members"` + ContentDigest string `json:"content_digest"` +} + +func SafeRelPath(p string) bool { + p = strings.TrimSpace(p) + if p == "" || filepath.IsAbs(p) { + return false + } + clean := filepath.ToSlash(filepath.Clean(p)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(p, "..") { + return false + } + return true +} + +func DigestBytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func (r ResultV1) MembersDigest() string { + type row struct { + Kind string `json:"kind"` + Path string `json:"path"` + Digest string `json:"digest"` + Size int `json:"size"` + } + members := r.Members + if members == nil { + members = []ResultV1Member{} + } + rows := make([]row, 0, len(members)) + for _, m := range members { + rows = append(rows, row{m.Kind, m.Path, m.Digest, m.Size}) + } + raw, _ := json.Marshal(rows) + return DigestBytes(raw) +} + +func (r ResultV1) Validate(expectedRunID string, patches map[string][]byte) error { + if r.SchemaVersion != ResultV1SchemaVersion { + return fmt.Errorf("%w: schema", ErrTypedResult) + } + if strings.TrimSpace(r.RunID) == "" { + return fmt.Errorf("%w: run_id", ErrTypedResult) + } + if expectedRunID != "" && r.RunID != expectedRunID { + return fmt.Errorf("%w: run mismatch", ErrTypedResult) + } + if r.Outcome != "success" && r.Outcome != "error" { + return fmt.Errorf("%w: outcome", ErrTypedResult) + } + if r.Outcome == "success" && strings.TrimSpace(r.ErrorCode) != "" { + return fmt.Errorf("%w: error_code", ErrTypedResult) + } + if r.Outcome == "error" { + if _, ok := allowedErrorCodes[r.ErrorCode]; !ok { + return fmt.Errorf("%w: error_code", ErrTypedResult) + } + } + if len(r.Members) > MaxResultMembers { + return fmt.Errorf("%w: members", ErrTypedResult) + } + total := 0 + seen := map[string]struct{}{} + for _, m := range r.Members { + if m.Kind != "file" || !SafeRelPath(m.Path) { + return fmt.Errorf("%w: member path", ErrTypedResult) + } + if _, dup := seen[m.Path]; dup { + return fmt.Errorf("%w: duplicate member", ErrTypedResult) + } + seen[m.Path] = struct{}{} + if m.Size < 0 || m.Size > MaxResultMemberBytes { + return fmt.Errorf("%w: member size", ErrTypedResult) + } + total += m.Size + body, ok := patches[m.Path] + if !ok { + return fmt.Errorf("%w: member missing body", ErrTypedResult) + } + if len(body) != m.Size || DigestBytes(body) != m.Digest { + return fmt.Errorf("%w: digest", ErrTypedResult) + } + } + if total > MaxResultBodyBytes { + return fmt.Errorf("%w: body", ErrTypedResult) + } + if r.ContentDigest != r.MembersDigest() { + return fmt.Errorf("%w: content_digest", ErrTypedResult) + } + return nil +} + +func TypedRetryable(r ResultV1) bool { + if r.Outcome != "error" || !r.Retryable { + return false + } + _, ok := retryableErrorCodes[r.ErrorCode] + return ok +} + +func BuildResultV1(runID, outcome string, retryable bool, errorCode string, patches []struct { + RelPath string + Content []byte +}) ResultV1 { + members := make([]ResultV1Member, 0, len(patches)) + for _, p := range patches { + if !SafeRelPath(p.RelPath) { + continue + } + members = append(members, ResultV1Member{ + Kind: "file", Path: p.RelPath, Digest: DigestBytes(p.Content), Size: len(p.Content), + }) + } + r := ResultV1{ + SchemaVersion: ResultV1SchemaVersion, + RunID: runID, + Outcome: outcome, + Retryable: retryable, + ErrorCode: errorCode, + Members: members, + } + r.ContentDigest = r.MembersDigest() + return r +} diff --git a/adapters/pitransport/resultv1_test.go b/adapters/pitransport/resultv1_test.go new file mode 100644 index 0000000..8446f05 --- /dev/null +++ b/adapters/pitransport/resultv1_test.go @@ -0,0 +1,126 @@ +package pitransport + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestSafeRelPathRejectsEscape(t *testing.T) { + if SafeRelPath("/etc/passwd") || SafeRelPath("../x") || SafeRelPath("a/../../b") || SafeRelPath("./../x") { + t.Fatal("escape") + } + if !SafeRelPath("output.md") || !SafeRelPath("tasks/01_draft/output.md") { + t.Fatal("relative deliverable") + } +} + +func TestResultV1FailClosedOnBadDigest(t *testing.T) { + body := []byte("hello") + r := BuildResultV1("run-1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", body}}) + patches := map[string][]byte{"output.md": []byte("tampered")} + if err := r.Validate("run-1", patches); err == nil { + t.Fatal("tamper must fail closed") + } + patches["output.md"] = body + if err := r.Validate("run-1", patches); err != nil { + t.Fatal(err) + } + if err := r.Validate("other", patches); err == nil { + t.Fatal("run mismatch") + } +} + +func TestResultV1RejectsUnknownMajor(t *testing.T) { + r := ResultV1{SchemaVersion: 99, RunID: "r", Outcome: "success", ContentDigest: "x"} + if err := r.Validate("r", nil); err == nil { + t.Fatal("unknown major") + } +} + +func TestResultV1RejectsUnknownErrorCode(t *testing.T) { + r := BuildResultV1("run-1", "error", true, "DROP TABLE users", nil) + if err := r.Validate("run-1", map[string][]byte{}); err == nil { + t.Fatal("arbitrary error_code must fail closed") + } +} + +func TestResultV1RejectsMissingMemberBody(t *testing.T) { + r := BuildResultV1("run-1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + if err := r.Validate("run-1", map[string][]byte{}); err == nil { + t.Fatal("member without patch body must fail closed") + } +} + +func TestTypedRetryableAllowlist(t *testing.T) { + ok := BuildResultV1("r", "error", true, "TRANSIENT", nil) + if !TypedRetryable(ok) { + t.Fatal("TRANSIENT retryable") + } + denied := BuildResultV1("r", "error", true, "PROMPT_EMPTY", nil) + if TypedRetryable(denied) { + t.Fatal("PROMPT_EMPTY must not retry") + } +} + +func TestMembersDigestMatchesNodeCanonicalJSON(t *testing.T) { + r := BuildResultV1("run-js", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + script := `import { membersDigest } from './adapters/pitransport/testdata/result-v1.mjs'; +const members = ` + mustJSON(t, r.Members) + `; +process.stdout.write(membersDigest(members)); +` + _, thisFile, _, _ := runtime.Caller(0) + root := filepath.Join(filepath.Dir(thisFile), "..", "..") + cmd := exec.Command("node", "--input-type=module", "-e", script) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("node digest: %v %s", err, out) + } + if got := strings.TrimSpace(string(out)); got != r.ContentDigest { + t.Fatalf("js digest %s != go %s", got, r.ContentDigest) + } +} + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestGoldenResultV1Fixture(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "pi-run-result-v1.golden.json")) + if err != nil { + t.Fatal(err) + } + var rv ResultV1 + if err := json.Unmarshal(raw, &rv); err != nil { + t.Fatal(err) + } + if err := rv.Validate("golden-run-1", map[string][]byte{"output.md": []byte("hello")}); err != nil { + t.Fatal(err) + } + built := BuildResultV1("golden-run-1", "success", false, "", []struct { + RelPath string + Content []byte + }{{"output.md", []byte("hello")}}) + if built.ContentDigest != rv.ContentDigest { + t.Fatalf("golden digest drift go=%s file=%s", built.ContentDigest, rv.ContentDigest) + } +} diff --git a/adapters/pitransport/runner_auth.go b/adapters/pitransport/runner_auth.go new file mode 100644 index 0000000..d0e0319 --- /dev/null +++ b/adapters/pitransport/runner_auth.go @@ -0,0 +1,13 @@ +package pitransport + +import "net/http" + +const runnerAuthHeader = "Authorization" + +// ApplyRunnerAuth 为 Runner HTTP 请求附加 Bearer token(token 为空时不设置)。 +func ApplyRunnerAuth(req *http.Request, token string) { + if req == nil || token == "" { + return + } + req.Header.Set(runnerAuthHeader, "Bearer "+token) +} diff --git a/adapters/pitransport/testdata/pi-run-result-v1.golden.json b/adapters/pitransport/testdata/pi-run-result-v1.golden.json new file mode 100644 index 0000000..1d7ec08 --- /dev/null +++ b/adapters/pitransport/testdata/pi-run-result-v1.golden.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "run_id": "golden-run-1", + "outcome": "success", + "retryable": false, + "members": [ + { + "kind": "file", + "path": "output.md", + "digest": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "size": 5 + } + ], + "content_digest": "ee8a9ea18dc27e1abaaa1541db75c045fb5969400e6d38b8305176931dcff22a" +} diff --git a/adapters/pitransport/testdata/result-v1.mjs b/adapters/pitransport/testdata/result-v1.mjs new file mode 100644 index 0000000..ac4f9fd --- /dev/null +++ b/adapters/pitransport/testdata/result-v1.mjs @@ -0,0 +1,125 @@ +import { createHash } from "node:crypto"; + +export const RESULT_V1_SCHEMA_VERSION = 1; +export const MAX_RESULT_MEMBERS = 32; +export const MAX_RESULT_MEMBER_BYTES = 2 * 1024 * 1024; +/** Soft cap for HTTP wire payloads; Go fail-closes at 8MB. */ +export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024; +/** Tail window for execution.log patches; full log remains on shared volume. */ +export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024; + +const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]); +const ALLOWED_CODES = new Set([ + "PROMPT_EMPTY", + "UNAUTHORIZED", + "TIMEOUT", + "CANCELLED", + "TRANSIENT", + "MISSING_TARGET", + "RUN_FAILED", +]); + +export function typedErrorCode(payload) { + const raw = String(payload?.fallback_reason || payload?.error || ""); + const t = raw.toLowerCase(); + if (t.includes("prompt_empty")) return "PROMPT_EMPTY"; + if (t.includes("unauthorized")) return "UNAUTHORIZED"; + if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT"; + if (t.includes("cancel")) return "CANCELLED"; + if ( + t.includes("connection") || + t.includes("terminated") || + t.includes("econnreset") || + t.includes("socket hang") + ) { + return "TRANSIENT"; + } + if (t.includes("missing target")) return "MISSING_TARGET"; + if (payload?.retryable) return "TRANSIENT"; + if (!payload?.ok) return "RUN_FAILED"; + return ""; +} + +export function membersDigest(members) { + const rows = (Array.isArray(members) ? members : []).map((m) => ({ + kind: m.kind, + path: m.path, + digest: m.digest, + size: m.size, + })); + return createHash("sha256").update(JSON.stringify(rows)).digest("hex"); +} + +/** Keep only the trailing bytes of execution.log for the HTTP patch wire. */ +export function truncateExecutionLogContent(content, maxBytes = EXECUTION_LOG_PATCH_BYTES) { + const buf = Buffer.from(String(content || ""), "utf8"); + if (buf.length <= maxBytes) { + return buf.toString("utf8"); + } + const tail = buf.subarray(buf.length - maxBytes); + return `[truncated ${buf.length - maxBytes} bytes]\n` + tail.toString("utf8"); +} + +/** + * Cap patch bodies so members and patches stay aligned under wire limits. + * Oversized / overflow members are dropped from both lists (fail closed on wire size). + */ +export function capPatches(patches, opts = {}) { + const maxMember = opts.maxMemberBytes ?? MAX_RESULT_MEMBER_BYTES; + const maxTotal = opts.maxTotalBytes ?? MAX_RESULT_WIRE_BYTES; + const maxMembers = opts.maxMembers ?? MAX_RESULT_MEMBERS; + const out = []; + let total = 0; + for (const p of Array.isArray(patches) ? patches : []) { + const rel = String(p?.rel_path || ""); + if (!rel || rel.includes("..") || rel.startsWith("/")) continue; + let content = String(p.content || ""); + if (rel === "execution.log" || rel.endsWith("/execution.log")) { + content = truncateExecutionLogContent(content); + } + const buf = Buffer.from(content, "utf8"); + if (buf.length > maxMember) continue; + if (out.length >= maxMembers) break; + if (total + buf.length > maxTotal) continue; + total += buf.length; + out.push({ rel_path: rel, content }); + } + return out; +} + +export function estimateWireBytes(payload) { + return Buffer.byteLength(JSON.stringify(payload), "utf8"); +} + +export function withResultV1(payload) { + const out = { ...payload }; + delete out.stderr; + const capped = capPatches(Array.isArray(out.patches) ? out.patches : []); + out.patches = capped; + const members = []; + for (const p of capped) { + const rel = String(p?.rel_path || ""); + const buf = Buffer.from(String(p.content || ""), "utf8"); + members.push({ + kind: "file", + path: rel, + digest: createHash("sha256").update(buf).digest("hex"), + size: buf.length, + }); + } + const ok = !!out.ok; + const errorCode = ok ? "" : typedErrorCode(out); + const retryable = !ok && RETRYABLE_CODES.has(errorCode) && !!out.retryable; + out.capabilities = { typed_result: true }; + out.runner_protocol = "typed-v1"; + out.result_v1 = { + schema_version: RESULT_V1_SCHEMA_VERSION, + run_id: String(out.run_id || "unknown").slice(0, 128), + outcome: ok ? "success" : "error", + retryable, + error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED", + members, + content_digest: membersDigest(members), + }; + return out; +} diff --git a/adapters/pitransport/testdata/settle-policy.mjs b/adapters/pitransport/testdata/settle-policy.mjs new file mode 100644 index 0000000..e95e4b0 --- /dev/null +++ b/adapters/pitransport/testdata/settle-policy.mjs @@ -0,0 +1,125 @@ +/** + * Success / retry classification for a PI RPC session. + * Write() arguments in traces are never treated as deliverables. + */ + +export function newSettleState() { + return { + retryFailed: false, + providerTransient: false, + lastErr: "", + succeeded: false, + }; +} + +function messageField(ev, key) { + const msg = ev && ev.message; + if (!msg || typeof msg !== "object") { + return ""; + } + const v = msg[key]; + return typeof v === "string" ? v : ""; +} + +export function isTransientText(s) { + const t = String(s || "").toLowerCase(); + return t.includes("connection error") || + t.includes("terminated") || + t.includes("auto_retry") || + t.includes("econnreset") || + t.includes("socket hang up"); +} + +export function isPermanentReason(s) { + const t = String(s || "").toLowerCase(); + return t.includes("prompt_empty") || + t.includes("unauthorized") || + t.includes("治理"); +} + +export function applyAgentEvent(state, ev) { + if (!state || !ev || typeof ev !== "object") { + return state; + } + const t = ev.type || ""; + const stop = messageField(ev, "stopReason"); + const msgErr = messageField(ev, "errorMessage") || + (typeof ev.errorMessage === "string" ? ev.errorMessage : "") || + (typeof ev.finalError === "string" ? ev.finalError : "") || + (ev.error && typeof ev.error.message === "string" ? ev.error.message : ""); + + if (msgErr && isTransientText(msgErr)) { + state.providerTransient = true; + state.lastErr = msgErr; + } + if (stop === "error") { + state.providerTransient = true; + if (msgErr) { + state.lastErr = msgErr; + } + } + if (t === "auto_retry_start") { + state.providerTransient = true; + if (ev.errorMessage) { + state.lastErr = String(ev.errorMessage); + } + } + if (t === "auto_retry_end") { + if (ev.success === false) { + state.retryFailed = true; + state.providerTransient = true; + state.lastErr = ev.finalError || ev.errorMessage || state.lastErr || "auto_retry_failed"; + } else if (ev.success === true) { + // SDK in-process retry recovered. Drop this-round transient so a later + // agent_settled can succeed; later events may set a new error again. + state.retryFailed = false; + state.providerTransient = false; + state.lastErr = ""; + } else { + state.retryFailed = false; + } + } + if (t === "agent_error") { + const err = (ev.error && ev.error.message) || ev.message || "agent_error"; + const text = typeof err === "string" ? err : "agent_error"; + state.lastErr = text; + if (isTransientText(text)) { + state.providerTransient = true; + } + } + if (t === "agent_settled") { + // PI emits agent_settled even after exhausted SDK retries. + state.succeeded = !state.retryFailed && !state.providerTransient; + } + return state; +} + +export function classifyRunOutcome({ + succeeded = false, + retryFailed = false, + providerTransient = false, + lastErr = "", + missing = [], +} = {}) { + const miss = Array.isArray(missing) ? missing.filter(Boolean) : []; + if (isPermanentReason(lastErr)) { + return { ok: false, retryable: false, fallback_reason: lastErr || "permanent_failure" }; + } + if (!succeeded) { + const reason = lastErr || "agent_not_settled"; + const retryable = (retryFailed || providerTransient || isTransientText(reason)) && + !isPermanentReason(reason); + return { ok: false, retryable, fallback_reason: reason }; + } + if (miss.length > 0) { + // Clean settle without writing the target is still retryable once at the + // product layer (Go AssessStepFailure → settled-no-deliverable). Runner + // signals retryable; Go remains the final adjudicator and bound. + return { + ok: false, + retryable: true, + fallback_reason: `missing target deliverable ${miss.join(",")}`, + }; + } + return { ok: true, retryable: false, fallback_reason: "" }; +} diff --git a/conformance/fake.go b/conformance/fake.go new file mode 100644 index 0000000..72ef049 --- /dev/null +++ b/conformance/fake.go @@ -0,0 +1,147 @@ +// Package conformance 提供所有 Backend 实现共用的行为验收套件。 +// +// 用法:实现方在自己的测试中调用 conformance.Run(t, backend, conformance.Case{...})。 +// fake 后端用于契约自测;真实实现(runtime、PI adapter)必须通过同一套件, +// fake 通过不等于真实适配完成(分别记证据)。 +package conformance + +import ( + "context" + "fmt" + "testing" + "time" + + "agent-native/contract" +) + +// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。 +// 不模拟任何模型行为;按 Script 逐项返回。 +type FakeBackend struct { + Desc contract.BackendDescriptor + // Result 为每次 Execute 返回的终态(浅拷贝后返回)。 + Result contract.Result + // BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消, + // 用于验证取消语义。 + BlockUntil <-chan struct{} + // CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。 + CanceledWithCtx bool + // LastRequest 记录最近一次请求。 + LastRequest contract.Request +} + +func (f *FakeBackend) Descriptor() contract.BackendDescriptor { return f.Desc } + +func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contract.Result, error) { + f.LastRequest = req + if f.BlockUntil != nil { + select { + case <-f.BlockUntil: + case <-ctx.Done(): + f.CanceledWithCtx = true + res := *(&f.Result) + res.Outcome = contract.OutcomeCanceled + res.Err = &contract.Error{ + Code: contract.ErrCanceled, + SideEffects: contract.SideEffectNone, + } + return &res, nil + } + } + res := f.Result + return &res, nil +} + +// Case 是一条契约验收用例。 +type Case struct { + Name string + // Backend 为被测实现。 + Backend contract.Backend + // Request 为请求模板。 + Request contract.Request + // CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。 + CancelAfter time.Duration + // WantOutcome 为期望终态。 + WantOutcome contract.Outcome + // WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。 + WantErrCode contract.ErrorCode +} + +// Run 执行一条契约用例。 +func Run(t *testing.T, c Case) { + t.Helper() + ctx := context.Background() + var cancel context.CancelFunc + if c.CancelAfter > 0 { + ctx, cancel = context.WithTimeout(ctx, c.CancelAfter) + defer cancel() + } + res, err := c.Backend.Execute(ctx, c.Request) + if err != nil { + t.Fatalf("[%s] Execute returned transport error %v; business outcomes must be in Result", c.Name, err) + } + if res == nil { + t.Fatalf("[%s] Execute returned nil Result", c.Name) + } + if res.Outcome != c.WantOutcome { + t.Fatalf("[%s] Outcome = %q, want %q", c.Name, res.Outcome, c.WantOutcome) + } + if c.WantErrCode != "" { + if res.Err == nil { + t.Fatalf("[%s] expected error code %q, got nil Err", c.Name, c.WantErrCode) + } + if res.Err.Code != c.WantErrCode { + t.Fatalf("[%s] Err.Code = %q, want %q", c.Name, res.Err.Code, c.WantErrCode) + } + if res.Err.SideEffects == "" { + t.Fatalf("[%s] Err.SideEffects must be explicit (none|possible), got empty", c.Name) + } + } + if res.Outcome == contract.OutcomeSucceeded && res.Err != nil { + t.Fatalf("[%s] Succeeded result must not carry Err", c.Name) + } +} + +// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。 +// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。 +func Suite(t *testing.T, mk func() contract.Backend) { + t.Helper() + + t.Run("succeeded_minimal", func(t *testing.T) { + b := mk() + Run(t, Case{ + Name: "succeeded", + Backend: b, + Request: contract.Request{ExecutionID: "e1", Prompt: "hi"}, + WantOutcome: contract.OutcomeSucceeded, + }) + }) + + t.Run("descriptor_has_name", func(t *testing.T) { + b := mk() + d := b.Descriptor() + if d.Name == "" { + t.Fatal("Descriptor().Name must not be empty") + } + if len(d.Capabilities) == 0 { + t.Fatal("Descriptor().Capabilities must declare at least one capability") + } + }) + + t.Run("error_sideeffects_explicit", func(t *testing.T) { + b := mk() + // 通过描述能力判定是否有失败路径可测;无失败路径的实现跳过。 + if !b.Descriptor().Has(contract.CapText) && !b.Descriptor().Has(contract.CapFiles) { + t.Skip("no text/files capability to force failure path") + } + // 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed + // 时错误字段完整。具体实现应补充自身失败注入用例。 + res, _ := b.Execute(context.Background(), contract.Request{ExecutionID: "e2"}) + if res != nil && res.Outcome == contract.OutcomeFailed { + if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" { + t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err) + } + } + }) + + _ = fmt.Sprint() // keep fmt import stable for future diagnostics +} diff --git a/contract/contract.go b/contract/contract.go new file mode 100644 index 0000000..450cb50 --- /dev/null +++ b/contract/contract.go @@ -0,0 +1,259 @@ +// Package contract 定义 Agent-Native 的公共执行契约。 +// +// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体 +// Backend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体 +// 内核、产品类型、数据库或配置包。 +// +// 语义不变量(实现方与消费方都必须遵守): +// +// 1. ExecutionID 在一次逻辑执行内唯一;步骤重试与受控 fallback 必须复用 +// 同一个 ExecutionID,并共享预算与截止时间。 +// 2. Outcome 取值 Succeeded/Failed/Canceled/Unknown 四态之一;Unknown 表示 +// 结果不确定(可能已有副作用),绝不静默降级为 Succeeded 或 Failed。 +// 3. 重试只有一个责任层:步骤级重试归调用方(Squad),传输级有界重试归 +// adapter,循环 turn 上限归 runtime;不得多层重试产生重复写入。 +// 4. 取消必须传播到模型请求、工具调用与子进程;不支持可靠取消的实现必须 +// 报告 cancel_requested 并保持 Unknown,不得伪报 Canceled。 +// 5. 原生执行失败且已有文件或外部动作、或结果 Unknown 时,禁止无条件换 +// 后端重放;是否可重放由调用方依据 Error.SideEffects 判定。 +// 6. 执行 transcript 与产品聊天历史不是同一账本;SessionScope 仅用于隔离 +// 上下文,不授予跨会话可见性。 +// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方 +// 提供的进程/容器隔离,不能由本契约推导。 +package contract + +import ( + "context" + "time" +) + +// Capability 声明后端可验证的能力。解析器按任务需求选择实现;必须能力 +// 缺失时拒绝执行,而不是静默降级到 Stub。 +type Capability string + +const ( + // CapText 表示能返回非空文本结果(对话、摘要等纯文本任务)。 + CapText Capability = "text" + // CapFiles 表示能按 Targets 交付文件候选产物(reply.md/output 流程)。 + CapFiles Capability = "files" + // CapStream 表示支持过程事件流(OnEvent 回调有实际内容)。 + CapStream Capability = "stream" + // CapTools 表示支持受控工具调用(受 ToolPolicy 约束)。 + CapTools Capability = "tools" + // CapCancel 表示支持可靠取消(取消传播到模型/工具/子进程)。 + CapCancel Capability = "cancel" + // CapResume 表示支持跨进程恢复执行(配合 ExecutionJournal 端口)。 + CapResume Capability = "resume" + // CapUsage 表示能报告 token 用量。 + CapUsage Capability = "usage" + // CapStructuredOutput 表示支持结构化(非纯文本)输出。 + CapStructuredOutput Capability = "structured-output" +) + +// ToolProfile 是预定义工具策略名;与现有产品配置兼容。 +const ( + ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合 + ToolProfileReasoning = "reasoning" // 无工具,仅推理 +) + +// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。 +type ToolPolicy struct { + // Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。 + Profile string `json:"profile"` + // Allowlist 显式列出额外允许的工具名;与 Profile 取交集语义由实现声明, + // 未声明时视为仅 Allowlist。 + Allowlist []string `json:"allowlist,omitempty"` +} + +// WorkspaceHandle 是调用方已授权的工作区句柄。Root 之外的路径访问必须失败。 +// 它表达路径授权,不构成操作系统级隔离。 +type WorkspaceHandle struct { + // Root 为授权根目录的绝对路径(已归一化)。 + Root string `json:"root"` + // Writable 声明允许写入的相对路径集合;空表示由产品规则决定(兼容现状)。 + Writable []string `json:"writable,omitempty"` +} + +// SessionScope 隔离执行上下文:不同 tenant/project/session 的会话、transcript、 +// 授权与预算互不可见。字段为产品侧标识,契约层只要求非空时保持隔离。 +type SessionScope struct { + Tenant string `json:"tenant,omitempty"` + Project string `json:"project,omitempty"` + Session string `json:"session,omitempty"` +} + +// Budget 约束一次执行(含共享 ExecutionID 的重试)。 +type Budget struct { + // MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。 + MaxPromptTokens int `json:"max_prompt_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + // Deadline 为截止时间;零值表示无外部截止(仍受 ctx 控制)。 + Deadline time.Time `json:"deadline,omitempty"` +} + +// Message 是可选的历史上下文(多轮对话/延续任务)。实现可以忽略,但不得 +// 跨 SessionScope 混入。 +type Message struct { + Role string `json:"role"` // user | assistant | tool | system + Content string `json:"content"` +} + +// Request 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程 +// (Prompt+Instructions+Targets→reply.md)是其子集 facade。 +type Request struct { + // ExecutionID 由调用方生成,重试与受控 fallback 复用同值。 + ExecutionID string `json:"execution_id"` + // SessionScope 为隔离身份;可为零值(一次性任务)。 + SessionScope SessionScope `json:"session_scope"` + + Prompt string `json:"prompt"` + Instructions string `json:"instructions,omitempty"` + // Context 为可选历史消息;实现不得将其与 Prompt 之外的数据混合。 + Context []Message `json:"context,omitempty"` + + // Targets 为期望交付的文件相对路径(文件任务);纯文本任务可空。 + Targets []string `json:"targets,omitempty"` + + // Workspace 为授权工作区;文件任务的读写都必须限制在 Root 内。 + Workspace WorkspaceHandle `json:"workspace"` + // ToolPolicy 为工具范围;空 Profile 等同 reasoning。 + ToolPolicy ToolPolicy `json:"tool_policy"` + // Budget 为本次执行预算。 + Budget Budget `json:"budget"` + // Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。 + Required []Capability `json:"required,omitempty"` + + // OnEvent 为可选过程事件回调(nil = 调用方不需要过程流)。 + // 回调必须在 Execute 返回前完成;回调内不得阻塞。 + OnEvent func(Event) `json:"-"` +} + +// Outcome 是执行终态。Unknown 优先于乐观判定:凡无法确认结果(含进程崩溃、 +// 传输中断后未收到终态)一律 Unknown,由调用方恢复。 +type Outcome string + +const ( + OutcomeSucceeded Outcome = "succeeded" + OutcomeFailed Outcome = "failed" + OutcomeCanceled Outcome = "canceled" + OutcomeUnknown Outcome = "unknown" +) + +// EventKind 过程事件类型(与现有 ProcessEvent 投影兼容)。 +type EventKind string + +const ( + EventTextDelta EventKind = "text_delta" + EventReasoningDelta EventKind = "reasoning_delta" + EventToolCallDelta EventKind = "tool_call_delta" + EventToolExecStart EventKind = "tool_execution_start" + EventToolExecEnd EventKind = "tool_execution_end" + EventMessageEnd EventKind = "message_end" +) + +// Event 是过程事件帧;消费方(Core)负责映射为 SSE/持久化事件。 +type Event struct { + Kind EventKind `json:"kind"` + Text string `json:"text,omitempty"` // *_delta 文本 + Tool string `json:"tool,omitempty"` // tool_* 工具名 + Detail string `json:"detail,omitempty"` // 工具参数/结果摘要 +} + +// SideEffects 描述失败时的副作用状态,决定是否允许重试/fallback 重放。 +type SideEffects string + +const ( + // SideEffectNone:确认未产生任何外部副作用(未派发或纯读取后失败)。 + SideEffectNone SideEffects = "none" + // SideEffectPossible:可能已有副作用(文件已写、外部工具已调用、 + // 终态未知)。禁止无条件重放;恢复判断归调用方。 + SideEffectPossible SideEffects = "possible" +) + +// ErrorCode 稳定错误分类。未知代码按 Unknown 处理。 +type ErrorCode string + +const ( + ErrCanceled ErrorCode = "canceled" + ErrDeadlineExceeded ErrorCode = "deadline_exceeded" + ErrBudgetExceeded ErrorCode = "budget_exceeded" + ErrProviderUnavailable ErrorCode = "provider_unavailable" + ErrProviderRejected ErrorCode = "provider_rejected" + ErrToolRejected ErrorCode = "tool_rejected" + ErrPathOutsideWorkspace ErrorCode = "path_outside_workspace" + ErrMissingCapability ErrorCode = "missing_capability" + ErrMissingDeliverable ErrorCode = "missing_deliverable" + ErrEmptyOutput ErrorCode = "empty_output" + ErrUnknownOutcome ErrorCode = "unknown_outcome" + ErrInternal ErrorCode = "internal" +) + +// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界 +// 重试或调用方作为新步骤重试;与 SideEffects 独立判定。 +type Error struct { + Code ErrorCode `json:"code"` + Reason string `json:"reason,omitempty"` + Retryable bool `json:"retryable,omitempty"` + SideEffects SideEffects `json:"side_effects"` +} + +func (e *Error) Error() string { return string(e.Code) + ": " + e.Reason } + +// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。 +type Usage struct { + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` +} + +// Result 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。 +type Result struct { + Outcome Outcome `json:"outcome"` + // Text 为文本结果(CapText 任务必填)。 + Text string `json:"text,omitempty"` + // Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。 + Files map[string][]byte `json:"files,omitempty"` + // Logs 为执行日志/过程摘要(兼容现有 Result.Logs)。 + Logs string `json:"logs,omitempty"` + // Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。 + Err *Error `json:"err,omitempty"` + // Usage 为 token 用量。 + Usage Usage `json:"usage"` +} + +// BackendDescriptor 声明实现身份与可验证能力;前端只展示经授权的描述。 +type BackendDescriptor struct { + // Name 为规范后端名(如 agent-native、pi-agent)。规范名 agent-native; + // pi-native 为兼容别名,注册表负责别名解析与弃用周期。 + Name string `json:"name"` + // Capabilities 为该实现声明且经验证的能力集合。 + Capabilities []Capability `json:"capabilities"` + // AdapterVersion 标识实现版本(内核或适配器),用于收据与回退审计。 + AdapterVersion string `json:"adapter_version,omitempty"` +} + +// Has 报告描述是否声明指定能力。 +func (d BackendDescriptor) Has(c Capability) bool { + for _, x := range d.Capabilities { + if x == c { + return true + } + } + return false +} + +// Backend 是统一执行接口。实现必须: +// - 在 ctx 取消时尽快返回(Canceled 或 Unknown); +// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用); +// - 不自行扩大工具范围或读取 Workspace 之外的路径。 +type Backend interface { + Descriptor() BackendDescriptor + // Execute 执行一次授权调用。返回的 Result 永不为 nil;ctx 错误映射为 + // Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。 + Execute(ctx context.Context, req Request) (*Result, error) +} + +// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得 +// 回落到 Stub。 +type Resolver interface { + Resolve(name string, required []Capability) (Backend, error) +} diff --git a/contract/contract_test.go b/contract/contract_test.go new file mode 100644 index 0000000..88087ee --- /dev/null +++ b/contract/contract_test.go @@ -0,0 +1,43 @@ +package contract_test + +import ( + "testing" + + "agent-native/conformance" + "agent-native/contract" +) + +// 契约自测:fake 后端通过最小套件,验证套件本身与类型语义一致。 +func TestFakeBackendSuite(t *testing.T) { + conformance.Suite(t, func() contract.Backend { + return &conformance.FakeBackend{ + Desc: contract.BackendDescriptor{ + Name: "fake", + Capabilities: []contract.Capability{contract.CapText}, + }, + Result: contract.Result{ + Outcome: contract.OutcomeSucceeded, + Text: "ok", + }, + } + }) +} + +func TestFakeCancelSemantics(t *testing.T) { + block := make(chan struct{}) + b := &conformance.FakeBackend{ + Desc: contract.BackendDescriptor{Name: "fake", Capabilities: []contract.Capability{contract.CapCancel}}, + BlockUntil: block, + } + conformance.Run(t, conformance.Case{ + Name: "cancel", + Backend: b, + Request: contract.Request{ExecutionID: "e-cancel"}, + CancelAfter: 50_000_000, // 50ms in ns (avoid time import here) + WantOutcome: contract.OutcomeCanceled, + WantErrCode: contract.ErrCanceled, + }) + if !b.CanceledWithCtx { + t.Fatal("backend must observe ctx cancellation before returning") + } +} diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md new file mode 100644 index 0000000..ec9053e --- /dev/null +++ b/docs/parity-matrix.md @@ -0,0 +1,68 @@ +--- +title: "U9a Parity Matrix:Agent-Native 对齐 PI 0.85.1 核心能力盘点" +date: 2026-09-29 +task_id: multi-repo-design-2026-09-28 +track: U9 (R14:产品核心能力可替代,非上游复刻) +baseline_fixed: true +--- + +# U9a 能力盘点(三层基线固定) + +## 基线 + +| 层 | 版本/内容 | 证据 | +|---|---|---| +| 上游包 | `@earendil-works/pi-coding-agent@0.85.1`(gitHead `d981de12`,MIT) | deploy/pi-agent/Dockerfile:18 | +| 项目 wrapper | deploy/pi-agent:runner.mjs(`/v1/run`、`/v1/run/stream`、`/v1/search`)+ entrypoint(`--mode rpc --no-session`、`--tools read,write,edit --approve`)+ extensions/tavily-search.mjs + result-v1/settle-policy/reasoning-materialize/change-report-narrative .mjs | deploy/pi-agent/* | +| 产品调用侧 | internal/agent/piagent.go(typed_result、NDJSON 流式优先+一次性回落、data-root 不符拒绝、AssessStepFailure 重试分类)、pi_runner_search.go(/v1/search)、MaterializeTargets | internal/agent/* | +| 原生现状 | agent-native:runtime(loop/tools/llm/session)+ exec 冻结端口 + contract v0.1.0(尚未被 runtime 消费)+ adapters/pitransport + abtest(6 例 + Judge) | modules/agent-native | + +对标口径:**产品实际依赖**(wrapper 真实暴露面),不是上游全部功能。任何重构建制以固定 0.85.1 为准,不对上游 main。 + +## Parity Matrix + +状态:native = available / partial / missing / not_required。 + +| # | 能力域 | 上游/wrapper 现状 | 产品依赖 | native 现状 | 差距 → 批次 | +|---|---|---|---|---|---| +| 1 | 多轮执行循环 + 工具调度 | rpc 模式内核 | 必需(全部小队任务) | **available**(loop + 并行工具 + turn cap,runtime/loop.go) | 行为等价性入 Batch2 回归 | +| 2 | 模型流式(Chat Completions/SSE) | MiniMax openai-compatible | 必需 | **available**(llm SSE 客户端) | 流错误/截断负例入 Batch1 | +| 3 | 文件工具 read/write/edit/list | wrapper 限定 `--tools read,write,edit --approve` | 必需 | **available**(builtin 四件 + jail 路径约束) | 工具语义负例(越界/截断/错误格式)Batch2 | +| 4 | 工具策略(workspace/reasoning profile) | wrapper 参数组合 | 必需 | **partial**(jail 有;profile 组装在 Core,TaskPolicy 未接 runtime) | Batch2 | +| 5 | **搜索(Tavily /v1/search)** | 项目自加扩展(非上游核心) | **必需**(pi_runner_search.go 消费方) | **missing** | **Batch1**:builtin search 工具 + Provider 接口(密钥 Core 注入) | +| 6 | typed-result-v1 编解码/摘要 | result-v1.mjs(canonical JSON digest) | 必需(PI 互操作合同) | **available**(adapters/pitransport,含 Node 交叉验证测试) | 维持;native 自产结果不经此编码 | +| 7 | 结果 settle/outcome 分类 | settle-policy.mjs(agent_settled 等) | 必需(成功/失败判定入口) | **available**(exec.AssessStepFailure) | **Batch1**:与 settle-policy 逐事件 fixture 对齐 | +| 8 | 过程流 NDJSON(proc 帧) | /v1/run/stream 实时转发 | 必需(小队过程视图 2026-09-28 设计) | **partial**(runtime Event 有;词汇表与 proc 帧未对齐) | **Batch1**:事件词汇 conformance | +| 9 | 交付物物化(Targets→Patches 校验) | 产品侧 MaterializeTargets | 必需 | **available**(exec.deliverable) | 回归入 Batch2 | +| 10 | 取消传播 | 产品侧 stream ctx 取消(retryable) | 必需 | **partial**(loop ctx 有;模型/工具层传播未验证,contract CapCancel 未接) | **Batch2** | +| 11 | 长上下文 compaction | 上游核心有(长会话压缩);wrapper 未禁用 | **依赖未证实**(任务为有界单发,--no-session) | missing | Batch3 可选:先实测长任务 token 压力再定 | +| 12 | 会话/执行恢复 | 上游 sessions;wrapper 明确 `--no-session`(临时会话) | **not_required**(产品恢复走 task_runs/重跑,不延续上游会话) | partial(JSONL transcript) | Batch3:对齐 contract 的 Unknown/resume 语义即可 | +| 13 | TS 扩展 ABI | 上游支持 | **not_required**(仅 Tavily 一个项目自写扩展 → 以 #5 builtin 工具替代) | — | 不做 | +| 14 | 终端 UI/主题/全供应商登录/全部 SDK 面 | 上游产品功能 | **not_required**(方案 KTD12 明确不复刻) | — | 不做 | +| 15 | reasoning-materialize / change-report-narrative(.mjs) | 项目自加后处理 | partial(app 组合根 pi-agent 条件门控的 summary/narrative) | missing | Batch3:由 Core 侧等价实现或显式降级,不搬 .mjs | +| 16 | usage/预算 | rpc 报用量 | 必需(Result token 字段) | **partial**(llm 用量有;预算/截止未贯穿) | Batch2 | + +## U9b 分批补齐计划(估算按有上下文实施者工作日) + +| 批 | 内容 | 估算 | 出口 | +|---|---|---|---| +| **Batch1 必需协议域**(✅ 2026-09-29 完成) | ① builtin `web_search` 工具(SearchProvider 接口,Tavily 兼容响应形状;密钥/端点经 Options 注入,测试用 fake)② settle/outcome 分类与 settle-policy.mjs 逐事件 fixture 对齐(Node fixture 复用 pitransport testdata 模式)③ 过程事件词汇表 conformance(runtime.Event ⇄ proc 帧:text_delta/reasoning_delta/tool_* 全集) | 实际 1 日 | 三域全部落地:`runtime/tools/search.go`(密钥池轮换+输出逐字段对齐+8 测试);`exec/settle_parity_test.go`(10 场景 Go+Node 双侧交叉验证,Go 为终裁者契约);`runtime/event_vocabulary_test.go`(行为级词汇 conformance)。#5/#7/#8 关闭 | +| **Batch2 安全与语义域** | ④ 取消传播(ctx→llm 请求/工具执行;contract CapCancel 接 runtime)⑤ 工具语义负例(jail 越界、长输出截断、错误格式)⑥ retry/failure 分类与传输错误矩阵回归 ⑦ 预算/截止贯穿(Budget→loop) | 2–3 日 | #3/#4/#10/#16 关闭;负例不冒充 PASS | +| **Batch3 长尾与决策域** | ⑧ 长任务 token 实测 → 决定 compaction 是否必需 ⑨ transcript→contract resume 语义对齐 ⑩ summary/narrative Core 等价实现或显式降级声明 | 3–5 日(含实测) | not_required 项有书面证据;partial 项二选一定案 | + +## U9c 对比验收(扩展现有 6 例) + +- 每个必需域(#1–#10、#16)至少:正常 1 + 异常 1 + 边界 1 配对用例,双后端同输入; +- Judge 增补:绝对成功率门槛(当前仅相对)、语义 rubric、实际后端身份断言(禁 fallback 混淆)、副作用检查; +- 固定语料重复配对(≥10 样本/域)报告分布;P95≤1.3× 沿用但 6 例不得外推结论; +- 准入:必需域全过 + 严重缺陷 0 + 语义无重大缺项;SKIP/零用例不算 PASS。 + +## U9d/e 渐进启用与收口 + +按方案原条款执行:隔离环境/内部账号允许清单 → 按任务类别开放 → 配置 PR 显式切换 → PI 适配器保留回退。默认后端不因验收通过自动切换。 + +## 记录与证据 + +- 本矩阵证据均指向仓库内文件(上文路径),不含运行时截图; +- Batch1 完成后在本文件追加"实测证据"列; +- 上游仅引固定 0.85.1 的 README/docs 结论,不引 main。 diff --git a/exec/backend.go b/exec/backend.go new file mode 100644 index 0000000..2ebbcc9 --- /dev/null +++ b/exec/backend.go @@ -0,0 +1,52 @@ +package exec + +import ( + "context" + "time" +) + +type Request struct { + WorkDir string `json:"work_dir"` + Prompt string `json:"prompt"` + Instructions string `json:"instructions"` + Targets []string `json:"targets"` + Timeout time.Duration `json:"timeout"` + // ToolProfile is workspace (default, document tools) or reasoning (no tools). + ToolProfile string `json:"tool_profile,omitempty"` + // OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。 + // 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine; + // 回调内不得阻塞(消费方自行节流/异步入队)。 + OnEvent func(ev ProcessEvent) +} + +// ProcessEvent 对 agentx 事件与远程 runner NDJSON 过程帧的统一投影, +// 供小队过程流式展示消费(见 .doc/system-design/2026-09-28-agent-process-streaming-and-subagent-view-design.md)。 +type ProcessEvent struct { + Type string // text_delta | reasoning_delta | tool_call_delta | tool_execution_start | tool_execution_end | message_end | ... + Text string // delta 文本(Type 为 *_delta 时) + Tool string // 工具名(tool_* 时) + Detail string // 工具参数/结果摘要(tool_execution_* 时) +} + +const ( + ToolProfileWorkspace = "workspace" + ToolProfileReasoning = "reasoning" +) + +type Result struct { + Success bool `json:"success"` + Patches map[string][]byte `json:"patches"` + Logs string `json:"logs"` + ErrorReason string `json:"error_reason"` + // 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 { + Name() string + Execute(ctx context.Context, req Request) (*Result, error) +} diff --git a/exec/backend_test.go b/exec/backend_test.go new file mode 100644 index 0000000..ae0e125 --- /dev/null +++ b/exec/backend_test.go @@ -0,0 +1,40 @@ +package exec_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "agent-native/exec" +) + +func TestStubBackendExecution(t *testing.T) { + tmpDir := t.TempDir() + + backend := &exec.StubBackend{} + if backend.Name() != "stub" { + t.Errorf("expected stub backend, got %s", backend.Name()) + } + + req := exec.Request{ + WorkDir: tmpDir, + Prompt: "请编写用户初始需求收集表", + Targets: []string{"output.md"}, + Timeout: 5 * time.Second, + } + + res, err := backend.Execute(context.Background(), req) + if err != nil { + t.Fatalf("stub execute failed: %v", err) + } + if !res.Success { + t.Fatalf("expected success") + } + + outputPath := filepath.Join(tmpDir, "output.md") + if _, err := os.Stat(outputPath); err != nil { + t.Fatalf("expected output.md to exist: %v", err) + } +} diff --git a/exec/deliverable.go b/exec/deliverable.go new file mode 100644 index 0000000..a685ae7 --- /dev/null +++ b/exec/deliverable.go @@ -0,0 +1,141 @@ +package exec + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +func slashClean(p string) string { + return filepath.ToSlash(filepath.Clean(p)) +} + +// MatchDeliverableRel reports whether a runner rel_path refers to target +// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir, +// and a duplicated step prefix (tasks/01_draft/output.md when workDir already +// ends with tasks/01_draft). Paths that escape workDir never match. +func MatchDeliverableRel(workDir, relPath, target string) bool { + target = slashClean(strings.TrimSpace(target)) + if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") { + return false + } + raw := strings.TrimSpace(relPath) + if raw == "" { + return false + } + abs := raw + if !filepath.IsAbs(raw) { + abs = filepath.Join(workDir, raw) + } + abs = filepath.Clean(abs) + rel, err := filepath.Rel(filepath.Clean(workDir), abs) + if err != nil { + return false + } + rel = slashClean(rel) + if rel == ".." || strings.HasPrefix(rel, "../") { + return false + } + if rel == target { + return true + } + if filepath.Base(rel) != filepath.Base(target) { + return false + } + prefix := slashClean(filepath.Dir(rel)) + wd := slashClean(workDir) + return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix)) +} + +// CollectTargetPatches maps incoming runner keys onto canonical target names +// and also accepts a non-empty file already at workDir/target. Empty bodies +// do not count — callers must not invent a deliverable. +func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte { + out := make(map[string][]byte, len(targets)) + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + for k, v := range incoming { + if len(bytes.TrimSpace(v)) == 0 { + continue + } + if MatchDeliverableRel(workDir, k, t) { + out[t] = v + break + } + } + if _, ok := out[t]; ok { + continue + } + p := filepath.Join(workDir, t) + if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 { + out[t] = b + } + } + return out +} + +// MaterializeTargets writes collected non-empty target bodies to workDir/. +// Returns a missing-target error without writing a stub if any required file is absent. +func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) { + collected := CollectTargetPatches(workDir, targets, incoming) + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + body, ok := collected[t] + if !ok || len(bytes.TrimSpace(body)) == 0 { + return collected, fmt.Errorf("missing target deliverable %s", t) + } + dest := filepath.Join(workDir, filepath.FromSlash(t)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return collected, err + } + if err := rejectSymlinkEscape(workDir, dest); err != nil { + return collected, err + } + // Container-written files may be root-owned; make writable before overwrite. + _ = os.Chmod(dest, 0o666) + if err := os.WriteFile(dest, body, 0o644); err != nil { + return collected, err + } + } + return collected, nil +} + +func rejectSymlinkEscape(workDir, dest string) error { + root, err := filepath.Abs(workDir) + if err != nil { + return err + } + absDest, err := filepath.Abs(dest) + if err != nil { + return err + } + rel, err := filepath.Rel(root, absDest) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("typed result path rejected") + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + resolvedRoot = root + } + parent := filepath.Dir(absDest) + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return fmt.Errorf("typed result path rejected") + } + relParent, err := filepath.Rel(resolvedRoot, resolvedParent) + if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(os.PathSeparator)) { + return fmt.Errorf("typed result path rejected") + } + if info, err := os.Lstat(absDest); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("typed result path rejected") + } + return nil +} diff --git a/exec/deliverable_test.go b/exec/deliverable_test.go new file mode 100644 index 0000000..5d198f1 --- /dev/null +++ b/exec/deliverable_test.go @@ -0,0 +1,131 @@ +package exec + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMatchDeliverableRel_Aliases(t *testing.T) { + workDir := filepath.Join(string(filepath.Separator), "app", "data", "workspaces", "bizdev", "prelim_x", "tasks", "01_draft") + abs := filepath.Join(workDir, "output.md") + cases := []struct { + rel string + want bool + }{ + {"output.md", true}, + {"./output.md", true}, + {abs, true}, + {"tasks/01_draft/output.md", true}, + {"execution.log", false}, + {"../output.md", false}, + {"../../output.md", false}, + {"/etc/passwd", false}, + {"tasks/02_review/output.md", false}, + } + for _, tc := range cases { + if got := MatchDeliverableRel(workDir, tc.rel, "output.md"); got != tc.want { + t.Errorf("MatchDeliverableRel(%q) = %v, want %v", tc.rel, got, tc.want) + } + } +} + +func TestMaterializeTargets_MapsAbsoluteAndNested(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "tasks", "01_draft") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + body := []byte("# 系统初步设计方案\n\n真实 runner 产物\n") + incoming := map[string][]byte{ + filepath.Join(workDir, "output.md"): body, + } + got, err := MaterializeTargets(workDir, []string{"output.md"}, incoming) + if err != nil { + t.Fatalf("absolute rel_path must materialize: %v", err) + } + if string(got["output.md"]) != string(body) { + t.Fatalf("canonical patch missing") + } + onDisk, err := os.ReadFile(filepath.Join(workDir, "output.md")) + if err != nil { + t.Fatal(err) + } + if string(onDisk) != string(body) { + t.Fatalf("disk output.md mismatch") + } + + nestedDir := filepath.Join(t.TempDir(), "ws", "tasks", "01_draft") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := MaterializeTargets(nestedDir, []string{"output.md"}, map[string][]byte{ + "tasks/01_draft/output.md": []byte("# nested alias\n"), + }); err != nil { + t.Fatalf("duplicated step prefix must map: %v", err) + } +} + +func TestMaterializeTargets_MissingAndEmptyFail(t *testing.T) { + workDir := t.TempDir() + if _, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{ + "execution.log": []byte("Outcome: SUCCESS\n"), + }); err == nil || !strings.Contains(err.Error(), "missing target deliverable output.md") { + t.Fatalf("must fail closed without inventing output.md, got %v", err) + } + if _, err := os.Stat(filepath.Join(workDir, "output.md")); err == nil { + t.Fatal("must not write a stub output.md") + } + if _, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{ + "output.md": []byte(" \n"), + }); err == nil { + t.Fatal("whitespace-only body must not count as a deliverable") + } +} + +func TestMaterializeTargets_EmptyPatchKeepsExistingFile(t *testing.T) { + workDir := t.TempDir() + body := []byte("# 调研报告\n已写在盘上的正文\n") + path := filepath.Join(workDir, "output.md") + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatal(err) + } + got, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{ + "output.md": nil, + }) + if err != nil { + t.Fatal(err) + } + if string(got["output.md"]) != string(body) { + t.Fatalf("collected %q", got["output.md"]) + } + disk, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(disk) != string(body) { + t.Fatalf("disk overwritten with %q", disk) + } +} + +func TestMaterializeTargets_RejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.md"), []byte("leak"), 0o644); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "out") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + _, err := MaterializeTargets(root, []string{"out/secret.md"}, map[string][]byte{ + "out/secret.md": []byte("overwrite"), + }) + if err == nil { + t.Fatal("symlink escape must fail closed") + } + got, _ := os.ReadFile(filepath.Join(outside, "secret.md")) + if string(got) != "leak" { + t.Fatalf("must not write through symlink, got %q", got) + } +} diff --git a/exec/limiter.go b/exec/limiter.go new file mode 100644 index 0000000..1e6ebe4 --- /dev/null +++ b/exec/limiter.go @@ -0,0 +1,46 @@ +package exec + +import ( + "context" + "fmt" +) + +// LimitingBackend 用有界信号量限制底层 agent 并发,保护 GPU/API 资源。 +type LimitingBackend struct { + inner Backend + sem chan struct{} +} + +// WrapWithConcurrencyLimit 包装 backend;max<=0 时默认 8。 +func WrapWithConcurrencyLimit(inner Backend, max int) Backend { + if inner == nil { + return nil + } + if max <= 0 { + max = 8 + } + return &LimitingBackend{ + inner: inner, + sem: make(chan struct{}, max), + } +} + +func (l *LimitingBackend) Name() string { + if l.inner == nil { + return "limiting" + } + return l.inner.Name() +} + +func (l *LimitingBackend) Execute(ctx context.Context, req Request) (*Result, error) { + select { + case l.sem <- struct{}{}: + defer func() { <-l.sem }() + case <-ctx.Done(): + return &Result{ + Success: false, + ErrorReason: fmt.Sprintf("agent concurrency wait canceled: %v", ctx.Err()), + }, ctx.Err() + } + return l.inner.Execute(ctx, req) +} diff --git a/exec/limiter_test.go b/exec/limiter_test.go new file mode 100644 index 0000000..4155d4f --- /dev/null +++ b/exec/limiter_test.go @@ -0,0 +1,50 @@ +package exec_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "agent-native/exec" +) + +type slowBackend struct { + running atomic.Int32 + peak atomic.Int32 +} + +func (s *slowBackend) Name() string { return "slow" } + +func (s *slowBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { + cur := s.running.Add(1) + for { + p := s.peak.Load() + if cur <= p || s.peak.CompareAndSwap(p, cur) { + break + } + } + time.Sleep(40 * time.Millisecond) + s.running.Add(-1) + return &exec.Result{Success: true}, nil +} + +func TestLimitingBackendCapsConcurrency(t *testing.T) { + inner := &slowBackend{} + limited := exec.WrapWithConcurrencyLimit(inner, 2) + + var wg sync.WaitGroup + for i := 0; i < 6; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = limited.Execute(context.Background(), exec.Request{}) + }() + } + wg.Wait() + + if peak := inner.peak.Load(); peak > 2 { + t.Fatalf("expected peak concurrency <= 2, got %d", peak) + } +} diff --git a/exec/settle_parity_test.go b/exec/settle_parity_test.go new file mode 100644 index 0000000..7285a57 --- /dev/null +++ b/exec/settle_parity_test.go @@ -0,0 +1,142 @@ +package exec + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// settleParityCase 是 PI settle-policy.mjs 与 Go AssessStepFailure 的共享 +// 行为夹具(U9b Batch1-②):同一场景两侧必须得到一致的成功/可重试判定。 +// events 供 node 事件流分类(applyAgentEvent→classifyRunOutcome); +// signals 供 Go 结果信号分类(AssessStepFailure);expect 为共同期望。 +type settleParityCase struct { + Name string `json:"name"` + Events []map[string]any `json:"events"` + Signals struct { + Success bool `json:"success"` + ErrorReason string `json:"error_reason"` + Retryable bool `json:"retryable"` + TargetMissing bool `json:"target_missing"` + } `json:"signals"` + Expect struct { + OK bool `json:"ok"` + Retryable bool `json:"retryable"` + } `json:"expect"` + // GoOnly 为 true 的场景(如 target-exists)没有 node 对应信号,只断言 Go。 + GoOnly bool `json:"go_only"` +} + +func nodeParityScript(casesAbsPath string) string { + return ` +import { newSettleState, applyAgentEvent, classifyRunOutcome } from './settle-policy.mjs'; +import fs from 'fs'; +const cases = JSON.parse(fs.readFileSync(` + "`" + casesAbsPath + "`" + `, 'utf8')); +const out = []; +for (const c of cases) { + if (c.go_only) { out.push({name: c.name, go_only: true}); continue; } + const st = newSettleState(); + for (const ev of c.events || []) { applyAgentEvent(st, ev); } + const r = classifyRunOutcome({ + succeeded: st.succeeded, + retryFailed: st.retryFailed, + providerTransient: st.providerTransient, + lastErr: st.lastErr, + missing: c.signals && c.signals.target_missing ? ["output.md"] : [], + }); + out.push({name: c.name, ok: r.ok, retryable: r.retryable}); +} +process.stdout.write(JSON.stringify(out)); +` +} + +func TestSettlePolicyParityWithNode(t *testing.T) { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not available") + } + casesPath, err := filepath.Abs(filepath.Join("testdata", "settle-parity-cases.json")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(casesPath) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var cases []settleParityCase + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if len(cases) < 8 { + t.Fatalf("expect a meaningful scenario set, got %d", len(cases)) + } + + // node 侧:cwd 置于 settle-policy.mjs 所在 testdata,脚本相对导入(同 digest 交叉验证模式)。 + tdDir, err := filepath.Abs(filepath.Join("..", "adapters", "pitransport", "testdata")) + if err != nil { + t.Fatal(err) + } + nodeCmd := exec.Command("node", "--input-type=module", "-e", nodeParityScript(casesPath)) + nodeCmd.Dir = tdDir // './settle-policy.mjs' 相对 cwd 解析 + out, err := nodeCmd.Output() + if err != nil { + t.Fatalf("node parity runner: %v", err) + } + var nodeResults []struct { + Name string `json:"name"` + OK *bool `json:"ok"` + Retryable *bool `json:"retryable"` + GoOnly bool `json:"go_only"` + } + if err := json.Unmarshal(out, &nodeResults); err != nil { + t.Fatalf("parse node output: %v: %s", err, out) + } + nodeBy := map[string]int{} + for i, r := range nodeResults { + nodeBy[r.Name] = i + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + sig := StepFailureSignals{ + Result: &Result{ + Success: c.Signals.Success, + ErrorReason: c.Signals.ErrorReason, + Retryable: c.Signals.Retryable, + }, + TargetMissing: c.Signals.TargetMissing, + } + got := AssessStepFailure(sig) + // 契约:Go 是终裁者——成功不得触发重试;mjs 判可重试的必须保持 + // Transient;mjs 判永久的不得被翻成 Transient。 + switch { + case c.Expect.OK: + if got.Class == FailureTransient { + t.Fatalf("Go flipped success to transient: %s", got.Reason) + } + case c.Expect.Retryable: + if got.Class != FailureTransient { + t.Fatalf("Go must stay transient: class=%v reason=%s", got.Class, got.Reason) + } + default: + if got.Class == FailureTransient { + t.Fatalf("Go flipped permanent to transient: %s", got.Reason) + } + } + if c.GoOnly { + t.Log("Go-only signal; node side n/a") + return + } + idx, ok := nodeBy[c.Name] + if !ok || nodeResults[idx].OK == nil { + t.Fatalf("node result missing for %s", c.Name) + } + if *nodeResults[idx].OK != c.Expect.OK || *nodeResults[idx].Retryable != c.Expect.Retryable { + t.Fatalf("node: ok=%v retryable=%v; want ok=%v retryable=%v", + *nodeResults[idx].OK, *nodeResults[idx].Retryable, c.Expect.OK, c.Expect.Retryable) + } + }) + } +} diff --git a/exec/step_retry.go b/exec/step_retry.go new file mode 100644 index 0000000..212c800 --- /dev/null +++ b/exec/step_retry.go @@ -0,0 +1,199 @@ +package exec + +import ( + "os" + "path/filepath" + "strings" + "unicode" +) + +// MaxTransientStepInvocations is the product-layer bound: the first +// invocation plus one fresh re-invocation. SDK in-process auto_retry is +// separate and must not be confused with this. +const MaxTransientStepInvocations = 2 + +type FailureClass int + +const ( + FailureNone FailureClass = iota + FailureTransient + FailurePermanent +) + +func (c FailureClass) String() string { + switch c { + case FailureTransient: + return "transient" + case FailurePermanent: + return "permanent" + default: + return "none" + } +} + +type TraceSignals struct { + ProviderTransient bool + Settled bool + RetryExhausted bool + HasUsableDeliverable bool // always false: write() args in traces are not deliverables +} + +type StepFailureSignals struct { + Err error + Result *Result + WorkDir string + ExtraText string + TargetMissing bool + // TargetExists means this invocation wrote a non-empty file that differs + // from the pre-invoke baseline. Do not retry or overwrite it. The step + // still fails unless the backend already reported a trusted success. + TargetExists bool +} + +type FailureAssessment struct { + Class FailureClass + Reason string +} + +func ScanTraceText(text string) TraceSignals { + var out TraceSignals + if text == "" { + return out + } + // Line scan only. Never parse write.arguments.content into a body. + for _, line := range strings.Split(text, "\n") { + if line == "" { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, `"type":"agent_settled"`) || strings.Contains(lower, `"type": "agent_settled"`) { + out.Settled = true + } + if strings.Contains(lower, `"type":"auto_retry_end"`) && strings.Contains(lower, `"success":false`) { + out.ProviderTransient = true + out.RetryExhausted = true + } + if strings.Contains(lower, `"type":"auto_retry_start"`) { + out.ProviderTransient = true + } + if strings.Contains(lower, `"stopreason":"error"`) { + out.ProviderTransient = true + } + if strings.Contains(lower, "connection error") { + out.ProviderTransient = true + } + if strings.Contains(lower, `"errormessage":"terminated"`) || strings.Contains(lower, `"errormessage": "terminated"`) { + out.ProviderTransient = true + } + } + return out +} + +func ReadWorkDirTraces(workDir string) string { + if strings.TrimSpace(workDir) == "" { + return "" + } + p := filepath.Join(workDir, "execution.log") + b, err := os.ReadFile(p) + if err != nil { + return "" + } + const capBytes = 512 << 10 + if len(b) > capBytes { + b = b[len(b)-capBytes:] + } + return string(b) +} + +func AssessStepFailure(sig StepFailureSignals) FailureAssessment { + reason := combinedFailureText(sig) + lower := strings.ToLower(reason) + trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir)) + missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable") + if sig.TargetExists { + return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"} + } + if isPermanentReason(lower) { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)} + } + + runnerFailed := (sig.Result != nil && sig.Result.Retryable) || isRunnerExplicitFailure(lower) + provider := trace.ProviderTransient || hasProviderTermOrConn(lower) + if runnerFailed { + return FailureAssessment{Class: FailureTransient, Reason: "runner-explicit-failure: " + compactReason(reason)} + } + if provider && missing { + kind := "provider-transient-missing-target" + if trace.Settled { + kind = "settled-failure-missing-target" + } + return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)} + } + // Model settled cleanly but never wrote the target (e.g. text-only reply, + // no write tool). One product-layer re-invocation with a corrective prompt + // is worth trying; still bounded by MaxTransientStepInvocations. + if missing && trace.Settled && !provider { + return FailureAssessment{Class: FailureTransient, Reason: "settled-no-deliverable: " + compactReason(reason)} + } + if missing { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)} + } + return FailureAssessment{Class: FailurePermanent, Reason: "fail-closed: " + compactReason(reason)} +} + +func combinedFailureText(sig StepFailureSignals) string { + var parts []string + if sig.Err != nil { + parts = append(parts, sig.Err.Error()) + } + if sig.Result != nil { + if sig.Result.ErrorReason != "" { + parts = append(parts, sig.Result.ErrorReason) + } + if sig.Result.Logs != "" { + parts = append(parts, sig.Result.Logs) + } + } + return strings.Join(parts, " ") +} + +func isPermanentReason(lower string) bool { + return strings.Contains(lower, "prompt_empty") || + strings.Contains(lower, "unauthorized") || + strings.Contains(lower, "治理强制终止") || + strings.Contains(lower, "治理拦截") +} + +func hasProviderTermOrConn(lower string) bool { + return strings.Contains(lower, "connection error") || + strings.Contains(lower, "terminated") || + strings.Contains(lower, "econnreset") || + strings.Contains(lower, "socket hang up") +} + +func isRunnerExplicitFailure(lower string) bool { + return strings.Contains(lower, "pi remote runner call failed") || + strings.Contains(lower, "auto_retry_failed") || + strings.Contains(lower, "auto_retry_end") || + (hasProviderTermOrConn(lower) && !strings.Contains(lower, "missing target deliverable")) +} + +func compactReason(s string) string { + s = strings.Join(strings.FieldsFunc(s, func(r rune) bool { + return r == '\n' || r == '\r' + }), " ") + s = strings.TrimSpace(s) + if s == "" { + return "unknown" + } + // Drop accidental write-argument bodies if a caller stuffed logs here. + if len(s) > 240 { + s = s[:240] + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\t' { + return -1 + } + return r + }, s) +} diff --git a/exec/step_retry_test.go b/exec/step_retry_test.go new file mode 100644 index 0000000..ff14f99 --- /dev/null +++ b/exec/step_retry_test.go @@ -0,0 +1,181 @@ +package exec + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// Synthetic UI-11 timeline — event types only. Do not include write.content. +const ui11SettledFailureTrace = ` +{"type":"message_end","message":{"stopReason":"error","errorMessage":"terminated"}} +{"type":"auto_retry_start","attempt":1,"maxAttempts":3,"errorMessage":"terminated"} +{"type":"message_end","message":{"stopReason":"error","errorMessage":"Connection error."}} +{"type":"auto_retry_start","attempt":2,"maxAttempts":3,"errorMessage":"Connection error."} +{"type":"auto_retry_start","attempt":3,"maxAttempts":3,"errorMessage":"Connection error."} +{"type":"auto_retry_end","success":false,"attempt":3,"finalError":"Connection error."} +{"type":"agent_settled"} +` + +func TestScanTraceText_UI11ProviderTransient(t *testing.T) { + got := ScanTraceText(ui11SettledFailureTrace) + if !got.ProviderTransient { + t.Fatal("terminated + Connection error + auto_retry_end success=false must be provider-transient") + } + if !got.Settled { + t.Fatal("agent_settled must be recorded") + } + if !got.RetryExhausted { + t.Fatal("auto_retry_end success=false must mark SDK retry exhausted") + } +} + +func TestScanTraceText_IgnoresWriteArguments(t *testing.T) { + // A truncated write toolCall must never become a deliverable signal. + line := `{"type":"message_end","message":{"content":[{"type":"toolCall","name":"write","arguments":{"path":"/app/data/workspaces/x/tasks/01_draft/output.md","content":"# 伪造正文不应被采用\n"}}]}}` + got := ScanTraceText(line) + if got.HasUsableDeliverable { + t.Fatal("write arguments in a trace line are not a usable deliverable") + } + if got.ProviderTransient { + t.Fatal("a write toolCall alone is not a provider transient") + } +} + +func TestAssessStepFailure_TransientVsPermanent(t *testing.T) { + cases := []struct { + name string + sig StepFailureSignals + want FailureClass + }{ + { + name: "runner retryable flag", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "Connection error.", Retryable: true}}, + want: FailureTransient, + }, + { + name: "connection error text", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "Connection error."}}, + want: FailureTransient, + }, + { + name: "terminated text", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "terminated"}}, + want: FailureTransient, + }, + { + name: "missing after UI-11 settled failure", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "missing target deliverable output.md"}, + ExtraText: ui11SettledFailureTrace, + TargetMissing: true, + }, + want: FailureTransient, + }, + { + name: "settled clean missing is transient settled-no-deliverable", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "missing target deliverable output.md"}, + ExtraText: `{"type":"agent_settled"}`, + TargetMissing: true, + }, + want: FailureTransient, + }, + { + name: "missing without settle remains permanent", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "missing target deliverable output.md"}, + TargetMissing: true, + }, + want: FailurePermanent, + }, + { + name: "prompt_empty is permanent", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "prompt_empty"}}, + want: FailurePermanent, + }, + { + name: "unauthorized is permanent", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "unauthorized"}}, + want: FailurePermanent, + }, + { + name: "governance abort is permanent", + sig: StepFailureSignals{Err: errors.New("治理强制终止: 原地打转")}, + want: FailurePermanent, + }, + { + name: "remote runner unreachable is transient", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "pi remote runner call failed: connection refused"}}, + want: FailureTransient, + }, + { + name: "existing target is never retried", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "Connection error.", Retryable: true}, + TargetExists: true, + }, + want: FailurePermanent, + }, + { + name: "provider signal without missing is not enough", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "agent_not_settled"}, + ExtraText: `{"type":"auto_retry_end","success":false}`, + }, + want: FailurePermanent, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := AssessStepFailure(tc.sig) + if got.Class != tc.want { + t.Fatalf("class=%s reason=%s want=%s", got.Class, got.Reason, tc.want) + } + }) + } +} + +func TestAssessStepFailure_ReadsWorkDirExecutionLog(t *testing.T) { + dir := t.TempDir() + log := "=== PI-AGENT TASK EXECUTION LOG ===\nOutcome: SUCCESS\nError: None\n" + ui11SettledFailureTrace + if err := os.WriteFile(filepath.Join(dir, "execution.log"), []byte(log), 0o644); err != nil { + t.Fatal(err) + } + got := AssessStepFailure(StepFailureSignals{ + WorkDir: dir, + TargetMissing: true, + Result: &Result{Success: true, ErrorReason: "missing target deliverable output.md"}, + }) + if got.Class != FailureTransient { + t.Fatalf("old runner Outcome:SUCCESS + settled failure must be transient, got %s %s", got.Class, got.Reason) + } + if strings.Contains(got.Reason, "伪造") || strings.Contains(got.Reason, "系统初步设计") { + t.Fatalf("assessment must not carry write-argument body: %s", got.Reason) + } +} + +func TestAssessStepFailure_SettledNoDeliverableReason(t *testing.T) { + got := AssessStepFailure(StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "missing target deliverable output.md"}, + ExtraText: `{"type":"agent_settled"}`, + TargetMissing: true, + }) + if got.Class != FailureTransient { + t.Fatalf("class=%s want transient", got.Class) + } + if !strings.Contains(got.Reason, "settled-no-deliverable") { + t.Fatalf("reason=%q want settled-no-deliverable", got.Reason) + } +} + +func TestMaxTransientStepInvocations_IsBounded(t *testing.T) { + if MaxTransientStepInvocations < 2 { + t.Fatal("need at least one fresh re-invocation after the first") + } + if MaxTransientStepInvocations > 3 { + t.Fatalf("bound too loose: %d would retry permanent-looking gaps too many times", MaxTransientStepInvocations) + } +} diff --git a/exec/stub.go b/exec/stub.go new file mode 100644 index 0000000..4a9a3c4 --- /dev/null +++ b/exec/stub.go @@ -0,0 +1,47 @@ +package exec + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type StubBackend struct{} + +func (s *StubBackend) Name() string { return "stub" } + +func (s *StubBackend) Execute(ctx context.Context, req Request) (*Result, error) { + patches := make(map[string][]byte) + for _, target := range req.Targets { + outPath := filepath.Join(req.WorkDir, target) + _ = os.MkdirAll(filepath.Dir(outPath), 0o755) + + var content []byte + if strings.HasSuffix(target, ".json") { + // 若原文件已存在且为有效 JSON,保留其内容 + if existing, err := os.ReadFile(outPath); err == nil { + var dummy interface{} + if json.Unmarshal(existing, &dummy) == nil { + content = existing + } + } + if len(content) == 0 { + content = []byte(`{"status":"mock_ok"}`) + } + } else { + content = []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt)) + } + + _ = os.WriteFile(outPath, content, 0o644) + patches[target] = content + } + + return &Result{ + Success: true, + Patches: patches, + Logs: "Stub agent execution completed.", + }, nil +} diff --git a/exec/testdata/settle-parity-cases.json b/exec/testdata/settle-parity-cases.json new file mode 100644 index 0000000..94bbfe4 --- /dev/null +++ b/exec/testdata/settle-parity-cases.json @@ -0,0 +1,223 @@ +[ + { + "name": "provider-connection-error-missing-target", + "events": [ + { + "type": "message", + "message": { + "errorMessage": "Connection error." + } + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": false, + "error_reason": "Connection error.", + "retryable": true, + "target_missing": true + }, + "expect": { + "ok": false, + "retryable": true + } + }, + { + "name": "stop-reason-error", + "events": [ + { + "type": "message", + "message": { + "stopReason": "error", + "errorMessage": "provider 500" + } + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": false, + "error_reason": "provider 500", + "retryable": true + }, + "expect": { + "ok": false, + "retryable": true + } + }, + { + "name": "sdk-auto-retry-exhausted", + "events": [ + { + "type": "auto_retry_start", + "errorMessage": "econnreset" + }, + { + "type": "auto_retry_end", + "success": false, + "finalError": "socket hang up" + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": false, + "error_reason": "socket hang up", + "retryable": true + }, + "expect": { + "ok": false, + "retryable": true + } + }, + { + "name": "sdk-auto-retry-recovered-then-success", + "events": [ + { + "type": "auto_retry_start", + "errorMessage": "terminated" + }, + { + "type": "auto_retry_end", + "success": true + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": true, + "error_reason": "", + "retryable": false + }, + "expect": { + "ok": true, + "retryable": false + } + }, + { + "name": "clean-settle-with-deliverable", + "events": [ + { + "type": "agent_settled" + } + ], + "signals": { + "success": true, + "error_reason": "", + "retryable": false + }, + "expect": { + "ok": true, + "retryable": false + } + }, + { + "name": "clean-settle-missing-deliverable", + "events": [ + { + "type": "agent_settled" + } + ], + "signals": { + "success": true, + "error_reason": "missing target deliverable output.md", + "retryable": true, + "target_missing": true + }, + "expect": { + "ok": false, + "retryable": true + } + }, + { + "name": "permanent-unauthorized", + "events": [ + { + "type": "agent_error", + "error": { + "message": "unauthorized api key" + } + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": false, + "error_reason": "unauthorized api key", + "retryable": false + }, + "expect": { + "ok": false, + "retryable": false + } + }, + { + "name": "permanent-prompt-empty", + "events": [ + { + "type": "agent_error", + "error": { + "message": "prompt_empty" + } + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": false, + "error_reason": "prompt_empty", + "retryable": false + }, + "expect": { + "ok": false, + "retryable": false + } + }, + { + "name": "governance-block-permanent", + "events": [ + { + "type": "agent_error", + "error": { + "message": "治理拦截:越界写入" + } + }, + { + "type": "agent_settled" + } + ], + "signals": { + "success": false, + "error_reason": "治理拦截:越界写入", + "retryable": false + }, + "expect": { + "ok": false, + "retryable": false + } + }, + { + "name": "target-already-present-go-only", + "events": [ + { + "type": "agent_settled" + } + ], + "signals": { + "success": true, + "error_reason": "", + "retryable": false + }, + "expect": { + "ok": false, + "retryable": false + }, + "go_only": true + } +] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4de126e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module agent-native + +go 1.27 diff --git a/runtime/event_vocabulary_test.go b/runtime/event_vocabulary_test.go new file mode 100644 index 0000000..ad2a279 --- /dev/null +++ b/runtime/event_vocabulary_test.go @@ -0,0 +1,77 @@ +package runtime + +import ( + "context" + "slices" + "sync" + "testing" + + "agent-native/runtime/llm" + "agent-native/runtime/tools" +) + +// ProcessEventVocabulary 是过程流消费方(小队过程视图,对应 PI proc 帧转发) +// 依赖的最小事件词汇。U9b Batch1-③ 契约:runtime 必须在真实执行中产出 +// 全部词汇;新增词汇向后兼容,删除/改名是破坏性变更(消费方见 +// squad process_bridge 的 case 集)。 +var ProcessEventVocabulary = []string{ + "text_delta", + "reasoning_delta", + "tool_call_delta", + "tool_execution_start", + "tool_execution_end", + "message_end", +} + +// vocabScript 构造一段覆盖全部必需词汇的模型流: +// 推理增量 → 文本增量 → 工具调用增量(触发执行)→ 消息结束。 +func vocabScript() llm.StreamFunc { + var mu sync.Mutex + step := 0 + return func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 8) + go func() { + defer close(ch) + mu.Lock() + defer mu.Unlock() + switch step { + case 0: + step++ + ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"} + ch <- llm.Event{Kind: llm.KindTextDelta, Text: "正文"} + ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`} + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ + Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, + ToolCalls: []llm.ToolCall{{ID: "c1", Name: "list_dir", Arguments: `{"path":"."}`}}, + }} + default: + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{ + Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd, + }} + } + }() + return ch + } +} + +func TestLoopEmitsProcessVocabulary(t *testing.T) { + reg := tools.WorkspaceTools(t.TempDir()) + var observed []string + _, err := Run(context.Background(), Config{ + Model: "fake", + Tools: reg, + Stream: vocabScript(), + OnEvent: func(ev Event) { + observed = append(observed, ev.Type) + }, + MaxTurns: 4, + }, llm.Message{Role: llm.RoleUser, Content: "run"}) + if err != nil { + t.Fatalf("run: %v", err) + } + for _, want := range ProcessEventVocabulary { + if !slices.Contains(observed, want) { + t.Fatalf("required process event %q not observed; got %v", want, observed) + } + } +} diff --git a/runtime/llm/client.go b/runtime/llm/client.go new file mode 100644 index 0000000..5e5d236 --- /dev/null +++ b/runtime/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/runtime/llm/smoke_test.go b/runtime/llm/smoke_test.go new file mode 100644 index 0000000..c466ac8 --- /dev/null +++ b/runtime/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/runtime/llm/sse.go b/runtime/llm/sse.go new file mode 100644 index 0000000..6389917 --- /dev/null +++ b/runtime/llm/sse.go @@ -0,0 +1,223 @@ +package llm + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" +) + +type sseChoice struct { + Delta struct { + Content string `json:"content"` + Reasoning string `json:"reasoning_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}: + } + } + // reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流: + // 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner + // stripThinkBlocks"正文无 think"语义对齐)。 + if choice.Delta.Reasoning != "" { + select { + case <-ctx.Done(): + emitAbort(out, content.String(), ctx.Err()) + return + case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}: + } + } + 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/runtime/llm/sse_test.go b/runtime/llm/sse_test.go new file mode 100644 index 0000000..56c0455 --- /dev/null +++ b/runtime/llm/sse_test.go @@ -0,0 +1,224 @@ +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") + } +} + +func TestParseSSEReasoningDelta(t *testing.T) { + body := strings.Join([]string{ + `data: {"choices":[{"delta":{"reasoning_content":"先分析需求"},"index":0}]}`, + `data: {"choices":[{"delta":{"reasoning_content":"再检索标准"},"index":0}]}`, + `data: {"choices":[{"delta":{"content":"结论"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`, + `data: [DONE]`, + "", + }, "\n") + var reasoning []string + var text []string + var end Event + for ev := range ParseSSE(context.Background(), strings.NewReader(body)) { + switch ev.Kind { + case KindReasoningDelta: + reasoning = append(reasoning, ev.ReasoningDelta) + case KindTextDelta: + text = append(text, ev.Text) + case KindMessageEnd: + end = ev + } + } + if strings.Join(reasoning, "") != "先分析需求再检索标准" { + t.Fatalf("reasoning deltas %v", reasoning) + } + // 正文与思考链分流:Content 只含 text,不含 reasoning + if strings.Join(text, "") != "结论" { + t.Fatalf("text deltas %v", text) + } + if end.Message.Content != "结论" { + t.Fatalf("end content %q must not include reasoning", end.Message.Content) + } +} diff --git a/runtime/llm/types.go b/runtime/llm/types.go new file mode 100644 index 0000000..7cae603 --- /dev/null +++ b/runtime/llm/types.go @@ -0,0 +1,97 @@ +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" + KindReasoningDelta EventKind = "reasoning_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 + ReasoningDelta 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 diff --git a/runtime/loop.go b/runtime/loop.go new file mode 100644 index 0000000..c125d9e --- /dev/null +++ b/runtime/loop.go @@ -0,0 +1,264 @@ +package runtime + +import ( + "context" + "encoding/json" + "strings" + "sync" + + "agent-native/runtime/llm" + "agent-native/runtime/session" + "agent-native/runtime/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 + // Detail carries tool arguments/results or reasoning deltas for + // process-visualization consumers; log writers may ignore it. + Detail 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.KindReasoningDelta: + emit(cfg, Event{Type: "reasoning_delta", Text: ev.ReasoningDelta}) + case llm.KindToolCallDelta: + emit(cfg, Event{Type: "tool_call_delta", Tool: ev.ToolName, Detail: ev.ArgumentsDelta}) + 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/runtime/loop_test.go b/runtime/loop_test.go new file mode 100644 index 0000000..e9bd9d9 --- /dev/null +++ b/runtime/loop_test.go @@ -0,0 +1,186 @@ +package runtime + +import ( + "context" + "path/filepath" + "strings" + "sync" + "testing" + + "agent-native/runtime/llm" + "agent-native/runtime/session" + "agent-native/runtime/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/runtime/session/jsonl.go b/runtime/session/jsonl.go new file mode 100644 index 0000000..2c29fa0 --- /dev/null +++ b/runtime/session/jsonl.go @@ -0,0 +1,73 @@ +package session + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sync" + + "agent-native/runtime/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/runtime/session/jsonl_test.go b/runtime/session/jsonl_test.go new file mode 100644 index 0000000..871c624 --- /dev/null +++ b/runtime/session/jsonl_test.go @@ -0,0 +1,38 @@ +package session + +import ( + "path/filepath" + "testing" + + "agent-native/runtime/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]) + } + } +} diff --git a/runtime/tools/builtin.go b/runtime/tools/builtin.go new file mode 100644 index 0000000..7fef7ec --- /dev/null +++ b/runtime/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/runtime/tools/jail.go b/runtime/tools/jail.go new file mode 100644 index 0000000..f7162e4 --- /dev/null +++ b/runtime/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/runtime/tools/registry.go b/runtime/tools/registry.go new file mode 100644 index 0000000..0797477 --- /dev/null +++ b/runtime/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/runtime/tools/search.go b/runtime/tools/search.go new file mode 100644 index 0000000..562be35 --- /dev/null +++ b/runtime/tools/search.go @@ -0,0 +1,285 @@ +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "sync" + "time" +) + +// SearchProvider 是网络搜索能力的注入端口(U9b Batch1:替代 PI Tavily 扩展)。 +// 端点与密钥由 Core 装配;测试注入 fake。 +type SearchProvider interface { + Search(ctx context.Context, req SearchRequest) (SearchResponse, error) +} + +// SearchRequest 是一次搜索的参数(与扩展参数面一致)。 +type SearchRequest struct { + Query string + Depth string // basic | advanced + MaxResults int // 1..10,0 取默认 5 +} + +// SearchResponse 是 Tavily 兼容响应形状的最小集。 +type SearchResponse struct { + Answer string + Results []SearchResult +} + +// SearchResult 是单条检索结果。 +type SearchResult struct { + Title string `json:"title"` + Content string `json:"content"` + Score float64 `json:"score"` +} + +const ( + searchDefaultMaxResults = 5 + searchTimeout = 25 * time.Second +) + +// TavilyProvider 以密钥池方式调用 Tavily Search API: +// 429/401/403 时轮换下一把密钥重试(对齐扩展 TavilyKeyPool 语义)。 +type TavilyProvider struct { + endpoint string + keys []string + client *http.Client + + mu sync.Mutex + next int +} + +// NewTavilyProvider 创建提供者;endpoint 空则官方默认;keys 为空则 Search 报错 +// (由 Core 在装配期决定是否注册搜索工具,而非静默降级)。 +func NewTavilyProvider(endpoint string, keys []string) *TavilyProvider { + if strings.TrimSpace(endpoint) == "" { + endpoint = "https://api.tavily.com/search" + } + return &TavilyProvider{ + endpoint: endpoint, + keys: keys, + client: &http.Client{Timeout: searchTimeout}, + } +} + +func (p *TavilyProvider) currentKey() (string, int, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if len(p.keys) == 0 { + return "", 0, false + } + idx := p.next % len(p.keys) + return p.keys[idx], idx, true +} + +func (p *TavilyProvider) rotate(idx int) { + p.mu.Lock() + p.next = (idx + 1) % len(p.keys) + p.mu.Unlock() +} + +// rotatable 报告该 HTTP 状态是否应轮换密钥重试。 +func rotatable(status int) bool { return status == 429 || status == 401 || status == 403 } + +func (p *TavilyProvider) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) { + depth := "basic" + if req.Depth == "advanced" { + depth = "advanced" + } + max := req.MaxResults + if max < 1 { + max = searchDefaultMaxResults + } + if max > 10 { + max = 10 + } + + total := len(p.keys) + if total == 0 { + return SearchResponse{}, fmt.Errorf("search: no API key configured") + } + var lastErr error + for attempt := 0; attempt < total; attempt++ { + key, idx, ok := p.currentKey() + if !ok { + return SearchResponse{}, fmt.Errorf("search: no API key configured") + } + payload, _ := json.Marshal(map[string]any{ + "api_key": key, + "query": req.Query, + "search_depth": depth, + "include_answer": true, + "max_results": max, + }) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(payload)) + if err != nil { + return SearchResponse{}, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "agent-native-search/1.0") + + resp, err := p.client.Do(httpReq) + if err != nil { + lastErr = err + p.rotate(idx) // 网络失败也换下一把(与扩展一致) + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + resp.Body.Close() + + if resp.StatusCode/100 != 2 { + lastErr = fmt.Errorf("search: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + if rotatable(resp.StatusCode) { + p.rotate(idx) + continue + } + return SearchResponse{}, lastErr + } + + var decoded struct { + Answer string `json:"answer"` + Results []SearchResult `json:"results"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + return SearchResponse{}, fmt.Errorf("search: decode response: %w", err) + } + return SearchResponse{Answer: decoded.Answer, Results: decoded.Results}, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("search: no key attempt succeeded") + } + return SearchResponse{}, fmt.Errorf("search: all %d key(s) failed: %w", total, lastErr) +} + +// tavilySearchTool 与 deploy/pi-agent/extensions/tavily-search.mjs 的 +// 工具面逐字段对齐(名称/描述/参数/输出 Markdown/失败降级文案)。 +type tavilySearchTool struct{ provider SearchProvider } + +func (t *tavilySearchTool) Name() string { return "tavily_search" } + +func (t *tavilySearchTool) Description() string { + return "Search the live internet for real-time information, competitor intelligence, regulatory news, product specs, or market data via Tavily (with automatic API key pool rotation)." +} + +func (t *tavilySearchTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": { + "query": {"type": "string", "description": "The search query keywords in Chinese or English"}, + "search_depth": {"type": "string", "enum": ["basic", "advanced"], "description": "Search depth: 'basic' (fast summary) or 'advanced' (deep analysis, default 'basic')"}, + "max_results": {"type": "number", "description": "Maximum number of search results to retrieve (1 to 10, default 5)"} + }, + "required": ["query"] +}`) +} + +func (t *tavilySearchTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) { + var params struct { + Query string `json:"query"` + SearchDepth string `json:"search_depth"` + MaxResults float64 `json:"max_results"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return Result{}, err + } + query := strings.TrimSpace(params.Query) + if query == "" { + return Result{Content: "Error: query cannot be empty.", IsError: true}, nil + } + + resp, err := t.provider.Search(ctx, SearchRequest{ + Query: query, + Depth: params.SearchDepth, + MaxResults: int(params.MaxResults), + }) + if err != nil { + // 失败对模型可见并指引回退(与扩展降级文案一致);标错误利于分类。 + return Result{ + Content: fmt.Sprintf("[Tavily Search Failed]: %v\n(Agent should rely on workspace materials or local knowledge base as fallback.)", err), + IsError: true, + }, nil + } + return Result{Content: formatSearchResults(resp, query)}, nil +} + +// webSearchTool 是 web_search 别名(扩展同时注册两个名字)。 +type webSearchTool struct{ inner *tavilySearchTool } + +func (t *webSearchTool) Name() string { return "web_search" } +func (t *webSearchTool) Description() string { + return "Alias for tavily_search. Searches the live internet for information." +} +func (t *webSearchTool) Parameters() json.RawMessage { + return json.RawMessage(`{ + "type": "object", + "properties": {"query": {"type": "string", "description": "The search query keywords"}}, + "required": ["query"] +}`) +} + +func (t *webSearchTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) { + return t.inner.Execute(ctx, args) +} + +// RegisterSearch 把搜索工具注册进 registry(provider 非空才调用; +// reasoning profile 不注册)。 +func RegisterSearch(r *Registry, provider SearchProvider) { + inner := &tavilySearchTool{provider: provider} + r.Register(inner) + r.Register(&webSearchTool{inner: inner}) +} + +var ( + urlPattern = regexp.MustCompile(`http[s]?://[^\s]+`) + sensitivePattern = regexp.MustCompile(`避孕|计划生育|性腺|性器官|阴道|人流`) +) + +// sanitizeSearchText 对齐扩展的脱敏:敏感词→[医疗]、URL→#(结果不外泄链接)。 +func sanitizeSearchText(s string) string { + if s == "" { + return "" + } + s = sensitivePattern.ReplaceAllString(s, "[医疗]") + return urlPattern.ReplaceAllString(s, "#") +} + +// formatSearchResults 渲染与扩展 formatTavilyResults 一致的 Markdown。 +func formatSearchResults(resp SearchResponse, query string) string { + var b strings.Builder + fmt.Fprintf(&b, "## 🌐 Tavily 实时网络搜索结果: %q\n\n", query) + if strings.TrimSpace(resp.Answer) != "" { + fmt.Fprintf(&b, "> **智能总结**: %s\n\n", sanitizeSearchText(strings.TrimSpace(resp.Answer))) + } + if len(resp.Results) > 0 { + fmt.Fprintf(&b, "### 检索到的参考条目 (%d 条):\n\n", len(resp.Results)) + for i, item := range resp.Results { + title := strings.TrimSpace(item.Title) + if title == "" { + title = "无标题网页" + } + title = sanitizeSearchText(title) + snippet := "(无摘要)" + if c := strings.TrimSpace(strings.Join(strings.Fields(item.Content), " ")); c != "" { + snippet = c + } + if len(snippet) > 300 { + snippet = snippet[:300] + } + fmt.Fprintf(&b, "%d. **%s**\n", i+1, title) + fmt.Fprintf(&b, " - **摘要**: %s\n", snippet) + if item.Score != 0 { + fmt.Fprintf(&b, " - **相关度评分**: %.3f\n", item.Score) + } + b.WriteString("\n") + } + } else { + fmt.Fprintf(&b, "*未检索到与 %q 相关的网页结果。*\n\n", query) + } + return b.String() +} diff --git a/runtime/tools/search_test.go b/runtime/tools/search_test.go new file mode 100644 index 0000000..6bf030a --- /dev/null +++ b/runtime/tools/search_test.go @@ -0,0 +1,166 @@ +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type fakeProvider struct { + resp SearchResponse + err error + got SearchRequest +} + +func (f *fakeProvider) Search(_ context.Context, req SearchRequest) (SearchResponse, error) { + f.got = req + return f.resp, f.err +} + +func newSearchRegistry(p SearchProvider) *Registry { + r := NewRegistry(tTempDir()) + RegisterSearch(r, p) + return r +} + +func tTempDir() string { return "/tmp/agent-native-search-test" } + +func TestSearchToolSchemaAndAlias(t *testing.T) { + r := newSearchRegistry(&fakeProvider{}) + names := map[string]bool{} + for _, d := range r.Decls() { + names[d.Name] = true + } + if !names["tavily_search"] || !names["web_search"] { + t.Fatalf("missing tool decls: %v", names) + } +} + +func TestSearchToolFormatsResults(t *testing.T) { + fake := &fakeProvider{resp: SearchResponse{ + Answer: "实时总结 https://example.com/a", + Results: []SearchResult{ + {Title: "标题一", Content: strings.Repeat("摘", 400), Score: 0.91234}, + {Title: "", Content: " ", Score: 0}, + }, + }} + r := newSearchRegistry(fake) + res := r.Execute(context.Background(), "tavily_search", mustJSON(t, map[string]any{"query": "超声 法规"})) + if res.IsError { + t.Fatalf("unexpected error: %s", res.Content) + } + for _, want := range []string{ + `## 🌐 Tavily 实时网络搜索结果: "超声 法规"`, + "> **智能总结**: 实时总结 #", // URL 被脱敏为 # + "### 检索到的参考条目 (2 条):", + "无标题网页", + "(无摘要)", + "**相关度评分**: 0.912", + } { + if !strings.Contains(res.Content, want) { + t.Fatalf("output missing %q:\n%s", want, res.Content) + } + } + // 摘要截断:301 个连续字符不得出现 + if strings.Contains(res.Content, strings.Repeat("摘", 301)) { + t.Fatal("snippet not truncated at 300") + } + if fake.got.Query != "超声 法规" || fake.got.MaxResults != 0 { + t.Fatalf("request shape: %+v", fake.got) + } +} + +func TestSearchToolSanitizesSensitiveTerms(t *testing.T) { + fake := &fakeProvider{resp: SearchResponse{Answer: "涉及避孕内容"}} + r := newSearchRegistry(fake) + res := r.Execute(context.Background(), "web_search", mustJSON(t, map[string]any{"query": "x"})) + if !strings.Contains(res.Content, "[医疗]") || strings.Contains(res.Content, "避孕") { + t.Fatalf("sanitize failed: %s", res.Content) + } +} + +func TestSearchToolEmptyQueryRejected(t *testing.T) { + r := newSearchRegistry(&fakeProvider{}) + // registry schema 层先拒(required 校验空串);直调工具层同语义 + res := r.Execute(context.Background(), "tavily_search", mustJSON(t, map[string]any{"query": " "})) + if !res.IsError || !strings.Contains(res.Content, "query") { + t.Fatalf("empty query must error: %+v", res) + } + direct := (&tavilySearchTool{provider: &fakeProvider{}}).Execute + if out, err := direct(context.Background(), mustJSON(t, map[string]any{"query": ""})); err != nil || !out.IsError || !strings.Contains(out.Content, "query cannot be empty") { + t.Fatalf("tool-level empty query: %+v %v", out, err) + } +} + +func TestSearchToolProviderFailureDegrades(t *testing.T) { + r := newSearchRegistry(&fakeProvider{err: context.DeadlineExceeded}) + res := r.Execute(context.Background(), "tavily_search", mustJSON(t, map[string]any{"query": "q"})) + if !res.IsError { + t.Fatal("provider failure must be marked error") + } + if !strings.Contains(res.Content, "[Tavily Search Failed]") || + !strings.Contains(res.Content, "fallback") { + t.Fatalf("degradation text missing: %s", res.Content) + } +} + +func TestTavilyProviderKeyRotation(t *testing.T) { + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + calls = append(calls, body["api_key"].(string)) + switch body["api_key"] { + case "key1": + w.WriteHeader(http.StatusTooManyRequests) // 429 → 轮换 + case "key2": + w.WriteHeader(http.StatusUnauthorized) // 401 → 轮换 + default: + _, _ = w.Write([]byte(`{"answer":"ok","results":[{"title":"t","content":"c","score":0.5}]}`)) + } + })) + defer srv.Close() + + p := NewTavilyProvider(srv.URL, []string{"key1", "key2", "key3"}) + resp, err := p.Search(context.Background(), SearchRequest{Query: "q"}) + if err != nil { + t.Fatalf("rotation search: %v", err) + } + if resp.Answer != "ok" || len(resp.Results) != 1 { + t.Fatalf("response: %+v", resp) + } + if len(calls) != 3 || calls[0] != "key1" || calls[2] != "key3" { + t.Fatalf("rotation order: %v", calls) + } +} + +func TestTavilyProviderAllKeysFail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + p := NewTavilyProvider(srv.URL, []string{"k1", "k2"}) + if _, err := p.Search(context.Background(), SearchRequest{Query: "q"}); err == nil { + t.Fatal("all keys failing must error") + } +} + +func TestTavilyProviderNoKeys(t *testing.T) { + p := NewTavilyProvider("", nil) + if _, err := p.Search(context.Background(), SearchRequest{Query: "q"}); err == nil || + !strings.Contains(err.Error(), "no API key") { + t.Fatalf("no-key must fail closed: %v", err) + } +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} diff --git a/runtime/tools/tools_test.go b/runtime/tools/tools_test.go new file mode 100644 index 0000000..6f1f0c9 --- /dev/null +++ b/runtime/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) + } +}