- 重复调用安全网(critical-tool-loop):指纹=name+canonical JSON(args);批内重复第二次跳过回喂
「duplicate call」;跨轮关键重复(同指纹重现)→首次 block+criticalSeen,再次连续重复→终止整轮
StopReasonToolLoopTerminated(替代裸 8 轮针对同调用的硬截止)。
- 并行/串行混合:invokeParallel 默认并行,信号量(ToolParallelism 默认4)限并发,结果按调用索引
写回按原序渲染;串行触发=Options.ToolExecution:sequential 或 SetToolExecutionMode(name,sequential)
(对齐 OpenClaw「一个 sequential 标记→整组串行」);goroutine 不泄漏、ctx 取消快速返回。
- 工具钩子:ToolLoopHooks(BatchAdmit/CallAdmit/AfterCall/AfterOutcome) 经 Options.Hooks 注入,
均 nil-tolerant;供后续敏感操作代审复用(design/11)。terminate 用 every 语义。
- status 分类:ToolError.Kind() 区分 argument-validation(JSON-Schema 失败)/execution;
失败消息渲染 {kind,message} 让模型区分重发参数 vs 执行失败。
- tool-call-repair:LenientJSONUnmarshal 修 fence 包裹/尾逗号畸形;agent.extractToolCalls 与
loop.decodeToolCalls 走它;不可修复退化为文本不崩整轮。意图调用未区分回喂提示(后置)。
- tool_execution_update:流式路径 emitToolExecution 发 StreamKindToolExecution(start/end/end:error)。
go build/vet/test 全绿(46包)。TestRun_MaxRounds 改每轮不同参(原同 echo 被安全网拦,预期新行为)。
-race 受宿主 TSan VMA 限制无法运行(与代码无关)。
379 lines
12 KiB
Go
379 lines
12 KiB
Go
package loop
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
|
)
|
|
|
|
func TestRun_NoTools_FinalText(t *testing.T) {
|
|
t.Parallel()
|
|
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Model: "m", Content: "Hello!"}}}}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
res, err := l.Run(context.Background(), "sess-1", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "Hello!" {
|
|
t.Fatalf("Text = %q, want Hello!", res.Text)
|
|
}
|
|
if res.Steps != 1 {
|
|
t.Fatalf("Steps = %d, want 1", res.Steps)
|
|
}
|
|
if res.StopReason != agent.StopReasonStop {
|
|
t.Fatalf("StopReason = %q, want %q", res.StopReason, agent.StopReasonStop)
|
|
}
|
|
if res.ToolCallsExecuted != 0 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 0", res.ToolCallsExecuted)
|
|
}
|
|
}
|
|
|
|
func TestRun_ToolCallThenFinal_FeedsBack(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "Echoed!"}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
|
a := newTestAgent(t, ad, td)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "echo this")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "Echoed!" {
|
|
t.Fatalf("Text = %q, want Echoed!", res.Text)
|
|
}
|
|
if res.Steps != 2 {
|
|
t.Fatalf("Steps = %d, want 2", res.Steps)
|
|
}
|
|
if res.StopReason != agent.StopReasonStop {
|
|
t.Fatalf("StopReason = %q, want stop", res.StopReason)
|
|
}
|
|
if res.ToolCallsExecuted != 1 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
|
}
|
|
if len(ad.reqs) < 2 {
|
|
t.Fatalf("adapter requests = %d, want >= 2", len(ad.reqs))
|
|
}
|
|
last := ad.reqs[len(ad.reqs)-1]
|
|
got := last.Messages[len(last.Messages)-1]
|
|
if got.Role != model.RoleTool {
|
|
t.Fatalf("last message role = %q, want tool", got.Role)
|
|
}
|
|
if !strings.Contains(got.Content[0].Text, "hi") {
|
|
t.Fatalf("tool result text = %q, want to contain 'hi'", got.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRun_MaxRounds(t *testing.T) {
|
|
t.Parallel()
|
|
// Distinct calls per round so the critical-tool-loop safety net does not
|
|
// intervene; the goal here is to exercise the round budget.
|
|
call1 := `{"tool_calls":[{"name":"echo","args":{"q":"x"}}]}`
|
|
call2 := `{"tool_calls":[{"name":"echo","args":{"q":"y"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call1}},
|
|
{resp: model.Response{Content: call2}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "echo", result: "x"})
|
|
a := newTestAgent(t, ad, td)
|
|
l := New(Options{Agent: a, MaxRounds: 2})
|
|
|
|
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Steps != 2 {
|
|
t.Fatalf("Steps = %d, want 2", res.Steps)
|
|
}
|
|
if res.StopReason != StopReasonMaxRounds {
|
|
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonMaxRounds)
|
|
}
|
|
if res.ToolCallsExecuted != 2 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 2", res.ToolCallsExecuted)
|
|
}
|
|
}
|
|
|
|
func TestRun_ToolError_FeedsBack(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"boom","args":{}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "recovered"}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "boom", err: errors.New("kaboom")})
|
|
a := newTestAgent(t, ad, td)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "boom now")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "recovered" {
|
|
t.Fatalf("Text = %q, want recovered", res.Text)
|
|
}
|
|
if res.ToolCallsExecuted != 1 {
|
|
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
|
}
|
|
got := ad.reqs[len(ad.reqs)-1].Messages[len(ad.reqs[len(ad.reqs)-1].Messages)-1]
|
|
if !strings.Contains(got.Content[0].Text, "kaboom") {
|
|
t.Fatalf("tool result should carry the error, got %q", got.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRun_ToolTimeout_FeedsBack(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"slow","args":{}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "continued after timeout"}},
|
|
}}
|
|
td := dispatcherWithTimeout(t, 30*time.Millisecond, &mockTool{name: "slow", delay: 500 * time.Millisecond})
|
|
a := newTestAgent(t, ad, td)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "slow it")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "continued after timeout" {
|
|
t.Fatalf("Text = %q, want recovery text", res.Text)
|
|
}
|
|
got := ad.reqs[len(ad.reqs)-1].Messages[len(ad.reqs[len(ad.reqs)-1].Messages)-1]
|
|
if !strings.Contains(got.Content[0].Text, "timed out") {
|
|
t.Fatalf("tool result should report the timeout, got %q", got.Content[0].Text)
|
|
}
|
|
}
|
|
|
|
func TestRun_CheckpointPerStep(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
|
a := newTestAgent(t, ad, td)
|
|
cp := newTestCheckpointStore(t)
|
|
l := New(Options{Agent: a, Checkpoint: cp, MaxRounds: 8})
|
|
|
|
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
got, err := cp.List(context.Background(), "sess")
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("checkpoints = %d, want 2", len(got))
|
|
}
|
|
if got[0].Step != 1 || got[1].Step != 2 {
|
|
t.Fatalf("checkpoint steps = [%d,%d], want [1,2]", got[0].Step, got[1].Step)
|
|
}
|
|
}
|
|
|
|
func TestRun_TraceSpans(t *testing.T) {
|
|
t.Parallel()
|
|
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
|
|
ad := &mockAdapter{chat: []mgResp{
|
|
{resp: model.Response{Content: call}},
|
|
{resp: model.Response{Content: "done"}},
|
|
}}
|
|
td := dispatcherWith(t, &mockTool{name: "echo", result: "hi"})
|
|
a := newTestAgent(t, ad, td)
|
|
var buf bytes.Buffer
|
|
tr := trace.New(trace.NewJSONRecorder(&buf))
|
|
l := New(Options{Agent: a, Tracer: tr, MaxRounds: 8})
|
|
|
|
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
saw := map[trace.SpanKind]bool{}
|
|
for _, r := range decodeSpanLines(t, buf.Bytes()) {
|
|
saw[r.Kind] = true
|
|
}
|
|
for _, k := range []trace.SpanKind{trace.SpanKindLLM, trace.SpanKindTool, trace.SpanKindStateTransition} {
|
|
if !saw[k] {
|
|
t.Fatalf("missing span kind %q in %+v", k, saw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRun_MemoryInject(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
if _, err := store.Add(context.Background(), memory.MemoryItem{
|
|
PersonaID: "persona-a", Content: "她喜欢喝冰美式",
|
|
Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
|
}); err != nil {
|
|
t.Fatalf("seed memory: %v", err)
|
|
}
|
|
ret := memory.NewRetriever(store)
|
|
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "喜欢冰美式"}}}}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, Retriever: ret, PersonaID: "persona-a", MaxRounds: 8})
|
|
|
|
if _, err := l.Run(context.Background(), "sess", nil); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
// An empty conversation still routes through injectMemory; the retriever
|
|
// returns the persona's items by recency when the query is blank.
|
|
if len(ad.reqs) == 0 {
|
|
t.Fatal("no request captured")
|
|
}
|
|
found := false
|
|
for _, m := range ad.reqs[0].Messages {
|
|
if m.Role == model.RoleSystem && strings.Contains(m.Content[0].Text, "冰美式") {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("injected memory not found in request messages: %+v", ad.reqs[0].Messages)
|
|
}
|
|
}
|
|
|
|
func TestRun_MemoryExtract(t *testing.T) {
|
|
t.Parallel()
|
|
store := newTestMemoryStore(t)
|
|
ext := memory.NewManualExtractor("persona-a", "role-1")
|
|
ad := &mockAdapter{chat: []mgResp{{resp: model.Response{Content: "记住了"}}}}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, Memory: store, Extractor: ext, PersonaID: "persona-a", MaxRounds: 8})
|
|
|
|
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "记住 我爱喝冰美式")}); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
items, err := store.ListByPersona(context.Background(), "persona-a")
|
|
if err != nil {
|
|
t.Fatalf("ListByPersona: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items = %d, want 1", len(items))
|
|
}
|
|
if items[0].Content != "我爱喝冰美式" {
|
|
t.Fatalf("item content = %q, want 我爱喝冰美式", items[0].Content)
|
|
}
|
|
}
|
|
|
|
func TestRun_StreamDeltas(t *testing.T) {
|
|
t.Parallel()
|
|
ad := &mockAdapter{
|
|
stream: []mgResp{{resp: model.Response{Content: "Hello world"}}},
|
|
chunks: [][]string{{"Hel", "lo ", "world"}},
|
|
}
|
|
a := newTestAgent(t, ad, nil)
|
|
var mu sync.Mutex
|
|
var deltas []string
|
|
l := New(Options{
|
|
Agent: a,
|
|
OnStream: func(ev model.StreamEvent) { mu.Lock(); deltas = append(deltas, ev.Delta); mu.Unlock() },
|
|
MaxRounds: 8,
|
|
})
|
|
|
|
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.Text != "Hello world" {
|
|
t.Fatalf("Text = %q, want Hello world", res.Text)
|
|
}
|
|
mu.Lock()
|
|
got := append([]string(nil), deltas...)
|
|
mu.Unlock()
|
|
if len(got) != 3 || got[0] != "Hel" || got[1] != "lo " || got[2] != "world" {
|
|
t.Fatalf("deltas = %+v, want [Hel lo world]", got)
|
|
}
|
|
}
|
|
|
|
func TestRun_StreamGuardrailBlocks(t *testing.T) {
|
|
t.Parallel()
|
|
g := &mockGuard{inputVerdict: &guardrail.Verdict{Allow: false, Reason: "blocked in"}}
|
|
pipe := guardrail.New(guardrail.WithGuard(g))
|
|
ad := &mockAdapter{}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, Guards: pipe, OnStream: func(model.StreamEvent) {}, MaxRounds: 8})
|
|
|
|
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.StopReason != agent.StopReasonGuardrailInput {
|
|
t.Fatalf("StopReason = %q, want %q", res.StopReason, agent.StopReasonGuardrailInput)
|
|
}
|
|
if res.Text != "blocked in" {
|
|
t.Fatalf("Text = %q, want blocked in", res.Text)
|
|
}
|
|
}
|
|
|
|
func TestRun_ContextCanceled(t *testing.T) {
|
|
t.Parallel()
|
|
ad := &mockAdapter{}
|
|
a := newTestAgent(t, ad, nil)
|
|
l := New(Options{Agent: a, MaxRounds: 8})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := l.Run(ctx, "sess", []model.Message{model.TextMessage(model.RoleUser, "hi")}); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestRun_NilAgent(t *testing.T) {
|
|
t.Parallel()
|
|
l := New(Options{})
|
|
if _, err := l.Run(context.Background(), "sess", nil); !errors.Is(err, ErrNilAgent) {
|
|
t.Fatalf("Run error = %v, want ErrNilAgent", err)
|
|
}
|
|
}
|
|
|
|
func TestNew_DefaultMaxRounds(t *testing.T) {
|
|
t.Parallel()
|
|
if got := New(Options{}).MaxRounds(); got != DefaultMaxRounds {
|
|
t.Fatalf("MaxRounds = %d, want %d", got, DefaultMaxRounds)
|
|
}
|
|
if got := New(Options{MaxRounds: 3}).MaxRounds(); got != 3 {
|
|
t.Fatalf("MaxRounds = %d, want 3", got)
|
|
}
|
|
}
|
|
|
|
// decodeSpanLines reads every JSON span emitted as JSONL into recs, failing the
|
|
// test if any line is malformed.
|
|
func decodeSpanLines(t *testing.T, data []byte) []trace.SpanRecord {
|
|
t.Helper()
|
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
|
var recs []trace.SpanRecord
|
|
for sc.Scan() {
|
|
line := sc.Bytes()
|
|
if len(bytes.TrimSpace(line)) == 0 {
|
|
continue
|
|
}
|
|
var r trace.SpanRecord
|
|
if err := json.Unmarshal(line, &r); err != nil {
|
|
t.Fatalf("decode span: %v", err)
|
|
}
|
|
recs = append(recs, r)
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
t.Fatalf("scan spans: %v", err)
|
|
}
|
|
return recs
|
|
}
|