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

@ -315,3 +315,56 @@ func min(a, b int) int {
}
return b
}
// ⑩ ★ 真机实跑发现的最高频坑:`in` 传空字符串 ""(而非对象 {})。
//
// 两轮对照实验(隔离实例,各 5 次与 3 次 seq_create 尝试)里,
// 模型**都在同一处错了两次**:
//
// ① 实验 A:in 传 "" → 连续 2 次失败
// ② 实验 B(先查 seq_help):in 仍传 "" → 又连续 2 次失败
//
// 模型自述:「我把『无入参』理解成『不传这个字段』,结果序列化成了字符串」。
//
// ⇒ 现有文案虽已可执行("无入参请写 {},不要写成字符串"),
// 但**位置太深**:埋在「格式要点」第 2 条里,模型读到了仍会错。
// 本判据要求这条在**前两屏内**且**用醒目措辞**出现。
func TestHelpWarnsEmptyStringInProminently(t *testing.T) {
p := newTestPlugin(t)
out, err := p.dispatch("seq_help", map[string]interface{}{})
if err != nil {
t.Fatalf("seq_help 失败: %v", err)
}
text, _ := out.(string)
// ① 必须明确说"不要写成字符串"(这是模型实际犯的错)
if !strings.Contains(text, "不要写成字符串") {
t.Errorf("seq_help 未警示『in 不要写成字符串』(实跑中模型在此错了 4 次)")
}
// ② 这条必须出现在**前 400 字**内 —— 埋太深就会被跳过(实跑证明)
head := text
if len(head) > 400 {
head = head[:400]
}
if !strings.Contains(head, "不要写成字符串") {
t.Errorf("『in 不要写成字符串』未出现在前 400 字内(实跑证明埋太深会被忽略)")
}
// ③ 必须给出正确写法,让模型无需推断
if !strings.Contains(text, "{}") {
t.Error("未给出正确写法 {}")
}
}
// ⑪ ★ 同样的坑必须也出现在 seq_create 自己的描述里 ——
// 模型可能不查 help 就直接建序列。
func TestSeqCreateDescWarnsAboutIn(t *testing.T) {
p := newTestPlugin(t)
desc := p.toolDefs()["seq_create"].Description
if !strings.Contains(desc, "in") {
t.Errorf("seq_create 描述未提及 in:%s", desc)
}
// 描述里至少要点明 groups[].in 是对象
if !strings.Contains(desc, "对象") && !strings.Contains(desc, "{}") {
t.Errorf("seq_create 描述未说明 groups[].in 应为对象:%s", desc)
}
}