feat(trace,loop,orchestration): memory/guardrail/orchestration 真 span + 参数摘要脱敏 + Guardrail 输出侧异步(裁决 11.4.2/11.4.3/13.4.1)
- 11.4.2:三类节点首次真进图 —— memory.recall/memory.write(loop/memory.go 召回与写入点)、 guardrail.input/output(流式主路径两侧 + agent.RunStep 静音路径)、orchestration.spawn (scheduler 每次派发/收束);新增 trace.ChildSpan 让不持有 Tracer 的叶子包也能开子 span - 11.4.3(设计先行,规格先写进 design/19 §1.1):tool 节点参数摘要 —— 放行表 argValueKeys + trace.IsSecretKey 统一判据(密钥类键不给值)+ 单值 80 rune / ≤24 条 + 出图前再清洗; 前端 NodeDetail 新增「参数摘要」区,未放行键显示「值未展示」 - 13.4.1:流式主路径输出侧护栏改后台 goroutine(脱离 context、30s 兜底、不阻塞回合), 输入侧仍在模型调用前同步拦下;拦截闭环到 guardrail 节点 + 审计事件 - 拆文件:scheduler.go 加 span 后 546 行 → 编排 span 助手拆出 scheduler_spans.go(回到 492) - 契约:无帧名/事件形状变化;graph_update 节点 props 新增**可选**键(tool args_keys/args_summary、 guardrail action/allow/elapsed_ms、memory action/scope/hits/…、orchestration subagent/persona), 文档同步由主会话统一落
This commit is contained in:
1 parent
0dfad675b8
commit
b99268d019
14 files changed
+1396
-42
No files matched your search
@@ -0,0 +1,274 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"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/loop"
|
||||
"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/tool"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// roundExtractor 是确定性的记忆提取器:一轮结束后必然产生一次记忆写入,
|
||||
// 使「真实跑一轮」里 memory 的写侧也有节点(不实现 asyncExtractor,因此
|
||||
// 提取留在同步路径上,测试不用等后台)。
|
||||
type roundExtractor struct{}
|
||||
|
||||
func (roundExtractor) Extract(context.Context, []model.Message) ([]memory.MemoryItem, error) {
|
||||
return []memory.MemoryItem{{Content: "子代理记住了这次委托"}}, nil
|
||||
}
|
||||
|
||||
// delegateTool 模拟「派活」工具:它把前台会话当作 ParentSession 派一个后台
|
||||
// 子代理(与生产里的 subagent_spawn 同一个归属口径:会话来自 ctx,绝不从
|
||||
// 模型输出推断),并记下被派发的子代理 id 供测试等待。
|
||||
type delegateTool struct {
|
||||
sched *Scheduler
|
||||
mu sync.Mutex
|
||||
spawn []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *delegateTool) Name() string { return "delegate" }
|
||||
func (d *delegateTool) Description() string { return "dispatch a background sub-agent" }
|
||||
func (d *delegateTool) Parameters() (map[string]any, error) { return nil, nil }
|
||||
|
||||
func (d *delegateTool) Execute(ctx context.Context, _ any) (any, error) {
|
||||
session := loop.SessionFromContext(ctx)
|
||||
if session == "" {
|
||||
d.setErr(context.Canceled)
|
||||
return "no session in context", nil
|
||||
}
|
||||
// 与生产的 subagent_spawn 同一口径:后台子代理必须活得比前台回合久,
|
||||
// 因此派发用一个脱离取消但保留会话值的 context。
|
||||
spawnCtx := context.WithoutCancel(ctx)
|
||||
sub, err := d.sched.Spawn(spawnCtx, AgentSpec{Goal: "调研一下", ParentSession: session})
|
||||
if err != nil {
|
||||
d.setErr(err)
|
||||
return "dispatch failed", nil
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.spawn = append(d.spawn, sub.ID)
|
||||
d.mu.Unlock()
|
||||
return "已派发 " + sub.ID, nil
|
||||
}
|
||||
|
||||
func (d *delegateTool) setErr(err error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.err == nil {
|
||||
d.err = err
|
||||
}
|
||||
}
|
||||
|
||||
func (d *delegateTool) spawned() []string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return append([]string(nil), d.spawn...)
|
||||
}
|
||||
|
||||
// roundDiscardLogger 返回一个不写任何地方的 logger,保持测试输出干净。
|
||||
func roundDiscardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// flowRound 是一轮「前台 + 后台子代理」的真实运行装置:它把两条真实循环
|
||||
// (前台流式路径、子代理模板)、调度器与派活工具串起来。
|
||||
type flowRound struct {
|
||||
front *loop.Loop
|
||||
flow *loop.FlowPublisher
|
||||
capture *graphCapture
|
||||
sched *Scheduler
|
||||
delegate *delegateTool
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// newFlowRound 组装装置:真实 SQLite 内存库上的记忆栈、真实护栏、实时
|
||||
// tracer/flow,前台走流式路径(生产 chat 路径),子代理模板同样带记忆。
|
||||
func newFlowRound(t *testing.T) *flowRound {
|
||||
t.Helper()
|
||||
capture := &graphCapture{}
|
||||
flow := loop.NewFlowPublisher(capture.publish, nil)
|
||||
tracer := trace.New(nil)
|
||||
|
||||
ctx := context.Background()
|
||||
sqlDB, oerr := db.Open(ctx, ":memory:", roundDiscardLogger())
|
||||
if oerr != nil {
|
||||
t.Fatalf("db.Open: %v", oerr)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
memStore, merr := memory.NewStore(ctx, sqlDB, roundDiscardLogger())
|
||||
if merr != nil {
|
||||
t.Fatalf("memory.NewStore: %v", merr)
|
||||
}
|
||||
retriever := memory.NewRetriever(memStore)
|
||||
|
||||
front, frontDispatch := newFrontRoundLoop(t, flow, tracer, memStore, retriever)
|
||||
subLoop := newSubRoundLoop(t, flow, tracer, memStore, retriever)
|
||||
sched := newScheduler(t, Options{MaxParallel: 1, Loop: subLoop, Tracer: tracer, Flow: flow, Memory: memStore})
|
||||
|
||||
delegate := &delegateTool{sched: sched}
|
||||
if rerr := frontDispatch.Register(delegate); rerr != nil {
|
||||
t.Fatalf("Register delegate: %v", rerr)
|
||||
}
|
||||
return &flowRound{front: front, flow: flow, capture: capture, sched: sched, delegate: delegate, ctx: ctx}
|
||||
}
|
||||
|
||||
// newFrontRoundLoop 构建前台循环:派活工具 + 流式路径(生产 chat 路径)+ 真实
|
||||
// 记忆/护栏。它把 dispatcher 一并返回,调用方才能挂上需要调度器的派活工具。
|
||||
func newFrontRoundLoop(t *testing.T, flow *loop.FlowPublisher, tracer *trace.Tracer, memStore *memory.Store, retriever *memory.Retriever) (*loop.Loop, *tool.Dispatcher) {
|
||||
t.Helper()
|
||||
dispatch, derr := tool.New(registry.New())
|
||||
if derr != nil {
|
||||
t.Fatalf("tool.New: %v", derr)
|
||||
}
|
||||
adapter := &toolThenFinalAdapter{toolName: "delegate", args: map[string]any{"goal": "调研"}, final: "已派发,稍后回填"}
|
||||
ag, aerr := agent.New("front", dispatch, nil,
|
||||
agent.WithModel(adapter, nil), agent.WithOptions(agent.Options{Model: "mock-1"}))
|
||||
if aerr != nil {
|
||||
t.Fatalf("agent.New: %v", aerr)
|
||||
}
|
||||
front := loop.New(loop.Options{
|
||||
Agent: ag, Tools: dispatch, Tracer: tracer, Flow: flow, MaxRounds: 8,
|
||||
Guards: guardrail.New(), Memory: memStore, Retriever: retriever,
|
||||
Extractor: roundExtractor{}, PersonaID: "cyrene",
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
return front, dispatch
|
||||
}
|
||||
|
||||
// newSubRoundLoop 构建子代理模板:真实循环 + 真实记忆检索,因此泳道里也有
|
||||
// memory 节点,且它的 llm/tool 步骤会折进派生它的会话的图。
|
||||
func newSubRoundLoop(t *testing.T, flow *loop.FlowPublisher, tracer *trace.Tracer, memStore *memory.Store, retriever *memory.Retriever) *loop.Loop {
|
||||
t.Helper()
|
||||
dispatch, serr := tool.New(registry.New())
|
||||
if serr != nil {
|
||||
t.Fatalf("tool.New: %v", serr)
|
||||
}
|
||||
if rerr := dispatch.Register(&echoTool{name: "echo", result: map[string]any{"ok": true}}); rerr != nil {
|
||||
t.Fatalf("Register: %v", rerr)
|
||||
}
|
||||
adapter := &toolThenFinalAdapter{toolName: "echo", args: map[string]any{
|
||||
"path": "/tmp/sub-report.txt",
|
||||
"api_key": secretArg,
|
||||
}, final: "子代理完成"}
|
||||
ag, aerr := agent.New("be brief", dispatch, nil,
|
||||
agent.WithModel(adapter, nil), agent.WithOptions(agent.Options{Model: "mock-1"}))
|
||||
if aerr != nil {
|
||||
t.Fatalf("agent.New: %v", aerr)
|
||||
}
|
||||
return loop.New(loop.Options{
|
||||
Agent: ag, Tools: dispatch, Tracer: tracer, Flow: flow, MaxRounds: 8,
|
||||
Memory: memStore, Retriever: retriever, Extractor: roundExtractor{}, PersonaID: "cyrene",
|
||||
})
|
||||
}
|
||||
|
||||
// run 跑完前台一轮、等子代理收尾,并返回前台会话的最后一份图。
|
||||
func (r *flowRound) run(t *testing.T) (*trace.FlowGraph, string) {
|
||||
t.Helper()
|
||||
r.flow.StartRound(parentSession, "帮我派个活")
|
||||
res, err := r.front.Run(r.ctx, parentSession, []model.Message{model.TextMessage(model.RoleUser, "帮我派个活")})
|
||||
if err != nil {
|
||||
t.Fatalf("front Run: %v", err)
|
||||
}
|
||||
r.flow.EndRound(parentSession, res.Text)
|
||||
if len(r.delegate.spawned()) != 1 {
|
||||
t.Fatalf("派发次数 = %v,want 1(delegate 错误:%v)", r.delegate.spawned(), r.delegate.err)
|
||||
}
|
||||
subID := r.delegate.spawned()[0]
|
||||
merged, werr := r.sched.Wait(r.ctx, []string{subID})
|
||||
if werr != nil {
|
||||
t.Fatalf("Wait: %v", werr)
|
||||
}
|
||||
if merged.Succeeded != 1 {
|
||||
t.Fatalf("子代理未成功:%+v(summary=%s)", merged, merged.Summary)
|
||||
}
|
||||
return r.capture.graph(t, parentSession), subID
|
||||
}
|
||||
|
||||
// TestRealRound_FlowGraphHasAllThreeNodeKinds 是一条端到端的「真实跑一轮」用例
|
||||
// (裁决 11.4.2):前台会话带着真实记忆栈、真实护栏与实时 tracer/flow 跑完一轮,
|
||||
// 期间经工具派发一个后台子代理 —— 同一张图里必须同时出现 memory(记忆召回/写入)、
|
||||
// guardrail(输入/输出护栏)与 orchestration(派发标记)三类节点,且工具参数里的
|
||||
// 密钥值绝不出图(裁决 11.4.3 红线)。
|
||||
func TestRealRound_FlowGraphHasAllThreeNodeKinds(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := newFlowRound(t)
|
||||
g, subID := r.run(t)
|
||||
|
||||
kinds := make(map[string]int)
|
||||
for _, n := range g.Nodes {
|
||||
kinds[n.Kind]++
|
||||
}
|
||||
for _, want := range []string{"memory", "guardrail", "orchestration"} {
|
||||
if kinds[want] == 0 {
|
||||
t.Fatalf("图中缺 %q 节点(实得 %v)", want, kinds)
|
||||
}
|
||||
}
|
||||
assertOrchestrationNode(t, g, subID)
|
||||
assertSubagentLane(t, g, subID)
|
||||
assertRoundGraphRedaction(t, g)
|
||||
}
|
||||
|
||||
// assertOrchestrationNode 断言派发标记:带被派发的子代理 id、收尾为 done。
|
||||
func assertOrchestrationNode(t *testing.T, g *trace.FlowGraph, subID string) {
|
||||
t.Helper()
|
||||
orch := flowKindNodes(g, "orchestration")
|
||||
if len(orch) != 1 {
|
||||
t.Fatalf("orchestration 节点 = %d 个,want 1", len(orch))
|
||||
}
|
||||
if got := orch[0].Props["subagent"]; got != subID {
|
||||
t.Fatalf("orchestration 节点 subagent = %v,want %s(props:%+v)", got, subID, orch[0].Props)
|
||||
}
|
||||
if orch[0].Status != trace.FlowStatusDone {
|
||||
t.Fatalf("orchestration 节点状态 = %q,want done(props:%+v)", orch[0].Status, orch[0].Props)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSubagentLane 断言泳道仍在:子代理根 + 它自己的步骤链(含记忆召回与工具调用)。
|
||||
func assertSubagentLane(t *testing.T, g *trace.FlowGraph, subID string) {
|
||||
t.Helper()
|
||||
subNode := flowSubagentNode(t, g, subID)
|
||||
lane := flowReachable(g, subNode.ID)
|
||||
hasMemoryInLane, hasToolInLane := false, false
|
||||
for _, n := range g.Nodes {
|
||||
if !lane[n.ID] {
|
||||
continue
|
||||
}
|
||||
if n.Kind == "memory" {
|
||||
hasMemoryInLane = true
|
||||
}
|
||||
if n.Kind == "tool" {
|
||||
hasToolInLane = true
|
||||
}
|
||||
}
|
||||
if !hasMemoryInLane || !hasToolInLane {
|
||||
t.Fatalf("子代理泳道缺 memory/tool 步骤(memory=%v tool=%v)", hasMemoryInLane, hasToolInLane)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRoundGraphRedaction 断言红线:工具参数里的密钥不得出现在发布的图里,
|
||||
// 而放行的结构类键仍要可读(可视化要能看参数)。
|
||||
func assertRoundGraphRedaction(t *testing.T, g *trace.FlowGraph) {
|
||||
t.Helper()
|
||||
blob, jerr := json.Marshal(g)
|
||||
if jerr != nil {
|
||||
t.Fatalf("marshal graph: %v", jerr)
|
||||
}
|
||||
if strings.Contains(string(blob), secretArg) {
|
||||
t.Fatalf("发布的图泄漏了工具参数:%s", blob)
|
||||
}
|
||||
if !strings.Contains(string(blob), "/tmp/sub-report.txt") {
|
||||
t.Fatalf("放行键 path 的值没出图:%s", blob)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user