Import the current agent-native module as a fresh history.
ci / vet-and-test (push) Successful in 5s
ci / docker-checkout-hostgateway (push) Successful in 3s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-29 12:29:04 +00:00
co-authored by Cursor
commit fedf671a48
45 changed files with 5533 additions and 0 deletions
+20
View File
@@ -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"
+51
View File
@@ -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 说明见包内注释)。
+247
View File
@@ -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()
}
+122
View File
@@ -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)
}
}
+110
View File
@@ -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}
}
+96
View File
@@ -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
}
+108
View File
@@ -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
}
}
+178
View File
@@ -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
}
+126
View File
@@ -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)
}
}
+13
View File
@@ -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)
}
@@ -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"
}
+125
View File
@@ -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;
}
+125
View File
@@ -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: "" };
}
+147
View File
@@ -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
}
+259
View File
@@ -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)
}
+43
View File
@@ -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")
}
}
+68
View File
@@ -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。
+52
View File
@@ -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)
}
+40
View File
@@ -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)
}
}
+141
View File
@@ -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/<target>.
// 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
}
+131
View File
@@ -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)
}
}
+46
View File
@@ -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)
}
+50
View File
@@ -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)
}
}
+142
View File
@@ -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)
}
})
}
}
+199
View File
@@ -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)
}
+181
View File
@@ -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)
}
}
+47
View File
@@ -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
}
+223
View File
@@ -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
}
]
+3
View File
@@ -0,0 +1,3 @@
module agent-native
go 1.27
+77
View File
@@ -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)
}
}
}
+152
View File
@@ -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
}
+50
View File
@@ -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)
}
+223
View File
@@ -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
}
+224
View File
@@ -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)
}
}
+97
View File
@@ -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
+264
View File
@@ -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) }
+186
View File
@@ -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()
}
+73
View File
@@ -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()
}
+38
View File
@@ -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])
}
}
}
+198
View File
@@ -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
}
+85
View File
@@ -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))
}
+202
View File
@@ -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
+285
View File
@@ -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, &params); 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()
}
+166
View File
@@ -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
}
+105
View File
@@ -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)
}
}