Files
Cyrene_v2/internal/runtime/loop/spanlog.go
T
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00

348 lines
13 KiB
Go
Raw 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 loop
import (
"fmt"
"sort"
"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"
)
// 记录在 span 上的日志事件类型,使 flow 图的节点 log[] 展示
// 每一步发生了什么(design/19 §1 节点实时日志)。仅当该步真正
// 完成时才追加事件,因此日志绝不宣告未运行的工作。
const (
// EventLLMDone 在模型调用返回后记录到 llm_call span 上。
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、请求了多少工具调用
// 以及该调用耗时。不记录任何提示词或补全文本,
// 因此 llm 节点的日志绝不携带对话内容。ag 是该 turn 的 agent
// (按角色池条目),因此多角色进程上报的是实际
// 服务该调用的模型,而不是默认角色的模型。
func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome, ag *agent.Agent) {
if span == nil || out == nil {
return
}
props := map[string]any{
"stop_reason": out.stopReason,
"tool_calls": len(out.toolCalls),
"elapsed_ms": elapsedMS(span),
}
if m := modelName(out, ag); m != "" {
props["model"] = m
}
if out.modelStopReason != "" {
props["model_stop_reason"] = out.modelStopReason
}
_ = span.Record(trace.Event{Type: EventLLMDone, Props: props})
}
// recordToolDone 把一次工具调用的脱敏摘要追加到其 span:工具
// 名、是否成功、参数名(绝不记录其值)、失败时的
// 错误种类,以及截断后的结果摘要。参数值被
// 刻意省略 —— 工具参数正是凭据、消息正文
// 或含机密的文件路径会出现的地方 —— 且这里记录的一切
// 在发布前都会由图再次清洗。
//
// 参数摘要(裁决 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": 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
}
props["error"] = trimSummary(out.Err.Error())
} else {
props["result_summary"] = trimSummary(formatToolResult(out.Result))
}
_ = span.Record(trace.Event{Type: EventToolDone, Props: props})
}
// recordToolResult 把一次**成功**调用的结果落成两样东西,都不是正文:
//
// - result_summary:与 `tool_execution.end` 帧**逐字同源**的已脱敏、已截断
// 摘要(toolFrameResultSummary,同一条 trace.RedactText 路径)—— 回看面
// `toolCalls[].resultSummary` 读的就是它;
// - result_digest:字节数 / rune 数 / 内容 sha256 前缀(trace.DigestOfValue)。
//
// 🔴 原文绝不进 span:工具结果正文本来就留在会话库里,span 与 stderr 的 JSONL
// 都不需要它,而它们正是最容易扩散的两个落盘面(一次 stderr tail 就能把整段
// 工具输出拷走)。排查「这次调用拿到了多大一份东西、两次是不是同一份」靠
// Digest 就够了。
func recordToolResult(span *trace.Span, result any) {
if span == nil {
return
}
if summary := toolFrameResultSummary(result); summary != "" {
_ = span.SetProp("result_summary", summary)
}
_ = span.SetProp("result_digest", trace.DigestOfValue(result))
}
// 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 配置的模型。两者
// 都不可用时为空。
func modelName(out *stepOutcome, ag *agent.Agent) string {
if out.model != "" {
return out.model
}
if ag == nil {
return ""
}
return ag.Model()
}
// elapsedMS 报告 span 迄今已打开多久。span 的 Duration 仅由
// Finish 打上,因此在 Finish 之前记录的日志事件在此测量它。
// 实现委托给 trace.ElapsedMS,使 agent 侧的护栏 span 与本包用同一算法。
func elapsedMS(span *trace.Span) int64 {
return trace.ElapsedMS(span)
}
// argKeys 按排序返回工具调用的参数名。只记录
// 参数名;值留在 trace 之外(见 recordToolDone)。
func argKeys(args map[string]any) []string {
if len(args) == 0 {
return nil
}
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// trimSummary 把日志取值缩短到 maxLogSummaryRunes 个 rune,
// 且不切开多字节 rune(被截断时末位换成省略号)。
func trimSummary(s string) string {
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 truncateRunes(s, max-1) + "…"
}