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

+13 -7
View File
@@ -70,17 +70,23 @@ func (g *RunGraph) Upsert(spec NodeSpec) {
if from != "" && from != spec.ID {
g.addEdge(from, spec.ID)
}
// A sub-agent root is drawn on the trunk it hangs off, but it does not become
// the trunk's continuation point: the main chain resumes where the branch left
// it (the spawn node). Without this, every node after a spawn would look like a
// step of the last branch (the frontend derives lanes from the edges). The
// first trunk node still advances the cursor, so a graph built without an input
// seed cannot orphan its later trunk nodes.
if lane != "" || n.Kind != nodeKindSubagent || g.laneLast[trunkLane] == "" {
// 两类节点是挂在主干上的「标记」而非主干的续点,因此不推进主干游标:
// subagent 根开出一条泳道(见下),orchestration 记录一次派发/收束
// (design/19 §1 旁路节点,裁决 11.4.2)—— 它们之后的主干节点仍从原
// 位置续链,标记本身不会被误当成主干的一步。泳道内的节点(lane != "")
// 照常推进本泳道游标。
if lane != "" || !branchMarker(n.Kind) || g.laneLast[trunkLane] == "" {
g.laneLast[lane] = spec.ID
}
}
// branchMarker 报告某类节点是否为「主干上的标记」:子代理泳道根与编排
// 派发节点都挂在主干上却不接替主干链,使主干(input→llm→tool→…→output)
// 与并行分支、派发标记各成一条不会被彼此打断的链。
func branchMarker(kind string) bool {
return kind == nodeKindSubagent || kind == nodeKindOrchestrate
}
// rememberParent records a span's parent link even when the span is not rendered
// as a node, so laneOf can walk through skipped containers.
func (g *RunGraph) rememberParent(id, parent string) {
@@ -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"])
}
}
+18 -1
View File
@@ -39,6 +39,17 @@ func safeSpanProps(rec SpanRecord) map[string]any {
// safePropKeys are the span prop keys forwarded to a node's props.
//
// 键分五类,全部非敏感(值仍会逐个经 redactValue 清洗):
//
// - 既有:stop_reason / step / error / error_kind / tool / persona;
// - tool 参数摘要(裁决 11.4.3):args_keys(全量参数名)、args_summary
// (仅放行键的值,长度已在 loop/spanlog.go 侧截断),密钥类键的值绝不在其中;
// - guardrail(裁决 11.4.2):allow(裁决结果)、action(检查名)、elapsed_ms(耗时);
// - memory(裁决 11.4.2):hits / count / failed(命中、写入与失败条数)、
// bytes(注入块字节数)、scope / action(记忆作用域与操作名)—— 只记元数据,
// 不含任何记忆正文;
// - orchestration(裁决 11.4.2):subagent(被派发的子代理 id)。
//
// persona 是子代理分支根节点上的「目标角色 id」:orchestration/scheduler.go 只在
// subagent_spawn 显式指定角色时才设置它,且该 id 必须先通过角色注册表校验
// (ErrUnknownPersona 会拒绝未注册的 id,orchestration/persona.go),因此它是注册表里
@@ -46,7 +57,13 @@ func safeSpanProps(rec SpanRecord) map[string]any {
// 与会话材料对外可见。它是 Docs/api-client.md §3.5(v0.47)对客户端的承诺
// (design/22 §8-2:让流程图显示「这个子代理是以谁的身份在替班」),漏出白名单就会被
// safeSpanProps 静默丢掉。未指定角色派发时该键不存在,符合 v0.47「纯增量」的约定。
var safePropKeys = []string{"stop_reason", "step", "error", "error_kind", "tool", "persona"}
var safePropKeys = []string{
"stop_reason", "step", "error", "error_kind", "tool", "persona",
"args_keys", "args_summary",
"allow", "action", "elapsed_ms",
"hits", "count", "failed", "bytes", "scope",
"subagent",
}
// summarize renders an arbitrary value as a short text string for a prop summary.
func summarize(v any) string {
+62
View File
@@ -0,0 +1,62 @@
package trace
import (
"context"
"time"
)
// 本文件是 span 生产者侧的桥接层:它让不持有 *Tracer 句柄的叶子包
// (internal/runtime/agent 的护栏检查等)也能在当前 span 之下再开一个
// 子 span,从而把「尽可能深」的结构化追踪补齐(design/04 §9:LLM /
// 工具 / 编排 / 状态转移 / 子代理 / 记忆读写 / guardrail 全都要有生产者)。
//
// 图谱(graph*.go)与生产者(span 生产点)在这里分工:图构建器只负责把
// span 折成节点,生产者只负责如实记录发生了什么。两者都不需要对方存在
// —— 没有 tracer 时 ChildSpan 返回 nil,生产点照常跑完业务。
// ChildSpan 在当前 ctx 携带的 span 之下开一个具名字 span:新 span 复用其父
// span 的 Tracer(因此 Finish 时同样交给 Writer 落盘),ParentID 指向父
// span,返回的 ctx 把新 span 作为当前 span 继续往下传。
//
// ctx 未携带 span(未开追踪、或在根 span 之外调用)时返回 (ctx, nil):
// 调用方必须容忍 nil span(不要用它,也不要把 nil span 的 ctx 当真)。
// 空 ctx 按 context.Background() 处理。
func ChildSpan(ctx context.Context, name string, kind SpanKind) (context.Context, *Span) {
if ctx == nil {
ctx = context.Background()
}
parent := SpanFromContext(ctx)
if parent == nil {
return ctx, nil
}
parent.mu.RLock()
tracer := parent.tracer
parent.mu.RUnlock()
if tracer == nil {
return ctx, nil
}
span := tracer.newSpan(name, kind, parent)
return context.WithValue(ctx, spanKey{}, span), span
}
// FinishChild 关闭一个由 ChildSpan 开出的 span 并记录耗时与结果:span 为
// nil(未开追踪)时是空操作,因此生产点可以无条件调用它,不必在调用处
// 再写一遍 nil 判断。status 为 ok/error,err 为 nil 时代码不记录 error 属性。
func FinishChild(span *Span, status SpanStatus, err error) {
if span == nil {
return
}
if err != nil {
_ = span.SetProp("error", err.Error())
}
span.Finish(status)
}
// ElapsedMS 报告 span 自开始以来经过的毫秒数;nil span 或零 StartAt 返回 0。
// 它让叶子包在不依赖 loop 内部助手的情况下记录「耗时」属性。
func ElapsedMS(span *Span) int64 {
if span == nil || span.StartedAt.IsZero() {
return 0
}
return time.Since(span.StartedAt).Milliseconds()
}
+17 -3
View File
@@ -42,8 +42,10 @@ var secretKeyNames = map[string]bool{
// secretKeySuffixes catch prefixed variants such as openai_api_key.
var secretKeySuffixes = []string{"_api_key", "_access_token", "_auth_token", "_secret", "_token"}
// isSecretKey reports whether a map key names a credential.
func isSecretKey(key string) bool {
// IsSecretKey 报告某个 map 键是否命名了一个凭据。它是「密钥类键」的唯一判据:
// 出图前的清洗与参数摘要的生产者(loop/spanlog.go,裁决 11.4.3「密钥类参数
// 一律不显示值」)共用它,因此生产侧与最后一道防线不可能判得不一致。
func IsSecretKey(key string) bool {
k := strings.NewReplacer("-", "_", ".", "_").Replace(strings.ToLower(strings.TrimSpace(key)))
if secretKeyNames[k] {
return true
@@ -80,7 +82,7 @@ func RedactEvent(ev Event) Event {
func redactProps(props map[string]any) map[string]any {
out := make(map[string]any, len(props))
for k, v := range props {
if isSecretKey(k) {
if IsSecretKey(k) {
out[k] = redactedMarker
continue
}
@@ -135,6 +137,18 @@ func redactValue(v any) any {
out[i] = RedactText(item)
}
return out
case map[string]string:
// 参数摘要以 map[string]string 生产时也必须逐键清洗,
// 否则密钥会绕过键名规则原样出图(裁决 11.4.3 红线)。
out := make(map[string]any, len(x))
for k, v := range x {
if IsSecretKey(k) {
out[k] = redactedMarker
continue
}
out[k] = RedactText(v)
}
return out
default:
return v
}