From 9fa7e6527835126a52336c932fba44d4dd490b46 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 09:34:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(loop):=20tool=5Fexecution.end=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B9=9F=E5=B8=A6=20resultSummary=EF=BC=88Z3=EF=BC=8C?= =?UTF-8?q?=E4=B8=BB=E4=BC=9A=E8=AF=9D=E8=A3=81=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText), 缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏) --- internal/runtime/loop/tool_frame_summary.go | 30 +++- .../runtime/loop/tool_frame_summary_test.go | 106 +++++++++++-- .../gateway/ws_tool_result_summary_test.go | 143 +++++++++++++++++- 3 files changed, 250 insertions(+), 29 deletions(-) diff --git a/internal/runtime/loop/tool_frame_summary.go b/internal/runtime/loop/tool_frame_summary.go index 722cd28..ce133b2 100644 --- a/internal/runtime/loop/tool_frame_summary.go +++ b/internal/runtime/loop/tool_frame_summary.go @@ -12,8 +12,14 @@ import ( // 客户端回看一条回复时读的是 `toolCalls[].resultSummary`(图节点 `props.result_summary`, // 见 gateway/history_toolcalls.go),实时帧读的就是这里的产物。 // -// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具返回值只在这一层存在; -// 网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文, +// 🔴 **失败也是结果**:回看面对一次失败的调用回的是图上的 `error` 摘要 +// (`toolCallResultSummary` 在 `props.result_summary` 缺席时回落 `props.error`), +// 所以实时帧在失败时同样带一份**已脱敏**的错误文本 —— 同一份脱敏路径、同一份上限, +// 客户端不必为「实时」与「回看」写两套判据。键**只在「真的没有结果」时缺席**: +// 调用成功但结果为空,或错误文本本身为空(见 `toolEndFrameSummary`)。 +// +// 🔴 为什么必须在 loop 侧算:`.end` 帧发出的那一刻,工具的返回值与错误原因只在这一层 +// 存在;网关拿到的 `model.StreamEvent` 里没有原文(也**不该**有 —— 帧一旦携带上游原文, // 脱敏面就被绕过了)。所以摘要只在这里、在**同一份已脱敏文本**上生成,网关只做搬运 // 与截断,绝不反解上游原文。 @@ -33,14 +39,22 @@ func toolFrameResultSummary(result any) string { return trace.RedactText(renderToolResultSummary(result)) } -// toolEndFrameSummary 是一次调用结束时**要不要带摘要**的取舍点:只有结果真正拿到 -// (Invoke 未报错)才给摘要。失败 —— 含工具自身报错、取消、超时(三者对调用方都是 -// `Invoke` 返回 error)—— 一律返回空串,帧上该键随之**整键缺席**,绝不用空串顶位, -// 也绝不把错误文本冒充成「结果」:错误的原因属于图与回看面(那里 `status:"error"` 的 -// 调用给出的就是图上的 `error` 摘要),实时帧只如实说「这次没拿到结果」。 +// 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 "" + return trace.RedactText(err.Error()) } return toolFrameResultSummary(result) } diff --git a/internal/runtime/loop/tool_frame_summary_test.go b/internal/runtime/loop/tool_frame_summary_test.go index 708215d..4da3ce9 100644 --- a/internal/runtime/loop/tool_frame_summary_test.go +++ b/internal/runtime/loop/tool_frame_summary_test.go @@ -9,12 +9,14 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的四条口径: +// 本文件钉住 `tool_execution.end` 帧上的 resultSummary(契约 v0.82)的五条口径: // ① 结果真拿到 ⇒ 帧在场,且文本与客户端**回看**同一次调用所见的 // `toolCalls[].resultSummary`(图节点 props.result_summary)**逐字一致**; -// ② 失败/取消/超时 ⇒ 空串(下游据此让键整键缺席),绝不用空串顶位、绝不把错误 -// 冒充成结果;③ 凭据形状必须被清洗(与出图同一条 trace.RedactText); -// ④ nil 与空串结果也不产出「看起来像结果」的占位文本。 +// ② **失败也是结果**(取消 / 超时同普通报错,一视同仁):帧带错误文本的已脱敏摘要, +// 与回看面回落的图上 `error` 摘要**逐字一致**;③ 只有真的没有结果(结果为空、或错误 +// 文本为空)才留空 ⇒ 下游让键整键缺席,绝不用空串顶位;④ 凭据形状必须被清洗 +// (成功与失败两条路径共用同一条 trace.RedactText);⑤ nil 与空串结果不产出 +// 「看起来像结果」的占位文本。 // toolNodeResultSummary 反读最后一份图快照里 tool 节点的 props.result_summary —— // 也就是回看面 `toolCalls[].resultSummary` 的**同一份数据源**。没有该 prop 时返回 @@ -35,6 +37,24 @@ func toolNodeResultSummary(t *testing.T, g *trace.FlowGraph) (string, bool) { return "", false } +// toolNodeProp 反读最后一份图快照里 tool 节点的某个字符串 prop —— 回看面 +// `toolCalls[]` 各项的取值源。没有该 prop(或它不是字符串)时返回 ("", false)。 +func toolNodeProp(t *testing.T, g *trace.FlowGraph, key string) (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[key].(string) + return s, ok + } + t.Fatalf("图里没有 tool 节点,无法反读 props[%q](nodes=%+v)", key, g.Nodes) + return "", false +} + // TestRun_ToolEndFrameCarriesResultSummary 覆盖成功路径:end 帧带结果摘要, // start 帧不带(摘要只在结果落定那一刻存在)。 func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) { @@ -53,11 +73,16 @@ func TestRun_ToolEndFrameCarriesResultSummary(t *testing.T) { } } -// TestRun_ToolEndFrameOmitsSummaryOnError 覆盖失败/取消/超时路径:三者对循环都是 -// `Invoke` 返回 error ⇒ end 帧的 resultSummary **留空**(下游据此让键整键缺席), -// 绝不把错误文本冒充成「结果」。 -func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) { +// TestRun_ToolEndFrameCarriesErrorSummaryOnFailure 覆盖失败/取消/超时路径:三者对循环 +// 都是 `Invoke` 返回 error,而**失败也是结果** —— end 帧带的是错误文本的**已脱敏**摘要, +// 且与回看面回落的图上 `error` 摘要**逐字一致**(同一次调用,实时与事后只有一种说法)。 +// 失败时若还带回部分结果,摘要仍取错误文本:部分结果是过程,失败原因才是结论。 +// +// 错误文本故意带凭据形状:这样「同源钉」在**脱敏**上也灵敏 —— 若哪天失败路径绕过 +// `trace.RedactText`,图上那份仍是清洗过的、帧上那份不是,逐字比对会立刻红。 +func TestRun_ToolEndFrameCarriesErrorSummaryOnFailure(t *testing.T) { t.Parallel() + const secret = "abcdef1234567890" cases := []struct { name string result any @@ -68,7 +93,7 @@ func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - evs, _ := runToolTurn(t, tc.result, errors.New("boom")) + evs, graphs := runToolTurn(t, tc.result, errors.New("boom: token="+secret)) ex := toolExecEvents(evs) if len(ex) != 2 { t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex) @@ -76,14 +101,65 @@ func TestRun_ToolEndFrameOmitsSummaryOnError(t *testing.T) { 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) + got := ex[1].ResultSummary + if got == "" { + t.Fatalf("失败调用的 end 帧没带摘要;失败也是结果,回看面对同一次调用给的是 error 摘要") + } + if !strings.Contains(got, "boom") { + t.Fatalf("end 帧 resultSummary = %q,want 含工具报错文本 boom", got) + } + if strings.Contains(got, secret) { + t.Fatalf("失败帧 resultSummary 里带着凭据原文:%q", got) + } + fromGraph, ok := toolNodeProp(t, graphs[len(graphs)-1], "error") + if !ok || fromGraph == "" { + t.Fatalf("图上 tool 节点没有 error prop (present=%v);回看面会缺席,而实时帧带着错误摘要", ok) + } + if got != fromGraph { + t.Fatalf("实时帧 resultSummary = %q,图上 error = %q;同一份文本才允许两边都出", got, fromGraph) } }) } } +// TestRun_ToolEndFrameErrorSummaryIsRedacted 钉「脱敏不放宽」在失败路径上同样成立: +// 错误文本里带凭据形状时,帧上的摘要必须已经是清洗后的文本 —— 失败不是绕过脱敏的后门。 +func TestRun_ToolEndFrameErrorSummaryIsRedacted(t *testing.T) { + t.Parallel() + const secret = "abcdef1234567890" + evs, _ := runToolTurn(t, nil, errors.New("lookup failed: token="+secret)) + 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("失败帧 resultSummary 里带着凭据原文:%q", got) + } + if !strings.Contains(got, "[redacted]") { + t.Fatalf("失败帧 resultSummary = %q,want 含 [redacted](与结果摘要同一条清洗路径)", got) + } +} + +// TestRun_ToolEndFrameErrorSummaryIsBounded 钉上限:超长错误文本不得整段上帧 —— +// 它走的仍是同一条有界脱敏路径(loop 侧被 trace 收得更短),线上再按与回看面共用的 +// ≤500 rune 预算兜底。 +func TestRun_ToolEndFrameErrorSummaryIsBounded(t *testing.T) { + t.Parallel() + evs, _ := runToolTurn(t, nil, errors.New(strings.Repeat("详", 2000))) + ex := toolExecEvents(evs) + if len(ex) != 2 { + t.Fatalf("tool_execution events = %d, want 2: %+v", len(ex), ex) + } + got := ex[1].ResultSummary + if got == "" { + t.Fatal("超长失败的 end 帧没带摘要;失败也是结果") + } + if n := len([]rune(got)); n > 500 { + t.Fatalf("失败帧 resultSummary 长度 = %d rune,超过与回看面共用的上限 500", n) + } +} + // TestRun_ToolEndFrameSummaryMatchesGraphResultSummary 是**同源钉**:同一个工具结果, // 实时帧上的 resultSummary 必须与最后一份图快照里 tool 节点的 props.result_summary // (回看面 `toolCalls[].resultSummary` 的取值源)逐字相等。它用真建图器(真 span → @@ -140,7 +216,8 @@ func TestRun_ToolEndFrameSummaryIsRedacted(t *testing.T) { } // TestToolFrameResultSummary_EmptyResultsStayEmpty 钉住「绝不产出看起来像结果的占位 -// 文本」:nil 与空串结果的摘要必须为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty")。 +// 文本」与「只有真的没有结果才缺席」:nil 与空串结果的摘要必须为空,**错误文本为空的 +// 失败**同样为空 ⇒ 下游让键整键缺席(不是回 "null"/"empty",也不用空串顶位)。 func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) { t.Parallel() for _, v := range []any{nil, ""} { @@ -151,4 +228,7 @@ func TestToolFrameResultSummary_EmptyResultsStayEmpty(t *testing.T) { t.Fatalf("toolEndFrameSummary(%#v, nil) = %q, want 空串", v, got) } } + if got := toolEndFrameSummary(nil, errors.New("")); got != "" { + t.Fatalf("toolEndFrameSummary(nil, 空错误) = %q,want 空串(真的没有结果才缺席)", got) + } } diff --git a/internal/services/gateway/ws_tool_result_summary_test.go b/internal/services/gateway/ws_tool_result_summary_test.go index feef3fe..e305e06 100644 --- a/internal/services/gateway/ws_tool_result_summary_test.go +++ b/internal/services/gateway/ws_tool_result_summary_test.go @@ -3,6 +3,7 @@ package gateway import ( "context" "encoding/json" + "errors" "strings" "testing" @@ -19,7 +20,9 @@ import ( // 安卓端按「键在场 ⇒ 渲染结果区、缺席 ⇒ 只显示工具名与状态」编程,因此这里要同时锁住 // ① 成功路径经**真实生产链路**(真 loop → bus → relay → broadcastStream)到达客户端时 // 在场且文本正确;② 空摘要 ⇒ 键**整键缺席**(绝不回空串顶位);③ 只有 end 帧带它; -// ④ 超长按与回看面**同一个**上限(maxToolCallSummaryRunes)截断,末位 `…`。 +// ④ 超长按与回看面**同一个**上限(maxToolCallSummaryRunes)截断,末位 `…`; +// ⑤ **失败也是结果**:失败调用(phase=error)的 end 帧同样带摘要 —— loop 侧给的是 +// 已脱敏的错误文本(与回看面回落的图上 `error` 摘要同源),网关只搬运与截断。 // TestStreamFramesToolExecutionEndResultSummary 是端到端锁定:驱动一个真工具回合, // end 帧必须带工具返回值的已脱敏摘要,start 帧不带。 @@ -39,8 +42,9 @@ func TestStreamFramesToolExecutionEndResultSummary(t *testing.T) { } } -// TestFrameResultSummaryShapeMatrix 钉住载荷形状的四个边界(直接经生产映射函数 -// streamEventData,不经 hub):在场语义、缺席语义、end-only、上限。 +// TestFrameResultSummaryShapeMatrix 钉住载荷形状的各个边界(直接经生产映射函数 +// streamEventData,不经 hub):在场语义、缺席语义(各 phase 的空摘要)、 +// 失败也带、非 end 帧不带、上限(超长截断 / 恰好等于上限)。 func TestFrameResultSummaryShapeMatrix(t *testing.T) { t.Parallel() @@ -55,16 +59,34 @@ func TestFrameResultSummaryShapeMatrix(t *testing.T) { } }) - t.Run("无结果_键整键缺席", func(t *testing.T) { + t.Run("空摘要_键整键缺席", func(t *testing.T) { t.Parallel() - // 失败 / 取消 / 超时:loop 侧留空 ⇒ 这里必须让键**缺席**, + // 「真的没有结果」:loop 侧留空 ⇒ 这里必须让键**缺席**, // 绝不回空串(空串会让安卓端渲染出一个空的结果区)。 + // ⚠️ 失败 / 取消 / 超时**不落在这里** —— 那是「有结论」的情形, + // loop 侧给的是已脱敏的错误文本(见下一个子用例)。 + for _, phase := range []string{model.ToolPhaseDone, model.ToolPhaseError} { + _, data := streamEventData(model.StreamEvent{ + Kind: model.StreamKindToolExecutionEnd, Tool: "web_search", + Phase: phase, + }) + if v, ok := data["resultSummary"]; ok { + t.Fatalf("phase=%s 空摘要仍然出了键 resultSummary=%v;缺席语义要求整键缺席", phase, v) + } + } + }) + + t.Run("失败_带错误摘要且逐字搬运", func(t *testing.T) { + t.Parallel() + // 失败也是结果:loop 侧把 `error` 文本脱敏后填进同一字段,网关逐字搬运 + // (只截断、不二次脱敏),因此失败的 end 帧同样有结果区。 + const want = `tool "web_search" failed: upstream 503: upstream 503` _, data := streamEventData(model.StreamEvent{ Kind: model.StreamKindToolExecutionEnd, Tool: "web_search", - Phase: model.ToolPhaseError, + Phase: model.ToolPhaseError, ResultSummary: want, }) - if v, ok := data["resultSummary"]; ok { - t.Fatalf("空摘要仍然出了键 resultSummary=%v;缺席语义要求整键缺席", v) + if got, _ := data["resultSummary"].(string); got != want { + t.Fatalf("失败帧 resultSummary = %v, want %q(错误摘要逐字搬运)", data["resultSummary"], want) } }) @@ -171,3 +193,108 @@ func TestWSToolExecutionEndResultSummaryOverTheWire(t *testing.T) { t.Fatalf("线上 tool_execution.end.resultSummary = %v, want %q(工具返回值的渲染)", data["resultSummary"], want) } } + +// wsFailTool 是注册进真实 dispatcher 的最小**失败**工具:报错文本里故意带一个 +// 凭据形状的串,用来在线上同时钉住「失败也带摘要」与「失败路径不放宽脱敏」—— +// 摘要是 loop 侧脱敏后的那一份,客户端拿到的东西里不该有凭据原文。 +type wsFailTool struct{} + +func (wsFailTool) Name() string { return "web_search" } +func (wsFailTool) Description() string { return "test failing tool" } +func (wsFailTool) Parameters() (map[string]any, error) { return map[string]any{}, nil } +func (wsFailTool) Execute(context.Context, any) (any, error) { + return nil, errors.New("upstream 503: token=abcdef1234567890") +} + +// wsFailAdapter 是两步脚本:第一步返回一个指向 wsFailTool 的原生工具调用, +// 第二步(错误回灌给模型之后)正常收尾,使回合完整跑完。 +type wsFailAdapter struct{ calls int } + +func (a *wsFailAdapter) Name() string { return "ws-fail-test" } + +func (a *wsFailAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) { + return model.Response{Content: "done"}, nil +} + +func (a *wsFailAdapter) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) { + a.calls++ + if a.calls == 1 { + return model.Response{Model: "m", ToolCalls: []model.ToolCall{{ + ID: "call_fail_1", Name: "web_search", Args: map[string]any{}, + }}}, nil + } + return model.Response{Model: "m", Content: "done"}, nil +} + +// TestWSToolExecutionEndErrorSummaryOverTheWire 是失败路径的**线上**锁定(与成功路径 +// 那个对称):真 HTTP/WS 服务 + 真 loop + 真失败工具,客户端从连接上读回 +// `tool_execution.end` 帧 —— 它必须 phase=error **且**带 resultSummary(失败也是结果), +// 内容是已脱敏的错误文本。这条用例是「实时与回看对同一次失败给出同一份文本」的线上凭据: +// 现场如果摘掉 loop 侧的脱敏,这里会直接读到凭据原文。 +func TestWSToolExecutionEndErrorSummaryOverTheWire(t *testing.T) { + t.Parallel() + const secret = "abcdef1234567890" + b := bus.New() + reg := registry.New() + td, err := tool.New(reg) + if err != nil { + t.Fatalf("tool.New: %v", err) + } + if err := td.Register(wsFailTool{}); err != nil { + t.Fatalf("register tool: %v", err) + } + ag, err := agent.New("You are a helpful assistant.", td, nil, agent.WithModel(&wsFailAdapter{}, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + lp := loop.New(loop.Options{ + Agent: ag, + MaxRounds: 8, + OnStream: func(ev model.StreamEvent) { + _ = b.Publish(context.Background(), wsStreamSubject, ev) + }, + }) + srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{ + Loop: lp, Bus: b, History: newReplayHistoryStore(), Streaming: true, + }) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + conn := dialWS(t, ts.URL, tok) + defer conn.Close() + waitConnCount(t, srv, 1) + + if err := conn.WriteJSON(wsInbound{Type: "chat", Data: json.RawMessage(`{"sessionId":"web","text":"search?"}`)}); err != nil { + t.Fatalf("write chat: %v", err) + } + frames := readChatFramesUntil(t, conn, "tool_execution.end") + + var end *wsEvent + for i := range frames { + if frames[i].Type == "tool_execution.end" { + end = &frames[i] + } + } + if end == nil { + t.Fatalf("线上没有收到 tool_execution.end 帧(已见 %v)", chatFrameTypes(frames)) + } + data, ok := end.Data.(map[string]any) + if !ok { + t.Fatalf("tool_execution.end 载荷 = %#v,want 对象", end.Data) + } + if got, _ := data["phase"].(string); got != model.ToolPhaseError { + t.Fatalf("线上 tool_execution.end.phase = %v, want error(失败路径)", data["phase"]) + } + got, _ := data["resultSummary"].(string) + if got == "" { + t.Fatalf("线上失败帧没带 resultSummary(data=%#v);失败也是结果,与回看面同一份文本", + data) + } + if !strings.Contains(got, "upstream 503") { + t.Fatalf("线上失败帧 resultSummary = %q,want 含工具报错文本 upstream 503", got) + } + if strings.Contains(got, secret) { + t.Fatalf("线上失败帧 resultSummary 里带着凭据原文:%q", got) + } + if !strings.Contains(got, "[redacted]") { + t.Fatalf("线上失败帧 resultSummary = %q,want 含 [redacted](与出图同一条清洗)", got) + } +}