|
|
374c19246b
|
fix(seq): 修掉 seq_create 的 O(n²),并加极端压测
## 起因:1000×1000 压测直接跑爆
用户要求「1000 条序列 × 每条 1000 个组内 toolcall」。第一版跑满 8 分钟超时。
分阶段计时定位到瓶颈:
| 阶段 | 200 条 × 1000 工具 |
|---|---|
| 创建 | **27.0s**(135ms/条,**随序列数线性增长**) |
| 执行(组内 1000 并发) | 0.55s(20 万次调用,2.7µs/次) |
| 删除 | 4.7ms |
瓶颈在创建,不在执行。
## 根因
```go
// handlers.go:70 —— 每次 seq_create 之后
graphErr := p.store.CheckGraph()
// store.go:166 —— List() 全量 + 逐条 Load() 全部序列
```
1000 条各 250KB ⇒ 每次创建都重读 250MB 并反序列化。第 N 条的创建代价
随 N 线性增长,总计 O(n²)。
## ★ 走过的弯路:我一度建议「把校验挪到运行期」—— 那是错的
store.go:163 明确写着:
两条检查(都必须在**建序列/保存**时做,而不是等运行):
1. 每个 seq_call 的目标必须存在(不存在会在运行期才发现,浪费一整轮)
**校验时机是语义,不是性能旋钮。** 目标不存在若等到运行才发现,模型已经
白白花掉一整轮工具调用。性能问题不能靠挪语义来解。
## 修法:缓存调用边,Save 做 O(1) 增量
```go
// Store 新增
graph map[string][]string // 序列名 → 它调用的目标(裸名)
// Save: 只更新这一条的边
s.graph[seq.Name] = edgesOf(seq)
// Delete: 移除这一条的边
delete(s.graph, name)
```
`callTargets` 只依赖 AST,不必每次从盘重建。**校验语义完全不变** —— 目标
存在性与三色 DFS 环检测都照旧在建序列时执行。
## 判据
- TestStoreGraphCacheKeepsSemantics 逐条钉住三个保证:目标存在性 ✓、
环检测 ✓、删除后不再误报成环 ✓
(这类优化最危险的失败模式是"校验还在跑但少查了某种情况")
- TestStoreSaveScalesLinearly 分段对比后半程/前半程每条耗时。
★ 判据自己改过一次:初版用「总耗时 ÷ 单条耗时」,而单条只有 48µs 时
噪声占比过高,同一份代码两次跑出 84× 和 203× —— 判据不稳定时报的
失败就是噪声,比没判据更糟。改成分段对比(平方时后半程会慢约 n/2
倍,线性时基本持平),阈值 3 倍留足磁盘与 GC 抖动余量。
实测 300 条:84~203× 单条(线性期望 300×),平方会是 90000×。
## 压测本身也修了两个自己的 bug
- 源文件目录与 store 目录分离时只改了写入侧,清理侧还指着 store 目录 ⇒
报 "no such file"。看起来像文件被提前删了,真因是路径拼错。
- newE2EPlugin 的 runner 参数写死 *e2eRunner,压测换替身就编译不过 ⇒
改为接受 seqRunner 接口。
## 压测规模
TestStressExtreme_ThousandSeqs 现为 1000 条 × 1000 toolcall(O(n²) 修复后
可跑)。判据全是**不变量**:每工具恰好调一次、1000 槽在交错延迟下仍按
声明序合并(并发下若按完成序合并必然错位)、删除后无残留。
|
2026-09-27 15:58:54 +08:00 |
|
|
|
2232d5483c
|
feat(parallel): 并发安全改为声明式,并审计标注 37 个工具
把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的
NoMemory 走。
## ★ 起因:提示词在跟内核不一致
阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe
只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量
是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教
模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。**
并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。
## 声明形态(照 SDK,不自创)
### 插件:结构体字段
s.RegisterTool("config_get", sdk.ToolDef{
Name: ..., Description: ...,
Parameters: map[string]interface{}{...},
// 已核实只读:…
ParallelSafe: true, ← 插在 Parameters 之后、handler 之前
}, p.handleGet(s))
位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位,
声明项在末尾,不打散 gofmt 对齐。
### 新增 SDK 声明项:ToolDef.Serial
ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。
为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过"
与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。
内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。
### 内置工具:toolDef 的 toolParallel 选项
内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项:
toolDef(名字, 描述, 属性) // 默认串行
toolDef(名字, 描述, 属性, "toolParallel") // 已核实只读,可并发
读工具表的老调用点一行不用动,声明就写在工具定义那一行。
## ★ 走过的弯路(都留了判据)
1. **硬编码白名单**:先在 toolParallelSafe 里查一张
builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 ——
工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移,
而判据一改就忘。已删,改为从定义读。
2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出
当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil /
a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报
"声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。
3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着
`toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。
4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件
a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在
io 包里,根因在 tooldefs.go。已补。
5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到,
823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号
(空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。
工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。
## 提示词措辞同步修正
「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型
**把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。
## 判据
- TestSerialOverridesParallelSafe Serial 优先于 ParallelSafe
- TestToolParallelDeclarationsAudit 并发面不许再归零
- TestNoToolDeclaresBothParallelAndSerial 两者同标即谎话
- TestBuiltinParallelDeclaredWhereDefined 声明写在定义处、且内核真读到
- TestStoreListIgnoresForeignJSON 压测抓到的 List() 缺陷
|
2026-09-27 15:15:57 +08:00 |
|
|
|
b1b96b788d
|
test(seq): 三个压力测试 —— 超长序列 / 100 工具并行 / 串行降级
与单元判据的分工:单元判据钉住**语义**(一条路径对不对);压力测试钉住
**规模下的不变量**。沿用仓内既有范式(media/soak_test.go):
testing.Short() 跳过 + 独立 -run 跑。
① 超长序列
· 解析 10 / 100 / 1000 组(250KB 文本):6.8ms,无硬上限误报
· 执行 200 组 × 5 工具 = 1000 次调用:2.0ms
断言:每工具恰好被调 nGroups 次(无遗漏/重复)、结果含**最后一组**
—— 组间串行在规模下仍成立
② 100 工具组内并行
· 100 工具全声明并发安全 ⇒ 11ms,完成顺序**确实被打乱**(判据会校验
这一点,否则它测不到并发)
· 断言每个槽拿到**自己**的结果(并发下若按完成顺序合并就会错位)
③ 串行降级
· 50 个工具里**一个**未声明并发安全 ⇒ 整批退回串行,
完成顺序严格等于声明序(106ms vs 并发的 11ms,降级确实生效)
★ 压力测试第一次跑就抓到一个**真实分层缺陷**:
「含非并发安全工具则整批串行」这条规则**只在上层 runGroup 实现**,
而引擎层 execGroup 只信 g.Parallel 字段 ⇒ 任何人直接调 execGroup
都会拿到不受约束的并发。
已修:降级判据下沉到引擎层,新增 batchCanRun(g, runner),
toolRunner 增加 parallelSafe 方法(生产路径行为不变,只是把判据
放到了它本该在的层)。
过程中压测自身也暴露了两个测试缺陷(都修了):
· fixture 让 100 个工具写同一个标量槽 o,被静态校验正确拦下
("组内并行下同名写入是数据竞争")—— 压测不该去撞这条规则;
· ★ e2eRunner.called 是无锁 append,100 工具并发时 -race 报出**真竞态**
(不是误报)—— 加锁 + 提供 calledSnapshot 供断言。
另:建序列与跑序列原本用了**不同 plugin 实例**(序列存在实例的 store 里,
换实例就读不到自己刚建的),已改为同一实例。
回归:go test -race ./internal/plugins/seq 全绿;go test ./internal/... 全绿。
|
2026-09-27 14:16:09 +08:00 |
|
|
|
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 |
|