feat(trace,loop,orchestration): memory/guardrail/orchestration 真 span + 参数摘要脱敏 + Guardrail 输出侧异步(裁决 11.4.2/11.4.3/13.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-10-04 12:31:20 +08:00
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)
}
}
@@ -322,6 +322,9 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
// 工具白名单(design/05 §7 裁决 02.4.1)随运行上下文传递:名单非空时
// 只放行所列工具;空名单=全开,与挂载前的行为一致。
runCtx = loop.WithToolWhitelist(runCtx, sub.ToolWhitelist)
// 派发标记挂在原始 ctx 上(不是 runCtx):它是这一批派发/收束的
// orchestration 节点(裁决 11.4.2),与子代理泳道根并列成链。
orchSpan := s.orchSpan(ctx, sub)
runCtx, span := s.subSpan(runCtx, sub)
sub.setCancel(cancel)
defer cancel()
@@ -329,10 +332,12 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
res, err := s.scopedRunner(sub).Run(runCtx, sub.ID, sub.Context)
if err != nil {
s.spanErr(sub, span, err)
s.finishOrchSpan(sub, orchSpan, err)
s.abortOrFail(sub, err)
return
}
s.spanOK(sub, span, res)
s.finishOrchSpan(sub, orchSpan, nil)
sub.setResult(res)
s.finish(sub, StatusDone, doneSummary(res), nil)
}
@@ -0,0 +1,65 @@
package orchestration
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件是调度层的「编排 span」:把每次子代理派发与收束记成一个
// orchestration 节点(design/19 §1 旁路节点,裁决 11.4.2)。它从
// scheduler.go 拆出,因为该文件已接近 AGENTS §10 的 500 行上限,
// 而 span 的开启/收尾是一块独立职责。
// orchestration.dispatch 的 span 名与收尾事件类型:此前调度只发
// SpanKindSubagent,因此全仓没有任何 orchestration 节点。
const (
spanNameOrchestrationSpawn = "orchestration.spawn"
orchestrationEventDone = "orchestration.done"
)
// orchSpan 为一次子代理派发开一个 orchestration span。它是挂在主干上的
// 「派发标记」:与泳道根并列而不是它的父级 —— 若把它当作子代理 span 的父级,
// 泳道链会多出一节(图构建器把非 subagent 祖先一路穿透解析泳道),既有泳道
// 契约就被改写了。span 只带非敏感属性(被派发的子代理 id、目标角色 id),
// 声明了归属会话时同时发布一个「运行中」占位节点。未配置 tracer 时返回 nil。
func (s *Scheduler) orchSpan(ctx context.Context, sub *SubAgent) *trace.Span {
if s.tracer == nil || sub == nil {
return nil
}
_, span := s.tracer.StartSpan(ctx, spanNameOrchestrationSpawn, trace.SpanKindOrchestration)
if sub.ID != "" {
_ = span.SetProp("subagent", sub.ID)
}
if sub.PersonaID != "" {
_ = span.SetProp("persona", sub.PersonaID)
}
if sess := sub.ParentSession; sess != "" && s.flow != nil {
_ = span.SetProp("session", sess)
s.flow.SpanStarted(sess, span)
}
return span
}
// finishOrchSpan 收尾一次派发:记录耗时(失败另记原因)与一条脱敏事件,
// 失败时终态为 error,然后把已关闭的 span 折回所属会话的图。
func (s *Scheduler) finishOrchSpan(sub *SubAgent, span *trace.Span, err error) {
if span == nil {
return
}
props := map[string]any{"elapsed_ms": trace.ElapsedMS(span)}
if err != nil {
props["error"] = err.Error()
_ = span.SetProp("error", err.Error())
}
_ = span.Record(trace.Event{Type: orchestrationEventDone, Props: props})
status := trace.SpanStatusOK
if err != nil {
status = trace.SpanStatusError
}
span.Finish(status)
if s.flow == nil || sub == nil || sub.ParentSession == "" {
return
}
s.flow.SpanFinished(sub.ParentSession, span)
}