执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。
- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
`tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
`StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
**Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
`AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
`stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
`streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
(最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。
验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。
未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
386 lines
12 KiB
Go
386 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()
|
||
// 每轮使用不同的调用,使关键工具循环安全网不会
|
||
// 介入;这里的目标是演练轮次预算。
|
||
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)
|
||
}
|
||
// 空对话仍会走 injectMemory;查询为空时 retriever
|
||
// 按最近程度返回该角色的条目。
|
||
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) {
|
||
if ev.Kind != model.StreamKindTextDelta {
|
||
return
|
||
}
|
||
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 把作为 JSONL 发出的每个 JSON span 读入 recs,
|
||
// 任何一行格式错误都让测试失败。
|
||
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
|
||
}
|