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

138 lines
4.8 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 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"])
}
}