llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package agent
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.gopher.ink/mirrors2/AutoZero/llm"
|
||
"git.gopher.ink/mirrors2/AutoZero/session"
|
||
"git.gopher.ink/mirrors2/AutoZero/tools"
|
||
)
|
||
|
||
// quote 把字符串编码为 JSON 字符串字面量(用于拼接工具参数)。
|
||
func quote(s string) string {
|
||
b, _ := json.Marshal(s)
|
||
return string(b)
|
||
}
|
||
|
||
func newTestAgent(t *testing.T, extra ...*tools.Tool) (*Agent, *session.Session) {
|
||
t.Helper()
|
||
t.Setenv("HOME", t.TempDir())
|
||
reg := tools.NewRegistry()
|
||
for _, tl := range tools.Builtin() {
|
||
if err := reg.Register(tl); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
for _, tl := range extra {
|
||
if err := reg.Register(tl); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
sess, err := session.New()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var out bytes.Buffer
|
||
a := &Agent{
|
||
Tools: reg,
|
||
Session: sess,
|
||
System: "test",
|
||
Opt: Options{Stdout: &out, Stderr: &out, ApproveAll: true},
|
||
}
|
||
return a, sess
|
||
}
|
||
|
||
// TestSynthMissingToolResults 验证为未配对的 tool_use 合成错误结果,且幂等。
|
||
func TestSynthMissingToolResults(t *testing.T) {
|
||
a, sess := newTestAgent(t)
|
||
sess.Messages = []llm.Message{
|
||
{Role: llm.RoleUser, Content: "go"},
|
||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
|
||
{ID: "c1", Name: "ls"}, {ID: "c2", Name: "grep"},
|
||
}},
|
||
{Role: llm.RoleTool, ToolCallID: "c1", Content: "ok"},
|
||
}
|
||
|
||
a.synthMissingToolResults()
|
||
|
||
var found bool
|
||
for _, m := range sess.Messages {
|
||
if m.ToolCallID == "c2" {
|
||
found = true
|
||
if m.Role != llm.RoleTool || m.Content == "" {
|
||
t.Fatalf("bad synthesized message: %+v", m)
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("missing tool_result for c2 was not synthesized")
|
||
}
|
||
|
||
// 幂等:重复调用不追加
|
||
n := len(sess.Messages)
|
||
a.synthMissingToolResults()
|
||
if len(sess.Messages) != n {
|
||
t.Fatalf("synth is not idempotent: %d -> %d", n, len(sess.Messages))
|
||
}
|
||
}
|
||
|
||
// TestOrchestrationOrderAndConcurrency:两段只读并发、中间夹一个写工具,
|
||
// 验证回填顺序与调用顺序一致,且总耗时接近并发而非串行。
|
||
func TestOrchestrationOrderAndConcurrency(t *testing.T) {
|
||
const delay = 150 * time.Millisecond
|
||
slow := func(name string) *tools.Tool {
|
||
return &tools.Tool{
|
||
Name: name,
|
||
Description: "slow read",
|
||
ReadOnly: true,
|
||
ConcurrencySafe: true,
|
||
Parameters: map[string]any{"type": "object"},
|
||
Run: func(_ context.Context, _ string) (tools.Result, error) {
|
||
time.Sleep(delay)
|
||
return tools.TextResult(name + " done"), nil
|
||
},
|
||
}
|
||
}
|
||
a, sess := newTestAgent(t, slow("ra"), slow("rb"), slow("rc"))
|
||
target := filepath.Join(t.TempDir(), "out.txt")
|
||
|
||
calls := []llm.ToolCall{
|
||
{ID: "1", Name: "ra"}, {ID: "2", Name: "rb"},
|
||
{ID: "3", Name: "write", Input: `{"path":` + quote(target) + `,"content":"x"}`},
|
||
{ID: "4", Name: "rc"},
|
||
}
|
||
start := time.Now()
|
||
if err := a.execTools(context.Background(), calls); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
elapsed := time.Since(start)
|
||
|
||
// 串行 >= 450ms(3×delay+写开销);并发批次 ~2×delay。留出余量断言。
|
||
if elapsed >= 4*delay {
|
||
t.Fatalf("calls appear serialized: elapsed=%v", elapsed)
|
||
}
|
||
|
||
// 回填必须与调用顺序一致且 ID 正确配对
|
||
results := sess.Messages[len(sess.Messages)-4:]
|
||
wantOrder := []string{"ra", "rb", "wrote", "rc"}
|
||
wantID := []string{"1", "2", "3", "4"}
|
||
for i, m := range results {
|
||
if m.Role != llm.RoleTool {
|
||
t.Fatalf("message %d is not a tool result: %+v", i, m)
|
||
}
|
||
if m.ToolCallID != wantID[i] {
|
||
t.Fatalf("result %d: got ToolCallID %q, want %q", i, m.ToolCallID, wantID[i])
|
||
}
|
||
if !strings.Contains(m.Content, wantOrder[i]) {
|
||
t.Fatalf("result %d content %q does not contain %q", i, m.Content, wantOrder[i])
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestRunInterruptedPairing:assistant 发起多个调用但只回填了部分
|
||
// (模拟 Append 中途失败),synth 必须把缺口补齐。
|
||
func TestRunInterruptedPairing(t *testing.T) {
|
||
a, sess := newTestAgent(t)
|
||
// 构造:assistant 发起 3 个调用,只回填了 2 个(模拟 Append 中途失败)
|
||
sess.Messages = []llm.Message{
|
||
{Role: llm.RoleUser, Content: "go"},
|
||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{
|
||
{ID: "a", Name: "ls"}, {ID: "b", Name: "grep"}, {ID: "c", Name: "read"},
|
||
}},
|
||
{Role: llm.RoleTool, ToolCallID: "a", Content: "ok"},
|
||
{Role: llm.RoleTool, ToolCallID: "b", Content: "ok"},
|
||
}
|
||
a.synthMissingToolResults()
|
||
for _, id := range []string{"a", "b", "c"} {
|
||
found := false
|
||
for _, m := range sess.Messages {
|
||
if m.ToolCallID == id && m.Role == llm.RoleTool {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("tool result for %q missing after synth", id)
|
||
}
|
||
}
|
||
}
|