Files
HomeAgent/internal/plugins/seq/handlers.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

462 lines
14 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
// 本文件是 seq_* 工具的实现:解析参数 → 调 Store / 执行引擎 → 渲染结果。
//
// 两条贯穿全文件的纪律:
// 1. **不静默降级**:参数缺失、序列不存在、目标不存在都**报错**并说明
// 该怎么做。模型拿到含糊的"失败"只会原样重试。
// 2. **错误信息要可执行**:说清缺什么、可用的是什么。
// seqCreate 创建/更新序列。groups 与 file 二选一。
func (p *Plugin) seqCreate(args map[string]interface{}) (interface{}, error) {
name := argString(args, "name")
if name == "" {
return nil, fmt.Errorf("缺少 name(序列名)")
}
groupsRaw, hasGroups := args["groups"]
file := argString(args, "file")
switch {
case file != "" && hasGroups:
return nil, fmt.Errorf("groups 与 file **二选一**,不能同时传")
case file == "" && !hasGroups:
return nil, fmt.Errorf("必须提供 groups 或 file 其中之一(长序列建议写文件后用 file 传)")
}
var (
text []byte
from = "参数"
)
if file != "" {
b, err := p.readSeqFile(file)
if err != nil {
return nil, err
}
text = b
from = "文件 " + file
} else {
b, err := marshalGroups(name, groupsRaw)
if err != nil {
return nil, err
}
text = b
}
seq, err := Parse(text)
if err != nil {
return nil, fmt.Errorf("来自%s的序列解析失败: %w", from, err)
}
if seq.Name == "" {
seq.Name = name
}
if seq.Name != name {
return nil, fmt.Errorf("序列名不一致:参数给了 %q,内容里是 %q(请统一)", name, seq.Name)
}
if d := argString(args, "description"); d != "" {
seq.Description = d
}
if err := p.store.Save(seq); err != nil {
return nil, err
}
// 跨序列引用可能成环:存完复查一次(Save 只校验同序列内的 group 引用)
graphErr := p.store.CheckGraph()
var sb strings.Builder
fmt.Fprintf(&sb, "序列 %q 已保存(%s,%d 个 group)", seq.Name, from, len(seq.Groups))
for _, g := range seq.Groups {
fmt.Fprintf(&sb, "\n- %s", g.Name)
if len(g.In) > 0 {
fmt.Fprintf(&sb, " 入参[%s]", keyList(g.In))
}
if len(g.Out) > 0 {
fmt.Fprintf(&sb, " 出参[%s]", keyList(g.Out))
}
fmt.Fprintf(&sb, " 工具%d个", len(g.Tools))
}
if graphErr != nil {
// 保存成功但图不合法 —— 必须**说清**,否则模型会以为可以跑了
fmt.Fprintf(&sb, "\n⚠️ 序列已保存,但调用图有问题(现在执行会失败):%v", graphErr)
}
return sb.String(), nil
}
// readSeqFile 读序列文件,带路径逃逸防护。
func (p *Plugin) readSeqFile(path string) ([]byte, error) {
abs, err := absPath(path)
if err != nil {
return nil, err
}
b, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("序列文件 %s 不存在", path)
}
return nil, fmt.Errorf("读序列文件 %s 失败: %w", path, err)
}
return b, nil
}
// absPath 做基础路径校验:拒绝空、拒绝明显的穿越写法。
func absPath(p string) (string, error) {
if strings.TrimSpace(p) == "" {
return "", fmt.Errorf("路径为空")
}
if strings.Contains(p, "\x00") {
return "", fmt.Errorf("路径含非法字符")
}
// 允许绝对与相对路径,但禁止 .. 段(与 files 插件的目录逃逸防护同源思路)
for _, seg := range strings.Split(filepathToSlash(p), "/") {
if seg == ".." {
return "", fmt.Errorf("路径 %q 含 .. 段,不允许", p)
}
}
return p, nil
}
func filepathToSlash(p string) string { return strings.ReplaceAll(p, `\`, "/") }
// marshalGroups 把 groups 参数([]interface{})序列化为 JSON 文本。
// marshalGroups 把 groups 参数([]interface{})序列化为 JSON 文本。
//
// ⚠️ name **必须**一起放进文档:Parse 要求 name 非空,而调用方传的 name
// 在参数顶层。此前这里只包 groups,于是**传参方式完全不可用**
// ("序列缺少 name")—— 而下面的 `if seq.Name == ""` 回落分支是死代码。
// 这是端到端判据(TestE2E_CreateListRun)抓出来的,包内判据抓不到:
// 它们直接构造 *Sequence,不经过这条路径。
func marshalGroups(name string, raw interface{}) ([]byte, error) {
arr, ok := raw.([]interface{})
if !ok {
return nil, fmt.Errorf("groups 必须是数组,实际是 %T", raw)
}
doc := map[string]interface{}{"name": name, "groups": arr}
b, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, fmt.Errorf("groups 序列化失败(每个 group 应为对象): %w", err)
}
return b, nil
}
// seqList 列出全部序列及其签名。
func (p *Plugin) seqList() (interface{}, error) {
names := p.store.List()
if len(names) == 0 {
return "当前没有任何序列(用 seq_create 新建)", nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "共 %d 条序列:", len(names))
for _, n := range names {
seq, err := p.store.Load(n)
if err != nil {
fmt.Fprintf(&sb, "\n- %s ⚠️ 读取失败: %v", n, err)
continue
}
desc := seq.Description
if desc == "" {
desc = "(无描述)"
}
fmt.Fprintf(&sb, "\n- %s:%s(%d 个 group)", n, desc, len(seq.Groups))
for _, g := range seq.Groups {
fmt.Fprintf(&sb, "\n · %s", g.Name)
if len(g.In) > 0 {
fmt.Fprintf(&sb, " 入参[%s]", keyList(g.In))
}
if len(g.Out) > 0 {
fmt.Fprintf(&sb, " 出参[%s]", keyList(g.Out))
}
}
}
return sb.String(), nil
}
// seqDelete 删除序列。
func (p *Plugin) seqDelete(args map[string]interface{}) (interface{}, error) {
name := argString(args, "name")
if name == "" {
return nil, fmt.Errorf("缺少 name(序列名)")
}
if err := p.store.Delete(name); err != nil {
return nil, err
}
return fmt.Sprintf("序列 %q 已删除", name), nil
}
// seqRun 执行一条序列的全部 group。
func (p *Plugin) seqRun(args map[string]interface{}) (interface{}, error) {
name := argString(args, "name")
if name == "" {
return nil, fmt.Errorf("缺少 name(序列名)")
}
seq, err := p.store.Load(name)
if err != nil {
return nil, err
}
in, _ := args["args"].(map[string]interface{})
if in == nil {
in = map[string]interface{}{}
}
return p.runSequence(seq, in, nil)
}
// seqCall 按名调用一个 group(when 非空时为条件调用)。
func (p *Plugin) seqCall(args map[string]interface{}, when string) (interface{}, error) {
target := argString(args, "target")
if target == "" {
return nil, fmt.Errorf("缺少 target(组名或 #序列名)")
}
seqName := argString(args, "name")
callArgs, _ := args["args"].(map[string]interface{})
if callArgs == nil {
callArgs = map[string]interface{}{}
}
var seq *Sequence
var err error
if strings.HasPrefix(target, "#") {
seqName = strings.TrimPrefix(target, "#")
seq, err = p.store.Load(seqName)
} else {
if seqName == "" {
return nil, fmt.Errorf("按组名调用时必须给出 name(该组所属的序列名)")
}
seq, err = p.store.Load(seqName)
}
if err != nil {
return nil, err
}
idx := -1
for i, g := range seq.Groups {
if g.Name == target || strings.TrimPrefix(target, "#") == g.Name {
idx = i
break
}
}
if idx < 0 {
return nil, fmt.Errorf("序列 %q 里没有 group %q(现有:%s)",
seq.Name, target, groupNameList(seq))
}
// 条件调用:求值在**进入前**,为假则整次跳过
if strings.TrimSpace(when) != "" {
ok, cerr := evalCond(when, callArgs)
if cerr != nil {
// ★ 求值失败**不得**降级为"跳过"
return nil, fmt.Errorf("seq_when_call 的条件求值失败(这是参数问题,不是工具故障): %w", cerr)
}
if !ok {
return fmt.Sprintf("条件为假,已跳过 %q(不产出任何槽)", target), nil
}
}
res, gerr := p.runGroup(seq.Groups[idx], callArgs, seq.Name)
if gerr != nil {
return nil, gerr
}
return renderGroupResult(res), nil
}
// runSequence 顺序执行全部 group。
//
// group 间**串行**:后者可能依赖前者的出参槽(具名槽即数据边)。
func (p *Plugin) runSequence(seq *Sequence, in map[string]interface{}, _ any) (interface{}, error) {
if p.callDepth >= maxCallDepth {
return nil, fmt.Errorf("嵌套调用深度超过上界 %d(可能存在循环调用)", maxCallDepth)
}
p.callDepth++
defer func() { p.callDepth-- }()
var lines []string
slots := map[string]interface{}{}
failed := false
for i, g := range seq.Groups {
// 每组的入参 = 顶层入参 + 已产出槽(具名槽在组间传递)
groupArgs := map[string]interface{}{}
for k, v := range in {
groupArgs[k] = v
}
for k, v := range slots {
groupArgs[k] = v
}
res, err := p.runGroup(g, groupArgs, seq.Name)
if err != nil {
lines = append(lines, fmt.Sprintf("第 %d 组 %q 失败: %v", i+1, g.Name, err))
failed = true
if g.OnError != "continue" {
break
}
continue
}
line := fmt.Sprintf("第 %d 组 %q", i+1, g.Name)
if res.Skipped {
line += "(条件为假,已跳过)"
} else {
line += fmt.Sprintf(" 工具 %d 个", len(res.Tools))
if len(res.Missing) > 0 {
line += fmt.Sprintf(" ⚠️缺失工具: %s", strings.Join(res.Missing, ", "))
}
}
lines = append(lines, line)
// 槽合并(后续组可读)
for k, v := range res.Slots {
slots[k] = v
}
}
var sb strings.Builder
fmt.Fprintf(&sb, "序列 %q 执行完毕(%d/%d 组):", seq.Name, len(lines), len(seq.Groups))
for _, l := range lines {
sb.WriteString("\n- " + l)
}
if len(slots) > 0 {
sb.WriteString("\n\n变量槽:")
keys := make([]string, 0, len(slots))
for k := range slots {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&sb, "\n %s = %s", k, renderSlot(slots[k]))
}
}
if failed {
sb.WriteString("\n⚠️ 有 group 失败(见上)")
}
return sb.String(), nil
}
// runGroup 执行单个 group,含黑名单、并发安全与深度检查。
func (p *Plugin) runGroup(g Group, args map[string]interface{}, seqName string) (GroupResult, error) {
// ① 黑名单先行
for _, t := range g.Tools {
if blacklisted(t.Tool) {
return GroupResult{Group: g.Name}, fmt.Errorf(
"group %q 试图调用被禁止的工具 %s"+
"(序列不得对外发消息/改插件表/再起子 agent)", g.Name, t.Tool)
}
if t.Tool == "seq_call" || t.Tool == "seq_when_call" {
tgt, _ := t.Args["target"].(string)
if strings.HasPrefix(tgt, "#") {
if strings.TrimPrefix(tgt, "#") == seqName {
return GroupResult{Group: g.Name}, fmt.Errorf("group %q 调用了序列自身(无限递归)", g.Name)
}
}
}
}
// ② 组内是否真的可以并发:全部声明 ParallelSafe 才并发。
// 查不到声明 ⇒ 保守按串行(动态注册下工具可能随时消失)。
runnable := g.Parallel
if runnable {
for _, t := range g.Tools {
if !p.runner.parallelSafe(t.Tool) {
runnable = false
break
}
}
}
if !runnable {
g.Parallel = false
}
// ③ 存在性预检(missing 策略的输入)
preMissing := false
for _, t := range g.Tools {
if t.Tool == "seq_call" || t.Tool == "seq_when_call" {
continue // 内建调用另行处理
}
if !p.runner.exists(t.Tool) {
preMissing = true
break
}
}
if preMissing {
switch g.missingPolicy() {
case "skip", "degrade":
// 逐个剔除缺失的工具
var kept []ToolCall
for _, t := range g.Tools {
if (t.Tool == "seq_call" || t.Tool == "seq_when_call") || p.runner.exists(t.Tool) {
kept = append(kept, t)
}
}
if len(kept) == 0 {
return GroupResult{Group: g.Name, Skipped: true, Slots: map[string]interface{}{}}, nil
}
g.Tools = kept
}
}
return execGroup(g, args, p.runner)
}
func groupNameList(seq *Sequence) string {
names := make([]string, 0, len(seq.Groups))
for _, g := range seq.Groups {
names = append(names, g.Name)
}
if len(names) == 0 {
return "(无)"
}
return strings.Join(names, ", ")
}
func renderGroupResult(res GroupResult) string {
var sb strings.Builder
fmt.Fprintf(&sb, "group %q", res.Group)
if res.Skipped {
sb.WriteString("(条件为假,已跳过)")
return sb.String()
}
fmt.Fprintf(&sb, " 完成 %d 个工具", len(res.Tools))
if len(res.Missing) > 0 {
fmt.Fprintf(&sb, ",缺失: %s", strings.Join(res.Missing, ", "))
}
if len(res.Slots) > 0 {
keys := make([]string, 0, len(res.Slots))
for k := range res.Slots {
keys = append(keys, k)
}
sort.Strings(keys)
sb.WriteString("\n出参:")
for _, k := range keys {
fmt.Fprintf(&sb, "\n %s = %s", k, renderSlot(res.Slots[k]))
}
}
return sb.String()
}
// renderSlot 渲染一个变量槽的值。
//
// ⚠️ 截断**必须显式标注**(方案 B:只统计不静默裁剪)。
// 我此前在这里写了裸 truncate(…, 160) —— 那正是本仓反复吃亏的
// 「静默降级」:模型拿到 160 字的残缺值却**不知道**后面还有内容,
// 会基于残缺数据下结论。端到端判据 TestSeqRunDoesNotSilentlyTruncateSlot
// 正是为此而写(它抓到过这个缺陷)。
//
// 行为:≤ slotDisplayLimit 时给全;超过时给前段 + 显式的「已截断,共 N 字」。
// 标注让模型能自己决定是否改用更窄的查询重取。
func renderSlot(v interface{}) string {
s := renderResult(v)
if len(s) <= slotDisplayLimit {
return s
}
return fmt.Sprintf("%s …【已截断:共 %d 字,此处显示前 %d 字。"+
"若需完整内容,请用更窄的查询条件重跑,或把大结果转存后按需取回】",
s[:slotDisplayLimit], len(s), slotDisplayLimit)
}
// slotDisplayLimit 是变量槽的单条显示上限。
//
// 它**只影响展示**,不影响执行:槽里存的始终是完整值(模型可在本轮内
// 通过条件表达式读到完整内容)。截断仅为控制**回填文本**的长度。
const slotDisplayLimit = 160