- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态) - 裁决编号与 design 节号(02.4.3 / 11.4.2 / design/05 §9 等)逐字保留 - 复核:go build 绿;orchestration 及其子包(audit/comm)测试绿;gofmt 干净
283 lines
8.4 KiB
Go
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{}
|
|
}
|