mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
217e32b4945bf94ff05a7d901ccfefdffdb95e50
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 0c959e3ff3 |
fix(rtchild): pump 裸读写 data race —— 锁贯彻到底
## 缺陷(-race 实测)
rtChild 里 pump 是**唯一**"构造后被改写、又有并发读者"的字段,
但它的读写绕过了 rc.mu:
Dispose(StopRTChild 的 goroutine) rc.pump = nil ← 写
rtEmit (rtLoop 的 goroutine) rc.pump ← 读
-race 栈:
Read at … core.(*Agent).rtEmit rtchild.go:1275
Previous write … core.(*rtChild).Dispose rtchild.go:377
## 为什么之前没被发现
TestRTChild_E2E_Sustained 覆盖同一个窗口,但它是**偶发命中**:
全量负载下 5 次中 1 次,单跑 20 次 0 次,基线
|
|||
| cc0eb51286 |
refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
|
|||
| 387ae945df |
fix(agent): 子 agent 继承技能索引 —— 修 agent 把终端当命令执行器用
## 缺陷(生产实测)
`SetSkillIndexProvider` 只在 cmd/homed/bootstrap.go 里对**根 agent** 调了一次。
rtchild / 驻留子各自 `New(AgentConfig{...})` 构造载体,都没传这个字段 ⇒
子的 system prompt 里**完全没有 skill 索引**。
症状(日志实录):A2A 会话由「临时分诊助手」(驻留子)接手,它手上没有
`cmd_run`(cmd 插件 `auto_start=false`),而 `cmd-tools` skill 正文里写着
「先 plugin_start cmd」。它看不到这条 skill,于是把 `terminal_create` 当成
命令执行器:每条 `cd … && echo … && grep …` 开一个新终端,终端随命令结束退出,
`terminal_read` 立刻撞 `read /dev/ptmx: input/output error`,然后为下一条命令
再开一个 —— 终端列表被填满,每一条都死在半路。
与本仓 Security 那次同型:父有的能力,子没继承。
## 修法
rtchild 与驻留子的 AgentConfig 各补一行 `SkillIndexProvider: a.skillIndex`,
与 `SystemPrompt` / `StageHost` / `Security` 同一组「必须继承父」的字段。
## 顺带两件事
### 1. 内置 skill 的更新永远到不了已部署机器
原落盘规则是「目标目录已存在就跳过」,本意是保住用户改动。但它把两件事混成一件:
用户改过的版本 ⇒ 应该保留
内核自己写过的旧版 ⇒ 应该随内核更新
两者都表现为「目录已存在」,于是
|
|||
| 637a3a3d3f |
refactor(agent): R4 工作式子语义落地 + 清除 R1–R3 遗留的死代码
## 1. 工作式子的工具禁令收成单一事实来源 禁令此前写了两遍:定义侧(把工具从 childTools 滤掉)与执行侧 (模型硬调时拒绝)。两处名单必须逐字一致,任何一边落后就是真实缺口: 定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试) 执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效** 现在只有 workerForbidden 一张表,每条禁令把"匹配方式 + 类别 + 解释" 绑在一个结构体里;定义侧 workerToolAllowed、执行侧 workerToolReject 都从它派生。 上一版我用 reason 字符串 + 下标猜分类 + hint 查表做了三处间接, 属于新造的技术债,已重写为单表直查。 判据(均经变异验证): TestWorkerToolDenyListIsSingleSource 长出第二份名单 ⇒ 红(实测确认) TestWorkerToolAllowedMatchesReject 两侧判据漂移 ⇒ 红 TestWorkerToolRejectExplainsWhy 拒绝文案不可执行 ⇒ 红 ## 2. 删除零使用的抽象(不留技术债) R1–R3 引入了 ChildKind / Host / Deliverer / DeviceResolver / ToolSpec / ToolResult / DeclaredKinds / KindWorker / State / SetState / BumpRound / ChannelDisposition,实现过程中发现它们是假抽象: · 各类型要用载体 Agent 的大半能力,要满足接口就得在实现里类型断言回 *Agent · 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放 · 实测:26 个导出符号里 20 个零使用、零实现 已删除 host.go 与 kind.go,并把"四种类型清单"和"转投驻留子为何不是类型" 这两个有价值的结论移进 kinds.go 的文档注释(知识保留,死代码删除)。 child 包现在只剩实际被用的 9 个符号:Kind / Child / Entry / Registry / StopDeps / ErrChildExists / ErrChildNotFound(+ 2 个 Kind 常量)。 ## 3. 修掉误导性签名 Entry.Dispose(policy ResidualPolicy) 里 policy **两个实现都不读** —— 残余处置实际走 ApplyResidual / FinishResidentWith,在 Dispose 之前就 已经完成。看签名会以为这里处理残余,是误导。已去掉该参数, 并删掉 child 包里重复定义的 ResidualPolicy(core 包已有一份,且多一个 stash)。 ## 验证 go build ./... PASS go vet ./... PASS gofmt -l 干净 go test ./internal/agent/core/ -count=1 ok 15.300s go test ./internal/agent/core/ -count=1 -race ok 18.040s 干净检出(git worktree)单独 build + vet + test PASS |
|||
| a02feaaff7 |
refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。
## 1. 抽出 child 包(类型抽象 + 单一登记表)
新增 internal/agent/core/child/:
child.go Kind / Child(嵌入用)/ State / 残余与通道处置
registry.go 单一 Registry + Entry 接口 + Cleanup 骨架
kind.go ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
kinds.go 四种子 agent 的清单与迁移状态
此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。
现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。
## 2. rt 的输出改为异步(用户的原始诉求)
rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。
新增 outputpump.go(输出通道的异步壳):
· Push 立即返回,交付由独立消费 goroutine 完成
· 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
· 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
· Close 排空后退出,不吞最后半句
· 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)
## 3. 工具钩子统一(本轮最大发现)
实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。
⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。
新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。
## 判据(均经变异验证)
TestRTChild_OutputIsAsync 改回同步 ⇒ 红(实测确认)
TestOutputPump_DropsOldestOnFull 改阻塞/无界 ⇒ 红
TestOutputPump_CloseDrains 丢弃队列 ⇒ 红
TestOutputPump_ResolvesDeviceEachTime 缓存指针 ⇒ 红
TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
TestRTChild_StageCanDenyTool 同上
TestWorkerToolsGoThroughPluginStages 同上(变异实测确认)
TestWorkerToolsStaySerial 改成并发 ⇒ 红
TestNoChildTypeTablesInCore 源码扫描,禁类型专属表回潮
TestRTChild_HasGraphMemory 等 5 条 rt 接入图记忆
## 顺带修复
· rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
· rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。
## 保留的语义(逐项确认仍在)
驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
三种通道处置:ChannelRelease / Suspend / Takeover
残余 keep/drop + harvestResidentMemory
child.Start() 的 7 个 goroutine
验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
|
|||
| 5194a96560 |
refactor(security): 安全策略全面收敛到 security 模块 —— agent 只提供事实,不再做策略决策
## 架构定位(用户澄清)
security 是**外围内核到核心内核之间的防火墙**:自己提供接口、判定内聚在自己内部。
device 是设备抽象模块、持有 channel,两者不冲突。
现成的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` ——
总线只交事件,guard 自己判,总线一行策略都没有。本次把同样的形状套到输入/主体上。
## 收敛前的问题
`agent/core/security_profile.go` 214 行里绝大部分是**策略逻辑**:
name, _ := payload["security_profile"].(string) // ① 解析 security 的协议
a.security.ActivateProfile(name, 0)
if a.parentID != "" { // ② 选哪条判定链
return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
}
a.security.DeclarePrincipalPolicy("id", id, PrincipalPolicy{...}) // ③ 拼策略结构
三个后果:
① **协议解析外泄**:`security_profile` 是 security 的概念,解析却在外人手里
② **策略决策外泄**:判断"父走 CheckTool / 子走 CheckToolFor"是策略语义
③ **提权口子**:`Kind: "resident"` 是调用方**声明自己的类别** —— 说自己是
内部主体就享受内部豁免(不过 HardPrivateTools)。类别必须从事实推出
且三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效。
## 收敛后的形态
security 新增 `round.go`(自己提供接口):
RoundInput{AgentID, IsChild, Payload, Source} ← 只有事实,**无 Kind**
rt.BeginRound(in) → security 解析档位 + 判定主体类别
round.CheckTool(t) → 本轮判定(调用方不需要知道任何组合规则)
round.End() → 归位(防跨轮泄漏)
rt.DeclareChildPolicy / RevokeChildPolicy ← 子策略生命周期
agent 侧只剩:`beginSecurityRound(payload, source)` + `defer End()`。
`security_profile.go` 从 214 行策略逻辑退化为转发层。
顺带全局收敛 `Principal.Kind`:裸 string → 固定枚举
(KindInternal / KindSubAgent / KindUser / KindGroup),并新增
`ParsePrincipalKind` —— 拼错返回 false 而不是静默回退到某类别。
## 轮次边界接到**全部三条**输入路径
task.go runInputTask ← 主 agent + 驻留子(各有自己的 Agent 实例)
rtchild.go rtRunOnce ← rtchild 不走调度器,是独立的一条
★ 收敛把**判定逻辑**收到了一处,但"一轮何时开始/结束"仍得各路径自己声明 ——
只有该路径知道边界。所以判据必须钉"每条路径都声明了"。
## 判据(5 个变异全部实测咬住)
| 判据 | 变异 |
|---|---|
| `TestSecurityProfileFromInputChangesToolDecision` | BeginRound 不解析 payload ⇒ 红 |
| `TestProfileRestoredAfterRoundEnd` | End 不归位 ⇒ 红 |
| `TestUnknownSecurityProfileFallsBackWithoutPanic` | 拼错沿用旧档 ⇒ 红 |
| `TestAgentCoreHoldsNoPolicySemantics` | 把 `Kind:` / `CheckToolFor(` 写回 agent ⇒ 红 |
| `TestPrincipalKindIsDerivedBySecurity` | 分类规则错位 / 枚举接受拼错值 ⇒ 红 |
| `TestEveryInputPathDeclaresRound` | rtchild 路径漏 beginSecurityRound ⇒ 红 |
`TestAgentCoreHoldsNoPolicySemantics` 逐行扫描(跳过注释),禁四类形态:
`payload["security_profile"]` / `Kind:` / `.CheckToolFor(` / `PrincipalPolicy{`。
## 实测(真实整机装配)
① security 自己解析出的档位 = "qq.guest"
② security 自己判定的主体 = {ID:main Kind:user}
③ 本轮判定 cmd_run = Allowed:false
reason="tool \"cmd_run\" is disabled by profile \"qq.guest\""
## 已知限制(已写进 round.go 与 future-plan)
`Round` 是 per-round 句柄,但 `Runtime.active` 仍是**进程级单例**。
多 agent 轮次**交错**时无法表达两个不同档位(串行场景完全正确)。
彻底修法是把档位挂到 TaskFrame 上按任务隔离,需先改 CheckTool 签名。
build / vet / go test ./...(62 ok / 0 fail)/ -race(security + agent/core +
kernelmod/all)全绿;make build 通过。
|
|||
| e78a38b3d8 |
fix(security): 父为子声明工具策略 —— 修正上一版「子继承父权限」的错误模型
## 上一版错在哪
上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。
那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。
区别是实质的:
错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大
驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。
## 这不是新机制,是没接线的旧机制
`security.Runtime` 里早就有:
CheckToolFor(p Principal, tool) // 按主体判定
PrincipalPolicy{AllowTools, DenyTools, // 主体策略
HardPrivateTools}
DeclarePrincipalPolicy(scope, key, policy) // 声明入口
而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。
## 改动
① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
**撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
等于任何执行体销毁时都能抹掉全站策略。
② `agent/core`:`securityDecisionFor` 分两条路径
父(parentID == "") → CheckTool(tool) // 内核自身
子(parentID != "") → CheckToolFor(Principal{self, "resident"},
tool) // 叠加父的声明
CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
(那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。
③ 创建点声明、销毁点撤销(生命周期对称)
SpawnResident → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
SpawnRTChild → 同上
teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy
声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
「子已跑工具、策略还没进内核」的窗口。
声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。
④ 工具面:`resident_agents create` 与 `rt_children create` 新增
`allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
因为模型很容易把白名单误读成额外授予。
## 判据(4 个变异全部实测咬住)
| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |
`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。
`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。
## 实测(真实整机装配 + 真执行)
① 父 ExecuteTool(cmd_run) → 走到分派(未被拦)
② 父声明 deny=[cmd_run] 后,子的判定 → Allowed=false
reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
③ 父自己的判定 → 仍 Allowed(主体隔离)
④ 撤销后子的判定 → 恢复 Allowed
build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。
## 仍未接线
- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
|
|||
| 1d007e1d49 |
fix(security): 工具策略闸真的接上了 —— 根因是依赖图少一条边,不是少一行赋值
## 缺陷
生产日志实证:`[security] module enabled (31 variables)`、`[device] ready:
34 tools, security=true`,691 个日志文件里 `denied by policy` 命中 **0 次**。
`executeToolCallInner` 里的策略闸读 `a.security`,而它**永远是 nil**:
codegraph_callers("SetSecurity") → 4 个调用方,全是 security_toolgate_test.go
全仓唯一给 a.security 赋值的行:security_profile.go(就在 SetSecurity 体内)
buildAgent 完全不读 k.Security;AgentConfig 里也没有 Security 字段
## 为什么判据全绿而生产是坏的
`security_toolgate_test.go` 的 4 条判据**自己调了 a.SetSecurity(...)** ——
它们把缺失的那根线补上了,所以永远绿。测的是「接线之后行为对」,
不是「生产有没有接线」。这是「机制 ≠ 接线」最纯粹的样本。
## 根因:模块边界没连上,不是少一行
`kernelmod/agent` 的 Requires() **没有声明 FacSecurity**。依赖图因此没有
理由把 security 的产物交给 agent —— 只有声明了的 device 拿到了。
即使不声明,security 也在第 1 层、agent 在第 4 层,`ks.Security` 恰好已被
填好,运行期照样能读到。**这正是它藏了整轮发布的原因**:那个"恰好"是偶然
(security 只依赖 config),不是设计。一旦 security 将来加了自己的依赖,
图不会拦,只会静默给出 nil。
所以只补 `agent.SetSecurity(k.Security)` 是错的:那会让代码读一个图不保证的
字段。必须同时改三处。
## 改动
① kernelmod/agent: Requires 加 FacSecurity(让图知道这条边)
② AgentConfig 加 Security 字段;New() 写 a.security;buildAgent 传 k.Security
③ Init 里 ks.Security == nil 时**响亮失败**(与 device 模块对 ks.Security
的检查同型)—— 不能把 nil 一路传下去变成静默放行
④ 子 agent 继承:resident / rtchild 各自 New(AgentConfig{...}),原先从构造起
a.security 就是 nil。它们是**最需要闸门**的执行体(驻留子长期无人盯、
rtchild 通话中一边听一边执行工具),却恰恰完全没有闸门。
## 判据(全部通过变异验证,删掉修复必红)
新增 internal/kernelmod/all/security_wiring_test.go(钉**装配**,不是钉注入):
| 判据 | 变异 |
|---|---|
| TestAgentDeclaresSecurityDependency | 从 Requires 删 FacSecurity ⇒ 红 |
| TestAgentAssemblyWiresSecurity | buildAgent 传 nil ⇒ 红;New() 不写 a.security ⇒ 红 |
| TestSubAgentsInheritSecurity | 删 resident/rtchild 的 Security ⇒ 红 |
| TestAgentAssemblyRefusesNilSecurity | 删 Init 的 nil 检查 ⇒ 红 |
`TestAgentAssemblyWiresSecurity` 走**真实登记表装配**(真 SQLite / 真配置库 /
真 20 模块 6 层),并对一个**未注册**的工具名配黑名单后调真实 `ExecuteTool`:
闸门生效 ⇒ "not permitted";闸门失效 ⇒ "does not exist"。两者文本不同,
无从混淆,因此不需要注册探针工具。
## 实测
生产形态探针(配置写入 `core.security.tool_deny=cmd_run` 后启动真实二进制):
[security] global policy applied: deny=[cmd_run] allow=[] sandbox=false
Agent.ExecuteTool("cmd_run") → denied (Tool cmd_run is not permitted:
tool "cmd_run" is blocked by security policy (denylist entry "cmd_run"))
分层未变(3+10+3+1+2+1 = 20 模块 6 层,digest 14b4f6f7),
build / vet / go test ./... / -race(kernelmod/all + agent/core + security) 全绿。
## 顺带
gofmt 漂移清零(原 1 个 rtchild_route_test.go)。
|
|||
| fbccfe17ed |
fix(rtchild): 同名复用串线 + 批量停止漏注销
三处缺陷,全部在「回收/复用」这条线上 —— 单实例 e2e 天然测不到:
1. RTTranscript 读序(主线)
原实现先查留档、命中即返回。留档以 id 为键而 id 由模型给,于是
回收后同名重建,rt_transcript 返回的永远是上一通电话的原文,
且完全不报错。改为「在跑实例优先,无活实例才落回留档」。
2. StopRTChildren 漏注销入站登记
单数路径会 Unregister(child/rt-<id>),复数路径没有。登记表共享、
按 name 全局唯一,残留随 create/destroy 单调累积,而外部没有任何
工具能单独注销一条 inputch。
3. 撤回一个错误的修法
一度在 SpawnRTChild 里 delete 留档。变异测试证明删掉它判据仍绿
(治不了串线),且有害:留档的意义正是「回收后父按需取原文」,
提前删掉会造成「推了信号却拉不到正文」。现仅在 CloseRTChild 写
同名键时自然覆写。
判据(均通过变异验证,去掉修复必红):
- internal/agent/core/rtchild_reuse_test.go(进程内,5 条)
- internal/plugins/rtchild_reuse_e2e_test.go(跨进程真插件,3 条)
★ 写判据时踩到两个坑,一并记在注释里:
- 前两版判据假绿(先塞留档被 Spawn 清理掉 / 新实例不 push 导致
「读到旧内容」与「读到空」都是 len==0),靠变异才发现没测到真东西
- 等 Rounds 不等于历史已入账(rtRunOnce 先 round++ 后 rtAppendHistory),
与
|
|||
| eb1deab5dd |
feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态
## rtchild(R1–R13)
第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。
- R1 CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2 rtSlot 单槽覆盖(latest-wins,从不排队)
- R3 独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4 ChatStream 逐块 → outputch(rt).Execute(text)
- R5 打断 = 原地截断,不重试
- R6 工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7 普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9 CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)
刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。
## 四类 agent 各自可配模型源
core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。
★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。
## 驻留子残余任务四态 + 通道三态
残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。
通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。
一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。
## 修复"手写字段白名单"丢值(7 处)
同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。
- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)
## 压缩偏离设计
CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。
并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。
## 判据
新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
"在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿
## 判据自身的坑(两次偶发红,都不是产品缺陷)
1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
→ "暂存 0 条"。修法:cancel + waitSchedulerStopped。
(offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
|