feat(ws): tool_execution.end 带可选 resultSummary(Y1,答应安卓端的那条)

- 形状:data.resultSummary(可选)。与回看面 toolCalls[].resultSummary 代码级同源:同一个常量
  (500 rune)与同一个截断函数,摘要由 **loop 侧**生成(trace.RedactText + renderToolResultSummary)
  再随 StreamEvent 下发,gateway 只搬运不加工(与回看面同口径)
- 缺席语义:结果真拿到才带;失败/取消/超时/未落图 ⇒ 整键缺席(绝不用空串顶位)
- 线上帧证据:真 WS + 真 JWT + 真 loop ⇒ 客户端收到 resultSummary;同源钉用真图快照反读逐字比对
- 3 个变异(空摘要也写键/去掉脱敏/失败当结果)
⚠️ 待裁:失败路径现按派工书「整键缺席」,但回看面对失败给的是 error 摘要 ⇒ 实时无结果区、事后有;
   若要实时也显示失败原因,一行可改(toolEndFrameSummary 的 return "")
This commit is contained in:
AskaEth committed 2026-10-06 21:48:48 +08:00
1 parent a820d9968e
commit 215eedf8e9
6 files changed
+433 -7

No files matched your search

+29 -1
View File
@@ -204,7 +204,9 @@ func withStreamID(data map[string]any, ev model.StreamEvent) map[string]any {
// toolExecutionData 构建 tool_execution.* 帧的载荷:工具名与细粒度
// phase 为结构化字段(phase 绝不塞进 delta,design/04 §1.1 裁决
// 01.4.1);id 复用 provider 的 tool call id(可关联 tool_call.* 帧),
// update 帧可再携带 delta 局部输出。键在空值时省略。
// update 帧可再携带 delta 局部输出。end 帧在**结果真的拿到**时带
// resultSummary(同一份已脱敏文本、同一份上限预算,见 frameResultSummary)。
// 键在空值时省略。
func toolExecutionData(ev model.StreamEvent) map[string]any {
data := map[string]any{"tool": ev.Tool, "phase": ev.Phase}
if ev.ID != "" {
@@ -213,9 +215,35 @@ func toolExecutionData(ev model.StreamEvent) map[string]any {
if ev.Kind == model.StreamKindToolExecutionUpdate && ev.Delta != "" {
data["delta"] = ev.Delta
}
if s := frameResultSummary(ev); s != "" {
data["resultSummary"] = s
}
return data
}
// frameResultSummary 取 tool_execution.end 帧上的结果摘要(契约 v0.82)。它只认
// end 帧,且只在 loop 真的填了摘要时给值 —— loop 侧
// (`internal/runtime/loop/tool_frame_summary.go`)已走**图侧同一条脱敏路径**
// (放行表 + 凭据形状清洗,即 `trace.RedactText`),因此这里与回看面同一口径:
// **只截断、不做二次脱敏**(二次脱敏会掩盖「写侧漏了」这个真问题),也绝不回上游
// 原文 —— 帧上根本没有原文可回,摘要就是 loop 给的这一份。
//
// 上限与回看面 `toolCalls[].resultSummary` **共用同一个常量与同一个截断函数**
// (`maxToolCallSummaryRunes` / `truncateToolSummary`,见 history_toolcalls.go):
// 「同一份预算」是代码级复用而不是口头约定。它在这里是**独立兜底** —— 正常路径上
// 图侧 RedactText 已把长度收到 ≤80 rune,只有写入侧放宽或出现绕过它的生产者时
// 这道上限才生效(同 history_toolcalls.go 对 argsSummary 的理由)。
//
// 🔴 缺席语义:空串 ⇒ 该键**整键缺席**,绝不用空串顶位 —— 安卓端按「键在场 ⇒ 渲染
// 结果区,缺席 ⇒ 只显示工具名与状态」编程,一个空的 resultSummary 会让它渲染出
// 一个空的结果区。
func frameResultSummary(ev model.StreamEvent) string {
if ev.Kind != model.StreamKindToolExecutionEnd {
return ""
}
return truncateToolSummary(ev.ResultSummary)
}
// isBroadcastAgentEvent 报告 subject 是否恰为 4 段广播形态
// cyrene.agent.<from>.event,调度器正是这样发布进度的。
// 它刻意不匹配寻址事件(cyrene.agent.<from>.<to>.event)