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

@@ -0,0 +1,62 @@
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),实时帧读的就是这里的产物。
//
// 🔴 为什么必须在 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 未报错)才给摘要。失败 —— 含工具自身报错、取消、超时(三者对调用方都是
// `Invoke` 返回 error)—— 一律返回空串,帧上该键随之**整键缺席**,绝不用空串顶位,
// 也绝不把错误文本冒充成「结果」:错误的原因属于图与回看面(那里 `status:"error"` 的
// 调用给出的就是图上的 `error` 摘要),实时帧只如实说「这次没拿到结果」。
func toolEndFrameSummary(result any, err error) string {
if err != nil {
return ""
}
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)
}
}
@@ -0,0 +1,154 @@
package loop
import (
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的四条口径:
// ① 结果真拿到 ⇒ 帧在场,且文本与客户端**回看**同一次调用所见的
// `toolCalls[].resultSummary`(图节点 props.result_summary)**逐字一致**;
// ② 失败/取消/超时 ⇒ 空串(下游据此让键整键缺席),绝不用空串顶位、绝不把错误
// 冒充成结果;③ 凭据形状必须被清洗(与出图同一条 trace.RedactText);
// ④ nil 与空串结果也不产出「看起来像结果」的占位文本。
// toolNodeResultSummary 反读最后一份图快照里 tool 节点的 props.result_summary ——
// 也就是回看面 `toolCalls[].resultSummary` 的**同一份数据源**。没有该 prop 时返回
// ("", false),使「图上缺席」与「图上给了空串」在测试里可分辨。
func toolNodeResultSummary(t *testing.T, g *trace.FlowGraph) (string, bool) {
t.Helper()
if g == nil {
t.Fatal("nil graph snapshot")
}
for _, n := range g.Nodes {
if n.Kind != "tool" {
continue
}
s, ok := n.Props["result_summary"].(string)
return s, ok
}
t.Fatalf("图里没有 tool 节点,无法反读 result_summary(nodes=%+v)", g.Nodes)
return "", false
}
// TestRun_ToolEndFrameCarriesResultSummary 覆盖成功路径:end 帧带结果摘要,
// start 帧不带(摘要只在结果落定那一刻存在)。
func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, map[string]any{"now": "2026-09-08 22:34:00"}, nil)
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2 (start+end): %+v", len(ex), ex)
}
if ex[0].ResultSummary != "" {
t.Fatalf("start 帧带了结果摘要 %q;结果尚未拿到,绝不能带", ex[0].ResultSummary)
}
want := `{"now":"2026-09-08 22:34:00"}`
if ex[1].ResultSummary != want {
t.Fatalf("end 帧 resultSummary = %q, want %q(工具返回值的 JSON 渲染)", ex[1].ResultSummary, want)
}
}
// TestRun_ToolEndFrameOmitsSummaryOnError 覆盖失败/取消/超时路径:三者对循环都是
// `Invoke` 返回 error ⇒ end 帧的 resultSummary **留空**(下游据此让键整键缺席),
// 绝不把错误文本冒充成「结果」。
func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
result any
}{
{"执行失败(无结果)", nil},
{"失败但带部分结果", map[string]any{"partial": true}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, tc.result, errors.New("boom"))
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
if ex[1].Phase != model.ToolPhaseError {
t.Fatalf("end phase = %q, want error(失败路径)", ex[1].Phase)
}
if ex[1].ResultSummary != "" {
t.Fatalf("失败调用的 end 帧带了 %q;失败/取消/超时一律留空,由下游让键缺席",
ex[1].ResultSummary)
}
})
}
}
// TestRun_ToolEndFrameSummaryMatchesGraphResultSummary 是**同源钉**:同一个工具结果,
// 实时帧上的 resultSummary 必须与最后一份图快照里 tool 节点的 props.result_summary
// (回看面 `toolCalls[].resultSummary` 的取值源)逐字相等。它用真建图器(真 span →
// trace.RunGraph)反读,因此改一边不改另一边会红 —— 只按字面量断言会与实现一起漂移。
func TestRun_ToolEndFrameSummaryMatchesGraphResultSummary(t *testing.T) {
t.Parallel()
result := map[string]any{"now": "2026-09-08 22:34:00", "note": "token=abcdef1234567890"}
evs, graphs := runToolTurn(t, result, nil)
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
fromGraph, ok := toolNodeResultSummary(t, graphs[len(graphs)-1])
if !ok || fromGraph == "" {
t.Fatalf("图上的 result_summary = %q (present=%v);回看面会缺席,而实时帧带着摘要,"+
"两边就此分叉", fromGraph, ok)
}
if ex[1].ResultSummary != fromGraph {
t.Fatalf("实时帧 resultSummary = %q,图上的 result_summary = %q;同一份文本才允许两边都出",
ex[1].ResultSummary, fromGraph)
}
}
// TestRun_ToolEndFrameSummaryIsRedacted 覆盖脱敏:喂一个含凭据形状的结果(两种形状:
// 顶层字符串与 JSON 对象里的键值),帧上的摘要必须已经是清洗后的文本 —— 绝不放宽、
// 绝不回上游原文。
func TestRun_ToolEndFrameSummaryIsRedacted(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
cases := []struct {
name string
result any
}{
{"顶层字符串", "token=" + secret},
{"对象里的键值", map[string]any{"token": secret}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
evs, _ := runToolTurn(t, tc.result, nil)
ex := toolExecEvents(evs)
if len(ex) != 2 {
t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex)
}
got := ex[1].ResultSummary
if strings.Contains(got, secret) {
t.Fatalf("end 帧 resultSummary 里带着凭据原文:%q", got)
}
if !strings.Contains(got, "[redacted]") {
t.Fatalf("end 帧 resultSummary = %q,want 含 [redacted](出图同一条凭据形状清洗)", got)
}
})
}
}
// TestToolFrameResultSummary_EmptyResultsStayEmpty 钉住「绝不产出看起来像结果的占位
// 文本」:nil 与空串结果的摘要必须为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty")。
func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) {
t.Parallel()
for _, v := range []any{nil, ""} {
if got := toolFrameResultSummary(v); got != "" {
t.Fatalf("toolFrameResultSummary(%#v) = %q, want 空串", v, got)
}
if got := toolEndFrameSummary(v, nil); got != "" {
t.Fatalf("toolEndFrameSummary(%#v, nil) = %q, want 空串", v, got)
}
}
}
+6 -6
View File
@@ -298,7 +298,7 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
return ToolOutcome{Err: fmt.Errorf("no tool dispatcher configured for %q", tc.Name), ErrKind: tool.KindExecution}
}
_, toolSpan := l.span(ctx, "tool."+tc.Name, trace.SpanKindTool)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionStart, model.ToolPhaseRunning, "")
// 路标识桥(design/15 §2.5.7、主人裁决 3):网关在**每一条在途路**上把互斥
// 身份经 loop.WithToolLegID 挂在 ctx 上(普通回合 / 并行路 / 队首续跑路都挂,
// 取值 = 该路的 turnID),这里把它桥接成工具层的 tool.WithLegID —— 工具侧
@@ -313,7 +313,7 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
if err != nil {
phase = model.ToolPhaseError
}
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase)
l.emitToolExecution(ctx, tc, model.StreamKindToolExecutionEnd, phase, toolEndFrameSummary(result, err))
out := ToolOutcome{Result: result, Err: err, ErrKind: errorKind(err)}
l.spanProp(toolSpan, "phase", phase)
l.recordToolDone(toolSpan, tc, out)
@@ -331,13 +331,13 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
// tool_execution 三事件(start / update / end)转发给前端;否则为
// 空操作(被静音的内部流程也会跳过它)。phase 是结构化字段
// (running/done/error),绝不塞进 Delta;工具名走 Tool 字段,
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧
// 关联。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase string) {
// ID 复用 provider 的 tool call id,使执行帧能与其 tool_call.* 帧关联;
// resultSummary 只在 end 帧、且只在结果真正拿到时非空(口径见 tool_frame_summary.go)。
func (l *Loop) emitToolExecution(ctx context.Context, tc agent.ToolCall, kind model.StreamKind, phase, resultSummary string) {
if l.onStream == nil || isMutedBroadcast(ctx) {
return
}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID}
ev := model.StreamEvent{Kind: kind, Phase: phase, Tool: tc.Name, ID: tc.ID, ResultSummary: resultSummary}
ev.SessionID = loopSession(ctx)
ev.TurnID = loopTurn(ctx)
l.onStream(ev)