Files
Cyrene_v2/internal/runtime/loop/tool_frame_summary.go
T
AskaEth 9fa7e65278 feat(loop): tool_execution.end 失败也带 resultSummary(Z3,主会话裁定)
与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText),
缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏)
2026-10-07 09:34:45 +08:00

77 lines
4.4 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 (
"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` prop → `trace.safeSpanProps` →
// `RedactText(summarize(result))`。`RedactText` 是导出 API(凭据形状清洗 + 截断,
// 连同密钥键名防线都在 trace/redact.go),但 `summarize` 只服务出图、未导出,
// 且 `internal/runtime/trace/**` 不在本切片文件面内 —— 因此这里按同一口径重述那一步
// 渲染(nil ⇒ 空、字符串原样、其余 JSON 编码,编码失败才回落 %v)。
//
// 🔴 两边必须逐字一致:由 `tool_frame_summary_test.go` 用**真建图器**
// (`trace.RunGraph` + 真 span → 反读图上那份 `result_summary`)逐字比对来钉住 ——
// 照字面量写断言会与实现一起漂移。若哪天 trace 导出了这一步渲染,这里应改为直接调用。
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)
}
}