|
|
8a98969fac
|
refactor(parallel): 内置工具的并发声明改为 SDK 同构的结构体字段
上一提交(2232d54)把并发安全改成了声明式,但内置工具那一路仍是将就:
声明靠往 required 变参里塞字符串 "toolParallel" 传递。
## 为什么那不算声明式
对照 SDK 的 NoMemory 逐条看:
| | SDK NoMemory | 当时的内置工具 |
|---|---|---|
| 载体 | `ToolDef.NoMemory` 字段 | required 里的字符串 |
| 拼错后果 | 编译器报错 | **静默失效** |
| 内核读取 | 查结构体字段 | 遍历工具表 + 解析字符串 |
"少一个工具能并发"恰恰是最难察觉的一类问题 —— 没有任何报错,
只是并行的批悄悄退化成串行。
## 改法
### 1. sdk.BuiltinToolDef 补声明项(与 NoMemory 同构)
```go
type BuiltinToolDef struct {
Name, Description string
Parameters map[string]interface{}
ParallelSafe bool // 零值 false = 默认串行(保守)
Serial bool // 优先于 ParallelSafe
}
func (d BuiltinToolDef) ConcurrencySafe() bool { return d.ParallelSafe && !d.Serial }
func (d BuiltinToolDef) ToSchema() map[string]interface{}
```
### 2. 工具定义处声明
```go
toolDef("memory_merge", ...) // 默认串行
toolDefWith("knowledge_search", ..., []string{"query"}, parallelOpts()) // 已核实只读
```
### 3. 内核一次聚合并缓存(照 StageHost.NoMemoryToolNames)
```go
graphOf() // 快照
declareParallelTool(name) // init 里登记
concurrencySafeOf(name) // 查表
```
不再每次 toolParallelSafe 都重跑 buildToolDefs()(O(工具数) 重复劳动,
而声明是静态的)。
## ★ 一个更隐蔽的问题:声明表曾经是空的
`declareParallelTool` 最初挂在 `toolDefWith` 的**运行时调用**上。而那 9 个
工具全在 `if a.knowledge != nil` / `if a.social != nil` / `if a.parentID != ""`
之类的条件分支里 —— 测试环境根本不走进这些分支 ⇒ 聚合表始终为空。
而判据查的是同一张表,于是**自证通过**:全绿,并发能力为零。
这就是判据设计的教训 —— 判据和数据源同源时,它证明的只是"我和我一致"。
现在判据双向核对:名单里的必须真声明了,声明了不在名单里的也会报出来;
并额外验证内核**真的读得到**(concurrencySafeOf 而非读同一份 map)。
## 顺带修掉的迁移事故
用正则批量改造 30+ 个 toolDef 调用点时,把 `person_set_trait("name", "content")`
这类**变参**调用误改成 toolDefWith(... "name", "content") —— 那是**写工具**,
差点被标成可并发。已全部回退并逐一核对:9 个声明并发,0 误伤。
|
2026-09-27 15:59:11 +08:00 |
|
|
|
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 |
|
|
|
7e1169bcda
|
feat(kernel): 内置工具注册进 ToolAPI 面(方案 B,补真机实跑发现的架构缺口)
真机实跑实证(独立实例 /tmp/seqtest,未触碰生产):
seq_run 报「工具 knowledge_list 不存在或未注册」,
而**同一轮模型直接调 knowledge_list 是成功的**。
根因:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个是
**内核内置**工具,在 core.executeToolCallInner 里按**前缀分派**,
由 buildToolDefs 直接生成 schema,**从不进 StageHost / IOManager**
⇒ ToolAPI(只有插件工具 + IO 设备工具)既查不到也调不了。
后果:序列只能编排插件/设备工具,无法编排记忆/知识/文档/人物
——恰恰是最常用的能力。
方案 B 的实现:
· internal/sdk: 新增 BuiltinProvider(Defs/Exec)与 SetBuiltinProvider。
用**晚绑定注入**而非让 toolImpl 依赖 core,理由:ToolAPI 是**全局单例**
却需要 per-agent 数据(驻留子是轻量内核,memory 为 nil;内置工具可见性
由 `if a.memory != nil` 等门控)。sdk 不能依赖 core(方向反了)。
· internal/sdk/tool_impl.go: ToolDefByName / ExecuteTool 补查内置工具。
⚠️ ExecuteTool 只在「io 确实没有该工具」时才转内置;io 的**执行失败**
必须如实上抛 —— 否则会把「设备离线」误报成「工具不存在」,让调用方
按 missing 策略跳过(与 P3 修过的父 io 吞错误同一族陷阱)。
内置工具**默认不声明 ParallelSafe**(含 SQLite 写与召回)。
· internal/agent/core/builtin_toolapi.go: Agent 侧 provider。
★ Defs **复用 buildToolDefs 的同一批生成逻辑**(筛出不在
StageHost/IOManager 中的那些),保证"模型看得到什么"与"插件看得到什么"
门控完全一致 —— 避免两套语义。
Exec 复用 executeToolCall 完整路径(授权闸 + 异常处理)。
· cmd/homed/bootstrap.go: agent 构造后注入。
★ 不会让模型看到重复工具(已核实):模型侧走 buildToolDefs
(a.io / a.stageHost **直调**),ToolAPI 只经 PluginSDK.Tool() 暴露给插件
—— 两条不重叠的路径。
判据(builtin_toolapi_test.go,6 条):
· 内置工具能从 ToolAPI 查到
· ★ 查到 ≠ 调得通:必须真的能执行
· 门控语义保持:未接 memory/knowledge 时不得声称有那些工具
· ★ 接了 knowledge 时必须可见(这正是要补的缺口)
· ToolAPI 上"不存在"必须是类型化 not-found(供 seq 的 missing 策略用)
· 内置工具默认不声明并发安全
真机复验(同一隔离实例,新二进制):
序列 "smoke2" 执行完毕(1/1 组)— 工具 1 个
变量槽: summary = cangjie/central-repo/agreement/...
⇒ knowledge_list 成功执行并回填具名槽。上一次的「不存在或未注册」已消除。
已知局限(记入待定):ToolAPI 单例而内置工具面是 per-agent,
多 agent 下看到的是"最近一个注入者"的面。本次不解决。
|
2026-09-27 13:40:22 +08:00 |
|
|
|
4fd18a2e83
|
docs(plan): 遗留项收敛——D4 与端到端已完成,提权无需决策
|
2026-09-27 13:18:28 +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 |
|
|
|
994f198bc5
|
fix(security): 设备授权闸下沉到 ToolAPI 路径(D4,堵住绕过)
问题:设备类工具的授权闸只存在于 core.executeToolCallInner
(toolcall.go:151-152),即**「agent 收到模型 tool_call」那条路径**。
而 ToolAPI.ExecuteTool 是**另一条**独立执行入口,不经那道闸
⇒ 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。
实测范围**不止序列**:cli 插件的 /terminal 直接经 ToolAPI 调 agentcli 的
终端工具(cli/plugin.go:1038 的注释自陈"SDK 的 ToolAPI 已允许跨插件调用
工具")。任何插件拿 ToolAPI 都能指挥未授权的设备。
改动:
· internal/sdk/tool.go: ToolAPI 新增 CanUse(toolName, args) bool。
**纯新增方法**,零值实现返回 true ⇒ 未实现者(存量插件、测试替身)
行为不变。
· internal/sdk/tool_impl.go: 实现 CanUse。判据只有一条——设备类工具按
`device/<id>` 查授权;非设备工具不受影响(闸的作用域必须窄,否则会把
所有工具锁死)。
授权查询走**可注入**的晚绑定闭包:toolImpl 在 internal/sdk,而
IsOutputAllowed 是 core.*Agent 的方法,sdk 不能依赖 core。
· internal/plugin/registry.go: 新增 SetDeviceAuthQuery。
· cmd/homed/bootstrap.go: 在 newMainAgent 末尾注入。⚠️ 必须在 agent
构造**之后**——判据要用 agent 自己的 allowedOutputs,而 registry 早于
agent 构造,故 registry 存的是晚绑定闭包。
判据(toolapi_auth_test.go,7 条),核心是**两条路径必须一致**:
· 收窄授权时 ToolAPI 路径同样被拦
· 已授权设备放行(防闸过严杀掉正常能力)
· 非设备工具不受影响
· 枚举类工具不受影响(与内核 TestDeviceToolAuth_EnumerationNotGated 同语义)
· 未配置白名单 = 完整授权
· ★ TestCanUseAgreesWithInnerPath:4 组用例逐例比对内核路径与 ToolAPI
路径的结论 —— 判定不同本身就是漏洞
· ★ TestCanUseMatchesInnerFailOpenOnMissingDeviceID:把现状
(缺 device_id 时**放行**)钉住。⚠️ 这是 fail-open,是既有的可疑设计
(core 的 TestDeviceToolAuth_* 依赖它),本次不擅自改语义;判据写明
"若要改成 fail-closed,必须两处同时改"。
过程中三次自伤:
1. 一度在 core 写了个 toolAPIRef —— **只实现部分方法的替身**是过度设计,
且两份实现必然漂移。改为判据直接用 sdk.NewTool(stageHost, iom),
与插件侧走**同一个**实现。
2. 判据里又写了 `var _ = agentIO.DeviceOutput` 这种压 unused import 的
占位 hack(第二次犯这个),并重造了 strings.Contains。都已去掉。
3. 注入点一开始找错了位置(以为 newStageAndRegistry 能拿到 agent,
实际 pluginReg 是 main() 的局部变量)。核实 newMainAgent 的签名后
确认它同时持有 agent 与 pluginReg,注入点落在那里。
变异验证:让 CanUse 恒返回 true(还原成原缺口)⇒ 两条判据 FAIL,
其中一条直指「内核路径=false 而 ToolAPI 路径=true —— 两条路径判定不一致」。
回归:go build ./... 通过;go test ./internal/... 全绿。
|
2026-09-27 13:15:22 +08:00 |
|
|
|
532500e3c9
|
docs(plan): 内核主线与插件线全部完成,记录两个设计决策与三处遗留
|
2026-09-27 13:06:10 +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 |
|
|
|
9746538a00
|
docs(design): 补 0.2 阶段行
|
2026-09-27 11:56:19 +08:00 |
|
|
|
ae3cdaa1a0
|
docs: 同步两份文档的实现进度(内核主线 0~2.5 全部完成)
|
2026-09-27 11:56:01 +08:00 |
|
|
|
0327419c88
|
feat(prompt): 提示词声明「同轮默认并行」及其例外(阶段 2.5)
⚠️ 本阶段有硬性顺序约束:必须在并行执行(阶段 2d)落地**之后**。
反序(先说"并发"、内核仍串行)会让提示词**对模型说谎** —— 模型据
"并发执行"推断安全性,写出真正依赖顺序的调用。宁可晚改,不可错改。
改动(tooldefs.go,buildSystemPrompt):
· 新增【工具执行顺序】段,讲清四件事:
1. 同一条回复里的多个工具调用**默认并行**(同时跑),不是依次执行
2. **不要依赖执行顺序** —— 参数依赖前一个结果就分两轮
3. **例外一:同通道 output_send__ 保序**(用户可见消息顺序敏感)
4. **例外二:不并发安全的工具整批退回串行**(写类工具 / 未声明者)
· 顺带说明并发安全由**工具自己声明**(ParallelSafe),不由模型判断
· 改掉 spawn_child 的落空表述:原文「应并行 spawn,不要自己串行逐个执行」
在并行化之前是**落空**的(模型照做,内核仍串行)。改为机制性表述,
并补一句「一次 spawn 只是启动动作,要拿结果仍需另一次 child_result」。
⚠️ 措辞刻意与 batchRunnable 的**真实**判据一致(全批 ParallelSafe 才并发
+ 同通道保序),而不是理想化表述 —— 提示词与实现不符,比不说更坏。
判据(prompt_parallel_test.go,5 条):
· 四要点齐全(并行 / 顺序 / 保序 / 并发安全声明)
· ★ 必须同时讲**例外** —— 只讲并行就是"说谎"的那一种
· 同通道保序须显式说明(保序是内核兜底,模型不知情就会浪费它)
· spawn_child 不再含旧的落空措辞
· 回归防护:既有要点(输出规则 / output_list_channels / 工具能力 / 记忆清理)不丢
★ 判据里的一次自伤:先写了 spawnChildDescription(a) 这个**不存在**的
helper("工具名反查描述"),编译失败后改为 toolDefDescription —— 内部
遍历 buildToolDefs 的**真实产物**。判据必须对着代码真实输出,不能另建一套
注册表。
变异验证:把两条例外改写成"以上适用于所有工具"⇒ 3 条判据 FAIL
(缺"保序"、缺"例外"、同通道未说明)。即"提示词说谎"这一失败模式
现已被判据覆盖。
回归:internal/agent/... internal/sdk/... internal/plugins/... 全绿(15 包)。
|
2026-09-27 11:55:33 +08:00 |
|
|
|
446645d0a0
|
docs(plan): 阶段 2 标记完成,记录并发规则与 2c 判据闭合
|
2026-09-27 11:27:53 +08:00 |
|
|
|
34c0df2705
|
feat(toolcall): 批次并发调度与同通道保序(阶段 2d,闭合 2c 判据缺口)
规则(三条全满足才并发):
1. 批内 >1 个工具
2. **全部**工具声明 ParallelSafe —— 一个不声明就整批降级,不做部分并发
3. 不含需保序的同通道输出发送
SDK:
· ToolDef 加 ParallelSafe bool。⚠️ 零值 false 是刻意的:存量插件不改一行
就得到**保守**行为(整批串行),不会因升级被意外并发。声明它是责任
而非特权。纯新增字段,无签名变更。
· io.ToolDef 同步加该字段(设备/通道工具走 io 路径,只查 StageHost 会漏)。
core:
· 新增 StepToolBatch —— runTaskSteps 是单线程驱动状态机的,
「每步一个工具」的游标模型无法表达「一批同时跑」,故需独立 step。
· stepToolBatch:fan-out(每工具一 goroutine,各写自己的 toolCtxs[i])
→ join → **按索引顺序**串行收尾(after_toolcall / 落消息 / 事件)。
收尾必须串行且按索引:f.Msgs 是共享切片,且按索引落才能让模型读到的
上下文顺序与它自己发出的顺序一致。
· runOneTool 抽出「before_toolcall + 执行」的单工具逻辑,串行/并发两条路共用。
· toolParallelSafe / batchRunnable 判据函数。
★ 修掉一个我自己引入的竞争:resolveTurnScenes 会把结果记进**共享**的
f.sceneDone / f.turnScene(memorypass.go:289)。最初在每个 goroutine 里
各调一次 —— 既是数据竞争,又会各自触发一次 EnterSceneWithHint,
重复计入场景强度(正是 sceneDone 注释警告过的问题)。改为在 fan-out
**之前**解析一次,goroutine 内只读。
判据(parallelsched_test.go,4 条):
· 全批 ParallelSafe ⇒ 并发峰值 >= 2(用阻塞设备观察真实并发)
· 一个非 ParallelSafe ⇒ 整批串行,但**仍全部执行**
· 同 output_send__<通道> 连发 3 条 ⇒ 严格按声明顺序到达
· ★ 并发下每个工具的 ctx 只带自己的 ToolCalls、after 读到自己结果
★ 并关闭了 2c 的判据缺口:此前两条 2c 判据在**串行**下无法区分
per-tool 与单槽(变体验证后仍全绿)。新增的并发版判据在退回单槽时
触发 **6 处 DATA RACE 报告 + 串味断言失败**(dup:k_a 与 k_a 撞名)。
至此 2c 可记为已验证。
过程中三次自伤:
· resolveTurnScenes 竞争(上述);
· 我的 harness 用 StageHost 注册 handler 遮蔽了设备工具,
slowDevice 根本没被调用("实际 0")——改为在 io.ToolDef 上声明;
· 批内并发峰值判据最初用 StageHost 声明 ParallelSafe,掩盖了
「设备工具也需要该字段」这一真实缺口。
回归:internal/agent/... internal/sdk/... internal/plugin/...
internal/plugins/... 全绿(17 包);core 包 -race 全绿。
|
2026-09-27 11:27:24 +08:00 |
|
|
|
3d12e82f65
|
refactor(toolcall): StageContext 拆 per-tool,为并发执行消除共享槽(阶段 2c)
问题:f.StageCtx 是**单槽**,批内每个工具都覆写它(ToolCalls=[单元素]、
ToolResults 覆写、Results[0] 回读)。串行下看不出问题,但并发下
N 个 goroutine 同写一个 ctx = 数据竞争,且 after_toolcall 插件可能读到
**别的工具**的结果。
改动(task.go):
· TaskFrame 增 toolCtxs []sdk.StageContext(每工具一份)
· buildToolContexts 在 stepLLM 设 PendingTools 时建池;
Extra **逐份浅拷贝**——共享同一 map 即竞争(stage handler 会写它)
· toolCtxFor(i) 取第 i 份,越界/未建时回落 f.StageCtx(测试替身安全)
· stepToolBegin(before_toolcall + 参数回填)、stepToolExec(写结果)、
stepToolAfter(读结果)三处全部切到 per-tool ctx
判据(toolbatch_test.go 追加两条):
· 每个工具的 before_toolcall ctx 只带自己的 ToolCalls[0].Name,
且 Extra[output_channel] 逐份带过去(stage.go:18 依赖它)
· after_toolcall 读到的 Result 必须属于当前工具,不能是批内另一个的
★ 诚实记录:这两条判据在**串行**下**测不出与单槽的差别**——串行时
每工具跑完才进下一个,不存在交错。变体验证(toolCtxFor 退回单槽)后
判据仍全绿。故 2c 记为「实现已就位、判据未闭合」,真正判据必须与 2d
(并发执行)一起写,并以 -race 确认无竞争。已在执行计划中标注。
过程中两次自伤:
· 我的 harness 没设 Extra[output_channel](那是 prepareInputTask 才写的,
task.go:380),判据一度报「产品缺陷」——核实后是我造的场景,已对齐生产;
· 阶段 1 的 TestStageCtxSuccessIsHonestEndToEnd 读 f.StageCtx.ToolResults,
拆分后失效——判据跟随新结构改为按批索引取 toolCtxs[i],
断言的仍是内核产出的 Success 值本身。
顺带记录(非本次引入):TestResidualKeep/Drop 偶发失败,根因是
offload_test.go 的 SpawnResident 起了子调度器 goroutine,而测试
enqueue 后无同步就读同一队列。干净基线 3/3 全绿属运气。已在计划中
记为待修,避免后续误判为并行化引入的回归。
|
2026-09-27 11:17:06 +08:00 |
|
|
|
28b42bcf0f
|
refactor(toolcall): 批内消息改为「一个 assistant 带全部 tool_calls」(阶段 2a)
问题:现状每个工具各自 append 一对(assistant[tool_calls=[tc]] + tool),
既不表达「这是一批」,也无法支撑并行:
· 产生 N 条 assistant 消息,同一段 assistant 文本语义上只该出现一次
· 并行下完成顺序不确定,若等结果回来再落消息,assistant 就必须等所有
结果齐了才能写——而 OpenAI 协议要求 assistant(tool_calls) 在结果**之前**
改动(task.go):
· TaskFrame 增 assistantMsgIdx
· 新增 ensureBatchAssistant:惰性写入,全批只写**一条** assistant,
携带 f.PendingTools 全部 tool_calls;后续工具只补 tool 消息
· stepToolBegin 的 denied / unhealthy 分支与 stepToolAfter 统一改用它
· stepLLM 设 PendingTools 时清零 assistantMsgIdx
· msgContent 的 ContentOnce 归位移入 ensureBatchAssistant(仍是只挂第一条)
⚠️ 依赖:before_toolcall 阶段**不得**改写工具参数——已核实全仓无此用法
(grep ToolCalls[0].Arguments 赋值无结果)。若将来某插件要改写 args,
需改为「回填后重写该条 assistant」。该前提已写入代码注释。
判据(toolbatch_test.go 追加 TestBatchLayoutSingleAssistantCarriesAllToolCalls):
· 带 tool_calls 的 assistant **恰好一条**且携带 2 个 tool_calls
· 其后紧跟 2 条 tool 消息且按声明顺序(c1、c2)
变异验证:让 ensureBatchAssistant 退化为「每工具一条」⇒ 判据 FAIL
「批内 assistant 应带 2 个 tool_calls,实际 1」。
stage 0.5 补的三条判据(配对完整性 / ContentOnce / denied 后继续)
在本改动后**仍然全绿**——它们正是为这种改动准备的保护网。
回归:internal/agent/... internal/sdk/... internal/plugins/... 全绿(18 包)。
|
2026-09-27 11:09:02 +08:00 |
|
|
|
25f5c53086
|
feat(io): 多模态块改 per-call 归档,为并行执行铺路(阶段 2b)
问题:ConsumeToolBlocks 此前是 **IOManager 级单队列**(取走即清空),
没有 call_id 维度。并行化后同批多个工具各自注入媒体时,后执行的
Consume 会**抢走**前一个的块 ⇒ 媒体挂到错误的 tool 消息上。而
task.go 里「媒体必须走 user message 且紧跟自己的 toolMsg」那条结论
是三轮实测才定下来的,并行会直接破坏它。多模态插件在 3 处调
SetToolBlocks(multimodal/plugin.go:136,246,320),是真实使用面。
改动:
· 字段 toolPendingBlocks: []interface{} → map[string][]interface{}
· 新增 SetToolBlocksFor / ConsumeToolBlocksFor / ClearToolBlocks(按 call_id)
· 保留 SetToolBlocks / ConsumeToolBlocks 作兼容入口(走 callID=""),
存量调用方与串行单工具场景不受影响;其注释写明并行下该路径不可靠
判据(toolblocks_test.go,4 条):
· 两个 call 各自注入、**交叉顺序**并发取回,各归其主
· 取走即消费(二次取回为空)
· 未知 callID 返回空且**不影响他人**的块
· 32 路并发零串味
变异说明:本阶段是从「单槽」直接改为 per-call,旧实现在这四条判据下
(per-call API 不存在)无法编译通过,等价于判据先失败;per-call 版
再经 `go test -race` 验证无数据竞争。回归:internal/agent/io 全绿。
|
2026-09-27 11:06:41 +08:00 |
|
|
|
bce40b5099
|
feat(toolcall): 按 schema 预校验参数,在分派前拦下(阶段 1c)
问题:`required` 在仓内被声明 69 处,却**无任何消费方**(内核从不读)。
校验散落在每个工具内部手写成中文字符串("path is required"),
要等工具真被调用才暴露——而模型看到这类与真因无关的报错只会原样重试
(实测 cmd_run 失败率 34%~48% 的成因)。
改动:
· core/argvalidate.go: validateToolArgs(纯函数)+ validateArgsAgainstSchema。
★ 校验器刻意**宽松**:只拦真正无法解析的形态,对模型实际会写的等价形态
一律放行。依据是工具内部 getter 的既有约定(utils.go 注释:
"实际调用里 bool/string/float 三种都出现过";unitNumberRe 修的正是
`"20s"` 少引号那类)。**校验比工具更严就是在制造新失败**。
· required 判据是**键存在性** + 非空字符串;显式 null 视为已提供
(模型可能有意传 null,工具按零值处理,判成缺失即误伤)
· boolean 全放行(getBool 的 true/"1"/"0"/"yes"/0/1 全都合法)
· integer 接受 int/float64/"20"/"20s";string 接受含 JSON 的长文本
· 无 schema / 无 required / 查不到 schema ⇒ 一律放行
· core/toolcall.go: 在 __arg_error 短路**之后**、分派**之前**接入。
· io/channel.go: 新增 IOManager.ToolDefOf——没有它就只校验到插件工具,
而 cmd_run / files_write 这类**设备/通道工具会完全绕过校验**。
判据(argvalidate_test.go,7 组):
· 缺 required 被拦下并指名字段
· ★ 误伤防线:bool 传 "true"/"0"、integer 传 float64/"20"、
显式 null、字段顺序不同 —— 全部必须放行
· 类型确实不符报 type 错误
· 无约束场景一律放行(含 args 为 nil + schema 带 required ⇒ 应拦,
这条我最初**误放进放行组**,写完立刻发现改正)
· 错误文案含字段名/必填/改法(否则模型只会原样重试)
· 端到端:缺参时**设备真的没被调用** + 文案指名字段
· 端到端反向:参数齐备照常执行(校验不得阻塞正常路径)
变异验证(两轮):
· 关闭分派前校验 ⇒ 端到端判据 FAIL「仍进入了工具」
· 把 boolean 校验改严格 ⇒ 宽松防线 FAIL 两个子用例(误伤 "true"/"0")
过程中三次自伤:臆造 sdkToolError 别名;number 分支写了没有绑定的 x(v);
把"显式 null"先当成缺失、过度修正后又漏掉"键不存在"的判定——
最终改为「键存在性 + 非空串」双条件,null 与缺失各归其位。
回归:internal/agent/... internal/sdk/... internal/plugin/...
internal/plugins/... 全绿(18 包)。
|
2026-09-27 10:56:02 +08:00 |
|
|
|
396d13e9af
|
feat(toolcall): 结果契约诚实化 —— Success 不再恒真 + 结构化失败可回填(阶段 1b/1d)
问题(实测,三条互相印证):
· ToolResult.Success 硬编码 true(task.go 唯一赋值点)⇒ 该字段在结构上
不可能为 false,是**谎报字段**;
· 工具失败以 nil error + 错误**值**返回(files 的 errorResult、
pluginmgr 的 {"error":…}),上游无从判别;
· stepToolAfter 用 `Result.(string)` 断言,而插件返回的多是 map ⇒
断言几乎恒失败,after_toolcall 阶段对结构化结果的改写**静默失效**。
改动:
· third_party/homeagent-sdk: 新增 ToolError{Field,Reason,Detail,Hint}
与 Error()。纯新增、无签名变更,存量插件不必改动(零值语义:
内核的失败识别同时兼容既有三种约定,新类型是可选项而非迁移要求)。
· internal/sdk: 补 ToolError 别名。
· core/toolerror.go: isToolError / toolErrorText / renderToolResult。
⚠️ 判据必须同时兼容仓内**三种**既有失败约定,且**不得**把成功误判:
① {"error": msg} ② {"isError":true,content:…} ③ *ToolError
明确不判失败的:exit_code != 0(业务结果,带真实 stdout/stderr)、
stderr 非空(cmd 成功常带 warn)、字符串/数字/bool/数组/nil
(自由文本按成功处理:宁可少报失败,也不把正常结果报成失败)。
· core/toolcall.go: executeToolCallOutcome 返回 toolOutcome{Text,Raw},
**Raw 必须在成功分支也带上**——否则结构化失败在 fmt.Sprintf("%v")
那一步被抹平,Success 又退回恒真。panic 与 60s 超时统一以 ToolError
表达(可执行 Hint,避免模型原样重试工具故障)。
· core/task.go: Success=!isToolError(Raw);修恒失败的类型断言;
TaskFrame 增 CurRaw(未降级的原值)。
判据:
· toolerror_test.go 单元级:三种失败约定识别 / 成功形态不误判 /
非零退出不算工具失败 / ToolError 识别。
· TestStageCtxSuccessIsHonestEndToEnd 端到端读 f.StageCtx.ToolResults,
验证**内核产出的值本身**,而非辅助函数。
变异验证(两轮):
· Success 退回硬编码 true ⇒ 端到端判据 3 个子用例 FAIL;
· 把字符串判据改成 strings.Contains(x,"error") ⇒ 「成功文本含 error 字样」
用例 FAIL。**第二轮暴露了判据缺口**(最初没有该用例),已补。
过程中三次自伤(均由判据/编译暴露):用正则批量包装 return 时把
多行 fmt.Sprintf 截断;包装范围溢出到返回 string 的辅助函数;
测试里重复注册同名工具导致 IOManager 取到错误的 device。
遗留:全量 go test ./internal/... 在本机无法完整跑完——/tmp 是 9.8G
tmpfs 且已 98% 占用,link 阶段报 "no space left on device";
/var/tmp 另有约 29G 陈旧 release worktree。与本次改动无关(未触碰
memory/* 等失败包),已在干净基线(7a566d5)对比确认。
|
2026-09-27 09:17:16 +08:00 |
|
|
|
4ba72977d2
|
test(toolcall): 补批内路径的三条缺失判据(阶段 0.5)
同一批多个 tool_call 的循环(StepToolBegin→Exec→After)此前只被
scheduler_critical_test.go:125 一条用例覆盖「按序执行」,缺的三条正是
阶段 2(并行执行层)要改的地方:
· tool_call_id 配对完整性 —— 阶段 2 改消息落法(一个 assistant 带全部
tool_calls + N 条 tool)时,配对断裂上游会直接报错
· ContentOnce 批内语义 —— 同一段 assistant 文本在批内重复 N 次,撑爆上下文
· denied 后继续批内 —— 改成 abort 会丢掉本可执行的后续调用
判据 toolbatch_test.go(4 条),全部确定性断言:阶段 0 已消除 map
迭代随机性,同批工具的落序与配对可稳定断言。
变异验证:令 stepToolBegin 跳过批内最后一个工具后,4 条判据同时 FAIL
(既有那条也 FAIL),报错直指 ToolsUsed=[tool_alpha]、
tool_call_id "c2" 被声明 0 次。
更正一处此前的不准确表述:我曾说「无任何测试直接驱动批内路径」——
不准确。scheduler_critical_test.go:125 已驱动「同批两工具按序执行」;
漏查是因为只 grep 了 PendingTools/ToolIdx 字段名,没查断言内容。
真正缺的是上表三条。
过程中三次自伤(均由「判据先写」暴露):臆造不存在的 helper;
stageHost 置 nil 后又使用;给 newTaskFrame 传 nil 导致 stepPrepare 于
task.go:519 nil 解引用 panic(改用仓内既有 a.stageCtxFromInput)。
顺带记录:生产两处 newTaskFrame 调用都传真实 ctx,但 stepPrepare 对
f.StageCtx 无 nil 兜底——本次不修(无生产触发路径),记为潜在缺口。
回归:internal/agent/... 与 internal/plugins/... 全绿。
|
2026-09-27 08:59:30 +08:00 |
|
|
|
7a566d50b7
|
fix(toolcall): 工具「不存在」类型化 + 修父 io 兜底吞错误 + 修并行 tool_call 落序随机
主线:工具调用并行化改造(阶段 0 与 0.2)。
① flush 顺序随机(process.go)
flushToolCall 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化
⇒ 同一批并行 tool_call 进入 resp.ToolCalls 的顺序每次运行都可能不同。
对 output_send__ 这类用户可见通道,分段消息到达顺序不可复现。
改为收集 index 后 sort.Ints 再 flush(两个调用点统一走 flushAll)。
判据 stream_flush_order_test.go(8 工具 × 200 轮),已变异验证可检测。
② 工具「不存在」类型化(io/channel.go、core/stages.go、core/toolcall.go)
工具是动态注册的,「不存在」是运行期常态而非异常。原先内核用
strings.Contains(err, "not found in any plugin") 判别——约定而非契约,
插件文案含该子串即被误判。改用哨兵 ErrToolNotFound + errors.Is
(沿用仓内 ErrInputChannelUnknown 的先例)。
⚠️ 顺带修一个静默 bug:IOManager 向父兜底时吞掉父的执行失败,
误报为「工具不存在」。后果是设备离线这类本该 retry 的失败被判为
「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。改为只传递
「确实不存在」,其余如实上抛。
「不存在」的文案改为可执行指引(get_plugin_tools / output_list_channels),
而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。
判据:toolcall_error_test.go(类型化 vs 诱饵子串、%w 穿透、执行期文案)、
channel_error_test.go(父失败不吞、真的不存在仍可判别)。
两者均经变异验证。回归:internal/agent/... 与 internal/plugins/... 全绿(14 包)。
设计文档:docs/zh/toolcall-contract-and-sequence-design.md
执行计划:docs/zh/toolcall-parallel-execution-plan.md
|
2026-09-27 08:55:25 +08:00 |
|
|
|
85e3d66e92
|
Merge branch 'feature/scene-writeback' — 场景式记忆修复 + WebUI 性能与星图改进
## 记忆:场景式记忆的 5 处根因(生产实测驱动)
现网 65 个场景里有 6 组是同一场面的双胞胎键,最严重的
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
日志里被「命中」179 次;孪生的 auto:chan:qq_part:morning 持有 210 refs
却有 0 features(聚类只读 scene_features,所以它永不被看见)。两套特征
体系各活各的,谁也发现不了谁。
- 1fa9ef6 键归一化 + 排除最弱维度:建键路径(createSceneLocked)漏过
NormalizeSceneKey,而 EnsureScene / effectiveScenes / RecallByScene
三处都过了 ⇒ '+' 与 '_' 成为两个合法主键,key UNIQUE 拦不住。
同时把权重仅 0.2 的 part(时段)排除出场景身份 —— 实测「morning 场景
吞掉 evening 指纹」(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)。
冲突后缀 '#N' 改 '.N'('#' 也会被归一化,是第四处双胞胎来源)。
- 758ec11 图整备覆盖 scenes:新增 DedupeScenes 并接入 mergeLoop。
原先 detectEntityMerge 的遍历入口 Recall(nil,nil,1,"") 只查
entities/relations,scenes 完全没有整备路径 —— 这是「双胞胎从 9-15 起
无人发现」的原因。生产库副本实测:65 → 57,合并 8 组,refs/rel/ent
一条没丢。
- 758ec11 证据桶按桶清:createSceneLocked 原先是
DELETE FROM situation_evidence(全表清)。多通道共用计数表,qq 的场景
一长出来就把 mc/webui 尚未攒够 minSceneEvidence=2 的证据抹掉 ⇒ 判据
实测「6 个通道各来 3 次只长出 2 个场景」。
- 2567a22 场景键两路合并去重:现网日志实测 scenes=[chan:qq chan:qq]。
声明路与通道派生路之间缺共同的 seen。
## SDK:补 ScenePolicy 声明项(经用户授权的公开接口扩展)
ChannelDef 已有 NoMemory/ContextPolicy/RecallPolicy 三件套,唯独没有
「这条输入算不算一场戏的一部分」,现状是无条件参与 ⇒ chan:system /
chan:kernel / chan:timer 这类纯内部信噪通道也在撑场面。
新增 ScenePolicyAuto/None + ValidScenePolicy,形状与既有两项完全一致;
ChannelDef.ScenePolicy 与 InjectOptions.ScenePolicy 均带 omitempty,
零值行为逐字节不变。默认取 auto(参与)而非 none:场景只附加检索路、
不改记忆本体,默认关会让存量通道突然失去召回。**现网不标任何一个通道**
(用户裁定「多写无影响、少写会缺场景」;实测 0-refs 通道召回返回空,
且 declared 场景不进相似度空间)。
⚠️ 本次合并会使 git_release_check 的「公开 SDK 接口冻结」项报 FAIL,
属预期:有意的新增接口,非破坏性变更。
## WebUI
- ae87f4b 服务端 gzip(首屏 wire 字节 -70%):状态机写成单一枚举而非
多个 bool;SSE 不压、必须透传 http.Flusher、Content-Length 需防陈旧值。
- d3315c6 前端按页签懒加载:空闲请求 37 → 17(-54%);顺带治掉三个
轮询器,并把 loadChatStarmapData 的空图分支硬取
getElementById("sm-container-chat") 改为可移植(该 bug 曾导致首页
首帧必抛、永不重试、星图永远空白)。
- 9346fed 星图跟随 agent 活动 + 搬到主页 + 修分类配色从未生效
(服务端发 "Concept"、JS 键是 "concept" ⇒ 永不匹配 ⇒ 1150 节点
全回退兜底灰)。
- 9c93f23 配色改中性灰蓝:上一提交修好后,1148/1149 个 Concept 节点
第一次真拿到亮青绿 ⇒ 整张图变绿。绿色不是渲染 bug,是「配色终于生效」
后暴露出的真实数据形状;之前的灰恰好是「全都没匹配上」的症状。
- 4a231a8 两处表达式合并为单行(纯格式化)。
## 文档
- f441574/6e94209/13a070f 场景记忆修复 plan(5 根因 → 7 步骤)
- bf5d891 介绍站补「场面涌现」板块
- SDK 站新增 docs/guide/scene-memory.md(概念 + 声明项用法)
## 验证
记忆 8 包 + agent/core 全绿;全仓 59 包 0 FAIL。生产部署后置清单全绿
(版本自报、插件子进程 25、Fatal 0、端到端)。场景清理后复验:涌现出的
新键不含 +/#、有 features 且有 refs。
|
2026-09-27 08:25:30 +08:00 |
|
|
|
9c93f232e3
|
fix(webui): 星图配色改中性灰蓝 + 图例按实际类型动态生成
### 为什么会出现「一片绿」
上一提交修好了「分类配色从未生效」那个 bug(服务端发 "Concept"、JS 键是
"concept" ⇒ 永不匹配 ⇒ 1150 个节点全回退兜底灰 0xcccccc)。修好之后
颜色值**一个字没改**(concept 键仍是 0x44ff88),于是 1148 个 Concept
节点第一次真的拿到了那个亮青绿 —— 1149 个节点里 1148 个是 Concept,
所以整张图变成绿的。
⇒ 结论:绿色不是渲染 bug,是「配色终于生效」后暴露出的真实数据形状。
之前的灰色恰好是「全都没匹配上」的症状。
### 换中性色(用户裁定)
默认色改为 SM_COLOR_DIM = 0x7d8a9e(中性灰蓝)。亮青绿配 1149 个
自发光球确实扎眼。
### 顺带把「图例说谎」也修了
原图例写死「人物/概念/对象/地点/来源」五项,而实测图谱里只有
Concept(1148)与 Source(1)—— 列出四个永不出现的类型,等于告诉
用户存在实际不存在的分类。
现在:
- 图例按**实际出现**的类型动态生成,标注占比
- 占比 <1% 的不单列(实测 1/1149 = 0.087%,单列会显示成「来源 0%」,
既难看又误导读者以为图里没有来源节点),归入「其他 N 个」
- 只有**一种有存在感**的类型时,附一句实话说明「节点同色不是分类图」
### 颜色规则也跟图例对齐
smColorFor:只有存在 >=1% 的第二类型时才按类型上色,否则全图中性色。
理由:99.9% 概念 + 0.1% 其他时按类型上色,得到的仍是一整片同色,
而那一两个异色点在视觉上就是噪点(实测 colors 只剩 ["7d8a9e"])。
不动服务端类型识别(用户裁定):nlp/extractor.go 至今不判类型、
graph.go:451 写死 Concept,根治要改记忆链路,本轮不碰。
### 途中修掉一个自己引入的 bug
图例空。首屏星图在**总览页**初始化,那时 #sm-legend 还不存在
(骨架由 renderStarmapTab 建),smLegend 内部 getElementById 返回 null
直接返回;而 renderStarmapTab 建完骨架后只调了 smUpdateStat()。
⇒ 浏览器实测图例 html 长度 0。在 renderStarmapTab 里补一次 smLegend()。
### 验证(真实 1149 节点数据集 + WebGL)
- 颜色:["7d8a9e"](单一中性色)—— 改前 ["44ff88"] 一片绿
- 图例文本:「概念 100% 其他 1 个(图谱实体几乎都是同一类型,节点同色;
出现新类型后会自动分类)」
- 统计:1149 节点 / 865 关系;控制台零异常
- go vet 干净;全量测试通过
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-09-27 07:14:34 +08:00 |
|
|
|
4a231a8d4c
|
style(webui): dashboard.js 两处表达式合并为单行
纯格式化,无语义变化:
- state.startedAt 的三元表达式(563 行附近)
- renderAll 里 Promise.all 的实参(624 行附近)
两处的表达式与参数列表逐字符相同,只是原先的换行被收拢。
已随二进制部署并验证上线:现网 `/` 返回的 HTML 里单行写法命中 1 处、
旧多行写法 0 残留。
|
2026-09-27 06:44:49 +08:00 |
|
|
|
d3315c68ca
|
perf(webui): 前端按页签懒加载 —— 首屏不再拉隐藏页签的数据,空闲请求砍到 1/3
生产实测的问题:renderAll 无论当前在哪个页签,都无条件拉 9 个接口并
渲染全部 7 个页签。首屏 792,933 B 里**约 230KB 花在用户看不见的隐藏
DOM 上** —— /api/v1/kernel(152KB,只有内核页要)与 /api/v1/settings
(72KB,只有设置页要),而 renderKernel() / renderOneSettings() 是在
**隐藏的 tab 容器**里构建 DOM 的。更糟的是每 15s 重来一遍。
### 依赖关系是实测出来的,不是猜的
逐个 render 函数 grep 它读的 state.*:
renderOverview → 无(只读 status/runtime/dom)★ 总览最便宜
renderKernel → state.kernel
renderOneSettings → state.settings / state.meta
renderPlugins → state.kernel / installedPlugins / disabledPlugins
renderAdapters → 无(自拉 /api/v1/adapters)
renderChat → 自建布局;星图/终端/命令是其子面板
于是「切到哪页才拉哪页的数据」写成一张 TABS 表(唯一真相表),
正确性由结构保证,而不是靠一串 if 串联。
### 顺带治掉三个轮询器(浏览器 40s 空载实测)
改前停在总览页 40s 内 37 个请求:
/runtime 17 次、/terminals 8 次、/cmd/history 8 次、/status 3 次
1. **terminals/cmd/history 的 5s 轮询是无条件的** —— 但这两个面板只
存在于**聊天页**(buildChatLayout 里的 chat-panel-terminal/-cmd),
在总览/设置/内核页渲染它们既没人看也只改看不见的 DOM。
改为「仅聊天页可见时才轮询」。
2. **/runtime 有两个消费者各拉一遍**:startRuntimeTicker(3s)与
starmapPullActivity(3s)。星图现在只读 state.runtime,不再自己发请求。
3. 轮询改走共享数据块 starmapFetchBlock(带 3s 节流 + 单一数据源)。
改后 40s 内 17 个请求(-54%),且不再有任何接口用于渲染不可见的面板。
### 首屏
overview 首屏只拉 status/runtime/chat/history/persona/memory-graph,
**不再拉 kernel 与 settings**。
### 刷新语义
区分「活数据」与「近乎不变的数据」:status/runtime 每 3s 允许重拉;
kernel/settings/plugins 首次拉过后**不再每 15s 重拉**(这正是 53MB/h
的主因)。切回页签也不重拉(数据没理由变)。四个变更操作
(源/MCP 的增删)改调 renderAll(true) 强制刷新;启停插件/保存设置那几处
本来就自己 refetch 再局部重渲染,不依赖 renderAll。
### ★ 途中修掉一个被上一提交引入的真 bug
loadChatStarmapData 的空图分支硬写 getElementById("sm-container-chat")。
星图搬到总览后,总览页与独立页签都没有这个 id ⇒ 首页首帧必抛
「Cannot set properties of null」,被 catch 吞掉但 starmapInit 没置上,
于是**永不重试、星图永远空白**。改为取 starmapActiveContainer() 并加
空值保护。浏览器实测:修前 ERRORS 非空,修后 STARTUP + 7 个页签全 clean。
(教训:把 UI 元素挪到新位置后,必须把所有按 id 直取该元素的地方找全 ——
grep 该 id 一次。)
同时删掉被 starmapActiveContainer 取代的死函数 starmapContainer()。
### 验证(浏览器实测,非估算)
- go vet 干净;全量测试通过
- 首屏(overview):只拉 status/runtime/chat-history/persona/memory-graph,
kernel 与 settings 确认**未出现**
- 逐页切换记录请求:每页只拉自己那几项
- 空载 40s:37 → 17 个请求
- 运行态面板仍正常渲染(rt-panel 存在、rt-sec-pipe / rt-sec-topo 均在)
- 启动 + 7 个页签全部无控制台异常
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-09-27 00:23:04 +08:00 |
|
|
|
ae87f4b8b2
|
perf(webui): 服务端 gzip —— 首屏 wire 字节 -70%
生产实测:首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding
(直连 127.0.0.1:8080 与经 nginx 的公网入口两条路径都验过:响应头里没有
该字段,wire 尺寸 == 原始尺寸)。同一份数据 gzip 后:
/api/v1/kernel 152,667 → 37,697 (-75%)
/api/v1/chat/history?limit=40 554,764 → 174,594 (-69%)
这些是高度重复的 JSON(同批 key 名反复出现、中文实体名、时间戳),
压缩比自然地高。真实实例上实测首屏 wire 字节 77,943 → 23,339(-70%)。
位置:链改为 proxyDispatch → gzip → logged → mux。夹在 proxyDispatch
与 logged 之间,是因为 proxyDispatch 命中时直接 return、响应来自上游
(其 Content-Encoding 由 httputil 处理),我们不插手;门户自身的全部
响应(requireAPI 的 401/503、requireWeb 的 302、HTML/CSS/JS、全部
JSON API)都压。
### 三个必须显式处理的坑
1. **SSE 不能压。** text/event-stream 进 gzip 缓冲后 flush 语义就废了
(前端收不到流式,要等缓冲攒够)。对 SSE 请求直接透传。
2. **必须透传 http.Flusher。** handleChatEvents / streamOpenAI 里是
`w.(http.Flusher)` 类型断言;包装 ResponseWriter 会让断言失败 ⇒
flusher 为 nil ⇒ 走降级分支 ⇒ SSE **静默**坏掉(不报错,只是收不到
流式)。这不是「顺手加一下」能过的改动,有专门的判据守着。
3. **204/304/HEAD 没有 body**,压它们只浪费 CPU 并加坏头。
另外 webp/png/zip/gzip 等已压缩类型也跳过(mascot.webp 133KB 就在内)。
### 小于 1KB 的响应不压
gzip 头 23 字节,几百字节的 JSON 压完反而更大。与 nginx 的
gzip_min_length 1000 对齐。实测 /api/v1/status(197B)不带
Content-Encoding。
### 状态机写成枚举而非多个 bool
第一版用 passthrough/decided/buffering/allowBuf 四个 bool 交叉表示,
结果出两个 bug:小响应内容被写成空、已压缩类型仍被压。根因是
「该不该压」在 Write / WriteHeader / 收尾三处各判一次且判据不一致。
改成单一 mode 枚举(undecided/passThrough/buffering/streaming)、
判据只在 WriteHeader 与 Write 各求值一次后,两个 bug 同时消失。
### ★ 一条判据我自己写错了,值得记下来
TestGzipDropsContentLength 初版断言「压缩响应不应带 Content-Length」,
实测失败。追查后证明**判据错了、代码是对的**:
Go 在 Del("Content-Length") 之后,若响应体小到能被一次性缓冲(<2048B),
net/http 会**自动重算**并补上压缩后的真实长度(实测 14000B → 119B →
响应头 Content-Length: 119,正确)。真正要防的是**陈旧长度**:留着
14000 而实发 119 时,客户端按 Content-Length 读满会先拿到 119 字节再吃
unexpected EOF(已用对照探针实测复现)。判据改成两条:①声明长度 ==
实际读到字节数 ②该值 == 压缩后长度而非压缩前长度。另加一条对照判据
TestGzipStaleContentLengthWouldBreak,把危害钉成可执行断言。
### 验证(不是「应该能跑」)
- go vet 干净;全量测试通过;新增 12 条 gzip 判据;覆盖率 66.5% → 67.0%
- 真实实例(独立数据目录 + 18081 端口)实测:
· SSE:无 Content-Encoding,2 次独立 TCP 读(逐帧下发,未被缓冲)
· /api/v1/status(197B):不带 Content-Encoding
· /api/v1/kernel -69%、/api/v1/settings -73%、/api/v1/plugins -63%
· 首屏 wire 字节 77,943 → 23,339(-70%)
· 内容完整性:gzip 解压后与明文逐字段相等(plugins/tools/build/
channels 名称集合与顺序均一致)
- ★ 途中被一个「MISMATCH」误导过一轮:/api/v1/kernel 两次请求字节不同。
追查发现是 IOManager.ListChannels 遍历 **map**(Go 每次迭代随机化),
**在本次改动之前就已不确定**,与 gzip 无关。差点被我误报成压缩 bug。
附:dashboard.js 被自动格式化器整体重排(6783 增 / 6293 删,纯空白与
引号风格)。已用 prettier 归一化后逐字节比对确认**零语义差异**。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-09-27 00:02:28 +08:00 |
|
|
|
9346fed0d9
|
feat(webui): 星图跟随 agent 活动 + 搬到主页 + 修分类配色从未生效
星图此前只是静态展示:starmapAnimate() 只转星空,节点完全静止,
与 agent 的动作零关联。本轮三件事。
★ 修一个从未被发现的 bug:分类配色一直是坏的
服务端 type 是首字母大写("Concept",见 internal/memory/graph.go),
而 smTypeColors 的键全是小写 ⇒ 永远匹配不上 ⇒ 1151 个节点全渲染成
同一个灰色 0xcccccc。浏览器实测确认:改前 colors=["cccccc"],
改后 ["44ff88"](概念绿)。
一、跟随 agent 动(三路信号,全部在渲染循环里推进,不另起定时器)
1. tool_call / stage / agent_output 的 SSE 事件 → 命中节点发光冲高
+ 尺寸微扩。工具名按**词**匹配实体(knowledge_list → knowledge_*)。
2. /runtime 调度器(3s)→ 排队/中断/挂起时全图绷紧;中断或抢占计数
上升时来一记强脉冲。
3. /memory/graph/pulse(10s,新端点)→ 新记忆「生长」:从 0 弹到
正常大小并留余晖。
另:距上次活动越近,全图越亮(抽样呼吸)—— agent 一忙图就活。
二、搬到主页 + 独立页签
总览页内嵌 360px 星图;顶部导航加「星图」独立页签(全高 + 图例)。
同一套 renderer 用 appendChild 在容器间搬运 canvas(three.js 的
canvas 只能有一个父节点,同时渲染会一边黑屏)。
三、性能:保留全部 1151 节点,但全部降规格
改前每节点 = 独立 SphereGeometry(16,12) + 独立光晕球 + 一张 256x64
CanvasTexture ⇒ 2302 个独立 geometry、约 88 万三角形、1151 个
<canvas>,仅文字贴图就吃约 72MB 显存。全景远看根本读不清那些标签。
改后:共享 SphereGeometry(8,6)(约 84 三角形/节点);标签改为 hover
时在容器角上显示 HTML 文本(零显存,且比 3D 贴图更清晰);
866 条边按关系类型合并成 4 个 LineSegments(draw call 866 → 4)。
hover 复位随之改为 baseScale —— 旧的 set(1,1,1) 会把按 mention_count
缩放过的大节点缩成最小尺寸。
四、/memory/graph 瘦身:不再下发稠密向量
星图是本接口唯一消费者,却从不读 vector。生产实测该字段占
79,314 / 402,811 字节 = 19%,而 8 块记忆就这么多,200 块就是 ~2MB
白查白发白堆。真实数据集实测响应 402,811 → 326,997 字节(-18%)。
新增 /memory/graph/pulse:只回 since 窗口内变动过的实体(id/name/type/
mention_count/updated_at)。星图每 10s 拉它来判断「哪个节点新长出来」,
而不必重拉 400KB 全量。
验证(不是「应该能跑」):
- go vet 干净;go test 全绿;新增 5 个测试(向量裁剪 / pulse 窗口 /
since 放大 / pulse 不带向量 / 类型断言失败时透传不丢数据)
- 覆盖率 65.5% → 66.5%
- 真实 1151 节点数据集上跑 headless chromium + SwiftShader 实测:
nodeMeshes=1151、edgeSegs=4、geoShared=true、控制台零报错、
图例与统计(1151 节点 / 866 关系)正常、canvas 在两个容器间正确搬运
- 脉冲匹配在浏览器里逐个 hint 验证:
knowledge_list → 2 个(只命中 knowledge_base / knowledge_list)
qq_get_message 等无匹配 → 8 个(走「整体活动」兜底)
★ 途中修掉自己的两个错:① 最早的子串匹配让 hint="knowledge" 命中
全部单字实体(一次 pulse 选中 250 个、队列顶到 260 上限);
② 改成词匹配后,旧的「补齐到 20 个」逻辑又把 1 个真实命中补成 20 个
无关节点 —— 现象与①一样,只是成因不同。补齐现在只在**完全无匹配**
时启用。
未做(本轮范围外):服务端 gzip(首屏 793KB 无压缩,实测可压到 ~240KB)、
renderAll 按页签懒加载(首屏仍在拉隐藏页签的 kernel+settings 共 230KB)、
setInterval 15s 全量重拉(空闲 53MB/h)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-09-26 23:31:58 +08:00 |
|
|
|
bf5d8915dd
|
docs(site): 介绍站补「场面涌现」板块
三层记忆那张图只讲了字面相关性召回,缺了按场合召回的那一半。
新增一段说明:场面指纹(通道/对象/工具/话题/时段)如何自己长成场面、
为什么只发生一次的不算场面、以及 ScenePolicy 声明项(默认参与)。
沿用现有 card/grid-3/reveal class,视觉与既有三张卡一致;
div 与 p 配平关系与改动前完全一致(142/142、差值 40 未变)。
|
2026-09-26 23:14:17 +08:00 |
|
|
|
2567a22be5
|
fix(memory): 场景键两路合并去重(现网日志实测 scenes=[chan:qq chan:qq])
现网 21:49 的 QQ 轮次日志打出 `scenes=[chan:qq chan:qq]` —— 同一键
出现两次。查因:tooldefs.go:63 与 task.go:793 是同一段拼接写法,
scenes := a.sceneKeysFor(...) // 内部有 seen 去重
turn := a.resolveTurnScenes(...)
for _, k := range turn.Keys {
scenes = append(scenes, k) // ← 两路之间没有共同的 seen
}
而声明路与通道派生路都会产出 chan:qq(既是插件声明的、也是从
evt.Source 派生的),于是重复。
功能上无害(RecallByScene 内部会再去重),但有两个实际代价:日志里
的 scenes=[...] 误导排查——会让人以为场景集合本身有问题;以及每次
白走一遍前缀匹配。
修法:抽 mergeSceneKeys 共用函数(顺带消掉两处重复代码),两处调用点
都走它。判据 scenemerge_test.go 5 例:3 个合并场景(同名 / 归一化后
同名 / 多路重复)+ scene_policy=none 时两路皆空(防「声明路关了但
涌现路还开着」的半开状态)。
记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
|
2026-09-26 21:55:12 +08:00 |
|
|
|
13a070fbb2
|
docs(memory): 标记步骤 3/5 完成,补步骤 4 部署前基线与硬约束
步骤 4 加了硬约束提醒:必须先部署含 R1 修复的二进制再清理,否则
重新涌现出来的还是带 +/# 的旧键。并记录部署前实测基线(PID 92115、
子进程插件 25、近 24h 异常日志 0、场景 65),以及 homed --version
这个 flag 并不存在(纪律清单那条要用 status 接口或 ps 核对)。
|
2026-09-26 20:17:17 +08:00 |
|
|
|
758ec11832
|
fix(memory): 图整备覆盖 scenes + 证据桶按桶清(R3/R5)
R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。
Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表
(graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现:
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features
(聚类只读 scene_features,所以它永远不被看见)。
新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、
特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行,
跨 origin 也合。接到 mergeLoop 尾部。
为什么不塞进 detectEntityMerge 的双重循环:
- 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、
~224GB 瞬时分配每轮,是独立问题);
- 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同
本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的
职责,不是这里的事。
只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险
的,去重不是「把像的一律合并」。
生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产):
65 个场景 → 合并 8 组 → 57 个;
auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。
R5:createSceneLocked 新场景成立时执行的是
`DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的
计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli
尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零,
于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。
改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。
判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、
scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是
sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键),
第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。
记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
|
2026-09-26 20:16:11 +08:00 |
|
|
|
8887e06274
|
feat(sdk+memory): 补 ScenePolicy 声明项,让通道能退出场面识别
缺口(R6):ChannelDef 的记忆声明已有三件套——NoMemory 管「进不进
记忆计算」、ContextPolicy 管「裁不裁上下文」、RecallPolicy 管「召不召回
记忆」,唯独没有「这条输入算不算一场戏的一部分」。现状是无条件参与:
situationFeaturesFor 里只要 evt.Source != "" 就产出一个 chan 特征,没有
可关的开关 ⇒ chan:system / chan:kernel / chan:timer 这类纯内部信噪通道
也在撑场面,每次触发都让不相干的场景长出来或变强,召回时又会把
「内核在跑定时器」当成「用户在这类场景下说过的话」取回。
穷举确认不是查漏:go.mod replace 指向 third_party/homeagent-sdk,
plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' -- sdk/ 为空。
SDK(纯追加,老插件行为逐字节不变):
- 常量 ScenePolicyAuto / ScenePolicyNone + ValidScenePolicy,形状与
ContextPolicy / RecallPolicy 完全一致
- ChannelDef.ScenePolicy 与 InjectOptions.ScenePolicy,均带 omitempty
- 默认取 auto(参与)而非 none:场景只附加检索路、不改记忆本体,
默认关会让存量通道突然失去召回;「关」是少数意图。与 ContextPolicy
刻意相反(同为破坏性操作,那里是默认关)。
内核:
- applyInjectOpts 搬运 scene_policy(与另外三个标志位同面)
- sceneSuppressed 完全照 recallDeclared 的形状:注入点 payload >
通道定义 > 默认。none 时连时段(part)特征都不产,也不派生场景键
(只停指纹采集而留声明路,等于给这个口子开后门)
- situationFeaturesFor / sceneKeysFor 由包级函数改为 Agent 方法
(需要 a.io 查通道定义),23 个调用点同步
判据:scenepolicy_test.go 7 例,改前编译期红(undefined:
pubsdk.ScenePolicyAuto),改后全绿。其中两例专门护住「未声明时行为
逐字节不变」,是纯追加承诺的护栏。
记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
存量通道标注待定:kernel/timer/offload-*/child/* 是纯 0-refs 信噪,
可直接标 none;但 mc:system(12 refs) 与 system(3 refs) 带真实记忆,
性质不明,不擅自标。
|
2026-09-26 20:09:17 +08:00 |
|
|
|
f441574b80
|
docs(memory): 补 R6/R7 与 SDK 声明项方案,重排为 7 步
R6:ChannelDef 的记忆声明已有三件套(NoMemory/ContextPolicy/
RecallPolicy),唯独没有「这条通道是否参与场面识别」,现状是无条件
参与 ⇒ chan:system/kernel/timer 等内部信噪通道也在场面聚类里。
穷举确认非查漏:go.mod replace 指向 third_party/homeagent-sdk,
plugin.go 中 scene 出现 0 次,SDK 自身 git 历史 -S'Scene' 为空。
R7:payload[scene] 的层级键(chan:qq/peer:group_1)已支持前缀
召回(RecallByScene scene.go:351),但因 R6 无人使用而闲置。
步骤重排为 7 步:SDK 声明项提到步骤 2(用户已授权动公开 SDK,
开发阶段非 release 阶段),并把「peer 覆盖面」拆到步骤 6 单列,
先只读调查插件手上有什么再动。
|
2026-09-26 20:03:51 +08:00 |
|
|
|
6e9420965d
|
docs(memory): 补 R4 覆盖面与 R5 证据桶两条根因
R4:现网 65 个场景键里 peer 主导 0 个、topic 主导 0 个,36 个
auto:chan + 26 个 chan: 全部锚在输入通道。两个独立原因:
采集侧全仓无插件在 InjectInput 填 peer/group_id/user_id/chat_id
(日志中 peer 出现 0 次);排序侧 chan 与 peer 权重同为 1.0 而
NewSituation 稳定排序让 chan 恒在前,即使采集到也进不了 Label(2)。
这与 R1/R2 是不同层面:R1 修完只会得到「正确的单一维度」。
R5:createSceneLocked 新场景成立即 DELETE FROM situation_evidence
(全表清),会连带清掉别的场景尚未攒够门槛的证据。
|
2026-09-26 19:58:00 +08:00 |
|
|
|
1fa9ef68a4
|
fix(memory): 场景键归一化 + 排除最弱维度,修双胞胎与空转
现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的
auto:chan:qq+part:morning 累积到 strength=270、6 个 features、
0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning
则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系
各活各的,谁也发现不了谁。
病因:键构造不唯一。
- Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而
EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
"+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法,
key UNIQUE 约束拦不住。
- Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测
morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含
part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。
这本就是加权 Jaccard 的正常行为,但键名不该写进时段。
- createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_",
造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找
auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。
修法:
- Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey;
全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能
没有名字 —— 没名字就没有键,场景根本长不出来)。
- createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空
时拒建无名场景。
- 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。
判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面
⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据
在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。
其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧
那条路本身是通的,坏的只是键的构造。
记忆 8 包 + agent/core 全绿。
|
2026-09-26 19:56:28 +08:00 |
|
|
|
8bd8271f49
|
Revert "fix(agent): output_send 缺收件人时从输入事件自动补"
This reverts commit 6b74862.
## 为什么撤
方案本身是错的,三点:
1. **假设 meta 的收件人字段跨通道通用。** 只实现了 qq(user_id/group_id),
而 wechat、群聊各有各的字段名。逐通道补全会变成一张靠猜的映射表,
每加一个通道就得重猜一遍。
2. **假设「回复来源 = 收件人」。** 线上实测直接推翻(2026-09-26 19:05):
一次请求从 webui 会话发起、却要发到 QQ(input source=webui,
output channel=qq)。收件人与来源根本不是一回事 —— 用户在
WebUI 里说"发个 QQ 给我",收件人只能来自上下文或用户明说。
按 channel 名补,等于用错误的假设去覆盖真实场景。
3. **没解决问题,反而引入新风险。** 19:05:46 那次照样报同样的错
("meta 中需要 group_id 或 user_id"),补全逻辑对跨通道场景无效。
而一旦补错,消息会发给错的人 —— 那比报错坏得多。
## 保留的部分
现象描述与排查结论留在这个 revert 的说明里,便于后续重新设计时
不再重复踩:各通道 meta 结构差异极大,要让插件自己声明收件人来源
(qq 插件知道自己的 user_id 从哪来),内核只转发不猜。
不采用"把格式写进工具描述"这类方案:那只缓解症状,
且各通道格式仍需逐个核实。
|
2026-09-26 19:11:51 +08:00 |
|
|
|
6b74862118
|
fix(agent): output_send 缺收件人时从输入事件自动补
## 现象(线上 2026-09-26 17:57)
用户从 QQ 私聊发来消息,agent 生成了回复也调了 output_send__qq,
但**没填 meta**:
17:57:45 qq_get_message → {user_id: 2198972886, message_type: private}
17:57:46 output_send__qq → 失败:meta 中需要 group_id 或 user_id 字段
17:58:14 output_send__qq_help → 查格式
17:58:14 output_send__qq → ok ← 靠重试成功,整轮耗时 74s
信息内核**本来就有**(输入事件里带着 user_id/group_id),却要模型从
qq_get_message 的返回里手抄进 meta。抄错就失败,失败才去查 _help。
而"回复"这件事的收件人是确定的(= 消息来源),本不该由模型负责。
运气差就不重试:同日 17:15 / 17:21 两次 `tools=[]` —— 模型压根没调
output_send,回复生成了但没发出去,日志连一行告警都没有。
## 改法
meta 缺收件人时,内核从**本轮输入事件**推导后补上。模型只需给内容。
## 边界(都刻意收窄:宁可不补,也不能补错)
- 显式传了 meta ⇒ 原样返回。主动 DM 别人等场景必须保持原行为。
- meta 里已有 group_id/user_id ⇒ 不覆盖。
- meta 是坏 JSON ⇒ 原样返回。让下游报"格式错",而不是被静默替换成
一个模型没要求过的收件人 —— 那比报错更坏:消息会发给错的人。
- 非 qq 通道(如 webui)⇒ 不补。webui 走 ResponseCh,不过 output_send。
其余异步通道(wechat 等)不猜:猜错等于发错人。
- 输入事件里没有收件人信息 ⇒ 留空,让下游按原逻辑报"需要 user_id"。
宁可报错让模型重试,也不要编一个收件人。
- 群消息里 user_id 是**发送者**不是收件人 ⇒ group_id 非 "0" 时优先用
group_id,否则会把消息发回给群成员本人。
数字型 user_id 要按整数格式化:JSON 反序列化成 float64 时
fmt.Sprint 会得到 "2.198972886e+09"。
## 判据:7 条
私聊补 user_id / 群聊补 group_id 且不补 user_id / 显式 meta 不改写 /
已有收件人不覆盖 / 坏 JSON 不静默替换 / webui 不补 / 无信息留空(含 nil evt)。
★ 实现时我先自己写了个 payloadString,编译报错才发现包内已有更完整的
版本(memorypass.go:176,含 float64/int64/int/json.Number 分支),
直接复用 —— 不重复造轮子。
全量 42 包绿。
|
2026-09-26 18:29:23 +08:00 |
|
|
|
bd1d5fff2b
|
chore(vendor): 主仓不再跟踪 SDK 示例代码(决策 A)
## 背景
`.gitignore` 第 26-45 行早已写明「外部插件与工具链维护在独立 SDK 仓
(决策 sdk_repo_only),本仓经 go.mod 的 replace 引用」,并忽略了
example/、tools/、docs/、site_build/、package/、scripts/。
但有 29 个文件**早于该规则**被跟踪,靠「已跟踪文件不受 .gitignore
影响」留着,注释还特意写了「这是有意的,不要『修』」。
本轮修 example/bili 时踩到了:那 87 行改动先落在主仓、再手动同步到
SDK 仓 —— 同一份代码两个仓各改一遍,正是这个遗留结构的成本。
## 核实:主仓到底需要什么
- 主仓 Go 代码只 import 两个包:`homeagent-sdk/sdk`(插件入口)、
`homeagent-sdk/meta`(版本号)
- `remotedevice/` 是 C 库,主仓 C 侧明确「不链接任何外部库」,
只有注释里提到它
- `tools/hmapdev/yaegi` 的 import 出现在 **SDK 仓自己的文件之间**
(yaegi/interp.go → yaegi/mocksdk),不是主仓依赖;
且 tools/ 本就在 .gitignore 里,从未被跟踪
所以 29 个文件全部可以删。实际仓库负担也只有 42 个文件 / 0.6MB
(工作树里那 971MB 绝大部分是未跟踪的构建产物,不是仓库体积)。
## 改动
- 删 21 个 example/ 文件(10 个示例的 plg.json + plugin.go + qq/plugin_test.go)
- 删 8 个 remotedevice/ C 文件
- 工作树里一并清掉(不受跟踪的构建产物顺带回收)
构建与全量测试均通过。
## 判据:3 条 + 变异(internal/meta/vendored_sdk_test.go)
- TestVendoredSDKHasNoExampleOrRemotedevice:防止示例代码被重新提交进来
- TestVendoredSDKKeepsRequiredPackages:**反向**保护,防止为省事把
sdk/ 与 meta/ 也删掉(上一条只防"多了",删过头要靠这条)
- TestGoModStillReplacesSDKToVendoredPath:决策 A 依赖 replace 指向 vendored 路径
★ 判据自己踩了两个坑,都靠实跑抓出来:
1. `git ls-files` 的路径参数**相对当前目录**解析,而测试跑在
internal/meta/ 下 ⇒ 就地执行返回空,表现为「必需包全都不在」的假红。
改用 `git -C <仓库根>`。
2. 把 tools/hmapdev/yaegi 当成主仓依赖写进必需清单 ⇒ 又一次假红。
根因是把 SDK 内部的引用误当成主仓依赖(它本就在 .gitignore 里)。
变异验证:塞一个 example 文件进版本控制 → 判红。
|
2026-09-26 17:18:41 +08:00 |
|
|
|
ceeef0b4e1
|
fix(proc): 杀插件进程组 + readerWG 超时兜底 —— 修孙进程拖死关停
## 现象
线上关停必超时:systemd 报 `State 'stop-sigterm' timed out. Killing.`,
进程组里 23 个插件全退完了,最后那条 `[homed] stopped` 仍打不出来。
其中只有 bili 报 `[proc] bili SIGKILL 后 2s 仍未被收割`,之后近 90 秒无日志。
## 根因
bili 用 exec.Command 拉 yt-dlp(源码 example/bili/plugin.go:122/212),
无 CommandContext、无 Setpgid、Stop() 是空的。yt-dlp 再 fork ffmpeg,
**孙进程继承插件的 stdout 管道写端**。
插件被 SIGKILL → 孙进程仍存活、写端不关
→ readLoop 的 scanner.Scan() 永不 EOF
→ p.readerWG.Wait() 永不返回(Kill 的最后一行,**无超时**)
→ StopAll 的 wg.Wait() 永不返回 ⇒ 关停挂死 ⇒ systemd SIGKILL
内核 process.go:340 的注释早已预警过这个场景("插件 fork 的孙子进程继承
同一 stdout 写端时,插件本体死了 EOF 也不会到"),但 Kill 没有对应保护。
## 内核三处修法(缺任一条都不够)
1. **spawn 时 Setpgid**:插件自成进程组,不再与内核同组
2. **Kill 杀整个进程组**(kill(-pgid)):孙进程一起死,管道写端才关。
兜底:负 pid 失败时退回杀本体(老插件/非 Unix 平台)
3. **readerWG.Wait() 加超时兜底**:这是唯一能保证 Kill 一定返回的地方。
超时后主动关读端逼 readLoop 退出,再兜一层仍不退就放弃等待 ——
宁可少等 2 秒,也不能把关停无限期挂住。
## 插件侧(bili)
CommandContext + Setpgid + Stop() 里 cancel 并 wait:
- 只 cancel 不 wait 的话内核会先释放共享段,而 yt-dlp 还在写 stdout
- waitRunGroup 杀整个进程组(ffmpeg 也在内),不留孤儿
## 判据:5 条 + 3 组变异
判据用**真实模板编译的插件**(复用 buildPluginWithRealTemplate,
与 e2e_template_test 同一条路)+ NewHost 启动,不是自造 shim:
裸 Spawn 没有 Host 建共享内存段,插件握手会报 permission denied。
★ 判据自己踩了三次坑,都由变异/合跑抓出来:
1. 给孙进程也加 Setpgid ⇒ 它逃出插件进程组,kill(-pgid) 杀不到,
造出假失败(真实场景 yt-dlp 不会脱离进程组)
2. 各测试数全局孙进程数 ⇒ 前一个泄漏的被后一个数进去,
单跑通过、合跑变红。改为记录基线只关心自己新增的
3. readerWG 超时那条用纯构造 &Process{cmd:nil} ⇒ Kill 第 607 行
早退,根本走不到那段,撤掉超时照样绿。补了「脱组孙进程」
场景(Setsid 逃出进程组)才真正覆盖到
变异:去 Setpgid → 判红;只杀本体不杀组 → 判红。
全量 41 包绿。
|
2026-09-26 16:58:41 +08:00 |
|
|
|
324181d663
|
fix(plugin): StopAll 并行停插件 —— 修关停必然超时被 SIGKILL
## 现象
systemd 每次都报 `State 'stop-sigterm' timed out. Killing.`
进程组里 23 个子进程插件**全退完了**,最后那条 `[homed] stopped`
仍打不出来,然后被 SIGKILL。
## 根因(算出来的,不是猜的)
单个插件的 Stop 最坏预算:
5s(CallContext plugin.stop)+ 5s(等 exited)+ 2s(Kill 后收割)= 12s
串行停 23 个 ⇒ 23 × 12s = 276s,而 systemd 只给 90s。
⇒ 关停必然超时。线上每一条 stop 记录都是 timed out,无一例外。
## 改法
StopAll 改为并行:取插件快照 + 各自的 SDK 句柄后**立即释放 registry 锁**,
每个插件一个 goroutine,等全部完成再释放共享段。
三处必须小心的点(都是并行化引入的新风险):
1. **先释放 registry 锁再并行停**。p.Stop() 会触发
markExited → onExit → ReclaimOwner,那条链要读共享内存段。
持着锁并行跑,若某插件的 onExit 需要拿 registry 锁(摘通道等)
就是自死锁。
2. **stop handler 的快照要在清空 sdkRefs 之前取**。handler 挂在
PluginSDK 上(r.sdkRefs),先清空就再也拿不到了。
3. **单个插件 panic 不带崩关停**(那会让剩下的插件全停不掉),
也不静默吞(留日志)。
## 判据:5 条 + 3 组变异 + race
- TestStopAllStopsInParallel:8 个插件各 120ms,串行 960ms / 并行 120ms。
判据直接量耗时,串行实现必然变红(实测串行时 962ms)。
- TestStopAllSurvivesPanickingPlugin:panic 不外冒、其它插件照停、不死锁
(带 10s 超时,死锁会超时而不是挂住测试)
- TestStopAllFreezesAutoRestart / StopsEachPluginExactlyOnce:
冻结自动重启、每个插件恰好 Stop 一次(重复会二次释放共享段)
- TestStopAllRunsStopHandlerBeforeStop:handler 必须先于 Stop,
走真实的 PluginSDK.RegisterStopHandler 路径
变异:退回串行 → 判红并打出实测耗时;去掉 handler 调用 → 顺序判红;
假装并行只清空 → 4 条判红。
`-race` 通过(并行化必须验锁,这是本次改动的头号风险)。
全量 41 包绿。
|
2026-09-26 16:23:56 +08:00 |
|
|
|
5dd98d7a05
|
feat(knowledge): 目录批量导入 + 派生数据批量收口
## 能力缺口
导入只能一条条 Add(knowledge_create)。agent 拿到一份 200 页的
文档目录要调 200 次工具,且每次都得自己决定分类与名字。
新增工具 `knowledge_import_dir(dir, category?, include_media?, dry_run?, max_items?)`。
语义是**复制**不是引用:源文件删改不影响已导入的副本。
- 文本经 Write 整份写入 <知识根>/<分类>/<名>/content.md
- 媒体按 sha256 进媒体库(内容寻址天然去重),条目只存 digest 引用
## 目录约定(自动适配,不要求改造资料)
1. 含 content.md 的目录 ⇒ 整体作为一个条目(与 scanDir 既有语义一致,
所以知识库自身目录能被原样再导入而不会被拆散)
2. 否则 .md/.txt 等文件各成一条,**目录路径即分类**
## 三个语义决策
- category 是**前缀叠加**(tech + 源结构),不替换:替换会丢掉源目录
自身最有价值的层级信息
- 同名冲突**跳过并计数**,绝不覆盖:Write 对同名本就是覆盖语义
(knowledge_create 靠它做更新),若直接调它,一次重导就会把手工
补充的内容悄悄抹掉,而日志只写"导入完成"
- dry_run **默认 true**:批量写,agent 第一次试某目录应先看清会写什么
## 安全边界(批量操作,缺一道就可能读到不该读的)
- 必须绝对路径:agent 的 cwd 不受控,相对路径会静默导到别处
- 符号链接不跟随:否则一个软链就把知识根之外的文件导进来
- 拒绝把知识库自身当源(自导会无限自我复制)
- category 复用 normalizeName(与 Write 同一道闸,两处分叉就成了绕过)
- MaxItems 默认 500:防 agent 误传 "/" 把盘灌满
## ★ 批量导入暴露的既有 O(N²)
Write 每条末尾都调 flushDenseLocked,而 saveDenseCacheLocked 是
**全量序列化整个 items map 再重写整个文件**。按 512 维 float64 估,
单条约 10KB,导入 500 条累计要写约 1.4GB。
仓库里索引侧早已有 indexDirty 的「标脏+延迟收口」(实测 writeIndexLocked
6.7ms/次、占单条 Add 绝大部分),**稠密缓存却还是逐条全量重写** ——
同一类开销只修了一半。
照 indexDirty 的模式补 batchDepth:批量期只标脏,endBatch 收口一次。
用 defer 保证提前 return 也会收口 —— 否则这批向量会留成"标脏未写",
下次启动被当作缺失而全量重算。
## 判据:17 条 + 变异
安全边界做了 4 组变异验证(去符号链接拦截/去绝对路径要求/去自导检查/
content.md 目录不下钻)。
★ 判据第一版有两处自己骗自己,被变异抓出来:
1. 符号链接判据造的是**目录软链**,而 WalkDir 对目录软链本来就不下钻
⇒ 有无防护结果都一样,是假绿。改成**文件软链**后才真正判红。
2. 同名冲突判据里已有条目写成 "a/b"、源映射出的是 "b"(不同名),
判据自己就错了 —— 修判据而不是改实现。
媒体路径用假 MediaPutter:验的是「调了 Put 且 digest 挂到条目上」,
媒体库自身的落盘去重是 media 包的判据,不该在这里重测。
全量 41 包绿。
|
2026-09-26 16:19:10 +08:00 |
|
|
|
ef695c2723
|
chore(meta): main 构建产出 1.4.0dev 路牌,不再自称旧 patch 线
|
2026-09-26 15:44:35 +08:00 |
|
|
|
dfc05780fd
|
feat(kbtree): 知识库暴露范围配置 —— 按树状只暴露指定分类
## 问题
kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent 自己的 agent
走进程内直调 knowledge_* 内核工具,不经此),但它只有 listen_addr 与
token 两个配置,**没有任何范围限制**:拿到 token 的任何 agent 都能
/tree 列出全部条目、/search 取回任意条目全文。
本机库里混着个人内容(航空发动机教材摘录、课表、身份合并规则),
不该 broadly 可读。
## 改动
1. `internal/plugins/kbtree/scope.go`(新):暴露范围语义
- 留空 = 全部可见(范围是"限制"不是"必填",留空保持既有行为)
- 前缀按**路径分段**匹配:public 命中 public 与 public/tech,
但**不**命中 publication(否则 publication 意外暴露)
- 根下无分类的条目在范围非空时不可见 —— 它没有分类可匹配,
放行等于范围形同虚设
- 分隔符容忍逗号/分号/空白/换行/竖线:这是给人手填的字段
2. `plugin.go`:注册 `expose_categories` 配置项,接入**全部四个端点**
- /tree 服务端裁剪子树(就地改,不重建:TreeView 字段多)
- /categories 过滤路径列表
- /counts 过滤计数并**重算 total**(数量本身也是信息泄露)
- /search ★ 过滤结果条目;这处最关键:
只过滤 /tree 而放过 /search 等于范围形同虚设(换个 ?q= 就能拿到全文)。
同时修正 limit 语义 —— 范围外条目不占名额,范围内条目不会被挤掉。
3. SDK 契约补 `Knowledge.Category`(纯增量)
- 此前 `sdk.Knowledge` 只有 Name/Content,内核明明返回了 Category
却在 knowledge_impl 的拷贝里丢掉 ⇒ 外部服务无法按分类判定,
范围过滤在 SDK 层根本做不了。
- Name/Content 均保留,无删除。
## 判据(8 条 + 4 组变异)
范围过滤最容易"只做一半",所以每个端点都单独钉。
★ 判据补强一处:初版只查条目名(priv1),结果「/categories 不过滤」
这个变异**完全逃过** —— 分类端点返回的是路径不是条目名。
补上分类路径断言(private)后判红。
变异验证:
- /search 不过滤 → 泄露 priv1 全文 ✓ 判红
- /categories 不过滤 → 泄露 private 分类路径 ✓ 判红(补强后)
- /counts 不过滤 → TestCategoriesAndCountsEndpoint 判红
- 前缀退化为字符串前缀 → publication 被误暴露 ✓ 判红
★ 过程中我的 fake 有两处与真实内核不符,先修 fake 再修实现:
1. 漏了内核 treeLocked 的"子分类提升一层" ⇒ 得到 children=0 的假空树
2. filterTree 无差别清空 t.Items ⇒ 整棵树只剩空壳节点
(第一版的实现是"看着测试红就改",实际是 fake 在骗我)
全量 41 包绿。SDK 接口纯增量,未发布故无需冻结检查。
|
2026-09-26 15:31:15 +08:00 |
|