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 全绿。
77 lines
4.3 KiB
Go
77 lines
4.3 KiB
Go
package loop
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// 本文件是 `tool_execution.end` 帧上 `resultSummary` 的**生产侧**(契约 v0.82)。
|
||
// 它回答「这次工具调用到底拿到了什么」,让实时工具卡与事后**回看**看到同一份文本:
|
||
// 客户端回看一条回复时读的是 `toolCalls[].resultSummary`(图节点 `props.result_summary`,
|
||
// 见 gateway/history_toolcalls.go),实时帧读的就是这里的产物。
|
||
//
|
||
// 🔴 **失败也是结果**:回看面对一次失败的调用回的是图上的 `error` 摘要
|
||
// (`toolCallResultSummary` 在 `props.result_summary` 缺席时回落 `props.error`),
|
||
// 所以实时帧在失败时同样带一份**已脱敏**的错误文本 —— 同一份脱敏路径、同一份上限,
|
||
// 客户端不必为「实时」与「回看」写两套判据。键**只在「真的没有结果」时缺席**:
|
||
// 调用成功但结果为空,或错误文本本身为空(见 `toolEndFrameSummary`)。
|
||
//
|
||
// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具的返回值与错误原因只在这一层
|
||
// 存在;网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文,
|
||
// 脱敏面就被绕过了)。所以摘要只在这里、在**同一份已脱敏文本**上生成,网关只做搬运
|
||
// 与截断,绝不反解上游原文。
|
||
|
||
// toolFrameResultSummary 把工具返回值渲染成 loop 侧的结果摘要文本,口径与图上那份
|
||
// `props.result_summary`(读回看面的数据源)**逐字一致**。
|
||
//
|
||
// 图侧的取值链是:tool span 的 `result_summary` prop(由 spanlog.go 的
|
||
// recordToolResult 用**本函数**写入)→ `trace.safeSpanProps` 的白名单投影 →
|
||
// 节点 `props.result_summary`。因此两侧不再是「各自重述同一步渲染」,而是同一个
|
||
// 生产函数:span 里不再存结果原文(那正是 M40 修掉的写侧泄漏),出图也不可能
|
||
// 再从原文反推 —— 摘要只在这里生成一次。
|
||
//
|
||
// 🔴 两边必须逐字一致:由 `tool_frame_summary_test.go` 用**真建图器**
|
||
// (`trace.RunGraph` + 真 span → 反读图上那份 `result_summary`)逐字比对来钉住 ——
|
||
// 照字面量写断言会与实现一起漂移。
|
||
func toolFrameResultSummary(result any) string {
|
||
return trace.RedactText(renderToolResultSummary(result))
|
||
}
|
||
|
||
// toolEndFrameSummary 是一次调用结束时**要不要带摘要**(以及带哪一份)的取舍点。
|
||
// **失败也是结果**:调用成功 ⇒ 工具返回值的渲染;失败 —— 含工具自身报错、取消、
|
||
// 超时(三者对调用方都是 `Invoke` 返回 error)—— ⇒ **错误文本的脱敏渲染**,因为
|
||
// 回看面对失败调用回的就是图上那份 `error` 摘要。两边给同一份文本,「实时」与
|
||
// 「回看」不再分叉(这一次调用到底发生了什么,只有一种说法)。
|
||
//
|
||
// 三条边界:① **超时 / 取消 / 工具报错一视同仁** —— 都是「这次调用有了结论」,错误文本
|
||
// 就是结论;失败时 `err` 优先,部分结果不顶掉失败原因(部分结果是过程,失败原因才是结论)。
|
||
// ② **脱敏不放宽**:错误文本走的仍是唯一脱敏源 `trace.RedactText`(凭据形状清洗 + 截断),
|
||
// 与结果摘要是同一条路径,不新开第二条旁路;下游网关再按与回看面共用的 ≤500 rune 上限兜底
|
||
// (loop 侧本就被 trace 收到更短)。③ **只有真的没有结果才留空**:`err == nil` 且结果为空
|
||
// (nil / 空串),或 `err` 的文本本身为空时返回空串 ⇒ 帧上该键**整键缺席**,绝不用空串顶位
|
||
// (空串会让客户端渲染出一个空的结果区,比没有结果区更糟)。
|
||
func toolEndFrameSummary(result any, err error) string {
|
||
if err != nil {
|
||
return trace.RedactText(err.Error())
|
||
}
|
||
return toolFrameResultSummary(result)
|
||
}
|
||
|
||
// renderToolResultSummary 把工具返回值渲染成一行文本(口径与 trace/summarize 相同)。
|
||
func renderToolResultSummary(v any) string {
|
||
switch x := v.(type) {
|
||
case nil:
|
||
return ""
|
||
case string:
|
||
return x
|
||
default:
|
||
b, err := json.Marshal(x)
|
||
if err != nil {
|
||
return fmt.Sprintf("%v", x)
|
||
}
|
||
return string(b)
|
||
}
|
||
}
|