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。
167 lines
5.4 KiB
Go
167 lines
5.4 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.gopher.ink/mirrors2/AutoZero/llm"
|
|
"git.gopher.ink/mirrors2/AutoZero/tools"
|
|
)
|
|
|
|
// streamToolProvider 在返回最终消息之前先通过 onDelta 通知一个工具调用已完整,
|
|
// 然后等只读工具真的启动——用来证明它是"边流边跑",而不是消息结束才跑。
|
|
type streamToolProvider struct {
|
|
read llm.ToolCall
|
|
side llm.ToolCall
|
|
readStart <-chan struct{}
|
|
sideStart <-chan struct{}
|
|
failStream bool
|
|
calls int
|
|
}
|
|
|
|
func (p *streamToolProvider) Stream(_ context.Context, _ string, _ []llm.Message, _ []llm.ToolDef,
|
|
_ llm.StreamOptions, onDelta func(llm.StreamDelta)) (llm.Message, error) {
|
|
p.calls++
|
|
if p.calls > 1 {
|
|
return llm.Message{Role: llm.RoleAssistant, Content: "done"}, nil
|
|
}
|
|
onDelta(llm.StreamDelta{ToolCall: &p.read})
|
|
if p.side.Name != "" {
|
|
onDelta(llm.StreamDelta{ToolCall: &p.side})
|
|
}
|
|
select {
|
|
case <-p.readStart:
|
|
case <-time.After(2 * time.Second):
|
|
return llm.Message{}, errors.New("read tool did not start during the stream")
|
|
}
|
|
// 给潜在的(错误的)提前启动留出调度窗口,再确认副作用工具没被提前跑。
|
|
time.Sleep(50 * time.Millisecond)
|
|
if p.sideStart != nil {
|
|
select {
|
|
case <-p.sideStart:
|
|
return llm.Message{}, errors.New("side-effect tool started during the stream")
|
|
default:
|
|
}
|
|
}
|
|
if p.failStream {
|
|
return llm.Message{}, errors.New("stream died")
|
|
}
|
|
calls := []llm.ToolCall{p.read}
|
|
if p.side.Name != "" {
|
|
calls = append(calls, p.side)
|
|
}
|
|
return llm.Message{Role: llm.RoleAssistant, ToolCalls: calls}, nil
|
|
}
|
|
|
|
func streamTools(t *testing.T) (read, side *tools.Tool, readStart, sideStart chan struct{}, readRuns, sideRuns *int32) {
|
|
t.Helper()
|
|
readStart = make(chan struct{})
|
|
sideStart = make(chan struct{})
|
|
var rr, sr int32
|
|
read = &tools.Tool{Name: "rotool", ReadOnly: true, ConcurrencySafe: true, Run: func(context.Context, string) (tools.Result, error) {
|
|
atomic.AddInt32(&rr, 1)
|
|
close(readStart)
|
|
return tools.TextResult("read-ok"), nil
|
|
}}
|
|
side = &tools.Tool{Name: "sidetool", SideEffect: true, Kind: tools.KindExec, Run: func(context.Context, string) (tools.Result, error) {
|
|
atomic.AddInt32(&sr, 1)
|
|
close(sideStart)
|
|
return tools.TextResult("side-ok"), nil
|
|
}}
|
|
return read, side, readStart, sideStart, &rr, &sr
|
|
}
|
|
|
|
// TestReadOnlyToolStartsDuringStream:只读工具在 provider 还没返回时就已开跑;
|
|
// 消息落盘后按原顺序回填,且不重复执行。
|
|
func TestReadOnlyToolStartsDuringStream(t *testing.T) {
|
|
read, side, readStart, sideStart, readRuns, sideRuns := streamTools(t)
|
|
prov := &streamToolProvider{
|
|
read: llm.ToolCall{ID: "r1", Name: "rotool", Input: "{}"},
|
|
side: llm.ToolCall{ID: "s1", Name: "sidetool", Input: "{}"},
|
|
readStart: readStart,
|
|
sideStart: sideStart,
|
|
}
|
|
a, sess := newTestAgent(t, read, side)
|
|
a.Provider = prov
|
|
|
|
if err := a.Run(context.Background(), "go"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := atomic.LoadInt32(readRuns); got != 1 {
|
|
t.Fatalf("read tool ran %d times, want 1", got)
|
|
}
|
|
if got := atomic.LoadInt32(sideRuns); got != 1 {
|
|
t.Fatalf("side tool ran %d times, want 1", got)
|
|
}
|
|
// 结果按调用顺序回填(r1 在 s1 前),且 assistant 消息在它们之前。
|
|
var toolIDs []string
|
|
for _, m := range sess.Messages {
|
|
if m.Role == llm.RoleTool {
|
|
toolIDs = append(toolIDs, m.ToolCallID)
|
|
}
|
|
}
|
|
if len(toolIDs) != 2 || toolIDs[0] != "r1" || toolIDs[1] != "s1" {
|
|
t.Fatalf("tool result order = %v", toolIDs)
|
|
}
|
|
}
|
|
|
|
// TestStreamFailureDiscardsEarlyResults:流在提前执行之后失败,结果不能落盘
|
|
// (没有对应的 tool_use),重试/下一轮从干净历史开始;只读工具没有副作用。
|
|
func TestStreamFailureDiscardsEarlyResults(t *testing.T) {
|
|
read, _, readStart, _, readRuns, _ := streamTools(t)
|
|
prov := &streamToolProvider{
|
|
read: llm.ToolCall{ID: "r1", Name: "rotool", Input: "{}"},
|
|
readStart: readStart,
|
|
failStream: true,
|
|
}
|
|
a, sess := newTestAgent(t, read)
|
|
a.Provider = prov
|
|
var events []ToolEvent
|
|
a.Opt.OnTool = func(ev ToolEvent) { events = append(events, ev) }
|
|
|
|
if err := a.Run(context.Background(), "go"); err == nil {
|
|
t.Fatal("want stream error")
|
|
}
|
|
if got := atomic.LoadInt32(readRuns); got != 1 {
|
|
t.Fatalf("read tool ran %d times, want 1", got)
|
|
}
|
|
if len(events) != 0 {
|
|
t.Fatalf("failed attempt leaked tool events into the UI: %+v", events)
|
|
}
|
|
for _, m := range sess.Messages {
|
|
if m.Role == llm.RoleTool || (m.Role == llm.RoleAssistant && len(m.ToolCalls) > 0) {
|
|
t.Fatalf("failed attempt leaked tool data into the transcript: %+v", sess.Messages)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStartEarlyToolEligibility:只有只读、非副作用、并发安全的工具能提前跑。
|
|
func TestStartEarlyToolEligibility(t *testing.T) {
|
|
read, side, readStart, sideStart, _, _ := streamTools(t)
|
|
a, _ := newTestAgent(t, read, side)
|
|
early := map[string]chan earlyOutcome{}
|
|
|
|
a.startEarlyTool(context.Background(), llm.ToolCall{ID: "s1", Name: "sidetool", Input: "{}"}, early)
|
|
if _, ok := early["s1"]; ok {
|
|
t.Fatal("side-effect tool must not start early")
|
|
}
|
|
select {
|
|
case <-sideStart:
|
|
t.Fatal("side-effect tool actually ran")
|
|
default:
|
|
}
|
|
|
|
a.startEarlyTool(context.Background(), llm.ToolCall{ID: "r1", Name: "rotool", Input: "{}"}, early)
|
|
if _, ok := early["r1"]; !ok {
|
|
t.Fatal("read-only tool should start early")
|
|
}
|
|
select {
|
|
case <-readStart:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("read-only tool did not start")
|
|
}
|
|
}
|