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,137 @@
|
||||
package trace
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// graphNodeKinds 返回图里出现的节点 kind 集合。
|
||||
func graphNodeKinds(g *FlowGraph) map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, n := range g.Nodes {
|
||||
out[n.Kind] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// spanRecordOn 在实时 tracer 上开一个 span、挂上 props、关掉它,并返回完成记录。
|
||||
// id 由 tracer 生成,因此每个记录天然唯一。
|
||||
func spanRecordOn(t *testing.T, tr *Tracer, name string, kind SpanKind, props map[string]any) SpanRecord {
|
||||
t.Helper()
|
||||
_, sp := tr.StartSpan(context.Background(), name, kind)
|
||||
for k, v := range props {
|
||||
if err := sp.SetProp(k, v); err != nil {
|
||||
t.Fatalf("SetProp(%q): %v", k, err)
|
||||
}
|
||||
}
|
||||
sp.Finish(SpanStatusOK)
|
||||
return sp.Snapshot()
|
||||
}
|
||||
|
||||
// TestGraphBuilder_MemoryGuardrailOrchestrationNodes 钉住三类旁路节点真的能
|
||||
// 成为图上的节点(裁决 11.4.2):memory / guardrail / orchestration 三类 span
|
||||
// 各有生产者后,kindForSpan 的映射必须把它们原样折进图,而不是当未知 kind 丢掉。
|
||||
func TestGraphBuilder_MemoryGuardrailOrchestrationNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := New(nil)
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, time.Now())
|
||||
g.SeedInput("帮我看看")
|
||||
|
||||
g.AddSpan(spanRecordOn(t, tr, "memory.recall", SpanKindMemory, map[string]any{"action": "recall", "scope": "owner"}))
|
||||
g.AddSpan(spanRecordOn(t, tr, "guardrail.input", SpanKindGuardrail, map[string]any{"action": "guardrail.input", "allow": true}))
|
||||
g.AddSpan(spanRecordOn(t, tr, "orchestration.spawn", SpanKindOrchestration, map[string]any{"subagent": "sub-1"}))
|
||||
|
||||
got := graphNodeKinds(g.Snapshot())
|
||||
for _, want := range []string{"memory", "guardrail", "orchestration"} {
|
||||
if !got[want] {
|
||||
t.Fatalf("图中缺 %q 节点(实得 %v)", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphBuilder_OrchestrationMarkerKeepsTrunkCursor 钉住编排派发标记的
|
||||
// 定位:它挂在主干上但不接替主干链 —— 之后的 llm/tool 仍从原位置续链,
|
||||
// 派发标记与泳道根一样只是主干上的一个标记(否则整条主干会被标记截断)。
|
||||
func TestGraphBuilder_OrchestrationMarkerKeepsTrunkCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := New(nil)
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, time.Now())
|
||||
g.SeedInput("派活")
|
||||
|
||||
orch := spanRecordOn(t, tr, "orchestration.spawn", SpanKindOrchestration, nil)
|
||||
g.AddSpan(orch)
|
||||
llm := spanRecordOn(t, tr, "llm.call", SpanKindLLM, nil)
|
||||
g.AddSpan(llm)
|
||||
|
||||
graph := g.Snapshot()
|
||||
if !hasEdge(graph, "input", orch.ID) {
|
||||
t.Fatalf("编排标记未挂在主干 input 上:%+v", graph.Edges)
|
||||
}
|
||||
if !hasEdge(graph, "input", llm.ID) {
|
||||
t.Fatalf("主干被编排标记截断:llm 应从 input 续链,实得 %+v", graph.Edges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSafeSpanProps_ToolArgsSummaryKeepsSecretsOut 是发布面的脱敏用例
|
||||
// (裁决 11.4.3):把带密钥的参数摘要喂进 tool span,断言出图后没有密钥值,
|
||||
// 只留下放行键的摘要与键名清单,且密钥类键在摘要里被替换为 [redacted]。
|
||||
func TestSafeSpanProps_ToolArgsSummaryKeepsSecretsOut(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-0123456789abcdef"
|
||||
tr := New(nil)
|
||||
g := NewRunGraph("web")
|
||||
g.Start(1, time.Now())
|
||||
g.SeedInput("读文件")
|
||||
|
||||
g.AddSpan(spanRecordOn(t, tr, "tool.fs.read", SpanKindTool, map[string]any{
|
||||
"args_keys": []string{"api_key", "content", "path"},
|
||||
"args_summary": map[string]any{
|
||||
"path": "/tmp/report.txt",
|
||||
"api_key": secret,
|
||||
},
|
||||
"result": secret,
|
||||
}))
|
||||
|
||||
blob, err := json.Marshal(g.Snapshot())
|
||||
if err != nil {
|
||||
t.Fatalf("marshal graph: %v", err)
|
||||
}
|
||||
out := string(blob)
|
||||
if strings.Contains(out, secret) {
|
||||
t.Fatalf("出图泄漏了密钥值:%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "/tmp/report.txt") {
|
||||
t.Fatalf("放行键的值没出图(可视化要能看参数):%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "args_summary") || !strings.Contains(out, "args_keys") {
|
||||
t.Fatalf("摘要键未随节点发布:%s", out)
|
||||
}
|
||||
if !strings.Contains(out, redactedMarker) {
|
||||
t.Fatalf("密钥类键的值应被替换为 %s:%s", redactedMarker, out)
|
||||
}
|
||||
if strings.Contains(out, "\"content\"") && strings.Contains(out, "args_summary\":{\"content") {
|
||||
t.Fatalf("表外键的值不应进入摘要:%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactValue_StringKeyedSummaryIsScrubbed 钉住 map[string]string 形态的
|
||||
// 摘要同样逐键清洗:生产者若改用字符串键值对,密钥仍不能原样出图。
|
||||
func TestRedactValue_StringKeyedSummaryIsScrubbed(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-0123456789abcdef"
|
||||
out, ok := redactValue(map[string]string{"api_key": secret, "path": "/tmp/a"}).(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("map[string]string 未被清洗为 map[string]any")
|
||||
}
|
||||
if out["api_key"] != redactedMarker {
|
||||
t.Fatalf("密钥类键 = %v,want %s", out["api_key"], redactedMarker)
|
||||
}
|
||||
if out["path"] != "/tmp/a" {
|
||||
t.Fatalf("放行键值被改坏:%v", out["path"])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user