|
|
7c0eec1b2f
|
docs: 第二层改名 Cold Context,清理四组已不存在的符号名
## 问题
README 与两份 ARCHITECTURE / OVERVIEW 仍在用旧称「Document 层」,
而第二层早已改名 cold context(工具名 `coldcontext_query` / `coldcontext_commit`)。
不止是层名。文档里引用的四个符号在代码里**一个都不存在**:
| 文档里写的 | 代码里实际 |
|---|---|
| `docStore.ContextToDoc` | `coldcontextStore.ContextToColdContext` |
| `FindColdDocs` | `FindColdContexts` |
| `docToTriples` | `coldDocToTriples` |
| `docStore.SetVectorizer` | (已无此调用,embedder 构造期注入) |
四处都用 `grep -rn "\b名字\b" --include=*.go` 确认过:旧名 0 处,新名各自 4 处。
## 改了什么
- `assets/docs/zh/ARCHITECTURE.md` 13 处、`en/ARCHITECTURE.md` 15 处
- `assets/docs/zh|en/OVERVIEW.md` 各 3 处
- README 两版:「文档层」→「冷上下文层」
- `assets/branding/mascot-xiaozhai-guide.md` 的丝带配色表(三色分别对应三层)
## 顺带修正层号
ARCHITECTURE 用的是 **L1 Context / L2 Cold Context / L3 Stable**,
我上一版 README 写成了 L0/L1/L2 —— 少了一层,且与架构文档对不上。已改为 L1/L2/L3。
## 顺带修掉断掉的交叉引用
`internal/agent/core/toolcall.go` 与 `recallcoldcontext_test.go` 的注释写
「ARCHITECTURE「记忆流转」② Document 层的读取分支」,指的正是刚改名的节。
不改就成了悬空引用。三个测试函数名(`TestContextToDoc` 等)指向已删函数,一并改名。
## 验证
- 全仓 `grep` 旧名:`*.md` / `*.go` 均 0 处(排除 build/third_party/.pi)
- `go build ./...` ✓
- `go test ./internal/memory/coldcontext/ ./internal/agent/core/` ✓
|
2026-10-31 11:39:13 +08:00 |
|
|
|
acaf0887c7
|
fix(webui): 聊天图片"加载成功却显示加载失败"——HTML 属性里的反斜杠转义
## 缺陷
生产截图:图片加载成功了(img 元素在、src 正确),界面却多显示一行
蓝字「图片加载失败"/>」。
根因是内联事件属性里用了反斜杠转义:
onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"
**HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
`<span class=` 之后就闭合了。浏览器切出来的是:
onerror = "this.parentElement.innerHTML='<span class="
剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。
症状极具误导性:看起来像"图片坏了",实际图片是好的;而那个 onerror
因为语法错误**永不执行** —— 真失败时反而什么都不显示。
## 修法
不用内联属性(那要求以后每次拼 HTML 都记得转义规则),改成
**捕获阶段的事件委托**:
- error 事件**不冒泡**,只能在捕获阶段拿到(注释里写明了)
- 用 createElement + textContent 构造占位,不再拼 innerHTML 字符串
- 只处理 .chat-attachment-img,不影响正文里的 markdown 图片
## 顺带:两个类没有 CSS
`chat-attachment-img` 与 `att-err` 只出现在 JS 里,CSS 里一条都没有。
图片靠内联 style 撑着还能看,失败占位则完全没有样式。已补。
## 判据(2 条,变异验证)
- `TestChatAttachmentImgHasNoBrokenInlineHandler`:不许内联 onerror、
必须用 DOM API、必须有 error 监听
- `TestDashboardNoEscapedQuoteInHTMLAttributes`:通用扫描,
任何 `name="...\"...` 形态都会红(下次别人写内联事件也拦得住)
变异:把内联 onerror 放回去 ⇒ 两条同时红。
★ 写判据时踩到的坑(都已修):判据被**自己的注释**打红(注释里必须写出
错误写法来解释),需先用已有的 stripJSComments 剥离;另外我先自己写了一个
stripJSComments,与 dashboard_js_scope_test.go 的重名 —— 应复用已有的。
## 顺带修一条脆弱判据
`TestRTChild_RapidInputCollapsesToLatest` 全量跑时偶发红
("连发 50 条却跑了 5 轮")。
探针查实:连发 50 条只花 ~80µs,而测试 provider 的 ChatStream **同步瞬间
返回**,两者同量级 ⇒ 消费方能在连发过程中抢到几次执行。探针显示
dropped=48,collapse 明明生效了。
所以 `Rounds > 3` 是在测调度抖动,不是测契约。改为断言**覆盖主导**
(dropped >= burst/2)。真实通话每轮几十毫秒,连发微秒级,生产里
"轮次≈1" 自然成立。
变异验证:让 dropped 不计数 ⇒ 判据红(dropped=0(连发 50 条))。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
|
2026-10-31 10:03:43 +08:00 |
|
|
|
82932821c0
|
feat(security): 每 agent 一个策略实例 —— sessionProfile / longtimeProfile 两层
## 用户裁定
> 每个 agent 一个,分为 sessionProfile 和 longtimeProfile
## 收敛前的缺陷
档位挂在 `Runtime.active`(**进程级单例**)上,多 agent 并发时串档:
主 agent 轮次 A:注入 qq.guest → active = "qq.guest"
驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖 A
主 agent 轮次 A 继续执行工具 → 判定按 owner 走 ← 串档
这不是理论风险:驻留子 / rtchild 与主 agent **同进程并发**(plan.md 十一)。
## 切分原则:共享注册表 + per-agent 执行视图
**不能整份复制 Runtime** —— `profiles` / `principals` / `scopes` 是插件
声明出来的注册表,每个 agent 各持一份就会变成「插件往 A 声明了档位,
B 看不见」,那是最难查的一类缺陷。
共享(进程级 1 份) per-agent(N 份)
──────────────────────── ───────────────────
gate / policy / content sessionProfile ← 轮次级
profiles / principals longtimeProfile ← agent 生命期
scopes / eventPolicy
## 两层档位语义(用户裁定)
sessionProfile —— 属于**这一轮**。输入携带它,End() 即清。
严格 per-round:同一用户下一条消息要重新声明。
longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留,
直到显式改掉或 agent 销毁。
**优先级 session > longtime**,这个顺序本身是安全语义:
session 是"这一轮的事实",必须能盖住 long-time 默认值 ——
否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。
反向(longtime 盖 session)= 提权:长期 owner 的 agent 上,
访客声明的 guest 被吞掉 ⇒ 访客拿到 owner 权限。
## 改动
① 新增 `internal/security/agentscope.go`:
rt.ForAgent(agentID, isChild) *AgentScope // 幂等
sc.BeginRound(RoundInput) // security 自己解析档位
sc.SetLongtimeProfile(name) / LongtimeProfile()
sc.EffectiveProfile() // session > longtime
sc.CheckTool(tool) // 全局基线 + 档位 + 主体策略
rt.ReleaseAgent(agentID) // 含 longtime 档一起清
② `Runtime` 新增 `scopesByAgent` 表;判定拆出 `checkToolWithProfile(p, profile, tool)`
与 `checkToolForWithProfile(...)` —— **档位由调用方显式传入**,
不再一律读全局 `r.active`(否则 per-agent 视图白做)。
③ `agent/core`:`securityScope()` 用 `sync.Once` 缓存本 agent 的视图;
`Stop()` 里 `ReleaseSecurityScope()`。
④ `Round.End()`:per-agent 路径只清 **session**,**保留** longtime;
旧进程级路径仍恢复进入前的值。
## 判据(3 个变异全部实测咬住)
| 判据 | 变异 |
|---|---|
| `TestAgentScopesDoNotShareSessionProfile` | ForAgent 退化为共享视图 ⇒ 红 |
| `TestLongtimeProfileSurvivesRounds` | 优先级写反 / longtime 不跨轮 ⇒ 红 |
| `TestReleaseAgentDropsLongtimeProfile` | ReleaseAgent 不删视图 ⇒ 红 |
| `TestLongtimeProfileRejectsUndeclared` | 未声明档位被接受 ⇒ 红 |
第一条同时钉:① 各自档位是自己的 ② **行为上**主 agent 仍被 guest 收窄、
子 agent 不受影响(只断言字符串相等会漏掉判定链没接上的情形)。
## 实测
- 真实并发探针:3 个 agent(guest / owner / guest)各 200 轮并发,
`-race` 干净,**零串档**
- build / vet / go test ./...(62 ok / 0 fail)/ -race 全绿
## 未变的约束
档位与主体策略**只能收窄**:`CheckToolFor` 内部先跑全局策略,
所以父的黑名单对子依然有效,子无法提权。
|
2026-10-31 07:24:03 +08:00 |
|
|
|
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 通过。
|
2026-10-31 07:14:09 +08:00 |
|
|
|
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 消费者
|
2026-10-31 06:35:32 +08:00 |
|
|
|
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)。
|
2026-10-30 23:54:08 +08:00 |
|
|
|
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),
与 dff745c 同一个坑;已改为等 history 条数
|
2026-10-30 21:54:40 +08:00 |
|
|
|
bcf45498a0
|
feat(skill): 补 kernel-rtchild + 两条判据锁住 rt_children 静默失败
## kernel-rtchild skill
`resident_agents` 有 `resident-subagent-hygiene` skill,而
`rt_children`/`rt_transcript` 这一对整个**没有 skill** —— 模型只能靠
工具描述猜语义。
最容易踩的坑:**工具执行期不可打断**。模型一旦进入工具执行期就听不见
新输入,所以调工具前必须先用 outputch 说一句"稍等",否则用户会觉得
"它没在听我说话",而实际上它只是卡在一个 3 秒的命令里。
skill 覆盖:三种子 agent 的分界、通道必须成对且两侧声明 rt、打断语义、
只出文本(TTS 是插件的活)、窗口裁剪但原文保留、回收后用 rt_transcript
分段取、闲置零 token 但必须 stop、四条回收判据、五种常见错误对照表。
## 两条判据(都来自一次真实事故)
生产 e2e 时我用 `input="..."`(工具声明的是 `inputch`)创建 rtchild:
`strArg` 只查精确键名 ⇒ 读到空串 ⇒ **本应报"必须成对给出",但它成功了**,
还把 output 通道划给了新子。之后灌语音,该子轮次永远是 0。
症状是"通道注册了、插件活着、推送 ok、但子没反应",
与真因(参数名拼错)隔了三层。
- `WrongParamNameIsRejectedNotSilent`:写错必须失败、必须指名正确参数名、
必须无副作用(不建子、不动通道归属)
- `PairAssignmentIsAtomic`:成对分配第二个失败时第一个必须回滚
(原来那句 `rollback()` 没有判据覆盖,是死代码)
变异验证:让代码对 `input`/`output` 静默兜底 ⇒ 第一条变红
(`参数名写错却创建成功了:[typo-child] —— 这会得到一个收不到输入的哑子`)。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
|
2026-10-30 18:09:50 +08:00 |
|
|
|
dff745cde2
|
test(rtchild): rt_children stop 判据改为等历史入账,不等 Rounds
判据偶发红(实测约 1/12):`stop 没有告诉模型用 rt_transcript 取回原文`。
根因是**判据自己等早了**,不是产品缺陷:
- `Rounds` 在 ChatStream 被调用时就自增(请求刚构建完)
- assistant 那条消息是**流结束后**才追加进 history 的
于是 stop 可能读到 n==0,走"本次通话没有上下文需要交接"分支 ——
而那个分支**本来就该**不提 rt_transcript(真没内容可取)。
改为等 RTTranscript 非空。
★ 这是同一类坑的第二次复发(第一次在 rtchild_r7b_test):
判据等"请求已发出"而不是"结果已落库"。
"""
|
2026-10-30 17:21:51 +08:00 |
|
|
|
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
|
2026-10-30 17:00:47 +08:00 |
|
|
|
b850feaef2
|
feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边
命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据
安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据
验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
|
2026-10-29 23:53:48 +08:00 |
|
|
|
0f98f36e71
|
fix(memory): L1 恢复为"工作记忆区 = 时序对话",工具结果展开、仅超长折叠
用户裁定的 L1 契约(2026-10-27):
· 保留 m 条(core.agent.max_context_size)、保护最新 n 条
(core.agent.context.protected_count)、其余按相似度淘汰 → 我的上一版把
m 写到了不存在的键(core.agent.context.max_context_size),运行时仍读
core.agent.max_context_size=200 ⇒ L1 堆到 119 条,工作记忆退化成历史堆。
· 送给模型的是**时序排列的对话** → 曾被渲染成"摘要 + 工具管道"
("—/ → 调用工具: a, b → 已回复…",助手回复还截 120 字),
指代消解因此失效(用户回"新乡",接不上"帮我找一套月租<400…")。
· 工具结果在 context 中**展开全文**,只有**超长**结果折叠成句柄
(toolresult_store + toolresult_read 按区间读回)→ 曾额外加 160/400 字、
每事件 3 条的截断,等于把已展开的结果又藏回去。
本次改动(仅渲染层,不动淘汰/归档/中断语义):
· formatMergedTimeline 渲染 [ts] 用户: … / [ts] 助手: …(回复全文)
· 中断轮的"用户原话"取自 qq_get_message/qq_get_history 的回执正文
(Input 是注入样板,不是人话)
· timelineToolLines:普通结果全文展开;ToolResultStores 命中的只给句柄
· 预算估算按实际渲染内容计(用户行 + 助手全文 + 工具行)
判据:internal/agent/core/timeline_contract_test.go
(时序对话/原话来源/全文展开/仅超长折叠)
|
2026-10-27 16:24:09 +08:00 |
|
|
|
279b61198c
|
fix(scheduler): 空闲时入队不再误报 "level insufficient"
现场:日志满屏
[agent] interrupt queued: task#2 class=interrupt level=3 from qq (L3)
vs <nil> (?) — level insufficient
而实际成因是"当前没有运行任务可抢占",与级别毫无关系。这类假因会
把运维引到"是不是级别配低了"的错误方向(本仓反复吃亏的静默/误导同族)。
改成四种成因分开写:
idle (no running task; queued for next pick) / running in critical section
/ preempt cooldown / level insufficient
|
2026-10-27 15:58:55 +08:00 |
|
|
|
9ffdebbb0b
|
fix(agent): 续写占位不再把「承诺后没做的事」一并劝退
现场(用户原话:"那你去啊?说完就停了"):模型经 output_send__qq 说了一句
"我这就去办",内核判定本批全是输出通道发送(ReplyOnly)后补的占位只有终止许可
——"若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具"——
模型照做返回纯文本,本轮结束,活没干。
但这条护栏不能简单删掉:注释里记着一次事故,通用「请继续」被读成
"再发一条消息",生产实测单轮 34 次 output_send__qq、持续 514 秒。
改法:把两种授权分开说清楚,护栏不削弱。
- 没做完的操作 → 现在继续做完(继续指**非发送类**工具)
- 真的做完了 → 返回纯文本收尾,并明确「不要再发第二条消息」
判据:continuation_placeholder_test.go(必须同时含"继续做完"与
"不要再发第二条消息";不得再出现"无需再调用任何工具")
|
2026-10-27 15:53:49 +08:00 |
|
|
|
20cf138d3b
|
fix(memory): 把插件的 NoMemory 声明落到事件上
声明链此前断在最后一步:
InjectOptions.NoMemory / ChannelDef.NoMemory
→ payload["no_memory"] → stageCtx.NoMemory ✅ 已合成
→ ContextEvent.NoMemory ❌ 从未赋值
后果:QQ 那句取件样板("来自「X」的私聊消息(message_id=…) 先用
qq_get_message 取正文…")每轮都进计算层(向量化/关键词/蒸馏),
而它是指令不是内容 —— 真实对话内容在记忆里被它挤薄。
按字段**既有契约**处置(context.go 的注释写明):只影响计算层,
归档照常。理由是同一条注释里的 2026-10-22 教训 —— coldcontext_query
的 Consume 已从 L2 删除原文,回填事件必须能落回 L2,否则三层全无。
判据:nomemory_flag_test.go
(NoMemory 事件 textForVector 为空但 Input/Response 保留;普通事件不受影响)
|
2026-10-27 15:40:43 +08:00 |
|
|
|
14daf5b584
|
fix(memory): 超长工具结果在时间线里只渲染句柄(不渲染正文)
用户口径(2026-10-27):渲染工具回执,但**超长的才不渲染** —— 那些结果
已经落盘进工具环(toolresult_store),时间线里只应给「有多少、怎么读」,
正文由 toolresult_read(event_id, offset, limit) 按需取。
为什么不能渲染它的预览:预览是多行文本,塞进历史只会压成"半截结果",
信息量还不如一个句柄;而模型真正需要的是"知道有这份东西、从哪儿读"。
判据:TestTimelineOversizedResultRendersHandleOnly
(落盘结果不出现预览正文、必须含 toolresult_read 与 event_id;未落盘回执照旧渲染)
|
2026-10-27 15:27:35 +08:00 |
|
|
|
5deb3eb10d
|
Revert "fix(memory): 中断的取件样板不再写入 L1(颗粒度=单条消息)"
This reverts commit 3a7440a2fe.
|
2026-10-27 15:04:55 +08:00 |
|
|
|
3a7440a2fe
|
fix(memory): 中断的取件样板不再写入 L1(颗粒度=单条消息)
用户口径(反复强调):进记忆的禁忌是**中断消息本身**,不是整轮。
此前 finishInputTask 无条件把 CleanInput 写进事件,而中断轮的 CleanInput 是
"来自「X」的私聊消息(message_id=…) 先用 qq_get_message 取正文…" 这类样板:
每轮一字不差堆进 L1,真实对话内容只能靠工具回执碎片体现 ——
实测用户回一句 "233719",模型反问"那串数字什么意思"(指代消解失效)。
改法:只清样板,不清内容。
- isInjectionBoilerplate 识别插件取件指令("来自「…」…message_id=" 或 "先用…取正文"),
命中则 Input 置空;
- 本轮的工具回执(qq_get_message 正文,NoMemory=false)、agent 回复照常入库;
- 内核中断正文不清空:测试 D1=B 要求「中断任务的上下文加载回被抢占任务之上」。
判据:internal/agent/core/interrupt_input_memory_test.go(样板被清、内核中断保留、
普通输入不受影响、回执与回复保留)
|
2026-10-27 15:02:57 +08:00 |
|
|
|
b2938ed4a7
|
fix(memory): 场景召回按块向量余弦排序 + 召回 query 可见性 + 对话回执放宽
① 场景召回是每轮常驻注入,此前按「场景权重→时间倒序」排,与 query 无关:
老图库的 rt/rtchild/inputch 等历史事实常年霸榜,实测 1833 字符里九成是它们,
刚聊的群聊内容一句没有 —— 这就是"忘事"的机制。
块里本来就存着向量,相关性就是一次余弦比较:RecallByScene 带出端点/原句块
向量,Indexer 按 max(cos) 排序并设 0.20 下限,取前 8。
无稠密空间时保持原顺序(不能用字面匹配冒充语义 —— 既有判据要求
「措辞与记忆零字面重合时场景路必须兜住」)。
② recall query 可见性:cleaner 缺失/生效/回空三种情况都打日志。此前静默回退
原文,"召回 query 其实是工具原始回执"这件事在日志里完全看不到。
实测 qq_get_message: 199 -> 27 bytes(cleaner 生效)。
③ 对话正文类工具(get_message/get_history/list_chats)时间线渲染放宽到 400 字
(160 字会把群聊上下文切成碎片)。
判据:internal/memory/scene_relevance_test.go
|
2026-10-27 13:25:25 +08:00 |
|
|
|
1dc32be736
|
fix(memory): 召回可用性——L2 片段带正文 + WebUI 上下文走双路
① L2 预填充此前只给摘要行('已回复老大:原来是这个谐音梗'),模型读得懂
'我回复过'却不知道梗是什么 ⇒ 召回命中仍答不知道。现取两段候选
(事件文本块 + 工具回执),JSON 回执先提取字符串值再压成单行。
② WebUI「上下文」页只显示 L3,而 L3 有 72h 冷化延迟 ⇒ 冷化没跑起来时
面板永远'无上下文',看着像召回彻底坏了(实际 L2 一直有命中)。
现 IndexerAPI 带上 L2 片段(与模型预填充同一 QueryScored),前端两路并列。
判据:internal/agent/core/crossmodal_body_test.go
|
2026-10-27 13:04:19 +08:00 |
|
|
|
051247e10f
|
fix(memory): 时间线按声明渲染工具回执 + 能力判定改为配置层
时间线此前只写「→ 调用工具: a, b」,工具取回的正文下一轮彻底消失:
模型记得自己查过,却不记得查到了什么(刚说的话转头就忘)。
ToolDef 上的 Cleaner / NoMemory 声明一直存在,缺的是消费端。
- formatMergedTimeline 渲染 e.ToolResults:Cleaner 优先,NoMemory 工具跳过,
单条 160 字、每事件最多 3 条(+N more),预算估算同步计入
- 模态能力改由配置层声明(ProviderManager.SetCapabilities),
不再靠 provider.(ModalProvider) 断言穿过 adapter 与安全装饰器
- a2a 通道 RecallPolicy none→auto(NoMemory 仍 true:可读不写)
|
2026-10-27 12:16:06 +08:00 |
|
|
|
4af07d08fe
|
fix(memory,llm): L2 预填充并入召回主路 + 回退换 provider 时换 model(视觉失效真因)
## 1. L2(冷上下文)预填充此前只走「注入文本」,等于没有
设计:prune 与 recall 是一对 —— 工具声明 ContextPolicy=prune + RecallPolicy=auto
时,工具输出既触发裁剪、也触发预填充(memoryPass → recallTextFor)。
但 L2 那一路只写在 buildSystemPrompt 里,query 用的是**注入文本**;
QQ 注入的是「来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文」,
与用户真正说的话毫无相似度 ⇒ 冷上下文预填充恒为空。
(现场:头天晚上讲的冷笑话,第二天早上完全唤不回;14 小时里
memory recall (input…) 只有 5 次、且 query 是元信息。)
改法:把 L2 检索并入 buildMemoryContext —— 输入路用输入文本、工具路用工具正文,
两条路共用同一次召回与预算;buildSystemPrompt 里那份重复注入删掉。
中断消息仍然 NoMemory + RecallPolicy=none(不裁剪、不召回、不进记忆),
这是设计要求,未改动。
## 2. 回退换 provider 时必须换 model(视觉失效的真因)
现场日志:
deepseek 402 Insufficient Balance
→ 回退 llmsproxy,但 req 仍带 model=「deepseek-flash」
→ llmsproxy 403 model_not_allowed
→ llmsproxy 被整源标记 unavailable(冷却)
→ 视觉回退 resolveModalFallback 跳过它 ⇒「配置了视觉却说没有视觉」
两处修正:
· 每个 provider 用自己的 model 发请求(model 是 provider 的属性)
· 403 区分「模型不被该 key 允许」与「凭证失效」:前者只否一个 model,
provider 仍可用于其他 model 与视觉回退
判据:internal/agent/core/provider_model_fallback_test.go
|
2026-10-27 11:49:26 +08:00 |
|
|
|
55fa2685ae
|
feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点
device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层
SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice
提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory
修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
tool_calls 导致'模型不调工具'的真因)
测试:go build ./... 通过;go test ./... 60 包全绿
|
2026-10-26 20:55:25 +08:00 |
|
|
|
7052644431
|
fix(core): contextoverflow 改回空中断(按原始设计)+ Python 解释器可配置
## 1. contextoverflow:L4 空中断(推翻三处偏移)
用户口径:**原始设计是注入 L4 空中断,目的是触发语义相关性裁剪,
不含任何消息,就是一个空中断。**此前的实现有三处偏移:
① **中断带正文** —— `Build` 生成「已裁剪 N 条低相关事件到文档记忆…」,
于是 L4 变成一条要模型回应的消息,白烧一整轮推理。
② **裁剪在 raise 之前做完** —— 先 `pruneByQuery` 再 raise,中断沦为
「裁剪已完成」的回执,晚了一步。
③ **上游 contextfull 后原地重跑** —— `StepPrepare` 重建后拿**同一份**
超限内容再发一次,必然再超限,快速烧光 recoverBudget。
(这条是修① ② 时暴露的连带问题。)
改动:
· `VectorContextOverflow.Build` 返回空串 ⇒ 空中断;级别仍是 L4
· 删除 `overflowNotice` 与 `vectorArgs.Pruned`(中断不需要知道裁了多少)
· `handleContextOverflow` 不再裁剪,只发空中断,如实返回 0
· 新增 `pruneForAirInterrupt`:裁剪由空中断任务**真正执行时**触发,
query 取上下文里最后一条真实用户输入作相关性基准
· 新增 `isAirInterrupt`:只认「内核来源 + type=interrupt + 无正文」,
插件带正文的中断不受影响(否则"插件发了个空中断"会变成无声裁剪)
· `prepareInputTask` 识别空中断 → 裁剪 → terminalSkipped
(不进 LLM、不写上下文、不发回复)
· 上游 contextfull 后改为 `outcomeSuspended`(让位等裁剪),不再原地重跑
顺带记录偏移成因(`raiseContextOverflow` 注释):当时的**观察**是对的
(L4 看不到被打断者上下文 ⇒ 模型会把"没检索到"推断成"不存在"),
但**结论**错了——它把"补偿 L4 看不到上下文"当成中断该带正文的理由。
模型需要的不是"知道有内容被移走",而是**留下来的内容与当前问题相关**。
判据(均经变异验证):
· `TestVectors_OverflowIsAirInterrupt` —— 正文必须为空 + 级别 L4
变异:Build 改回非空 ⇒ 红
· `TestOverflow_RaisesAirInterruptWithoutPruning` —— 发中断那步不改上下文
变异:加回 pruneByQuery ⇒ 红
· `TestAirInterrupt_ResolvedAsValidInput` —— 空中断须被认作有效输入
· `TestAirInterrupt_RejectsOrdinaryEmptyInput` —— 普通空包/插件带正文
中断**不得**误判
· 改写 5 条断言"发中断即裁剪"的旧测试(T1/T2/T5/大事件/自适应),
保留它们原本守的东西(超限判定、恢复预算、终止保护),
只把裁剪步骤显式移到 `pruneForAirInterrupt`
## 2. clawhubadapter:Python 解释器与工作目录可配置
真机事故:`bilibili-all-in-one` 声明 requirements 含 httpx,系统 Python
没装 ⇒ 启动即 `ModuleNotFoundError`。而解释器此前是**硬编码猜**的
(先 /usr/bin/python3,再 /usr/local/bin/python3)。
「猜」没有正确解:不同部署的 Python 布局完全不同(发行版 Python、
pyenv、uv、conda、venv…)。而往系统 Python 里 pip install 是**错的**:
PEP 668 明确禁止,`--break-system-packages` 会破坏 apt 包一致性。
新增两个配置项(与既有 skills_dir/simulator_dir 同一风格):
· `python_bin` —— 解释器绝对路径,最高优先
· `skill_workdir` —— skill 子进程工作目录
回退顺序:配置 → `<skill>/.venv/bin/python` → 系统 python3。
**配错必须响亮**:配置的路径不存在时返回 error,不静默退回系统解释器
(否则用户以为配好了,实际跑在缺依赖的 Python 上 —— 又一次静默降级)。
判据:`TestPickPython_ConfiguredWins`(配置优先于 venv)/
`TestPickPython_VenvWhenNotConfigured` / `TestPickPython_MissingConfiguredIsLoud`
`launchProcessIn` 是 `launchProcess` 的工作目录变体(不改既有 4 个调用点)。
|
2026-10-25 21:39:12 +08:00 |
|
|
|
c9a32a98f0
|
feat(agent): 超长工具结果落盘 + toolresult_read 按范围回读(补齐工具环)
问题(WSL2 真机诊断报告问题 2):单条工具结果 36783 token,直接冲破
整个对话预算 → overflow 判「单条超限,裁剪救不了」→ 整轮终止。
模型看到的只是"对话没反应"。日志:max_ctx=32768 target=26214
fixed=498 mem=8572 ctx=17144,而单条 36783 是它的 2.1 倍。
此前两条路都不通,本提交走第三条:
· **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺
数据下结论 —— 与本仓反复吃亏的「静默降级」同族。
· **只统计不裁剪**(方案 B,toolresult_budget.go):设计上正确(不欺骗
模型),但停在「报告」一步 —— oversizeTools 计数**没有任何消费者**
(OversizeToolReports/OversizeToolNames 全仓零调用者),所以那条超大
结果照样整条进上下文、照样撞窗口。
本提交把方案 B 缺的那一半补上:
结果超限(沿用 toolresult_budget.go 的 ContextTokens/8 阈值)
→ 正文完整写入 <context.json 同级>/toolresults/<evt_id>/tool_N.txt
→ 上下文里换成「预览 + event_id 句柄 + 总字节/行数 + 读法」
→ 模型调 toolresult_read(event_id, offset, limit) 取任意区间(≤64KB/次)
→ 事件离开 L1 时全文目录一并删除
设计要点:
· **存活期跟随事件,不引入 TTL**。与 Blocks 同一套处置(见 mediaref.go
「块存储在事件自身,不维护引用账本」)。刻意不做独立过期/清理定时器
——那会让"一件事记两处",两处一旦不一致就出现"事件还在但全文没了"。
· **正文不内联进 context.json**:它每 5s 全量重写,内联几 MB 正文会让
每次 flush 都写几 MB。
· **磁盘 IO 放 context.go**(记忆面持久化,架构守卫允许的唯一落点),
toolresult_store.go 只留纯逻辑 —— 满足 TestKernelHasNoMediaFileReading。
· **toolresult_read 按需暴露**:只在真有落盘结果时注册 schema
(本仓已有"175 个工具占满上下文"的教训)。
· 落盘失败**保持原内容**:宁可撞窗口也不让模型什么都看不到。
判据(toolresult_store_test.go,7 条,3 条经变异验证):
· TestOffloadReplacesFullTextWithPreview —— 上下文拿预览非全文,且三要素
齐全(说明是预览/给句柄/给读法)。变异:返回全文 ⇒ 红。
· TestReadBackReassemblesByteIdentical —— 分段拼回**逐字节相同**,含中文
多字节字符。这是"信息不丢"的唯一硬证据。变异:少读 1 字节 ⇒ 红。
· TestAppendClaimsPendingToolResults —— 事件进 L1 必须认领落盘记录,
否则重启后句柄全失效。变异:不认领 ⇒ 红。
· TestReadAfterPurgeIsExplicit —— 淘汰后明确告知,不静默返回空串。
· TestPersistedToEventDirNotInline / TestReadWithBadHandleIsActionable /
TestSmallResultNotOffloaded
|
2026-10-25 20:16:16 +08:00 |
|
|
|
d65b742bd7
|
fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 CI 连续两次红的真因)
`StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用
`RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。
后果不是报错而是**卡死**:
- internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads)
- internal/plugin 卡 840s 超时(TestStopPluginDoesNotTouchPersistentState)
- CI 的 `go test ./...` 与 `go test -race` 双双红
修法:拆出**不加锁**的内部版本,持锁路径只走它:
- `skillDirFrom(dataDir, name)`(纯函数)
- `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir,
避免"看起来没取锁、一改就取锁"的隐患)
- `announceSkillRemovedLocked`(持锁下直读 r.evBus)
修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。
## 防回归判据
`TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到,
所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。
## tools_ready 事件声明
新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath`
(该判据防"事件发了、客户端收不到"的静默失效)。
tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者),
不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。
## 验证
go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。
|
2026-10-25 18:25:37 +08:00 |
|
|
|
c4731d896e
|
fix(agent): 流式并行工具参数串味 + meta 传对象被静默丢弃
两个独立的真实生产故障,都表现为"agent 答应了却没做到"、用户感觉
"它忘了刚说过的话"。按天统计 QQ 发送失败率:10-21 是 1.3%,10-23 涨到 34%。
## 故障一:流式并行 tool_call 参数串味
`internal/agent/api/provider.go` 累积 SSE 分片时用**切片下标**:
for i, tc := range ck.ToolCalls {
acc := accs[i] // ✗ 并行分片时 i 恒为 0
}
并行多工具时上游把每个 tool_call 的 arguments 分成多个 chunk 下发,
每个 chunk 的 ToolCalls 切片长度通常是 1 ⇒ 12 个并行调用的参数分片
**全部拼进 accs[0]**,其余 11 个拿到空串。
这个 bug 从 2026-08-25(618f51a)就存在。8-26 的 7a51c9b 号称
"按 JSON index 分桶,修复空参数调用",实际只加了 StreamIndex 字段和
诊断日志,**没改累积逻辑** —— 看起来修了,其实没修。
修法:`idx := tc.StreamIndex`(上游 index 才是归属依据)。
生产指纹:单次响应 12 个 tool_call 全部报 "has empty arguments",
6 小时 216 次,全部来自 deepseek-v4.1-flash 的并行调用。
## 故障二:meta 传对象被静默丢弃
"一 call 多发"(f850d75, 10-22)之后,模型对 meta 的传参形态不稳定:
有时传字符串 `"{\"user_id\":123}"`,有时直接传对象 `{"user_id":123}`。
`stringOrArray` 只认 string / []string / []interface{},遇对象返回 nil;
而 `parseOutputItems` 对空值的处理是**静默填空数组**:
if len(metas) == 0 { metas = make([]string, len(payloads)) } // 全填 ""
⇒ 插件收到 meta="" ⇒ 报 "meta 中需要 group_id 或 user_id 字段"。
10-23 一天 132 次。
修法:非字符串值**序列化保留**(marshalToJSONString),数组元素同理
(coerceToString)。内核不知道 meta 的语义,但它知道"模型给出了一个值",
不能当"没提供"处理。
## 判据(6 条,均经变异验证)
- SSE:并行分片各归各位;同一 tool_call 的多片按 index 拼接完整
- meta:对象被保留为 JSON 字符串;字符串原样透传;数组元素一一对应;
确实缺失时仍为空
变异验证:退回 `accs[i]` ⇒ 两条 SSE 判据红;退回不序列化 ⇒ meta 判据红
("meta 被静默丢弃(插件将报「需要 group_id 或 user_id」)")。
|
2026-10-25 17:03:09 +08:00 |
|
|
|
77f596fb07
|
chore(log): 全仓运行期日志与 Fatalf 英文化(320 处 / 83 文件)
用户要求:"历史以来的全部写入日志的区域"改为英文,因为中文日志
在没有中文字形的终端/容器里会乱码,且跨机器、跨工具 grep 时不可靠。
## 范围
- 全部 log.Printf / log.Fatalf / log.Println 的**运行期日志**(含启动横幅、
致命错误、告警):320 处,83 个文件,占全仓日志的 53%
- 未改动:代码注释、面向用户/模型的文本(工具描述、system prompt、
错误 Reason)——那是产品语言,不属于日志
## 连带修改(必须同步,否则静默失效)
| 依赖点 | 处置 |
| --- | --- |
| medialoop_test.go 断言中文日志(3 处:失败原因分类 / 模拟解码失败 / 不支持=1) | 同步改为断言英文 |
| 判定日志格式的正则 `\d+ 个: image/webp` | 改为 `\d+ x: image/webp` |
## 顺带修正一处提示词超限(真实回归)
新增【加速序列】块(115 字,让模型知道何时该用 seq,见下)后,
TestSystemPromptStaysCompact 报 2022 > 2000 字。
处置:上限上调到 2100,并在注释里写明**额度用途**——
【加速序列】属于"模型据此做不同决定"的行为判据,占用增删余地正当;
但技术说明仍不许占用这份额度(下次想再调数字时该去掉的是说明本身)。
【加速序列】块内容只留判定条件(多对象重复 / 顺序已知 / 以前做过),
格式细节全部下沉到 seq-tools skill —— 避免两处真相。
## 验证
- go build ./... / go vet ./... 通过
- 分块跑全仓测试通过(agent、plugin、plugins/*、security、memory、
config、kernelboot、kernelmod、cmd)
- 残留中文日志仅 1 条注释,非日志调用
## 运维提示
日志文本变了,依赖 grep 中文的既有习惯要改为对应英文词,例如:
内核装配完成 -> kernel assembly complete
插件调度完成 -> plugin scheduling done
已建链 -> link established
已加载 N 个 -> N loaded
|
2026-10-25 15:55:26 +08:00 |
|
|
|
8d5e31f7c0
|
feat(agent/core): 中断向量表 —— 三个 L4 源统一登记,行为零变化
用户原话:「需要添加中断向量系统,独立成中断子模块就是为了方便添加这个
中断向量系统」。本次落地该目的。
## 为什么需要向量表
拆分前"有哪些内核中断、各自什么语义"散落在三个裸调用点上:
raiseKernelInterrupt("kernel", "kernel", "任务 #N panic…")
raiseKernelInterrupt("kernel/overflow", "kernel", "上下文超页…")
raiseKernelInterrupt("child/"+rc.id, "kernel", "驻留子已满…")
三者差异**只在 source 字符串**上,语义契约全靠注释与调用方自律。
最能说明问题的是 panic 的递归保护:
if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
// 否则同一个 panic 会自我放大成中断风暴
这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
**新增一种内核中断时,没人会记得要不要也加递归保护**。
## 设计
interruptVector 声明一条中断源的完整契约:
ID / Level / Source / Guard / Build
- Level 由条目自己声明(不再写死 L4)
- Guard 收容前置判定 —— panic 的递归保护落在这里
- Build 构造正文(模型唯一可见内容)
- 未登记 ID **panic**,不静默降级(打错 ID 会变成"中断发了但没人认领")
## 改动
新增 interrupt_vectors.go + interrupt_vectors_test.go(7 条判据)
三个调用点全部改为 raiseVector(id, args)
新增 raiseKernelInterruptAt / newKernelInterruptTaskAt 以表达条目声明的级别
## 行为零变化(刻意)
- 三个源的 source/正文/级别与改动前逐字一致
- ExpectsReply 字段**未实现**:按计划 §7.5/§7.6,它属于行为变更
(内核提示不再引起模型整轮回复),必须单独提交、单独验证
## 变异验证
- 去掉 panic 递归保护 ⇒ TestVectors_PanicGuardBlocksRecursiveL4 红
- 调用点绕过向量表 ⇒ TestVectors_PanicSourceComesFromTable 红
(source="kernel-direct",期望 "kernel")
## 验证
go build ./... / go test ./internal/agent/core/ / go test -race 全通过
|
2026-10-25 14:45:36 +08:00 |
|
|
|
01bef35c89
|
chore(agent/core): 清理 interrupt.go 迁移残留的孤儿注释
中断队列函数迁回 scheduler.go 后,interrupt.go 里留下了 8 处只有注释头、
没有函数体的孤儿(highestInterruptLocked / popInterruptLocked /
interruptCountLocked / enqueueInterruptLocked / installImmediateLocked /
registerInterrupt / requestPreempt / requestKernelPreempt / rearmPending)。
它们不影响编译,但会让人误以为这些函数还在中断管理器里——
正是本次重构要消灭的'同一件事记两处'。
同时修正 interruptState 上方的过时说明(原写'访问写法仍是 s.interruptQueues',
而队列已归还调度器)。
判据 TestInvariant_InterruptManagerHoldsOnlyStackState 仍绿(反射白名单未变)。
|
2026-10-25 13:48:35 +08:00 |
|
|
|
c5f236a569
|
refactor(agent/core): 中断队列归还调度器,中断管理器只留现场保护
用户裁定:「中断队列就该调度器管理啊,中断管理器只负责进入中断的任务」。
上一版我把 interruptQueues/immediate 也搬进了中断管理器,这是错的:
排队是**调度职责**——判断该不该打断、打断不了先排队、按什么次序再取出来,
全是调度策略,不是"进入中断"本身。
## 本次改动
中断管理器(interrupt.go)现在只有:
- 中断栈 suspendStack(**唯一操作者**:压栈/弹栈都在其内)
- 中断屏蔽 critical(对应 CPU 关中断)
- 让位信号原语 preemptArmed/preemptLevel
- 栈状态钩子 stackNotify
迁回调度器(scheduler.go):
- interruptQueues[5] / immediate 两个字段
- highestInterruptLocked / popInterruptLocked / interruptCountLocked
- enqueueInterruptLocked / installImmediateLocked
## 新增边界判据
TestInvariant_InterruptManagerHoldsOnlyStackState 用**反射白名单**钉住边界:
interruptState 只允许 6 个字段(栈/屏蔽/让位×2/上界/钩子)。
反向确认 interruptQueues 与 immediate 确实在 scheduler 上——避免"两边都没有"的假绿。
变异验证:把 interruptQueues 塞回 interruptState ⇒ 判据立刻变红
("中断管理器不应持有这些字段:[interruptQueues]")。
## 验证
- go build ./... 通过
- go test ./internal/agent/core/ 通过
- go test -race ./internal/agent/core/ 通过
- 12 条不变量判据全绿
## 顺带排查:/tmp 是 tmpfs 且已 91%
`df` 显示 /tmp 是 8.8G tmpfs,Go 链接期临时文件把它填满,
导致并发构建出现 write ... no space left on device(表现为
某包 `FAIL 0.008s`——不是代码问题)。已改用 TMPDIR=/var/tmp/gobuild(根分区 112G)。
|
2026-10-25 13:37:29 +08:00 |
|
|
|
b829968ac6
|
refactor(agent/core): 中断子系统独立成 interrupt.go + 修两处真实数据竞争
## 中断子系统拆分(用户按 OS 中断语义三次裁定的最终边界)
中断类比 OS:只提供中断优先级、中断队列、现场保护、中断屏蔽。
调度器建立在其之上——它问"中断侧该出谁",然后执行那个任务。
新增 internal/agent/core/interrupt.go(303 行),承载:
- 中断队列(interruptQueues[5] + immediate 单槽)
- 中断栈(suspendStack)—— **唯一操作者**
- 中断屏蔽(critical,对应关中断)
- 让位信号原语(preemptArmed/preemptLevel)
留在 scheduler.go 的是**策略**:
- 任务优先级语义(Level / effectiveLevel / canPreempt / clampPluginLevel)
- 恢复决策(nextInterruptSelection:栈顶 vs 中断队列比较)
- 就绪队列 / 停止配额 / 背压
### 关键设计:中断栈只有中断管理器能操作
调度器通过 stackTopLocked() **只读查询**栈顶做比较,
真正的弹栈由 restoreTopLocked() 在中断管理器内完成。
这条不变量在类型层面无法绕过。
### 解耦机制:stackNotify 钩子
中断栈变化时通告调度器,调度器据此唤醒调度循环重新求值。
中断管理器不知道调度器会怎么选,调度器也不碰栈内部 ⇒
将来给中断子系统加向量表或换多栈策略,不必改调度器。
### 测试
新增 interrupt_invariant_test.go:I1–I7 共 11 条**不变量**判据
(区别于既有的行为测试)。两条经变异验证确认会变红:
- canPreempt 改成恒 true → L4 不抢 L4 判据红
- maxInterruptFrames 改成 99 → 上界判据红
- 去掉 notifyStackLocked → 钩子判据红(且判据本身带超时,缺判据是"红"不是"挂")
## 修两处真实数据竞争(阻塞 CI,非本次引入)
-race 实测:internal/agent/core 原有 30 个测试失败,整包被判红。
根因(ab27d1a 上已存在,与拆分无关):
- core/stages.go:withExecuteCtx 往调用方共享的 tc.Arguments 原地写 ctx
- io/channel.go:bindExecCtx 同样原地写设备 args
原注释称"args 每次调用独立、天然一 goroutine 一份",但代码改的是调用方那份。
工具在自己的 goroutine 里跑,超时/取消路径下主 goroutine 仍在读同一份 args。
修法:复制 map 而非原地写。变异验证:退回原地写后
TestPreempt_DeferredDuringToolExec 立刻报 DATA RACE。
## 验证
- go build ./... 通过
- go test -p 1 ./... 全仓无失败
- -race 三关键包(core/io/proc)全绿,core 从 30 红归零
- CI 门禁自检 / 启动图判据 / 版本一致性 本地通过
## 遗留(不在本次)
- 中断向量表(计划 §7 已设计,按 §7.6 应在拆分后单独做)
- maxQueue 仍约束中断队列:OS 语义上说不通,但改它是行为变更,单独立项
|
2026-10-25 13:19:31 +08:00 |
|
|
|
a1632046aa
|
merge(ci): 合入 ci/boot-graph-tests —— Registry 加锁 + race 门扩到全仓 + 排除表收尾
合入 ci/boot-graph-tests 的 8 笔(ed61a8d..2b32934):
- 19c78ca fix(plugin): Registry 的 Set* 全部取写锁,读侧改一致性快照
- 3864f36 refactor: 删死包 kernelboot/modules,整机启动图判据迁到 kernelmod/all
- e70c2b5 feat(kernelboot): Run 增加执行期依赖校验,顺序被改坏时报可读错而非 SIGSEGV
- 653c87f fix(ci): 排除表守卫改为注入合成行真跑,杜绝形似而无效的假绿
- 599252f / 8cfb3e0 / c20e7c6 / c963d84 / 2b32934: race job 排除表收尾、撤两条
KERNEL-REGISTRY-SETLOCK / WEBUI-GZIP-RACE 排除项、空表改显式 `# none` 标记、
并修掉收尾过程中引入的 `&& false` 死条件
- caf1b71 / ed61a8d / 6d04bd2 / bf44654: 注释清理、stress 用例改「等状态」、
webui 排除项撤除、摘掉两份已作废的收尾补丁
合并预演实测(三方合并,无冲突):
- go build ./... → EXIT=0
- go test -race internal/plugin → ok 1.618s
- go test -race internal/kernelmod/all → ok 1.665s
- go test -race internal/plugins/webui → ok 60.108s
- python3 .github/scripts/check-ci-gates.py → EXIT=0
- registry.go 写锁 43 处在位
|
2026-10-25 10:46:06 +08:00 |
|
|
|
50508c83b5
|
fix(core): 裁剪收归基础设施,contextfull 只作为触发信号
## 问题
同一件事(上下文装不下)有三套实现、三份防护差异:
task.go 发 LLM 前 maybeHandleContextOverflow(有恢复预算)
task.go 上游报错 handleUpstreamContextFull(**直调内部,绕过预算**)
resident.go 轮首 checkContextFull(只对驻留子生效)
其中 handleUpstreamContextFull 是"另起炉灶":它自己又调一遍
handleContextOverflow,绕过 f.OverflowRecover 预算——而文档 §四.1 明确
「L4 遇 L4 不能抢占,反复触发只会排队堆积」。
## 改法
裁剪成为**基础设施**,contextfull 只是「判定已被外部短路」的一种入参:
enforceContextBudget(f, query, force)
force=false 本地水位:积累量 > ratio × 窗口
force=true 外部已确认超页(provider 返回 ErrContextFull)
之后的一切(恢复预算、裁剪、raise L4)完全相同。handleUpstreamContextFull
删除,只剩 isUpstreamContextFull 这个纯判定。
## 任务恢复入口补裁剪
中断任务跑完会把它的输入/输出提交进 a.context,恢复时重建出的前缀可能
**比被挂起时更大**。不在这里裁,这份更大的前缀会被原样发出去。
恢复用的是专用入口 enforceResumeContextBudget,两处语义与通用入口不同:
1. **query 取本帧最新有效上下文**(resumeQueryFrom 从 f.Msgs 尾部往回找),
不是 f.Input。被挂起的任务可能隔了很久,f.Input 早已与当前对话脱节;
拿旧 Input 算相关性,会裁掉"对该任务重要、但字面上不匹配旧输入"的事件。
2. **保护范围是本帧自己的现场**(len(f.Msgs)-f.PrefixLen),不是全局末尾
N 条。被挂起任务的现场可能是一整批工具结果,若落在保护范围外被归档,
恢复后的任务引用的内容已不在上下文里,任务自相矛盾。
为此新增 pruneForResume + contextTopKForProtected——不能复用 contextTopK,
它内部会把 topK 抬到 effectiveProtectedCount()+1(全局保护),两套下限
打架会让恢复场景「裁不动」(实测 protected=8、20 条上下文时 topK 被抬到 20)。
## 不变的部分
声明式裁剪(业务策略)语义原样保留:
· 中断/消息注入 —— pruneOnInput,判据 payload.context_policy / ChannelDef
· 工具内容注入 —— stepToolAfter,判据 ToolDef.ContextPolicy
两者走 memoryPass → pruneByQuery,与容量保护共用底层,但职责正交。
判据:任务恢复入口必须裁、恢复查询取本帧最新、保护本帧现场、
恢复预算对所有入口生效。变异验证:恢复不裁/保护退回全局/上游绕过预算,
三种坏法均被抓。
Co-Authored-By: Claude <noreply@anthropic.com>
|
2026-10-25 10:10:21 +08:00 |
|
|
|
ed61a8d604
|
test(core): ID:12 嵌套压力用例改「等状态」,不再赌 300ms 固定窗口
根因(-race 实测定性,非竞争):a.cancelLLM 的装配在进入 Chat 之前,
因此「任务已被选中运行」与「cancelLLM 已装上」之间存在一个空档。中断若
恰好落在空档里,registerInterrupt 已经打印 preempt 并置位让位信号,但
cancelCurrentLLM() 读到的 cancel 仍是 nil ⇒ 取消不生效,受害者跑满延迟后以
outcomeDone 结束(runTaskSteps 在 outcomeDone 处直接返回,不再经过安全点)
⇒ 栈深永远达不到目标值,「第 N 级注入后栈深未达 N:0」。
改法:把判据从「等固定时间」换成「等状态」——
· gatedProvider:Chat 进入即发 entered token 并阻塞到 ctx 取消或 gate 关闭,
使「受害者正处于一次可取消的 LLM 调用中」成为可等待的状态;
· 注入前 waitEntered 等该状态,注入后 waitStackDepth 轮询等到栈深真的达成;
· 排空阶段 close(gate) 让仍在运行的 L4 自然收尾。
延迟参数 300ms 未动,未改调度器参数,未加 skip,未改被测行为。
证据(internal/agent/core 下连跑 -race 10 轮):10/10 PASS;
另在 12 路 CPU 满载(改点前同条件下实测 12/30 轮失败)下 3/3 PASS。
Co-authored-by: pi <pi@local>
|
2026-10-24 14:34:24 +08:00 |
|
|
|
3f834794c5
|
refactor: SDK module 路径统一改为 hologramagentsdk(59 文件 + go.mod)
## 为什么必须做
SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:
replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk
一旦 SDK 侧改成新 module 名,主仓立刻炸:
go: ./third_party/homeagent-sdk@ used for two different module paths
(github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)
Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。
## 一个被证伪的前提
我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。
这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。
## 连带修掉两处
| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |
第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。
## 验证
- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
|
2026-10-23 15:51:02 +08:00 |
|
|
|
6327578693
|
fix(media): 支持 webp 解码 + 向量迁移失败必须留下原因
## 生产现状(2026-10-23 排查)
启动日志只有一行,没有任何细节:
[media] 启动向量迁移: 4 个图像块 → fp=cd2a495cf990 dim=512 workers=4
[media] 向量迁移完成: 成功=0 失败=4 总计=4
反查 `memory/graph.db` 才找到根因:
| mime | 块数 | fingerprint |
|---|---|---|
| image/jpeg | 153 | 当前 fp |
| image/png | 394 | 当前 fp |
| **image/webp** | **4** | **空**(从未嵌入成功) |
而 `providers/chineseclip/image.go` 只注册了 gif/jpeg/png:
_ "image/gif"
_ "image/jpeg"
_ "image/png"
⇒ `image.Decode` 对 webp 返回 `image: unknown format` ⇒ 这 4 个块
**永远**拿不到向量 ⇒ 语义检索永远搜不到它们,且没有任何报错。
## 两个独立缺陷
### ① 功能缺口:webp 不可解码
加 `_ "golang.org/x/image/webp"`。这是纯 Go 实现、无 cgo,不改变二进制形态。
代价:`x/image v0.25.0` 硬性要求 `x/text >= v0.23.0`,因此 `x/text` 从
`v0.3.8` 升到 `v0.23.0`。已实测 `providers/chineseclip` 全绿(分词器用
`x/text/unicode/norm` 做 NFC 归一化,升级未改变行为)。
### ② 可观测性缺口:失败被 `continue` 吞掉
原实现:
if err := a.reembedOneBlock(b, fp); err != nil {
atomic.AddInt64(&failed, 1)
continue ← 原因就此消失
}
现在按 `mime | error` 聚合后打印(不逐条打,上千张图会刷屏):
[media] 向量迁移完成: 成功=0 失败=0 不支持=4 总计=4 fp=cd2a495cf990
[media] 失败原因分类(按 mime | error 聚合):
[media] 4 个: image/webp | chineseclip: 解码图像: image: unknown format
同时区分 `ErrModalityUnsupported`(**永久**无向量,重试无意义)与临时失败,
单独计为 `不支持=N` —— 混在"失败"里会让人以为重试就能恢复。
## 判据(4 条,全部变异自证)
| 判据 | 覆盖 | 变异 |
|---|---|---|
| `TestWebpIsDecodable` | webp 能被 `image.Decode` 识别 | 注释掉 webp 注册 ⇒ 红并指名「image.go 里缺少 ...」 |
| `TestWebpPreprocessProducesTensor` | webp 能走完整预处理(缩放+归一化),不只解码 | 同上 ⇒ 红 |
| `TestPngStillDecodable` | 注册表改动没弄坏 png | — |
| `TestReembedStaleMedia_LogsFailureReasons` | 失败原因(mime + 错误原文 + 计数)必须进日志 | 删掉打印 ⇒ 红 |
| `TestReembedStaleMedia_SeparatesUnsupportedFromFailed` | 不支持模态单独计数 | — |
webp 夹具用 ffmpeg 生成的真实 2×2 webp(44 字节)。手写字节序列构造不出
合法容器——第一版手写就失败了,这是实测教训。
## 验证
- `providers/chineseclip` / `internal/agent/core` / `internal/memory/vector` 全绿
- `go build ./...` / `go vet ./...` 干净
- 全量回归:见提交后 CI
|
2026-10-23 15:50:59 +08:00 |
|
|
|
d3590192e7
|
fix(outputch): 内核不再替插件判断载荷语义 —— 空 payload 是合法占位
## 实测踩到的真 bug(生产环境)
QQ 插件重装后首次实测 `sticker_index` 用法,直接失败:
output_send__qq result: 参数有误,未发送任何内容:payload 不能为空
而插件侧明明有这个放行分支:
if payload == "" && !(rawType == "sticker" && stickerIndex > 0) {
return nil, fmt.Errorf("payload 不能为空")
}
分支永远走不到 —— 内核先拒了。
## 根因:两处内核越权判断
| 位置 | 旧判断 | 问题 |
|---|---|---|
| `parseOutputItems` | `strings.TrimSpace(p) == ""` ⇒ 拒绝 | 内核不知道 payload 语义。`type=sticker` + `meta.sticker_index` 要求 payload 留空占位,真实 URL 由插件从收藏表情取 |
| `sendOneOutput` | `stageCtx.FinalText == ""` ⇒ "输出被插件清空" | 原本就空的占位被判成异常 |
**正确边界**:参数**是否存在**归内核(没传 `payload` 键 ⇒ 拒绝,结构性错误内核能判);参数**值是否合法**归插件(空串 ⇒ 交插件判断并返回 error)。
这与本轮 outputch 的另一条改动是同一条原则(`type` 的能力校验下放插件):
**内核只透传与对齐,不解释插件自定义字段的含义**。
## 连带修正:`stringOrArray("")` 不再返回 nil
原来 `payload: ""` 被当成"未提供",与"没传 payload 键"混为一谈。
现在空串**保留为一个元素**:前者是模型明确给的空占位(须走到插件),
后者才是真的没提供(由 `len(payloads) == 0` 拦下)。
## 判据调整(原有判据与正确设计冲突)
`TestOutputSend_RejectsEmptyEntries` 断言"含空条目必须整 call 拒绝" —— 那是
按旧的越权设计写的,**与正确边界冲突**。改写为三条分别钉住边界:
| 判据 | 断言 |
|---|---|
| `EmptyPayloadDeferredToPlugin` | 内核不拦空条目,交插件判断(2 条都要送到) |
| `EmptyStringIsNotMissing` | `payload: ""` 是占位而非缺失,必须走到插件且原样透传空串 |
| `MissingPayloadStillRejected` | 完全没传 payload 键仍拒绝(内核能判的结构性错误) |
变异验证(把 trim 判空加回 `parseOutputItems`)⇒ 两条同时变红:
- 「内核不该拦空条目,应交插件判断,实际只发 0 条」
- 「空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 0 次调用」
## 验证
- `go test ./internal/agent/core/` 全绿(含新 3 条)
- 生产实测修复前后对照(journalctl 原文):
21:54:35 output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 ← 修复前
21:56:34 output_send__qq result: ok ← 修复后
- webui / agent-core 全绿
|
2026-10-22 22:42:02 +08:00 |
|
|
|
b20e32d81d
|
feat(outputch): 提示词显式声明「必须调 outputch 才送达」+ 契约值改名 Agent
## ① 提示词:只有显式调用 outputch 才会送达用户(用户明确要求)
模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** ——
在异步通道(qq/wechat/群聊)上那等于自言自语,用户永远收不到。
改动前【输出规则】开篇是「消息不会自动发送到对话来源通道,你必须自己决定
如何回复」,语气偏描述。改为开篇总述句:
**只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**
返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。
同时补上本次内核新增能力:payload 传数组可一次发多条。
## ② 契约值改名:ProxyAuthHomeAgent="homeagent" → ProxyAuthAgent="agent"
用户指示契约值一起改。常量名与字符串值同时改,但**保留旧值读取路径**:
ProxyAuthAgent = "agent" ← 新值
ProxyAuthAgentLegacy = "homeagent" ← 只用于兼容读取
ProxyAuthHomeAgent = ProxyAuthAgent (Deprecated 别名,存量源码可编译)
为什么必须保留:`Auth` 是**写进 plugin.json 的持久化声明值**。存量配置里
写着旧值 —— 本机实测 `/home/newqqagent/plugins/huawei_smarthome/plugin.json:19`
就是 `"auth": "homeagent"`。只认新值 ⇒ 该插件加载时被判为非法声明 ⇒
反代不生效,症状是「插件好好的但页面打不开」,且无任何报错指向改名。
### 归一化是安全关键
`EffectiveProxyAuth` 把**空串与旧值都归一化为 ProxyAuthAgent**。
判断点全都写成 `auth == ProxyAuthAgent`。若不归一化,就得每点写两遍比较
—— **漏一处就是鉴权绕过**:旧值被判为「非门户鉴权」⇒ 跳过 authorizeProxy
⇒ 请求直接透传给上游。所以兼容收在一个函数里,不散落在调用点。
### 顺带修掉两处隐患
1. `webui/proxy.go` 的裸字面量 `"homeagent"`(非法行占位)改为
`sdk.ProxyAuthAgent` —— 裸字面量在改名时会被漏掉,届时它悄悄变成
未知值,而未知值在旧代码里等于「无鉴权」。
2. 两处鉴权判断改用 `EffectiveProxyAuth(...)`,堵住旧值绕过。
## 判据
| 判据 | 覆盖 | 变异自证 |
|---|---|---|
| `TestSystemPromptRequiresExplicitOutputCall` | 总述句必须存在、必须说明「返回文本用户看不到」、同步通道是唯一例外、payload 可传数组 | 删掉总述句 ⇒ 红 |
| `TestProxyAuthLegacyValueStillAccepted` | 旧值仍被接受 + 必须归一化为新值 | 去掉归一化分支 ⇒ 「旧值应归一化为 "agent",实际 "homeagent"」红 |
## 验证
- `go build ./...` / `go vet ./...` 干净
- webui / internal-sdk / agent-core 全绿
- SDK 仓(独立提交 acd1166):`go build` + `go test ./...` 0 失败
|
2026-10-22 21:17:05 +08:00 |
|
|
|
f850d754c1
|
feat(outputch): 一 call 多发 + 多类型 —— 内核级通用能力(非某插件专属)
用户要求:内核 outputch 是通用基础,传入 meta/payload/type(三者均为**插件自定义
字段**)。要支持一次调用发多条、多种类型消息,通过 payload 数组 + type 数组实现。
## 内核原有形态的限制
output_send__<ch> 每次只能发一条单类型:
payload: string / type: string / meta: string
带附件或多段内容的通道只能额外注册独立 tool(如 QQ 的 qq_send_file),
而这不该是插件的职责 —— "发一条消息"本来就是输出门的语义。
## 改法
### ① 工具 schema:oneOf [string, array]
payload / type / meta 三者的 schema 改为同时接受字符串与数组。
payload 是字符串时走单条路径,**行为与改动前逐字相同**(向后兼容,用户拍板)。
### ② 解析规则(parseOutputItems)
- payload 与 type 必须等长;type 省略或单值 ⇒ 广播
- meta 允许**单值广播**(用户拍板)
- 长度不符 ⇒ **整 call 拒绝,一条都不发**
- 含空条目 ⇒ 同样整 call 拒绝(不能静默发空消息)
### ③ 能力校验下放插件(用户拍板)
删掉内核里的 `switch rawType { case text/voice/image/file: 查 caps }`:
· type 是**插件自定义枚举**,内核不认识它。写死四个值 = 内核在理解插件语义;
· 且插件的自定义类型(如 QQ 的 sticker)会被内核无端拒绝。
内核只保留两件它确实该管的校验:**授权闸**(IsOutputAllowed)与
**通道存在性**。type 与能力的匹配由插件在 handler 里判断并**返回 error**。
### ④ 部分失败:继续发剩余的,如实逐项汇报
用户要求"部分失败向 agent 返回失败项"。实现选择"继续发完"而不是"中止":
第 2 条图片失败不该把第 3 条文字也丢掉,否则"一次发多条"失去意义。
返回形态:
全部成功(多条) 已通过 [qq] 通道发送 3 条
部分失败 已通过 [qq] 通道发送 2/3 条:
[1] text ok
[2] image 失败:<插件返回的错误原文>
[3] text ok
失败的条目请单独重发。
单条成功仍返回 "ok"(与旧版完全一致,避免富回执驱动回声效应 ——
output.go 里有实测依据)。
### ⑤ 拆出 sendOneOutput
before_output / 安全回填 / after_output 这套顺序对**每一条**都必须完整走一遍。
混在批量循环里极易漏环(例如只给第一条跑 stage),所以把单条路径独立成函数,
批量只是循环调用它。
## 判据(8 条,全部有变异自证)
| 判据 | 覆盖 |
|---|---|
| SingleValueUnchanged | 单值 payload 行为与旧版逐字相同(返回 "ok") |
| ArraySendsInOrder | 数组按顺序发 N 条,type 一一对应 |
| MetaBroadcast | meta 单值广播给所有条 |
| LengthMismatchRejectsWholeCall | 长度不符 ⇒ 一条都不发 |
| PartialFailureReportsAndContinues | 第 2 条失败仍尝试第 3 条 + 如实列出失败项与插件错误原文 |
| KernelDoesNotInterpretType | 自定义 type "sticker" 原样透传,内核不拦 |
| RejectsEmptyEntries | 含空条目整 call 拒绝 |
| UnauthorizedStillBlocked | 授权闸不被批量绕过 |
变异全部被抓:
- 第 2 条失败即中止 → 「实际只调用 2 次」
- 恢复内核 type 能力校验 → 「内核不该对 type 做能力判断」
- meta 不广播 → 「meta 未广播到」
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 现有 output 相关测试(output_grant / output_rules / resident_output)全绿
## 未做(下一步)
- example 插件(qq)收回 qq_send_file / qq_send_sticker,改走 outputch
- outputch 说明迁入各插件 skill(保留 _help 机制)
- 插件侧补 type 能力自校验(校验已下放,需要它们真的做)
|
2026-10-22 21:05:17 +08:00 |
|
|
|
f256098c9f
|
refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent
仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:
- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
目录内的 module/app 描述同步;**证书路径原样保留**
(default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author
★ 必须保持原样的两处(改了会坏):
· legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
—— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
· 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
—— 那是 SDK 契约里的值,属独立仓库,本仓不改
## ② 修 21 处乱码(U+FFFD)
这些是历史编辑留下的编码破坏,全在注释里,例如:
「赌注重下���遍」→「赌注重下一遍」
「「不��滤」」 →「「不过滤」」
「**待��退场**」→「**待退场**」
★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。
## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)
实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:
【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
【三层记忆】 1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)
收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":
工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
关系用 memory_recall / 成文知识用 knowledge_search
判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。
## 判据
prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)
变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
|
2026-10-22 20:49:44 +08:00 |
|
|
|
6bea130639
|
fix(memory): 回填事件必须照常归档 —— 纠正 NoMemory 语义被错误扩大到存储层
用户指出:doc 召回部分需要回填记忆,因为 doc 召回后原文在 doc 层已经删除了。
## 我上一轮的错误(PR #22)
把「工具输出不进记忆」与「回填事件不归档」塞进了同一个 NoMemory 标记:
doc_query → Consume(L2 副本删除)→ 回填 L1(标记 NoMemory)
→ L1 裁掉它 → Prune 跳过归档(错误实现)
→ 三层都不在 ⇒ 内容永久丢失
Consume 已经把 L2 副本删掉,**L1 是唯一副本**。裁掉时不归档 = 丢数据。
这不是自激防护,这是把数据丢掉当成防护。
## 正确区分(两件事不能共用一个标记)
| 对象 | 进计算层 | 进归档 | 理由 |
|---|---|---|---|
| doc_query 的**工具返回值**(正文摘录) | ❌ | ❌ | 它是事件的摘录副本,再向量化 = 重复注入 ⇒ 自激 |
| **回填的事件本身**(原文) | ❌ | ✅ **必须** | L2 副本已删,L1 是唯一副本;不归档即丢失 |
## 修法
ContextEvent.NoMemory 语义定为「**不参与计算,照常归档**」,
与 ToolDef.NoMemory 一致:只影响计算层,不影响存储层。
自激的入口在"重新计算向量"那一侧(textForVector 返回空),
不在"归档"这一侧 —— 删掉 Prune 里的跳过逻辑。
## 判据(改写了那条断言错误行为的判据)
TestSelfExcite_RestoredEventsNotReArchived 现在断言三件事:
1. 回填事件带 NoMemory 标记且未被向量化(不参与计算)
2. 前提成立:L2 里确实已无该事件(Consume 删掉了)
3. ★ 被裁掉后必须能落回 L2(否则内容永久丢失)
变异验证:
- Prune 重新跳过 NoMemory ⇒ 红,报「三层都不在,内容永久丢失」
- restoreDocEvents 不置 NoMemory ⇒ 红(会重复加权)
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
|
2026-10-22 20:37:34 +08:00 |
|
|
|
71e2b83a76
|
fix(memory): 掐断 doc_query 的自激震荡 —— 工具输出与回填事件都不进记忆
用户指出(2026-10-22):doc_query 的结果走 NoMemory 路径,否则会对记忆块
产生自激震荡。核实后确认**两个入口都开着**。
## 回路
doc_query
├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
│ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
│ → Prune 因相关而保留它们(挤掉真正的新事件)
│ → 下一轮 Prune 又把它们归档回 L2
└─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁
## ① 工具输出:doc_query 未声明 NoMemory
基础设施早就有(textForVector 里按 ToolDef.NoMemory 跳过工具输出),
但 doc_query 是**内置工具**,它的声明无处安放:
StageHost.ToolDef 只覆盖插件工具,内置工具由 executeToolCallInner 的
switch 分派,不经过注册。
修法:
- internal/sdk/tool.go:BuiltinToolDef 加 NoMemory 字段(与 ToolDef.NoMemory
同义,两条路必须能声明同一件事)
- internal/agent/core/tooldefs.go:declareNoMemoryTool + noMemoryOf,
由 init() 里**显式名单**登记 doc_query
- internal/agent/core/context.go:textForVector 同时查插件 ToolDef 与
内置声明表(原来只查前者)
★ 为什么在 init 显式登记、不靠 toolDefWith 构造时登记:
工具表是按需构造的(受 `if a.docStore != nil` 之类条件控制),
而 textForVector 可能在构造之前被调用 ⇒ 声明会"时有时无"。
实测踩到:构造前查 noMemoryOf("doc_query") 得到 false。
★ declareParallelTool 顺带改为**按字段合并**(原来整体覆盖):
同一工具可能同时声明 parallel 与 noMemory,覆盖会让后登记的那项
静默丢失 —— 声明丢一个是最难察觉的一类问题。
## ② 回填事件:加 ContextEvent.NoMemory
ContextEvent 新增 NoMemory 字段(此前只有 ToolDef/ChannelDef 有)。
restoreDocEvents 回填时置位(含存量降级路径),于是:
- textForVector 直接返回 "" ⇒ 不向量化、不参与关键词/蒸馏
- PruneWithProtected 归档前过滤掉它们 ⇒ 不再产生新的 L2 副本
语义与 ToolDef.NoMemory 一致:**原文仍在上下文可读**,只是不进计算、
不再产生新记忆。这是「回填是为了让模型读到原文」的本意。
## 判据(3 条,钉住两个入口 + 一个后果)
selfexcite_test.go:
- DocQueryDeclaresNoMemory:声明可查 + 工具输出不进 textForVector
- RestoredEventsNotReArchived:回填事件带 NoMemory 标记 + Prune 后
L2 里不出现含回填内容的新文档(真跑 Prune 验证回路)
- RestoredEventsDontCrowdOutNewOnes:回填事件不被向量化(否则会
参与相关性打分、挤掉新事件)
变异全部被抓住:
- Prune 不过滤 NoMemory → 「回填事件被 Prune 又归档回了 L2」
- restoreDocEvents 不置 NoMemory → 「没有 NoMemory 标记」+ 回路成立
- init 不登记 doc_query → 「未声明 NoMemory」
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 公开 SDK(third_party/homeagent-sdk)未改动 —— 改的是 internal/sdk
|
2026-10-22 20:16:57 +08:00 |
|
|
|
e2cb6e70e0
|
feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词
上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。
## ① 向量输入:Summary + Content → 事件正文
denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。
改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。
★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
(Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。
## ② doc 层移除 jieba 语义生成
- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)
★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
→ 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
可用,jieba 就不参与。
## ③ doc_query 返回真实内容
旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。
新返回:
已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):
[1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
配色方案怎么定?
结论:紫蓝红三色带按六比三比一…
共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。
每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。
## ④ 提示词:agent 终于能理解三层记忆
- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
并在块尾明确引导「要完整原文用 doc_query」
## ⑤ 向量算法版本(否则修复对存量无效)
实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。
修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。
## 判据(每条都有变异自证)
document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)
core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导
变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。
## 验证
- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
· 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
· 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
区分度最高 0.18(旧实现 0.003)
|
2026-10-22 19:49:25 +08:00 |
|
|
|
47c95efb80
|
feat(memory): L2 与 L1 同构 —— 事件列表承载原文,正文内联在块里
块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改,
导致这条链出现结构性失效。本次按设计原文修复。
## 断点(生产实测)
**① L2 只存渲染视图,事件本体丢失**
ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。
生产库 1000 篇归档文档实测:
- response 段恰好 103 字符(=100+"..."):311/2094 = 15.1%
- 工具输出段恰好 203 字符:7593/13261
- ToolsUsed 完全不存
⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。
**② L2 里没有文本块,与 L1 不同构**
L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。
**③ 召回造合成事件,时间戳用归档时刻**
doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、
且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at
差近两小时,却没人用。
**④ 块在 Consume 时两层同时消失**
Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。
生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。
## 修法(按设计,不是按我的推断)
设计前提(写进 ARCHITECTURE 与计划文档):
· 记忆块是唯一物理载体,层是持有关系
· L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
· 正文(含多媒体正文)内联在块里;事件只是块的容器
· 任何一层都不压缩(视图不是记忆)
· 内核不得硬编码任何插件行为
1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体
2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文)
3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回
4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体
5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图
6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码
插件名,改由插件经 NoMemory/Cleaner 声明
## 顺带修掉的真实缺陷
simpleHash 是 O(n²) 且哈希串长度正比于内容:
for _, c := range s { h += fmt.Sprintf("%x", c) }
压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash
是 content 的 2.2 倍(52072 字 → 114491 字)。
换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。
## 存量
按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回
(整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档
全部正常加载。
## 判据(16 条,全部有变异自证)
document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、
视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力
core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、
回填不截断、存量降级、批量压力
变异全部被抓:
· 落库 truncate(100) → ArchivePreservesFullText(实测 len=303/原 10215)
· Input/Response 合成一块 → InputAndResponseAreSeparateTextBlocks(实际 1)
· 召回用 d.CreatedAt → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3)
· 召回不回填 Blocks → MigratesBlocksBackToContext
· 召回再截 2000 字 → TextIsNotTruncatedOnTheWayBack(2003/原 18020)
· 恢复 O(n²) hash → HashIsLinearAndFixedLength(超时 >300s)
## 验证
- go build ./... 通过
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出
- 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确
## 不改
- 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
|
2026-10-22 11:09:13 +08:00 |
|
|
|
4e70b8d5d8
|
refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。
- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
(857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
homed/waiter 可执行名、.hmap 后缀
|
2026-10-21 12:17:19 +08:00 |
|
|
|
4e66e644da
|
test(security): 修 -race 下 TempDir 清理假红 —— 断言已过却被清理判失败
## CI 实证(run #86 / #87,Race detector 连续两次红)
失败信息自相矛盾:
--- FAIL: TestE2E_ResponseSecretNeverReachesContext (0.00s)
persist_leak_test.go:94: ✅ 回复侧明文未进 context;变量库 1 条
testing.go:1369: TempDir RemoveAll cleanup: unlinkat /tmp/TestE2E_.../001: directory not empty
断言全过、变量库也拦住了,唯一失败点是 `t.TempDir()` 的严格清理:RemoveAll
要 rmdir 那一刻,变量库还有一次落盘(saveLocked 的 tmp→rename)在途/刚落完,
于是判 "directory not empty"。本地不复现是因为机器快、时序不同;CI 在 -race
下慢一拍就撞上。
## 修法
`newScrubStore` 改为自建 MkdirTemp + 重试清理(50×20ms),最后尽力删除且
**不让清理失败污染判据**。安全判据本身没有放宽——只修测试基础设施。
## 验证
本地 `go test -race ./internal/agent/core/ ./cmd/waiter/ -count=1`:
两个包全绿(16.2s / 1.0s)。
|
2026-10-20 10:24:31 +08:00 |
|
|
|
3119d48625
|
refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义
按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。
## 删掉内核里的终端语义
`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:
- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)
保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。
## 插件侧订阅事件总线自攒视图
新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:
EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
EventToolCall (内核发) → cmd_run 历史、terminal_create/close 归并
WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。
## agentcli 保持内置
它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。
## 文档
中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。
## 验收(本机实例实测)
- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
CLI `/terminals`(unix socket)返回同一份;
两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
|
2026-10-20 09:33:08 +08:00 |
|
|
|
b9097986c8
|
fix(prompt): 拼装时空行翻倍 + 人格支持版本占位符 + 配置项说明对齐分层原则
## 1. 提示词里的空行翻倍(用户实测发现)
抓包看实际 payload,system 里出现 3 处连续 3~4 个换行:
…别包装成"稍等我看一下"。\n\n\n【人格设定】
…/mascot.webp 直接能看。\n\n\n\n【场景记忆】
…能否同轮发出。\n\n\n【输出规则】
根因:拼装统一用 `prompt += "\n\n" + block`,而块自己常以换行结尾
(人格块是 "【人格设定】\n%s\n",自定义 system_prompt 也常带尾部换行)。
空行不致命但推高 token,也让「提示词到底长什么样」难以肉眼核对。
修法:新增 appendPromptBlock(trim 块两端 + 只拼一个 "\n\n"),所有拼装点改用它。
重新抓包:连续 3+ 换行 0 处。
## 2. 人格块支持版本占位符
buildSystemPrompt 只对 systemPrompt 调 expandPromptVars,人格块没展开 ——
人格里写死版本号仍会随发版说谎(实测线上人格卡写 v1.0.3)。现在人格同样展开
{{kernel_version}}/{{kernel_commit}}/{{sdk_version}}。
## 3. 人格/系统提示词两个配置项的说明重写
- core.agent.personal_prompt:DisplayName 改为「人格设定(称呼/语气/说话方式/形象)」,
说明只写人设;工具用法写工具描述、多步流程写技能;支持占位符;文件优先。
- core.agent.system_prompt:DisplayName 改为「系统提示词(身份与行为规则)」,
说明只写跨工具/跨通道成立的内容;运行时通道投递/并行/时序语义由内核注入无需重复;
留在提示词里的工具用法会随插件外置而失真。
## 4. 【记忆清理指令】→【记忆维护】
具体选哪个 memory_* 工具、各自副作用早已写在工具描述里(【记忆清理】前缀),
提示词里那份逐条列举属于重复。只保留「必须真做、不能只描述计划」这条
不看工具描述也会犯的错,并要求用一句人话说结果。
## 5. 新增 opt-in 提示词抓包(排查用)
HOMED_PROMPT_DUMP=<path> 时把每轮实际发给 LLM 的 messages 落 jsonl(0600)。
环境变量而非常规配置项:内容是用户隐私,只该在诊断时临时开。
本次排查即由它拿到证据;诊断后已移除临时 drop-in。
验证:go test(agent core / config / sdk / healthcheck)全绿;
实例重启后抓包复核空行归零;对话口吻仍为「短、口语、不列清单」。
|
2026-10-20 09:16:07 +08:00 |
|
|
|
d35b7b7275
|
feat(status): ONNX 状态补 dep_parser 字段;人格改成「说人话」的说话方式
## 1. ONNX 状态补依存句法分析器(onnx.dep_parser)
此前 onnx.enabled 只反映**多模态向量空间**,解析器状态完全不可见 —— 于是
只能靠翻启动日志猜,而日志又曾把「内嵌模型已启用」错报成「规则引擎」
(见上一个提交)。现在健康快照直接给:
"dep_parser": { "enabled": true,
"model_path": "/home/newqqagent/nlp/dep_parser.onnx",
"embedded": true }
- sdk.ONNXStatus 增加 DepParser / DepParserStatus(enabled/model_path/embedded)
- agent 侧由 nlp 包级默认解析器反查实际状态(判据是「真能 Parse 且报得出路径」,
不是「配置里写了 onnx_model_path」)
- healthcheck_kernel 的描述同步说明该字段
## 2. 人格:从「交报告」改成「聊天」
用户反馈实例说话一股人机味。实测原文:
第一件·文件读写:
- 写:files_write 往 /tmp/... 写入 hello-files,回执 11 字节
- 读:files_read 读回来,内容就是 hello-files,对上了
问题不在语气词,而在**结构**:清单体 + 小标题 + 加粗 + 把工具名/字节数/耗时
当成绩单报出来。旧人格只禁了「客服腔四件套」,没禁这些。
DefaultPersonaPrompt 的说话方式一节重写,明确:
- 短句为主;先给结果/情绪,再补解释
- 不用清单体(“-”/“1.”/“•”)、不用小标题/加粗/表格
- 不报工单式细节(不罗列工具名、参数、字节数、耗时)——说人话
- 不用客服腔,拿不准/失败直说
- 只有用户明确要清单/步骤/数据时才结构化
## 3. 本机实例人格同步优化(personal.md,优先级高于配置项)
按同一套规则重写 /home/newqqagent/personal/personal.md,并保留「照大肥鱼那股
活人味来」与形象特征。重启后实测(问「在吗?状态咋样」):
在呢在呢 (`・ω・´)
刚拉了个运行时快照看了眼,状态挺好——内核 1.4.0dev 跑着,29 个插件全部 loaded……
刚忙完就赶紧来找我聊天,老大你这节奏也太充实了呗~
无清单、无小标题、无工具名报账。
验证:go test(sdk / agent core / healthcheck / config)全绿;
本机实例重启后 onnx.dep_parser 三字段正确返回。
|
2026-10-20 09:08:20 +08:00 |
|