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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user