feat(toolcall): 工具结果只统计不裁剪(方案 B),并治掉 seq 侧的静默截断

问题(核实过):工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接
`Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。
时间线那侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),但那只管
a.context 的历史事件,**不管单条工具结果** ⇒ 一条巨大结果可能直接冲破
预算而内核不会提前发现。

为什么**不裁剪**(与方案 A 的取舍):
· 截断会让模型拿到**残缺**信息,而截断位置由内核武断决定;
· 模型无法得知"这里被截断了",会基于残缺数据下结论 —— 与本仓反复
  吃亏的「静默降级」同族(`20s` 少引号 → 静默降级 → cmd_run 失败率 34%);
· 处置权应交给调度器/上层(告警、拒绝、或让模型自己换更窄的查询),
  而不是内核单方面替模型决定。

改动:
· core/toolresult_budget.go: checkToolResultSize 只**计数+报告**;
  阈值默认 = ContextTokens/8(一条吃掉全部预算会把其它上下文全挤掉);
  报告经 toolResultReporter(可替换),默认 logReporter —— **不给模型发
  消息**:那是在已花掉的 token 之上再加一条 system,且对当前这轮决策无帮助。
· 接入点在 stepToolAfter 的 toolMsg 落定**之后**(那里才是模型最终看到的
  内容;stepToolExec 拿到的尚未经 after_toolcall 改写)。
· TaskFrame 记 oversizeTools / oversizeToolNames,供调度器与状态面查询
  "是否有工具在稳定产出超大结果"。

★ 顺带治掉 seq 侧一处**我自己留下的静默截断**:
handlers.go 里我当初随手写了 truncate(…, 160),把变量槽静默截到 160 字
且**无任何标注** —— 正是我批评过的静默降级。
改为 renderSlot:≤160 给全;超过则显式标注「已截断:共 N 字,此处显示前
160 字」并给出改法。**槽里存的始终是完整值**,截断只影响回填文本长度。
端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot 抓到了这个缺陷
("变量槽被截到 160/5000 字却没有任何标注")。

判据(toolresult_budget_test.go,4 条):
· 400KB 结果触发超限报告(含工具名与 token 数)
· ★ **默认不裁剪**:200KB 结果原样进 tool 消息(方案 B 的核心不变式)
· 小结果不误报(噪音会淹没有效信号)
· 报告文案可执行:带工具名、token 数、改法建议

变异验证:去掉统计调用 ⇒ 两条判据 FAIL("统计没生效" + "被裁剪了")。

另:检查项报 stepToolBatch 的 goroutine 竞态,-race 实测**误报**——
循环变量显式传参(非闭包捕获)、且按索引写各自槽位(非共享 map),
`-race` 下 20 轮并发判据全绿。
This commit is contained in:
JianFeeeee
2026-09-27 14:05:38 +08:00
parent 116dc413f0
commit 8ca28eb071
9 changed files with 523 additions and 11 deletions

View File

@ -0,0 +1,208 @@
package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 方案 B:工具结果**只统计不裁剪**。
//
// 现状核实:工具结果进 f.Msgs 时**没有任何长度上限**(task.go 直接
// `Content: result`),内核也**不预检**是否超长 —— 超限由上游 API 报错。
// 时间线那一侧有预算(ContextTokens = 0.8×窗口,进消息前就裁过),
// 但那只管 a.context 的历史事件,**不管单条工具结果**。
// ⇒ 一条巨大工具结果可能直接冲破预算,而内核**不会提前发现**。
//
// 本阶段的取舍:**不裁剪用户数据**(截断会让模型拿到残缺信息,且
// 截断位置由内核武断决定),改为**统计 + 告警**,把处置权交回给
// 调度器/上层。这与本仓「显式才是特权」的取向一致。
//
// 判据钉住四件事:会计数、会超阈值、默认**不**裁剪、报告可执行。
// bigToolDevice 返回一个指定大小的工具结果。
type bigToolDevice struct {
name string
toolName string
size int
}
func (d *bigToolDevice) Name() string { return d.name }
func (d *bigToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *bigToolDevice) Description() string { return "big result test device" }
func (d *bigToolDevice) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{{Name: d.toolName}}
}
func (d *bigToolDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return strings.Repeat("x", d.size), nil
}
func (d *bigToolDevice) Start() error { return nil }
func (d *bigToolDevice) Stop() error { return nil }
func (d *bigToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *bigToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
// ① 统计必须真的发生:巨大工具结果要触发一次「超预算」报告。
func TestHugeToolResultIsReported(t *testing.T) {
// 阈值刻意调小,让 200KB 的结果必然超限(不必真造 1M token)
a := newPreemptAgent(t, newPreemptProvider())
a.toolResultWarnTokens = 200 * 1024 // 200KB(EstimateTokens 为 rune×2)
rep := &countingReporter{}
a.toolResultReporter = rep
if err := a.io.RegisterDevice(&bigToolDevice{
name: "bigdev", toolName: "big_tool", size: 400 * 1024, // 400KB
}); err != nil {
t.Fatalf("注册设备失败: %v", err)
}
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "big_tool", Arguments: map[string]interface{}{}}}},
{Content: "final"},
}}
a2, _ := newBatchAgent(t, sp)
a2.toolResultWarnTokens = a.toolResultWarnTokens
a2.toolResultReporter = rep
if err := a2.io.RegisterDevice(&bigToolDevice{
name: "bigdev", toolName: "big_tool", size: 400 * 1024,
}); err != nil {
t.Fatalf("注册设备失败: %v", err)
}
f := a2.newTaskFrame("go", a2.stageCtxFromInput("go", "", ""))
if out := a2.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
}
if rep.n == 0 {
t.Error("400KB 的工具结果未触发任何超限报告 —— 统计没生效")
}
if rep.lastTool != "big_tool" {
t.Errorf("报告应指明是哪个工具,实际 %q", rep.lastTool)
}
if rep.lastTokens <= 0 {
t.Errorf("报告应带上估算 token 数,实际 %d", rep.lastTokens)
}
}
// ② ★ 方案 B 的核心:**默认不裁剪**。统计归统计,数据必须原样给模型。
func TestHugeToolResultIsNotTruncated(t *testing.T) {
a, _ := newBatchAgent(t, &batchProvider{responses: []*agentAPI.CompletionResponse{
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "big_tool", Arguments: map[string]interface{}{}}}},
{Content: "final"},
}})
const size = 200 * 1024
a.toolResultWarnTokens = 10 // 阈值调到极小,必定触发
rep := &countingReporter{}
a.toolResultReporter = rep
if err := a.io.RegisterDevice(&bigToolDevice{
name: "bigdev", toolName: "big_tool", size: size,
}); err != nil {
t.Fatalf("注册设备失败: %v", err)
}
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
}
// 模型看到的必须**原样**
var got string
for _, m := range f.Msgs {
if m.Role == "tool" {
got = m.Content
}
}
if len(got) != size {
t.Errorf("工具结果被裁剪了:得到 %d 字节,期望 %d(方案 B 只统计不裁剪)",
len(got), size)
}
// 且确实报告过
if rep.n == 0 {
t.Error("未触发超限报告")
}
}
// ③ 小结果不该误报(避免噪音淹没有效信号)。
func TestNormalToolResultNotReported(t *testing.T) {
a, _ := newBatchAgent(t, &batchProvider{responses: []*agentAPI.CompletionResponse{
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "small_tool", Arguments: map[string]interface{}{}}}},
{Content: "final"},
}})
a.toolResultWarnTokens = 100 * 1024 // 100KB
rep := &countingReporter{}
a.toolResultReporter = rep
if err := a.io.RegisterDevice(&smallToolDevice{}); err != nil {
t.Fatalf("注册失败: %v", err)
}
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
}
if rep.n != 0 {
t.Errorf("小结果被误报 %d 次 —— 噪音会淹没有效信号", rep.n)
}
}
// ④ 报告内容要可执行:说清是哪个工具、多大、占预算多少。
func TestReportIsActionable(t *testing.T) {
a, _ := newBatchAgent(t, &batchProvider{responses: []*agentAPI.CompletionResponse{
{ToolCalls: []agentAPI.ToolCall{{ID: "c1", Name: "big_tool", Arguments: map[string]interface{}{}}}},
{Content: "final"},
}})
a.toolResultWarnTokens = 1024
rep := &countingReporter{}
a.toolResultReporter = rep
if err := a.io.RegisterDevice(&bigToolDevice{
name: "bigdev", toolName: "big_tool", size: 50 * 1024,
}); err != nil {
t.Fatalf("注册失败: %v", err)
}
f := a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))
if out := a.runTaskSteps(f); out != outcomeDone {
t.Fatalf("runTaskSteps=%v err=%v", out, f.Err)
}
if rep.lastMsg == "" {
t.Fatal("报告文案为空")
}
for _, want := range []string{"big_tool", "token"} {
if !strings.Contains(rep.lastMsg, want) {
t.Errorf("报告缺少 %q:%s", want, rep.lastMsg)
}
}
}
// ---- 测试替身 ----
type countingReporter struct {
n int
lastTool string
lastTokens int
lastMsg string
}
func (r *countingReporter) report(tool string, tokens, budget int, msg string) {
r.n++
r.lastTool = tool
r.lastTokens = tokens
r.lastMsg = msg
}
// smallToolDevice 返回一个很小的结果。
type smallToolDevice struct{}
func (d *smallToolDevice) Name() string { return "smalldev" }
func (d *smallToolDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *smallToolDevice) Description() string { return "small result test device" }
func (d *smallToolDevice) Tools() []agentIO.ToolDef {
return []agentIO.ToolDef{{Name: "small_tool"}}
}
func (d *smallToolDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return "tiny", nil
}
func (d *smallToolDevice) Start() error { return nil }
func (d *smallToolDevice) Stop() error { return nil }
func (d *smallToolDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *smallToolDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }