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,378 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/memory"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// 本文件钉住三条裁决在循环侧的行为:
|
||||
// - 11.4.2:真实跑一轮后,图里真的出现 memory / guardrail 节点(各有生产者);
|
||||
// - 11.4.3:tool 节点的参数摘要出图、密钥值绝不出图;
|
||||
// - 13.4.1:输出侧检查异步(不阻塞回合)、输入侧检查仍在模型调用前同步拦下。
|
||||
|
||||
// flowRecorder 收集 FlowPublisher 发布的每一帧图,按会话保留最后一份快照。
|
||||
// 后台的输出侧检查会从 goroutine 里发布,因此读取必须加锁。
|
||||
type flowRecorder struct {
|
||||
mu sync.Mutex
|
||||
graphs map[string]*trace.FlowGraph
|
||||
frames int
|
||||
}
|
||||
|
||||
func newFlowRecorder() *flowRecorder {
|
||||
return &flowRecorder{graphs: make(map[string]*trace.FlowGraph)}
|
||||
}
|
||||
|
||||
// publisher 返回接在该记录器上的实时图发布者。
|
||||
func (r *flowRecorder) publisher() *FlowPublisher {
|
||||
return NewFlowPublisher(func(_ string, payload any) {
|
||||
g, ok := payload.(*trace.FlowGraph)
|
||||
if !ok || g == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.graphs[g.SessionID] = g
|
||||
r.frames++
|
||||
}, func(string) bool { return true })
|
||||
}
|
||||
|
||||
// last 返回某会话的最后一份图快照;一帧都没发过时判失败。
|
||||
func (r *flowRecorder) last(t *testing.T, session string) *trace.FlowGraph {
|
||||
t.Helper()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
g := r.graphs[session]
|
||||
if g == nil {
|
||||
t.Fatalf("会话 %q 一帧图都没发布(frames=%d)", session, r.frames)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// fixedExtractor 是确定性的记忆提取器:永远返回脚本化的条目,因此一轮结束后
|
||||
// 必然发生一次记忆写入(用来证明写侧有生产者)。它不实现 asyncExtractor,所以
|
||||
// 提取留在同步路径上,测试无需等后台。
|
||||
type fixedExtractor struct{ items []memory.MemoryItem }
|
||||
|
||||
func (e fixedExtractor) Extract(context.Context, []model.Message) ([]memory.MemoryItem, error) {
|
||||
return e.items, nil
|
||||
}
|
||||
|
||||
// nodesOfKind 返回图里某一类 kind 的节点,顺序即出现顺序。
|
||||
func nodesOfKind(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
|
||||
}
|
||||
|
||||
// nodeByKind 返回图里第一个某类 kind 的节点。
|
||||
func nodeByKind(t *testing.T, g *trace.FlowGraph, kind string) trace.FlowNode {
|
||||
t.Helper()
|
||||
nodes := nodesOfKind(g, kind)
|
||||
if len(nodes) == 0 {
|
||||
t.Fatalf("图中没有 %q 节点(实得 kind:%v)", kind, graphKinds(g))
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
// graphKinds 返回图里出现的 kind 列表,仅用于失败信息。
|
||||
func graphKinds(g *trace.FlowGraph) []string {
|
||||
out := make([]string, 0, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
out = append(out, n.Kind)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitUntil 轮询 cond 直到成立或超时(后台检查落定需要一点时间)。
|
||||
func waitUntil(t *testing.T, what string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("等待 %s 超时", what)
|
||||
}
|
||||
|
||||
// TestRun_RealRound_FlowGraphHasMemoryAndGuardrailNodes 真实跑一轮(流式路径,
|
||||
// 带真实记忆栈、真实护栏管线与实时 tracer/flow),断言图里出现 memory 与
|
||||
// guardrail 两类真节点(裁决 11.4.2),并顺带钉住 tool 节点的参数摘要
|
||||
// (裁决 11.4.3):放行的 path 出图、密钥值绝不出图。
|
||||
func TestRun_RealRound_FlowGraphHasMemoryAndGuardrailNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-0123456789abcdef"
|
||||
rec := newFlowRecorder()
|
||||
|
||||
call := `{"tool_calls":[{"name":"echo","args":{"path":"/tmp/report.txt","api_key":"` + secret + `"}}]}`
|
||||
ad := &mockAdapter{stream: []mgResp{
|
||||
{resp: model.Response{Model: "mock-1", Content: call}},
|
||||
{resp: model.Response{Model: "mock-1", Content: "报告读完了"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "echo", result: "报告内容"})
|
||||
a := newTestAgent(t, ad, td)
|
||||
store := newTestMemoryStore(t)
|
||||
l := New(Options{
|
||||
Agent: a, Tools: td, MaxRounds: 8,
|
||||
Tracer: trace.New(nil),
|
||||
Flow: rec.publisher(),
|
||||
Guards: guardrail.New(),
|
||||
Memory: store,
|
||||
Retriever: memory.NewRetriever(store),
|
||||
Extractor: fixedExtractor{items: []memory.MemoryItem{{Content: "主人喜欢甜食"}}},
|
||||
PersonaID: "cyrene",
|
||||
// 流式路径是生产路径(gateway 总会装 OnStream),护栏 span
|
||||
// 也在这一侧产生,因此这条「真实跑一轮」用例走它。
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "看看报告")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "报告读完了" {
|
||||
t.Fatalf("Text = %q, want 报告读完了", res.Text)
|
||||
}
|
||||
|
||||
g := rec.last(t, "web")
|
||||
assertMemoryNodes(t, g)
|
||||
assertGuardrailNodes(t, rec)
|
||||
assertToolArgsSummary(t, rec.last(t, "web"), secret)
|
||||
}
|
||||
|
||||
// assertMemoryNodes 断言读侧(召回)与写侧(提取落库)各留下一个真节点。
|
||||
func assertMemoryNodes(t *testing.T, g *trace.FlowGraph) {
|
||||
t.Helper()
|
||||
memoryNodes := nodesOfKind(g, "memory")
|
||||
if len(memoryNodes) < 2 {
|
||||
t.Fatalf("memory 节点 = %d 个,want ≥2(召回 + 写入);图中 kind:%v", len(memoryNodes), graphKinds(g))
|
||||
}
|
||||
recall := memoryNodes[0]
|
||||
if got := recall.Props["action"]; got != "recall" {
|
||||
t.Fatalf("memory 节点 action = %v,want recall(props:%+v)", got, recall.Props)
|
||||
}
|
||||
if got := recall.Props["scope"]; got != "owner" {
|
||||
t.Fatalf("memory 召回 scope = %v,want owner(props:%+v)", got, recall.Props)
|
||||
}
|
||||
}
|
||||
|
||||
// assertGuardrailNodes 断言输入/输出两侧都留下节点且都落下裁决。输出侧检查是
|
||||
// 异步的(裁决 13.4.1),因此先等它的裁决落定。
|
||||
func assertGuardrailNodes(t *testing.T, rec *flowRecorder) {
|
||||
t.Helper()
|
||||
waitUntil(t, "两类 guardrail 节点都落下裁决", func() bool {
|
||||
guardNodes := nodesOfKind(rec.last(t, "web"), "guardrail")
|
||||
if len(guardNodes) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, n := range guardNodes {
|
||||
if _, ok := n.Props["allow"].(bool); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
g := rec.last(t, "web")
|
||||
actions := make(map[string]bool)
|
||||
allowed := 0
|
||||
for _, n := range nodesOfKind(g, "guardrail") {
|
||||
action, _ := n.Props["action"].(string)
|
||||
actions[action] = true
|
||||
if allow, _ := n.Props["allow"].(bool); allow {
|
||||
allowed++
|
||||
}
|
||||
}
|
||||
if !actions[spanNameGuardrailInput] || !actions[spanNameGuardrailOutput] {
|
||||
t.Fatalf("guardrail 节点缺输入/输出任一侧(实得 %v;图中 kind:%v)", actions, graphKinds(g))
|
||||
}
|
||||
if allowed < 2 {
|
||||
t.Fatalf("allow=true 的 guardrail 节点 = %d 个,want ≥2(props:%+v)", allowed, nodesOfKind(g, "guardrail"))
|
||||
}
|
||||
}
|
||||
|
||||
// assertToolArgsSummary 钉住参数摘要的红线:放行的结构类键有值、密钥类键连条目
|
||||
// 都没有,且发布的图里不含密钥值。
|
||||
func assertToolArgsSummary(t *testing.T, g *trace.FlowGraph, secret string) {
|
||||
t.Helper()
|
||||
toolNode := nodeByKind(t, g, "tool")
|
||||
summary, ok := toolNode.Props["args_summary"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool 节点缺 args_summary(props:%+v)", toolNode.Props)
|
||||
}
|
||||
if summary["path"] != "/tmp/report.txt" {
|
||||
t.Fatalf("args_summary[path] = %v,want /tmp/report.txt", summary["path"])
|
||||
}
|
||||
if _, leaked := summary["api_key"]; leaked {
|
||||
t.Fatalf("密钥类键进了摘要:%+v", summary)
|
||||
}
|
||||
keys, ok := toolNode.Props["args_keys"].([]any)
|
||||
if !ok || len(keys) != 2 {
|
||||
t.Fatalf("args_keys = %v,want 两个参数名", toolNode.Props["args_keys"])
|
||||
}
|
||||
blob, merr := json.Marshal(g)
|
||||
if merr != nil {
|
||||
t.Fatalf("marshal graph: %v", merr)
|
||||
}
|
||||
if strings.Contains(string(blob), secret) {
|
||||
t.Fatalf("发布的图泄漏了工具参数里的密钥:%s", blob)
|
||||
}
|
||||
}
|
||||
|
||||
// gatedOutputGuard 的输出侧检查会一直等到 release 关闭(或 context 结束)才返回
|
||||
// 拦截裁决,用来证明该检查真的落在关键路径之外。
|
||||
type gatedOutputGuard struct {
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *gatedOutputGuard) CheckInput(context.Context, *model.Request) (*guardrail.Verdict, error) {
|
||||
return &guardrail.Verdict{Allow: true}, nil
|
||||
}
|
||||
|
||||
func (g *gatedOutputGuard) CheckOutput(ctx context.Context, _ *model.Response) (*guardrail.Verdict, error) {
|
||||
g.once.Do(func() { close(g.entered) })
|
||||
select {
|
||||
case <-g.release:
|
||||
return &guardrail.Verdict{Allow: false, Reason: "输出含敏感内容"}, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamStep_OutputGuardrailDoesNotBlockTurn 是裁决 13.4.1 的输出侧用例:
|
||||
// 输出侧检查挂住不放时,回合仍必须返回(真并发、不阻塞);检查放行后它的拦截
|
||||
// 结果仍要闭环 —— 落进 guardrail 节点并发出审计事件(异步 ≠ 可绕过)。
|
||||
func TestStreamStep_OutputGuardrailDoesNotBlockTurn(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := newFlowRecorder()
|
||||
guard := &gatedOutputGuard{entered: make(chan struct{}), release: make(chan struct{})}
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "mock-1", Content: "回答正文"}}}}
|
||||
td := dispatcherWith(t)
|
||||
a := newTestAgent(t, ad, td)
|
||||
|
||||
var mu sync.Mutex
|
||||
var audits []AuditEvent
|
||||
l := New(Options{
|
||||
Agent: a, Tools: td, MaxRounds: 8,
|
||||
Tracer: trace.New(nil), Flow: rec.publisher(),
|
||||
Guards: guardrail.New(guardrail.WithGuard(guard)),
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
OnAudit: func(ev AuditEvent) {
|
||||
mu.Lock()
|
||||
audits = append(audits, ev)
|
||||
mu.Unlock()
|
||||
},
|
||||
})
|
||||
|
||||
type runOut struct {
|
||||
res *Result
|
||||
err error
|
||||
}
|
||||
done := make(chan runOut, 1)
|
||||
go func() {
|
||||
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "你好")})
|
||||
done <- runOut{res: res, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-guard.entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("输出侧检查始终没有启动")
|
||||
}
|
||||
var out runOut
|
||||
select {
|
||||
case out = <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("回合被输出侧护栏阻塞了:检查未放行时 Run 必须已经返回(裁决 13.4.1 真并发)")
|
||||
}
|
||||
if out.err != nil {
|
||||
t.Fatalf("Run: %v", out.err)
|
||||
}
|
||||
if out.res.Text != "回答正文" {
|
||||
t.Fatalf("Text = %q,want 模型产出(输出侧检查不得改写已流转的回复)", out.res.Text)
|
||||
}
|
||||
|
||||
close(guard.release)
|
||||
waitUntil(t, "输出侧拦截闭环", func() bool {
|
||||
mu.Lock()
|
||||
audited := len(audits) == 1 && audits[0].What == auditGuardrailOutputBlock
|
||||
mu.Unlock()
|
||||
if !audited {
|
||||
return false
|
||||
}
|
||||
g := rec.last(t, "web")
|
||||
for _, n := range nodesOfKind(g, "guardrail") {
|
||||
if n.Props["action"] == spanNameGuardrailOutput && n.Props["allow"] == false {
|
||||
return n.Status == trace.FlowStatusError
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// denyingInputGuard 直接拒掉输入侧检查。
|
||||
type denyingInputGuard struct{}
|
||||
|
||||
func (denyingInputGuard) CheckInput(context.Context, *model.Request) (*guardrail.Verdict, error) {
|
||||
return &guardrail.Verdict{Allow: false, Reason: "输入含敏感内容"}, nil
|
||||
}
|
||||
|
||||
func (denyingInputGuard) CheckOutput(context.Context, *model.Response) (*guardrail.Verdict, error) {
|
||||
return &guardrail.Verdict{Allow: true}, nil
|
||||
}
|
||||
|
||||
// TestStreamStep_InputGuardrailBlocksBeforeModel 是裁决 13.4.1 的输入侧用例:
|
||||
// 输入侧检查仍是模型调用之前的同步前置门 —— 拒绝时模型一次都不许被调用,回合
|
||||
// 直接带着拦截理由返回,且图上留下一个显红的 guardrail 节点。
|
||||
func TestStreamStep_InputGuardrailBlocksBeforeModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := newFlowRecorder()
|
||||
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "mock-1", Content: "不该出现"}}}}
|
||||
td := dispatcherWith(t)
|
||||
a := newTestAgent(t, ad, td)
|
||||
l := New(Options{
|
||||
Agent: a, Tools: td, MaxRounds: 8,
|
||||
Tracer: trace.New(nil), Flow: rec.publisher(),
|
||||
Guards: guardrail.New(guardrail.WithGuard(denyingInputGuard{})),
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
|
||||
res, err := l.Run(context.Background(), "web", []model.Message{model.TextMessage(model.RoleUser, "危险输入")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "输入含敏感内容" || res.StopReason != agent.StopReasonGuardrailInput {
|
||||
t.Fatalf("res = %+v,want 输入侧拦截理由与 guardrail_input", res)
|
||||
}
|
||||
if ad.streamCalls != 0 {
|
||||
t.Fatalf("模型被调用了 %d 次,输入侧前置门必须在模型之前拦下", ad.streamCalls)
|
||||
}
|
||||
|
||||
g := rec.last(t, "web")
|
||||
node := nodeByKind(t, g, "guardrail")
|
||||
if node.Props["action"] != spanNameGuardrailInput {
|
||||
t.Fatalf("guardrail 节点 action = %v,want %s", node.Props["action"], spanNameGuardrailInput)
|
||||
}
|
||||
if allow, _ := node.Props["allow"].(bool); allow {
|
||||
t.Fatalf("被拦下的 guardrail 节点 allow = %v,want false(props:%+v)", node.Props["allow"], node.Props)
|
||||
}
|
||||
if node.Status != trace.FlowStatusError {
|
||||
t.Fatalf("guardrail 节点状态 = %q,want error", node.Status)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"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/trace"
|
||||
)
|
||||
|
||||
// memoryExtractTimeout 限制单次后台提取。它只管辖
|
||||
@@ -75,12 +76,21 @@ func (l *Loop) memoryBlock(ctx context.Context, messages []model.Message) string
|
||||
// 角色日常记忆(ScopePersona)属敏感项,绝不注入
|
||||
// (design/03 §5「不泄露主人敏感信息」+ 主人 2026-10-03
|
||||
// 裁决「用户习惯记忆与不敏感数据:可查、不可写」)。
|
||||
//
|
||||
// 召回本身包在一个 memory span 里(裁决 11.4.2,design/19 §1
|
||||
// 旁路节点):span 只记元数据(作用域/命中条数/注入块字节数/耗时),
|
||||
// 绝不记召回了什么内容。
|
||||
func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaID, query string) (string, error) {
|
||||
if !env.Restricted() {
|
||||
return l.retriever.Inject(ctx, personaID, query, 0)
|
||||
rctx, span := l.span(ctx, spanNameMemoryRecall, trace.SpanKindMemory)
|
||||
block, err := l.retriever.Inject(rctx, personaID, query, 0)
|
||||
l.finishMemoryRecallSpan(span, "owner", 0, len(block), err)
|
||||
return block, err
|
||||
}
|
||||
results, err := l.retriever.Retrieve(ctx, personaID, query, 0)
|
||||
rctx, span := l.span(ctx, spanNameMemoryRecall, trace.SpanKindMemory)
|
||||
results, err := l.retriever.Retrieve(rctx, personaID, query, 0)
|
||||
if err != nil {
|
||||
l.finishMemoryRecallSpan(span, "restricted", 0, 0, err)
|
||||
return "", err
|
||||
}
|
||||
shared := make([]memory.Result, 0, len(results))
|
||||
@@ -90,9 +100,12 @@ func (l *Loop) retrieveMemoryBlock(ctx context.Context, env SessionEnv, personaI
|
||||
}
|
||||
}
|
||||
if len(shared) == 0 {
|
||||
l.finishMemoryRecallSpan(span, "restricted", 0, 0, nil)
|
||||
return "", nil
|
||||
}
|
||||
return formatMemoryBlock(shared, "群聊隔离"), nil
|
||||
block := formatMemoryBlock(shared, "群聊隔离")
|
||||
l.finishMemoryRecallSpan(span, "restricted", len(shared), len(block), nil)
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// sharedScope 报告 scope 是否为群聊/陌生人可查的非敏感共享域:
|
||||
@@ -278,18 +291,31 @@ func (l *Loop) extractMemoryAsync(ctx context.Context, messages []model.Message,
|
||||
// (memory.FormatSourceConversation),无条件覆盖 extractor
|
||||
// 产出值 —— 模型自由文本绝无机会落库为来源。store
|
||||
// 失败会被记录日志,绝不致命,因此一条被拒的条目无法中止一个 turn。
|
||||
//
|
||||
// 整批写入包在一个 memory span 里(裁决 11.4.2,design/19 §1 旁路节点):
|
||||
// 只记写入/失败条数与耗时,绝不记写入了什么内容;没有条目时不产生节点
|
||||
// (span 绝不宣告未运行的工作)。
|
||||
func (l *Loop) persistMemories(ctx context.Context, items []memory.MemoryItem, step int) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
personaID := l.personaFor(ctx)
|
||||
source := memory.FormatSourceConversation(loopSession(ctx), loopTurn(ctx))
|
||||
wctx, span := l.span(ctx, spanNameMemoryWrite, trace.SpanKindMemory)
|
||||
written, failed := 0, 0
|
||||
for _, it := range items {
|
||||
if personaID != "" {
|
||||
it.PersonaID = personaID
|
||||
}
|
||||
it.SourceConversation = source
|
||||
if _, err := l.memory.Add(ctx, it); err != nil {
|
||||
if _, err := l.memory.Add(wctx, it); err != nil {
|
||||
failed++
|
||||
l.log.Warn("memory store", "step", step, "error", err)
|
||||
continue
|
||||
}
|
||||
written++
|
||||
}
|
||||
l.finishMemoryWriteSpan(span, written, failed, nil)
|
||||
}
|
||||
|
||||
// queryFromMessages 从对话导出检索查询:最后一个用户
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"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/trace"
|
||||
)
|
||||
|
||||
@@ -16,12 +18,49 @@ const (
|
||||
EventLLMDone = "llm.done"
|
||||
// EventToolDone 在工具调用落定后记录到 tool_call span 上。
|
||||
EventToolDone = "tool.done"
|
||||
// eventGuardrailDone 在输入/输出护栏检查落定后记录到 guardrail span 上。
|
||||
eventGuardrailDone = "guardrail.done"
|
||||
// eventMemoryDone 在记忆召回/写入落定后记录到 memory span 上。
|
||||
eventMemoryDone = "memory.done"
|
||||
)
|
||||
|
||||
// 旁路节点(design/19 §1,裁决 11.4.2)的 span 名:guardrail 记输入/输出
|
||||
// 检查,memory 记召回/写入。名字即图上的节点标签。
|
||||
const (
|
||||
spanNameGuardrailInput = "guardrail.input"
|
||||
spanNameGuardrailOutput = "guardrail.output"
|
||||
spanNameMemoryRecall = "memory.recall"
|
||||
spanNameMemoryWrite = "memory.write"
|
||||
)
|
||||
|
||||
// maxLogSummaryRunes 在发布前限制日志取值的长度。图在
|
||||
// 输出时还会再次脱敏与截断;这里只是让单个 span 保持小。
|
||||
const maxLogSummaryRunes = 240
|
||||
|
||||
// 工具参数摘要的规格(裁决 11.4.3,权威定义见 DevDocs/design/19 §1):
|
||||
// 单值最多 maxArgValueRunes 个 rune,一次调用最多放行 maxArgSummaryKeys 个参数值。
|
||||
const (
|
||||
maxArgValueRunes = 80
|
||||
maxArgSummaryKeys = 24
|
||||
)
|
||||
|
||||
// argValueKeys 是「值可显示」的参数键放行表(裁决 11.4.3):只放行结构/元数据类
|
||||
// 键,表外键一律只出键名。工具参数正是凭据、对话正文或含隐私的文件路径最容易
|
||||
// 出现的地方,因此放行表取保守口径 —— 想放行一个新键,必须先改设计再改这里。
|
||||
var argValueKeys = map[string]bool{
|
||||
// 文件与路径(读写目标本身是运维信息,不是内容)
|
||||
"path": true, "file": true, "file_path": true, "filename": true,
|
||||
"dir": true, "directory": true, "input_path": true, "output_path": true,
|
||||
// 检索/匹配(查询词与模式,出图前仍经凭据形状清洗)
|
||||
"query": true, "q": true, "pattern": true, "name": true, "id": true, "url": true,
|
||||
// 分类与格式
|
||||
"kind": true, "scope": true, "format": true, "encoding": true, "mode": true,
|
||||
"project": true, "project_id": true, "timezone": true,
|
||||
// 时间窗与分页(纯数值)
|
||||
"date": true, "from": true, "to": true, "limit": true, "offset": true, "page": true,
|
||||
"top_k": true, "max_results": true, "count": true,
|
||||
}
|
||||
|
||||
// recordLLMDone 把一次已完成模型调用的脱敏摘要追加到其
|
||||
// span:服务该 turn 的模型、两个 stop reason、请求了多少工具调用
|
||||
// 以及该调用耗时。不记录任何提示词或补全文本,
|
||||
@@ -52,16 +91,30 @@ func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome, ag *agent.Agent
|
||||
// 刻意省略 —— 工具参数正是凭据、消息正文
|
||||
// 或含机密的文件路径会出现的地方 —— 且这里记录的一切
|
||||
// 在发布前都会由图再次清洗。
|
||||
//
|
||||
// 参数摘要(裁决 11.4.3,规格见 design/19 §1):除全量参数名
|
||||
// (args_keys)外,另生成 args_summary —— 只含放行表内、且非密钥类
|
||||
// 键的值,单值截断到 maxArgValueRunes。摘要同时写进 span prop
|
||||
// (图的 safePropKeys 放行它,面板节点详情直接读),使「可视化思考
|
||||
// 链路」看得到参数而看不到凭据。
|
||||
func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutcome) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
keys := argKeys(tc.Args)
|
||||
props := map[string]any{
|
||||
"tool": tc.Name,
|
||||
"ok": out.Err == nil,
|
||||
"args_keys": argKeys(tc.Args),
|
||||
"args_keys": keys,
|
||||
"elapsed_ms": elapsedMS(span),
|
||||
}
|
||||
// 键名清单同时写进 span prop:面板节点详情要列出全部参数名,
|
||||
// 再对放行的键显示截断后的值、对其余键显示「值未展示」。
|
||||
_ = span.SetProp("args_keys", keys)
|
||||
if summary := argSummary(tc.Args); len(summary) > 0 {
|
||||
props["args_summary"] = summary
|
||||
_ = span.SetProp("args_summary", summary)
|
||||
}
|
||||
if out.Err != nil {
|
||||
if out.ErrKind != "" {
|
||||
props["error_kind"] = out.ErrKind
|
||||
@@ -73,6 +126,156 @@ func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutco
|
||||
_ = span.Record(trace.Event{Type: EventToolDone, Props: props})
|
||||
}
|
||||
|
||||
// argSummary 生成工具参数的「值摘要」:只放行 argValueKeys 表内的键,且
|
||||
// 密钥类键一律不给值(trace.IsSecretKey 与出图清洗同一判据);非标量值只给
|
||||
// 占位(数组(N)/对象(N)),因为容器里可能藏着正文或凭据。键按字典序处理,
|
||||
// 使摘要只随参数变化,不随 map 迭代顺序抖动。
|
||||
func argSummary(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any)
|
||||
for _, k := range argKeys(args) {
|
||||
if len(out) >= maxArgSummaryKeys {
|
||||
break
|
||||
}
|
||||
if !argValueVisible(k) {
|
||||
continue
|
||||
}
|
||||
if s := summarizeArgValue(args[k]); s != "" {
|
||||
out[k] = ellipsize(s, maxArgValueRunes)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// argValueVisible 报告某参数键的值是否允许出图:必须是放行表内的结构类键,
|
||||
// 且不得是密钥类键(放行表本身不含密钥名,这里是纵深防御)。
|
||||
func argValueVisible(key string) bool {
|
||||
if trace.IsSecretKey(key) {
|
||||
return false
|
||||
}
|
||||
return argValueKeys[strings.ToLower(strings.TrimSpace(key))]
|
||||
}
|
||||
|
||||
// summarizeArgValue 把参数值渲染成一行短文本:字符串原样,数字/布尔按字面量,
|
||||
// 数组与对象只给占位而不展开(不把容器内的正文或凭据带出来),其余类型返回空串。
|
||||
func summarizeArgValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case bool, float64, float32, int, int64, int32, uint, uint64, uint32:
|
||||
return fmt.Sprintf("%v", x)
|
||||
case []any:
|
||||
return fmt.Sprintf("数组(%d)", len(x))
|
||||
case []string:
|
||||
return fmt.Sprintf("数组(%d)", len(x))
|
||||
case map[string]any:
|
||||
return fmt.Sprintf("对象(%d)", len(x))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// finishGuardrailSpan 把一次护栏检查(输入侧同步门 / 输出侧异步检查)的结论
|
||||
// 落到其 span 上(裁决 11.4.2,design/19 §1 旁路节点):props 只放检查名、
|
||||
// 裁决与耗时(全部在 safePropKeys 白名单内,出图时仍会再清洗一遍),事件带
|
||||
// 一条脱敏后的 Reason 供节点日志查阅。拦截时 span 终态记为 error,面板上显红。
|
||||
// err 非 nil 表示检查本身失败(不是裁决),同样记为 error 并把原因脱敏后留下。
|
||||
func (l *Loop) finishGuardrailSpan(span *trace.Span, action string, verdict *guardrail.Verdict, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
allow := err == nil && (verdict == nil || verdict.Allow)
|
||||
props := map[string]any{
|
||||
"action": action,
|
||||
"allow": allow,
|
||||
"elapsed_ms": elapsedMS(span),
|
||||
}
|
||||
reason := ""
|
||||
if verdict != nil {
|
||||
reason = verdict.Reason
|
||||
}
|
||||
if err != nil {
|
||||
props["error"] = err.Error()
|
||||
reason = err.Error()
|
||||
}
|
||||
for k, v := range props {
|
||||
_ = span.SetProp(k, v)
|
||||
}
|
||||
// 一条事件同时覆盖「拦截原因」与「标记放行」(SensitiveMark 会带 Reason
|
||||
// 放行),使 mark 模式在面板上有消费者而不是无声通过。
|
||||
if !allow || reason != "" {
|
||||
_ = span.Record(trace.Event{Type: eventGuardrailDone, Props: map[string]any{
|
||||
"action": action,
|
||||
"allow": allow,
|
||||
"reason": trimSummary(reason),
|
||||
}})
|
||||
}
|
||||
if allow {
|
||||
l.finishSpan(span, trace.SpanStatusOK)
|
||||
return
|
||||
}
|
||||
l.finishSpan(span, trace.SpanStatusError)
|
||||
}
|
||||
|
||||
// finishMemoryRecallSpan 记录一次记忆召回的元数据:作用域、命中条数、注入块
|
||||
// 字节数与耗时。绝不记录召回了什么内容(design/19 §1:memory 旁路节点只记
|
||||
// 元数据,不含敏感内容)—— 记忆正文本身就可能是主人的隐私。
|
||||
func (l *Loop) finishMemoryRecallSpan(span *trace.Span, scope string, hits, size int, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
props := map[string]any{
|
||||
"action": "recall",
|
||||
"scope": scope,
|
||||
"bytes": size,
|
||||
"hits": hits,
|
||||
"elapsed_ms": elapsedMS(span),
|
||||
}
|
||||
l.finishMemorySpan(span, props, err)
|
||||
}
|
||||
|
||||
// finishMemoryWriteSpan 记录一次记忆写入的元数据:写入与失败的条数、耗时。
|
||||
// 绝不记录写入了什么内容(同 recall 的口径)。
|
||||
func (l *Loop) finishMemoryWriteSpan(span *trace.Span, written, failed int, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
props := map[string]any{
|
||||
"action": "write",
|
||||
"count": written,
|
||||
"failed": failed,
|
||||
"elapsed_ms": elapsedMS(span),
|
||||
}
|
||||
l.finishMemorySpan(span, props, err)
|
||||
}
|
||||
|
||||
// finishMemorySpan 是两次记忆读写的共同收尾:写 props、记一条事件、按结果
|
||||
// 关闭 span(失败记 error 终态)。props 由调用方给全,收尾只负责落盘与终态。
|
||||
func (l *Loop) finishMemorySpan(span *trace.Span, props map[string]any, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
props["error"] = err.Error()
|
||||
}
|
||||
for k, v := range props {
|
||||
_ = span.SetProp(k, v)
|
||||
}
|
||||
_ = span.Record(trace.Event{Type: eventMemoryDone, Props: props})
|
||||
if err != nil {
|
||||
l.finishSpan(span, trace.SpanStatusError)
|
||||
return
|
||||
}
|
||||
l.finishSpan(span, trace.SpanStatusOK)
|
||||
}
|
||||
|
||||
// modelName 报告服务某步的模型:流式路径知道时用路由出的
|
||||
// 请求模型,否则用该 turn 的 agent 配置的模型。两者
|
||||
// 都不可用时为空。
|
||||
@@ -88,11 +291,9 @@ func modelName(out *stepOutcome, ag *agent.Agent) string {
|
||||
|
||||
// elapsedMS 报告 span 迄今已打开多久。span 的 Duration 仅由
|
||||
// Finish 打上,因此在 Finish 之前记录的日志事件在此测量它。
|
||||
// 实现委托给 trace.ElapsedMS,使 agent 侧的护栏 span 与本包用同一算法。
|
||||
func elapsedMS(span *trace.Span) int64 {
|
||||
if span == nil || span.StartedAt.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return time.Since(span.StartedAt).Milliseconds()
|
||||
return trace.ElapsedMS(span)
|
||||
}
|
||||
|
||||
// argKeys 按排序返回工具调用的参数名。只记录
|
||||
@@ -110,11 +311,16 @@ func argKeys(args map[string]any) []string {
|
||||
}
|
||||
|
||||
// trimSummary 把日志取值缩短到 maxLogSummaryRunes 个 rune,
|
||||
// 且不切开多字节 rune。
|
||||
// 且不切开多字节 rune(被截断时末位换成省略号)。
|
||||
func trimSummary(s string) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= maxLogSummaryRunes {
|
||||
return ellipsize(s, maxLogSummaryRunes)
|
||||
}
|
||||
|
||||
// ellipsize 把 s 截到最多 max 个 rune;确实被截断时末位换成省略号,
|
||||
// 使面板上的一眼能看出「这里被截过」。max 非正时原样返回。
|
||||
func ellipsize(s string, max int) string {
|
||||
if max <= 0 || len([]rune(s)) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxLogSummaryRunes]) + "…"
|
||||
return truncateRunes(s, max-1) + "…"
|
||||
}
|
||||
@@ -9,14 +9,29 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// guardrailCheckTimeout 限制一次后台输出侧护栏检查。它只管辖脱离
|
||||
// 出来的检查 context,因此一个挂起的检查不会让 goroutine 泄露到进程
|
||||
// 生命周期结束,也不会拖住任何回合。
|
||||
const guardrailCheckTimeout = 30 * time.Second
|
||||
|
||||
// auditGuardrailOutputBlock 是输出侧护栏在后台判定拦截时发出的审计
|
||||
// "what"。输出侧检查已移出关键路径(裁决 13.4.1),拦截因此必须闭环:
|
||||
// 一条审计事件 + guardrail 节点上的 verdict,「异步」不等于「可绕过」。
|
||||
const auditGuardrailOutputBlock = "guardrail.output.block"
|
||||
|
||||
// streamStep 驱动一次流式模型调用。它是
|
||||
// agent.RunStep 的流式对应物:它从 agent 的公开表面构建请求、
|
||||
// 运行循环的输入护栏、把增量流经 OnStream、运行输出
|
||||
// 护栏并解码所有工具调用。由于 RunStep 不暴露流式
|
||||
// 路径,该方法归循环所有;它镜像 agent 的语义,使两者
|
||||
// 除增量回调外行为一致。
|
||||
//
|
||||
// 两侧护栏的分工(裁决 13.4.1,design/04 §5):**输入侧是模型调用前的同步
|
||||
// 前置门**(恶意输入不得先消耗一次模型调用),**输出侧是异步并行检查**
|
||||
// (模型产出不等待检查即可继续流转,结果异步落定)。
|
||||
func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepOutcome, error) {
|
||||
ag := l.agentFor(ctx)
|
||||
adapter := ag.Adapter()
|
||||
@@ -30,7 +45,11 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
req := l.buildRequest(ctx, messages, ag, knobs)
|
||||
|
||||
if l.guards != nil {
|
||||
verdict, err := l.guards.CheckInput(ctx, &req)
|
||||
// 输入侧同步前置门:检查包在自己的 guardrail span 里
|
||||
// (裁决 11.4.2),因此护栏也进了全链路流程图。
|
||||
gctx, gspan := l.span(ctx, spanNameGuardrailInput, trace.SpanKindGuardrail)
|
||||
verdict, err := l.guards.CheckInput(gctx, &req)
|
||||
l.finishGuardrailSpan(gspan, spanNameGuardrailInput, verdict, err)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loop: check input: %w", err)
|
||||
}
|
||||
@@ -65,15 +84,8 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
return nil, fmt.Errorf("loop: chat stream: %w", err)
|
||||
}
|
||||
|
||||
if l.guards != nil {
|
||||
verdict, err := l.guards.CheckOutput(ctx, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loop: check output: %w", err)
|
||||
}
|
||||
if verdict != nil && !verdict.Allow {
|
||||
return &stepOutcome{final: true, text: verdict.Reason, stopReason: agent.StopReasonGuardrailOutput}, nil
|
||||
}
|
||||
}
|
||||
// 输出侧不阻塞回合:检查在后台跑,本回合带着模型产出继续。
|
||||
l.checkOutputAsync(ctx, &resp)
|
||||
|
||||
if calls, ok := decodeToolCalls(resp); ok {
|
||||
return &stepOutcome{final: false, toolCalls: calls, stopReason: agent.StopReasonToolCalls,
|
||||
@@ -83,6 +95,37 @@ func (l *Loop) streamStep(ctx context.Context, messages []model.Message) (*stepO
|
||||
modelStopReason: resp.StopReason, model: resp.Model}, nil
|
||||
}
|
||||
|
||||
// checkOutputAsync 在后台运行输出侧护栏检查(裁决 13.4.1:真并发、不阻塞
|
||||
// 主会话)。启动 span 与「运行中」占位节点留在同步路径上,节点的出现顺序
|
||||
// 才与真实发生顺序一致;检查本身脱离本步的 context 跑(保留会话/actor 值,
|
||||
// 另加超时兜底),因此慢检查绝不拖住回合。
|
||||
//
|
||||
// 结果闭环在 guardrail 节点上(allow / 耗时 / 脱敏后的 Reason,裁决 11.4.2),
|
||||
// 判定拦截时再发一条审计事件 —— 输出侧的拦截始终留痕,只是不再挡住已产出的
|
||||
// 回复(这正是「真并发」与「可绕过」的区别:输入侧的前置门依旧同步拦下)。
|
||||
func (l *Loop) checkOutputAsync(ctx context.Context, resp *model.Response) {
|
||||
if l.guards == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
gctx, span := l.span(ctx, spanNameGuardrailOutput, trace.SpanKindGuardrail)
|
||||
bg, cancel := context.WithTimeout(context.WithoutCancel(gctx), guardrailCheckTimeout)
|
||||
go func() {
|
||||
defer cancel()
|
||||
verdict, err := l.guards.CheckOutput(bg, resp)
|
||||
l.finishGuardrailSpan(span, spanNameGuardrailOutput, verdict, err)
|
||||
if verdict != nil && !verdict.Allow {
|
||||
// Reason 由 guardrail 包保证不含被匹配的密钥(其契约),
|
||||
// 这里再经 RedactText 走一遍凭据形状清洗 + 截断,使审计面
|
||||
// 与出图面用同一判据。
|
||||
l.emitAudit(AuditEvent{
|
||||
Who: string(sessionEnvFrom(bg).ActorEnv),
|
||||
What: auditGuardrailOutputBlock,
|
||||
Result: trace.RedactText(verdict.Reason),
|
||||
})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// broadcasts 报告某流式事件是否可推给 UI。只有
|
||||
// 思考帧会被扣下,且仅当该 turn 的角色把
|
||||
// showToUser 解析为 false 时;文本、工具调用与工具进度帧
|
||||
|
||||
Reference in new issue
Block a user