与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText), 缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏)
77 lines
4.4 KiB
Go
77 lines
4.4 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` 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)
|
||
}
|
||
}
|