Commit Graph

8 Commits

Author SHA1 Message Date
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
116dc413f0 feat(seq): 新增 seq_help —— 格式说明 + 可照抄示例
动机来自真机实跑:模型写序列时踩了三个坑,各试 1~3 次才改对
  ① tools 漏末尾的 ';'       → 「末尾缺少 ';'」
  ② group 的 in 传成字符串     → 重试 3 次
  ③ as 指向未声明的 out 槽      → 静态校验拦下
这三处都是**格式细节**,塞不进工具描述(有长度限制),却恰是模型最易错处。
散落在七个描述里等于没有集中入口。

实现(help.go + tools.go + plugin.go):
· seq_help 无参数、纯文本返回(与仓内 output_send__*_help 同范式)
· 「格式要点」逐条写明:in/out 必须是**对象**、tools 必须是**字符串**、
  每个 tool 后(含最后一个)都要 ';'、as 必须在 out 声明、
  groups 与 file 二选一、组内并行组间串行
· 「条件 when」列出支持的表达式形态
· 「可照抄的完整示例」给一行**单行紧凑**的合法序列

★ 判据(plugin_test.go,3 条):
· seq_help 已注册、有 description、不声明并发安全
· 内容覆盖真机踩过的**每一个**坑(判据从"坑"出发而非从"打算写什么")
· ★ 示例**自己能被本包解析器接受**:validateHelpExample 从帮助文本里
  抽出示例喂给 Parse —— 模型是照抄的,示例自己解析不过就是给模型挖坑。
  而"从文本里有没有某个词"是看不出这类 bug 的。

过程中判据自己错了两次(都被这条示例判据照出来):
1. 抽取用 strings.Index(help, `{"name"`) ⇒ 先命中「格式要点」里**有意写的**
   示意片段,截到非示例的内容,报出莫名其妙的 invalid character '…'。
2. 修完又混用两套偏移基准(base 的下标拿去切 help)⇒ invalid character '\xaf'。
   ⇒ 重写为全程在同一 base 上定位。
★ 两次都说明:**判据的抽取逻辑本身就是需要验证的代码**,
它出错时报出的信息极具误导性(看起来像实现有 bug)。

示例形态也改过一次:原为多行缩进 JSON,改为**单行紧凑** —— 模型照抄时
免去缩进/换行带来的额外风险。

变异验证:去掉示例里的末尾 ';' ⇒ 示例判据 FAIL。

另一处:加 seq_help 后「恰好注册 6 个工具」判据 FAIL(实际 7)——
这正是那条判据的用意(防止悄悄多加工具稀释工具面),已更新并注明原因。

回归:go build ./... 通过;internal/plugins/... core sdk 全绿。
2026-09-27 13:48:15 +08:00
0b96d6d78c fix(seq): 类型不匹配的错误改成模型可执行的话(真机实跑发现)
真机实跑(独立实例)发现:模型把 group 的 `in` 传成字符串 "{}",
拿到的是 encoding/json 的原始报错:

  json: cannot unmarshal string into Go struct field rawSeq.groups.0.in
        of type map[string]string

这句说的是**事实**(string 解不成 map)而不是**该怎么做**
(in 应该写成对象 {"键":"类型"});残留的 `rawSeq` / `Go struct field`
更是 Go 内部实现细节,对模型无意义且会误导它去猜一个叫 rawSeq 的东西。
模型为此**重试了 3 次**才改对。

这与本仓反复吃亏的那类问题同源:`20s` 少引号 → 静默降级 →
cmd_run 失败率 34%。**报事实不报改法,模型只能猜。**

改动(parse.go):新增 friendlyJSONError,把原始报错翻译成可执行文案
· in / out 类型不符 ⇒ 说明"应写成对象 {键:类型};无入参请写 {}"
· tools 类型不符 ⇒ 说明"应写成字符串(内容是 ; 分隔的 JSON 对象)"
· groups / name / when / missing / timeout / on_error ⇒ 逐个说明期望
· 未知字段 ⇒ 列出 group 允许的全部字段名(拼写错误最常见)
· shortFieldName 剥掉 `rawSeq` 这类包内类型名前缀
· jsonKind / goTypeName 把 Go 类型翻译成模型看得懂的说法

判据(parse_test.go,2 条):
· in 传字符串 ⇒ 错误须指名字段、须说明该传对象、**且不得残留 Go 内部类型名**
· tools 传数组 ⇒ 错误须指明 tools 且说明它是字符串

★ 变异验证时踩了一次坑:第一次变异让 friendlyJSONError 不被调用,
结果**编译失败**(函数变成未使用),判据压根没跑,我却看到 "ok"。
改用可编译的变异(函数保留、开头直接 return err)后判据正确 FAIL。
★ 教训:**"变异后判据通过"要先确认变异真的生效**——编译失败 ≠ 判据通过。

真机复验:模型读一次即懂,并明确说"提示里的意思很明确";
修复前它为此重试 3 次。

回归:internal/plugins/... internal/agent/core internal/sdk 全绿。
2026-09-27 13:43:39 +08:00
3d31037f63 test(seq): 端到端接线判据,抓出「传参方式完全不可用」的真 bug
P1–P4 的判据都在**包内**(假 runner / 直接调函数),覆盖的是**语义**;
本轮补的是**接线**层——参数名对不对、返回值模型读不读得懂、跨层调用断不断。
接线层的 bug 语义判据抓不到:例如工具注册了但参数名拼错,单元判据全绿
而模型永远传不进来。

★ 抓到一个真 bug:**seq_create 走 groups 传参时完全不可用**。
根因:marshalGroups 只把 groups 包进 JSON 文档、不带 name,而 Parse 要求
name 非空 ⇒ 报「序列缺少 name」。而 seqCreate 里那句
`if seq.Name == "" { seq.Name = name }` 回落分支是**死代码**(Parse 早就失败了)。
后果:**只有 file 方式能用,传参方式一律失败**。
已修(name 一并包装)。包内判据抓不到这个——它们直接构造 *Sequence,
不经过这条路径;只有真正 dispatch 一遍才暴露。

判据(e2e_test.go,7 条):真实 dispatch 串通
seq_create → seq_list(须展示签名,模型据此按名调用)→ seq_run
(执行序按 tools 声明序、槽回填、结果里**不得**出现 Go 的 `map[` 语法)
· seq_call 按名调用 group 并返回其出参
· seq_when_call 条件为假 ⇒ 跳过且**零工具被执行**
· seq_when_call 条件畸形 ⇒ **报错**且零执行(不得静默跳过)
· seq_create 走**文件**(长序列的主力用法)
· groups 与 file 同时传 ⇒ 报错「二选一」
· seq_delete 不存在 ⇒ 报错(模型会以为删掉了)

过程中又一次臆造 helper(`writeFile`),改用 os.WriteFile;
并把三处 `_, _ = p.dispatch(...)` 补上 err 检查(正是
go-ignored-call-result 报的那类)。

回归:seq -race 全绿;go build ./... 通过;go test ./internal/... 全绿。
2026-09-27 13:18:19 +08:00
71c894c182 feat(seq): 六个 seq_* 工具、插件装配,并补内核两处缺口(插件线 P4)
内核缺口(都是 P3 落地时暴露的真实缺陷):
· **GetAllTools 丢 ParallelSafe**:它只带出 Name/Description/Parameters,
  插件看到的设备工具一律"不可并发" ⇒ 设备工具的并发声明**对插件不可见**。
· **ToolAPI 缺按名查**:新增 ToolDefByName。插件需要在**运行前**判断目标
  是否存在/是否并发安全(工具动态注册,"不存在"是常态),
  而 GetAllTools 只能拿到全量列表。查不到返回 nil,不 panic。

seq 插件:
· plugin.go:插件骨架 + kernelRunner(把 sdk.ToolAPI 收窄成三个方法,
  判据因此能用假实现驱动,不必构造整个内核)
· tools.go:六个工具定义(独立真相源,注册/判据/文档都从它取)
· handlers.go:seq_create/list/delete/run/call/when_call 的实现
· register.go + all.go:按 skillmgr 同一范式 init 注册

★ 过程中解决一个**我自己的设计矛盾**:
判据原先要求 `seq_call` / `seq_when_call` 进黑名单,但"按名调用
group/序列"恰恰是本包的核心能力——禁掉它,序列就退化成单层脚本。
分层澄清后:黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条
序列**;seq_call 系列留给序列内部组合,其递归由 maxCallDepth + 环检测
负责(设计文档 §8.3 本来就是这么定的,我把两层混了)。
`seq_run` 留在黑名单:序列内再跑整条序列语义上是递归。

**六个工具一律不声明 ParallelSafe**:seq_run/seq_call 会执行一串工具,
其中可能含写操作;标成并发安全会让内核把两条 seq_run 并发跑,
两个序列的执行顺序交错、变量表互相污染。

安全性:序列名与文件路径都做穿越防护(`..` 段、分隔符、空名)。

过程中四次自伤:
1. 臆造 `jsonMarshalIndent`(不存在)→ 改 encoding/json.MarshalIndent;
   并把 execGroup 的 runner 传错成 p(应 p.runner)。
2. seq 判据里写了 `black(name)`,而 blacklisted 是**谓词**不是函数。
3. 一次 python 替换删漏,把「跨序列目标存在性检查」那段从 CheckNew
   里整段摘掉又贴回原处——靠编译错误发现。
4. ★ 注册失败我写了 panic:内置插件在 main() 装配期加载,panic 会
   **直接拖垮内核启动**,而"某个工具没注册上"只该让该工具不可用。
   已改为 log.Printf + 继续(与 clawhubadapter / mcp 一致)。

变异验证:把 seq_run 移出黑名单 ⇒ 黑名单判据 FAIL。

判据(plugin_test.go,6 条):六个工具全部注册且 description/参数 schema
非空;seq_create 声明 required 并说明 groups/file 二选一;
seq_run 说明"按数组顺序";六个工具均未声明 ParallelSafe;
黑名单含递归风险项且**不误伤** seq_call 与普通工具。

回归:seq -race 全绿;internal/sdk/... internal/plugins/... 12 包全绿。
2026-09-27 13:05:28 +08:00
3d753126a3 feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)
store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
2026-09-27 12:47:18 +08:00
7532af7e9b feat(seq): 执行引擎 —— 组内并行 + 具名槽 + 条件求值(插件线 P2)
三个不变量(各有判据钉住):

1. **组内并行、组间串行**。parallel=true 时各工具并发执行。

2. ★ **合并按声明顺序**,不按完成顺序。
   并行下完成顺序不确定;若按完成顺序合并,同样的输入产出不同的结果,
   整条序列**不可复现**。做法:各工具把结果写进 `results[i]`(按索引),
   组屏障处按 tools 数组顺序一次性合并。
   顺序合并顺带解决了并发写 map —— **执行期完全不写共享 map**。

3. ★ **条件求值失败必须报错**,不得降级成"条件为假"。
   把求值失败当作跳过 = 序列安静地少做一步,而模型以为跑完了
   —— 与「静默吞工具」同族(那正是 P1 判据里刚堵上的同类问题)。

条件求值(设计文档 §5 的 L1+L2,不引表达式引擎):
· true/false、$args.key 裸引用(真值)
· == / != / > / < / >= / <= 、contains
· 字面量支持 "str" / 'str' / true / false / 数字 / 裸文本
· 布尔与字符串宽松比较(true == "true"),对齐 utils.getBool 的既有约定

变量插值两种形态(缺一不可):
· **整值引用** "$args.count" ⇒ 替换为**原始值并保留类型**
  (数字仍是数字;否则模型收到字符串 "3")
· **文本内插值** "ssh $args.host" ⇒ 在字符串内替换
· 标量渲染:对象/数组用**紧凑 JSON**,绝不用 fmt.Sprintf("%v")
  (那会产出 `map[k:v]` 这种模型读不懂的 Go 语法)

on_error:abort(默认)/ continue。失败时也留槽(记错误文本)——
否则后续组读到的是"缺失",而"缺失"与"值为空"在下游难以区分。

判据(exec_test.go,8 条):
· 具名槽写入正确
· ★ 结果按声明顺序合并(用 delay 让完成顺序**确实**打乱)
· array 槽同名 as 按声明顺序确定性追加
· 条件为假 ⇒ 整组跳过、槽**不赋值**、零工具被执行
· ★ 条件畸形(空键 / 引用未声明入参 / 语法不完整)⇒ 报错且**不执行任何工具**
· 条件为真 ⇒ 正常执行
· on_error 的 abort / continue 两种语义
· 插值:文本内替换 + 整值引用保留类型

过程中三次自伤:
1. ★ **toolRunner 接口第一版写成 call(name)**,不收 args ⇒ 插值判据成了
   摆设(永远"通过")。改为 call(name, args) 后插值才真正可观察。
2. toolRunner / compactJSON 定义在了 _test.go 里,exec.go 引用不到 ⇒
   build 失败。toolRunner 是**引擎的依赖契约**,必须在非测试文件。
3. fixture 里给 "slow" 配了不存在的返回值,误以为它该返回 "B" ——
   是我没配就断言,不是实现错。

变异验证:把合并改为"按完成顺序 append"⇒ array 槽顺序判据 FAIL,
报错直指 `[C A ran:slow]` vs 期望 `[A ran:slow C]`。
(第一版变异用了一个 no-op 的 sort.SliceStable,等于什么都没测,
 已改成真正模拟完成序的实现。)
`-race` 全绿;回归 internal/agent/... internal/plugins/... 全绿。

顺带修正设计文档:两处 tools 示例原写成 `{tool:cmd_run,...}`,
**不是合法 JSON**(P1 判据实测会解析失败)。已改为合法 JSON 并加注
「键要带引号,这是实现时判据跑出来的真实缺陷,不是假想」。
2026-09-27 12:29:56 +08:00
76030ae30e feat(seq): 序列文本 → AST 解析与静态校验(插件线 P1)
插件,不是内核:并行执行是内核提供的**唯一**基础设施(core 的
batchRunnable);分组 / 具名槽 / 条件 / 调用图全部在本包内自建,
**不要求内核开任何新接口**。

实现(parse.go):
· Sequence / Group / ToolCall 三个 AST 类型
· Parse:JSON → AST + **全部**静态校验一次做完
  (而非留到执行期——group 有独立签名,具名槽的价值就在于构建期就能
  查出错写的槽)
· splitToolList:`;` 仅在 brace 深度 0 且**不在字符串内**时才是分隔符
· 枚举/未知字段一律硬报错:DisallowUnknownFields + 显式校验
  (missing / on_error 报错时列出合法取值,不当默认值蒙过去)

静态校验规则:
· `as:X` 未在 out 声明 ⇒ 报错(具名槽的核心价值)
· `$args.X` 未在 in 声明 ⇒ 报错
· group 名重复 ⇒ 报错(签名名必须唯一才能按名调用)
· 非 array 槽被同名 as 写多次 ⇒ 报错(组内并行下同名写入是数据竞争);
  array 槽则允许(组屏障按序追加)
· tools 分隔符畸形(漏中间 / 漏末尾 / 连续 / 未闭合)⇒ **硬报错**,
  绝不静默吞掉一个工具

判据(parse_test.go,9 条),★ 两条最关键:
· 含分号的真实 command(取自 core 里那份线上日志 fixture)保持为 1 个工具
· ★ TestBracesInsideStringDoNotAffectDepth:字符串里的**不成对**花括号
  不得影响 depth

★ 本阶段的三次自伤(都靠变异测试暴露,不是靠判据变红):
1. **格式本身是错的**:我在设计文档里写的 `{tool:cmd_run,...}` 根本不是
   合法 JSON(键没引号),encoding/json 直接解析失败。判据一跑就暴露
   ——"写了格式却从没验证它能解析"。已改为要求合法 JSON(键带引号),
   这也是 DisallowUnknownFields 能生效的前提。
2. **判据验证的不是它声称验证的规则**:原本那条"分号在字符串内"的用例,
   分号其实落在 args 对象的**花括号内部**,depth>0 就足以保护 ⇒ 删掉
   分词器的字符串跟踪后**仍然全绿**。反复两次才找到真正的判别点:
   必须用**不成对**花括号在 depth 恰为 0 处,才只有字符串态能救它。
3. 手写多层转义把引号写成 \",使分词器永远进不了字符串态。改用
   json.Marshal **分层构造** fixture——手写转义没有不出错的机会。

变异验证(两轮,均能检出):
· 删掉"回到顶层必须紧跟 ;"检查 ⇒ 漏中间分隔符用例 FAIL
· 删掉字符串跟踪 ⇒ TestBracesInsideStringDoNotAffectDepth FAIL
  ("结构未闭合(括号深度 1)")

回归:internal/agent/... internal/plugins/... 全绿。
2026-09-27 12:05:27 +08:00