mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
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:
@ -289,3 +289,51 @@ func TestE2E_DeleteMissingErrors(t *testing.T) {
|
||||
t.Fatal("删除不存在的序列却返回成功")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑧ ★ 变量槽的值不得**静默**截断(方案 B 的要求在 seq 侧同样适用)。
|
||||
//
|
||||
// 背景:seq_run / seq_call 回填变量槽时,我当初随手写了 truncate(…, 160)。
|
||||
// 那正是本仓反复吃亏的「静默降级」——模型拿到 160 字的残缺值,
|
||||
// **不知道**后面还有内容,会基于残缺数据下结论。
|
||||
//
|
||||
// 正确做法:要么给全,要么**显式标注**被截断(并说明有多少)。
|
||||
func TestSeqRunDoesNotSilentlyTruncateSlot(t *testing.T) {
|
||||
long := strings.Repeat("L", 5000) // 远超 160
|
||||
r := newE2ERunner("uptime")
|
||||
r.results["uptime"] = long
|
||||
p := newE2EPlugin(t, r)
|
||||
|
||||
if _, err := p.dispatch("seq_create", map[string]interface{}{
|
||||
"name": "trunc",
|
||||
"groups": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "g1",
|
||||
"in": map[string]string{},
|
||||
"out": map[string]string{"summary": "string"},
|
||||
"tools": `{"tool":"uptime","args":{},"as":"summary"} ;`,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("seq_create: %v", err)
|
||||
}
|
||||
|
||||
out, err := p.dispatch("seq_run", map[string]interface{}{"name": "trunc"})
|
||||
if err != nil {
|
||||
t.Fatalf("seq_run: %v", err)
|
||||
}
|
||||
res, _ := out.(string)
|
||||
if !strings.Contains(res, "summary") {
|
||||
t.Fatalf("未回填 summary 槽: %s", res)
|
||||
}
|
||||
// 若确实截断,必须**显式标注**并说明被截了多少
|
||||
if strings.Count(res, "L") < 160 {
|
||||
t.Fatalf("summary 槽几乎为空: %s", res)
|
||||
}
|
||||
if strings.Count(res, "L") < len(long) {
|
||||
// 发生了截断 —— 那必须看得见
|
||||
if !strings.Contains(res, "已截断") && !strings.Contains(res, "省略") {
|
||||
t.Errorf("变量槽被截到 %d/%d 字却**没有任何标注** —— 模型会基于残缺值下结论",
|
||||
strings.Count(res, "L"), len(long))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -324,7 +324,7 @@ func (p *Plugin) runSequence(seq *Sequence, in map[string]interface{}, _ any) (i
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&sb, "\n %s = %s", k, truncate(renderResult(slots[k]), 160))
|
||||
fmt.Fprintf(&sb, "\n %s = %s", k, renderSlot(slots[k]))
|
||||
}
|
||||
}
|
||||
if failed {
|
||||
@ -428,15 +428,34 @@ func renderGroupResult(res GroupResult) string {
|
||||
sort.Strings(keys)
|
||||
sb.WriteString("\n出参:")
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&sb, "\n %s = %s", k, truncate(renderResult(res.Slots[k]), 160))
|
||||
fmt.Fprintf(&sb, "\n %s = %s", k, renderSlot(res.Slots[k]))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
// renderSlot 渲染一个变量槽的值。
|
||||
//
|
||||
// ⚠️ 截断**必须显式标注**(方案 B:只统计不静默裁剪)。
|
||||
// 我此前在这里写了裸 truncate(…, 160) —— 那正是本仓反复吃亏的
|
||||
// 「静默降级」:模型拿到 160 字的残缺值却**不知道**后面还有内容,
|
||||
// 会基于残缺数据下结论。端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot
|
||||
// 正是为此而写(它抓到过这个缺陷)。
|
||||
//
|
||||
// 行为:≤ slotDisplayLimit 时给全;超过时给前段 + 显式的「已截断,共 N 字」。
|
||||
// 标注让模型能自己决定是否改用更窄的查询重取。
|
||||
func renderSlot(v interface{}) string {
|
||||
s := renderResult(v)
|
||||
if len(s) <= slotDisplayLimit {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
return fmt.Sprintf("%s …【已截断:共 %d 字,此处显示前 %d 字。"+
|
||||
"若需完整内容,请用更窄的查询条件重跑,或把大结果转存后按需取回】",
|
||||
s[:slotDisplayLimit], len(s), slotDisplayLimit)
|
||||
}
|
||||
|
||||
// slotDisplayLimit 是变量槽的单条显示上限。
|
||||
//
|
||||
// 它**只影响展示**,不影响执行:槽里存的始终是完整值(模型可在本轮内
|
||||
// 通过条件表达式读到完整内容)。截断仅为控制**回填文本**的长度。
|
||||
const slotDisplayLimit = 160
|
||||
|
||||
@ -21,9 +21,11 @@ func seqHelpText() string {
|
||||
|
||||
const helpHeader = `【工具序列 seq】
|
||||
|
||||
一条序列 = 若干 group,**组内并行、组间串行**。每个 group 有独立签名
|
||||
(in 入参 / out 出参),可被 seq_call 按名调用。
|
||||
序列存的是**解析后的 AST**:保存时做完全部静态校验,执行期不再解析文本。
|
||||
⚠️ 最容易错的一处(真机实测模型在此连续失败 4 次):
|
||||
|
||||
group 的 in 必须是**对象**,无入参写 {} —— 不要写成字符串
|
||||
|
||||
"in": "" 或 "in": "{}"(字符串)一律被拒。「无入参」要表达成空**对象**。
|
||||
|
||||
常用操作:
|
||||
seq_list 列出全部序列及其签名
|
||||
@ -32,6 +34,10 @@ const helpHeader = `【工具序列 seq】
|
||||
seq_call 按名调用某个 group 或某条序列
|
||||
seq_when_call 条件调用,when 为真才执行
|
||||
seq_delete 删除
|
||||
|
||||
序列 = 若干 group,**组内并行、组间串行**;每个 group 有独立签名
|
||||
(in 入参 / out 出参),可被 seq_call 按名调用。
|
||||
序列存的是**解析后的 AST**:保存时做完全部静态校验,执行期不再解析文本。
|
||||
`
|
||||
|
||||
const helpFormat = `
|
||||
@ -42,6 +48,7 @@ const helpFormat = `
|
||||
2. 每个 group 的字段:
|
||||
name 必填,组名,全局唯一(它是签名名)
|
||||
in 入参声明,**对象**,如 {"host":"string"};无入参写 {}
|
||||
⚠️ 必须是对象 {"k":"type"};写 "" 或 "{}"(字符串)一律被拒
|
||||
out 出参声明,**对象**,如 {"summary":"string"};无出参写 {}
|
||||
when 条件屏障,默认 "true",只可读 $args.*
|
||||
parallel 默认 true;置 false 则组内串行(保序场景用)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,9 +31,12 @@ func seqToolDefs() []toolDefInfo {
|
||||
"type": "string", "description": "一句话说明这条序列做什么",
|
||||
},
|
||||
"groups": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "组数组,按数组顺序执行;与 file 二选一",
|
||||
"items": map[string]interface{}{"type": "object"},
|
||||
"type": "array",
|
||||
"description": "组数组,按数组顺序执行;与 file 二选一。" +
|
||||
"⚠️ 每个 group 的 in 必须是**对象**(无入参写 {}),写成空字符串会被拒绝;" +
|
||||
"tools 是字符串(内容为 ';' 分隔的 JSON 对象,每个 tool 后都要有 ';',含最后一个)。" +
|
||||
"格式细节先用 seq_help 查。",
|
||||
"items": map[string]interface{}{"type": "object"},
|
||||
},
|
||||
"file": map[string]interface{}{
|
||||
"type": "string",
|
||||
|
||||
Reference in New Issue
Block a user