feat(loop): tool_execution.end 失败也带 resultSummary(Z3,主会话裁定)
与回看面对齐:失败调用给该次错误文本的脱敏渲染(仍 ≤500 rune、仍走 trace.RedactText), 缺席只剩「真的没有结果」(空结果 / 空错误文本)。2 变异(失败仍缺席 / 绕过脱敏)
This commit is contained in:
1 parent
8e2441c183
commit
9fa7e65278
3 files changed
+250
-29
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user