Files
HomeAgent/internal/plugins/seq/tools.go
JianFeeeee 8ca28eb071 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 轮并发判据全绿。
2026-09-27 14:05:38 +08:00

157 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package seq
import "strings"
// seqToolDefs 返回本插件导出的六个工具定义。
//
// ⚠️ 全部**不声明** ParallelSafe:seq_run / seq_call 会执行**一串**工具,
// 其中可能含写操作。标成并发安全会让内核把两条 seq_run 并发跑起来,
// 两个序列的执行顺序交错、变量表互相污染。
//
// 为独立真相源:注册、判据、文档都从这里取,避免三处各写一份。
func seqToolDefs() []toolDefInfo {
return []toolDefInfo{
{
Name: "seq_create",
Description: "创建/更新一条工具序列。**groups 与 file 二选一**:传 groups 直接给结构," +
"或用 file 加载你已写好的序列文件(适合长序列——长参数会被 max_tokens 截断,写文件更稳)。\n" +
"序列由若干 group 组成:**组内并行、组间串行**;group 拥有独立签名(in 入参 / out 出参)," +
"可被 seq_call 按名调用。\n" +
"保存时会做静态校验:as 必须已在 out 声明、$args.x 必须已在 in 声明、组名不重复、" +
"组内 ; 分隔符必须完整。任一项不满足都会报错并说明原因。\n" +
"格式:JSON;每个 group 的 tools 是一个字符串,内含若干以 ' ; ' 分隔的 JSON 对象," +
"形如 {\"tool\":\"cmd_run\",\"args\":{...},\"as\":\"槽名\"}。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string", "description": "序列名(唯一)",
},
"description": map[string]interface{}{
"type": "string", "description": "一句话说明这条序列做什么",
},
"groups": map[string]interface{}{
"type": "array",
"description": "组数组,按数组顺序执行;与 file 二选一。" +
"⚠️ 每个 group 的 in 必须是**对象**(无入参写 {}),写成空字符串会被拒绝;" +
"tools 是字符串(内容为 ';' 分隔的 JSON 对象,每个 tool 后都要有 ';',含最后一个)。" +
"格式细节先用 seq_help 查。",
"items": map[string]interface{}{"type": "object"},
},
"file": map[string]interface{}{
"type": "string",
"description": "序列文件路径(与 groups 二选一)。目录内文件优先用 files 工具查看",
},
},
"required": []string{"name"},
},
},
{
Name: "seq_help",
Description: "查看工具序列的完整格式说明与可照抄的示例。**写序列前先查这个** —— " +
"tools 字段的分隔符、in/out 的写法、as 与 out 的关系都在这里。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
Name: "seq_list",
Description: "列出全部可用序列:名称、描述、组数与各组的签名(in/out)。" +
"按名调用前先用它确认名称与签名。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
Name: "seq_delete",
Description: "删除一条序列。序列不存在时会报错(不会静默成功)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string", "description": "要删除的序列名",
},
},
"required": []string{"name"},
},
},
{
Name: "seq_run",
Description: "执行一条序列:按 groups 数组的顺序逐组执行,组内并行、组间串行。" +
"每组先求值 when 条件,为真才执行。返回逐组摘要与最终变量槽快照。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string", "description": "要执行的序列名",
},
"args": map[string]interface{}{
"type": "object",
"description": "传给各组的入参(组内用 $args.<键> 读取)",
},
},
"required": []string{"name"},
},
},
{
Name: "seq_call",
Description: "按名调用一条序列里的 group:target 写组名(限本序列内)或 " +
"#序列名(跨序列)。返回该组的 out 槽。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string", "description": "所属序列名(组名调用时必填)",
},
"target": map[string]interface{}{
"type": "string",
"description": "组名,或 #序列名 表示跨序列调用",
},
"args": map[string]interface{}{
"type": "object",
"description": "传给该组的入参",
},
},
"required": []string{"target"},
},
},
{
Name: "seq_when_call",
Description: "条件按名调用:when 表达式为真才执行,否则整次调用跳过(不产出任何槽)。" +
"when 只可读传入的 args(如 $args.flag == true)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string", "description": "所属序列名(组名调用时必填)",
},
"target": map[string]interface{}{
"type": "string",
"description": "组名,或 #序列名 表示跨序列调用",
},
"args": map[string]interface{}{
"type": "object",
"description": "传给该组的入参",
},
"when": map[string]interface{}{
"type": "string",
"description": "条件表达式,如 $args.flag == true",
},
},
"required": []string{"target", "when"},
},
},
}
}
// 内部小工具:把 args 里的字符串字段取出来并 trim。
func argString(args map[string]interface{}, key string) string {
if args == nil {
return ""
}
s, _ := args[key].(string)
return strings.TrimSpace(s)
}