Files
AskaEth b99268d019
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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),
  文档同步由主会话统一落
2026-10-04 12:31:20 +08:00

275 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}