Files
Cyrene_v2/internal/runtime/orchestration/spawnflow_helpers_test.go
T
AskaEth eb1828ee7a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/orchestration(29 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 裁决编号与 design 节号(02.4.3 / 11.4.2 / design/05 §9 等)逐字保留
- 复核:go build 绿;orchestration 及其子包(audit/comm)测试绿;gofmt 干净
2026-10-04 13:30:04 +08:00

283 lines
8.4 KiB
Go

package orchestration
import (
"context"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// parentSession 是分支测试把自己的子代理归属到的 flow 会话;它与网关发布时所依的
// web 聊天会话一致。
const parentSession = "web"
// secretArg 与 secretValue 是植入某次工具调用参数及其结果里的凭据形状字符串。二者都
// 不得出现在已发布的流程图中。
const (
secretArg = "sk-arg-0123456789abcdef"
secretValue = "sk-live-0123456789abcdef"
)
// graphCapture 按会话记录发布器发出的每张流程图。
type graphCapture struct {
mu sync.Mutex
bySession map[string]*trace.FlowGraph
roundIDs map[string][]string
}
func (c *graphCapture) publish(_ string, payload any) {
g, ok := payload.(*trace.FlowGraph)
if !ok || g == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.bySession == nil {
c.bySession = make(map[string]*trace.FlowGraph)
c.roundIDs = make(map[string][]string)
}
c.bySession[g.SessionID] = g
if ids := c.roundIDs[g.SessionID]; len(ids) == 0 || ids[len(ids)-1] != g.ID {
c.roundIDs[g.SessionID] = append(ids, g.ID)
}
}
func (c *graphCapture) graph(t *testing.T, session string) *trace.FlowGraph {
t.Helper()
c.mu.Lock()
defer c.mu.Unlock()
g := c.bySession[session]
if g == nil {
t.Fatalf("no flow graph published for session %q (captured sessions: %v)", session, c.sessionsLocked())
}
return g
}
func (c *graphCapture) ids(session string) []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.roundIDs[session]...)
}
func (c *graphCapture) has(session string) bool {
c.mu.Lock()
defer c.mu.Unlock()
_, ok := c.bySession[session]
return ok
}
func (c *graphCapture) sessionsLocked() []string {
out := make([]string, 0, len(c.bySession))
for s := range c.bySession {
out = append(out, s)
}
return out
}
// toolThenFinalAdapter 的行为像一个先请求一次工具调用、随后作答的模型:请求里已带有
// 工具结果时,它返回脚本化的最终答复。依据请求(而不是调用计数器)来判断,使两个子
// 代理并发驱动同一个实例时它依然正确。
type toolThenFinalAdapter struct {
toolName string
args map[string]any
final string
}
func (a *toolThenFinalAdapter) Name() string { return "mock" }
func (a *toolThenFinalAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
for _, m := range req.Messages {
if m.Role == model.RoleTool {
return model.Response{Model: "mock-1", Content: a.final, StopReason: "end_turn"}, nil
}
}
return model.Response{Model: "mock-1", ToolCalls: []model.ToolCall{
{ID: "call-1", Name: a.toolName, Args: a.args},
}}, nil
}
func (a *toolThenFinalAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
// 发出一个增量,使测试能检出循环是否把子代理的流转发到了调用方的 UI channel。
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "内部草稿"})
}
return a.Chat(ctx, req, model.Options{})
}
// echoTool 是一个最小工具:可选延迟之后回报一个脚本化结果,使两个分支的步骤在时间上
// 重叠。
type echoTool struct {
name string
result any
delay time.Duration
}
func (e *echoTool) Name() string { return e.name }
func (e *echoTool) Description() string { return "echo the scripted result" }
func (e *echoTool) Parameters() (map[string]any, error) { return nil, nil }
func (e *echoTool) Execute(ctx context.Context, _ any) (any, error) {
if e.delay > 0 {
select {
case <-time.After(e.delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return e.result, nil
}
// branchLoop 在给定 dispatcher 上构建带 tracing 与 flow 发布接线的真实循环,外加驱动
// 它的调度器 —— 即生产形状,只是少了模型供应商。
func branchLoop(t *testing.T, flow *loop.FlowPublisher, tracer *trace.Tracer, d *tool.Dispatcher, opts Options) *Scheduler {
t.Helper()
ad := &toolThenFinalAdapter{toolName: "echo", args: map[string]any{
"command": "date",
"api_key": secretArg,
}, final: "子代理完成"}
ag, err := agent.New("be brief", d, nil,
agent.WithModel(ad, nil),
agent.WithOptions(agent.Options{Model: "mock-1"}))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
lp := loop.New(loop.Options{Agent: ag, Tools: d, Tracer: tracer, Flow: flow, MaxRounds: 8})
opts.Loop = lp
opts.Tracer = tracer
opts.Flow = flow
return newScheduler(t, opts)
}
// flowNodeByID 返回给定 id 的节点,不存在时判测试失败。
func flowNodeByID(t *testing.T, g *trace.FlowGraph, id string) trace.FlowNode {
t.Helper()
for _, n := range g.Nodes {
if n.ID == id {
return n
}
}
t.Fatalf("no node %q in graph %s (nodes: %v)", id, g.ID, nodeIDs(g))
return trace.FlowNode{}
}
func nodeIDs(g *trace.FlowGraph) []string {
out := make([]string, 0, len(g.Nodes))
for _, n := range g.Nodes {
out = append(out, n.ID)
}
return out
}
// flowKindNodes 按图的插入顺序(即 span 开始的顺序,也就是泳道的步骤顺序)返回给定
// 类别的每个节点。
func flowKindNodes(g *trace.FlowGraph, kind string) []trace.FlowNode {
out := make([]trace.FlowNode, 0, len(g.Nodes))
for _, n := range g.Nodes {
if n.Kind == kind {
out = append(out, n)
}
}
return out
}
// flowLaneNodes 按插入顺序返回某条泳道(一个可达集合)内的节点。
func flowLaneNodes(g *trace.FlowGraph, lane map[string]bool) []trace.FlowNode {
out := make([]trace.FlowNode, 0, len(lane))
for _, n := range g.Nodes {
if lane[n.ID] {
out = append(out, n)
}
}
return out
}
// flowLaneChain 按插入时间返回该分支的各个步骤,并断言它们构成单条链:子代理根喂给
// 第一步,每一步喂给下一步。泳道交叉链接(某个步骤可从两个父节点到达)或游离节点都会
// 破坏链断言。
func flowLaneChain(t *testing.T, g *trace.FlowGraph, sub trace.FlowNode) []trace.FlowNode {
t.Helper()
lane := flowReachable(g, sub.ID)
if len(lane) == 0 {
t.Fatalf("subagent node %s (label %s) produced no steps", sub.ID, sub.Label)
}
chain := flowLaneNodes(g, lane)
if !hasFlowEdge(g, sub.ID, chain[0].ID) {
t.Fatalf("edges = %+v, want the lane to start at %s (root %s)", g.Edges, sub.ID, chain[0].ID)
}
for i := 1; i < len(chain); i++ {
if !hasFlowEdge(g, chain[i-1].ID, chain[i].ID) {
t.Fatalf("lane chain broken between %s and %s (edges: %+v)", chain[i-1].ID, chain[i].ID, g.Edges)
}
}
return chain
}
// chainKinds 把一条泳道链渲染成 "llm>tool>llm",用于失败信息。
func chainKinds(chain []trace.FlowNode) string {
parts := make([]string, 0, len(chain))
for _, n := range chain {
parts = append(parts, n.Kind)
}
return strings.Join(parts, ">")
}
// flowSubagentNode 返回标签点名了给定子代理 id 的分支根。节点以 span id 为键,因此标签
// 才是标识其归属者的东西。
func flowSubagentNode(t *testing.T, g *trace.FlowGraph, subID string) trace.FlowNode {
t.Helper()
want := "subagent." + subID
for _, n := range flowKindNodes(g, "subagent") {
if n.Label == want || n.ID == want {
return n
}
}
t.Fatalf("no subagent node labelled %q in graph %s (nodes: %v)", want, g.ID, nodeIDs(g))
return trace.FlowNode{}
}
// flowReachable 返回从 id 可达的节点 id 集合,不含 id 自身。
func flowReachable(g *trace.FlowGraph, id string) map[string]bool {
out := make(map[string]bool)
queue := []string{id}
for len(queue) > 0 {
from := queue[0]
queue = queue[1:]
for _, e := range g.Edges {
if e.From != from || out[e.To] || e.To == id {
continue
}
out[e.To] = true
queue = append(queue, e.To)
}
}
return out
}
func hasFlowEdge(g *trace.FlowGraph, from, to string) bool {
for _, e := range g.Edges {
if e.From == from && e.To == to {
return true
}
}
return false
}
// flowLogEvent 返回该节点给定类型的日志条目。
func flowLogEvent(t *testing.T, n trace.FlowNode, typ string) trace.Event {
t.Helper()
for _, ev := range n.Log {
if ev.Type == typ {
return ev
}
}
t.Fatalf("node %s has no %q log entry (log: %+v)", n.ID, typ, n.Log)
return trace.Event{}
}