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{} }