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

+216 -10
View File
@@ -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) + "…"
}