mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
Compare commits
65 Commits
feature/re
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 11144c62b5 | |||
| d05161ac8d | |||
| 7587bd82d8 | |||
| 7b7d405463 | |||
| 5d395116d1 | |||
| fc8f153e34 | |||
| 3374e7dbd9 | |||
| ca6f4c510c | |||
| 88923ed663 | |||
| 631963fdc7 | |||
| 4078f3ac0a | |||
| 0c9a3900b8 | |||
| a35f2126a1 | |||
| 9b26db45bc | |||
| 47052cb115 | |||
| 09298886a2 | |||
| db8534e315 | |||
| fab27a1194 | |||
| 923d5d595f | |||
| 71faf8d9ad | |||
| c0274b71d5 | |||
| 7213edd181 | |||
| 8acd3ce1a8 | |||
| 943eef01cf | |||
| 01909bb914 | |||
| fe1d2672d8 | |||
| 69446a2649 | |||
| e273924511 | |||
| 53e7106985 | |||
| 6ddef5e49f | |||
| 95292aff8e | |||
| 2722d76095 | |||
| 01113664b4 | |||
| b1ec278136 | |||
| d202f2ceec | |||
| 895948b24e | |||
| 698ff7ddd5 | |||
| ccc2ac2d4d | |||
| 831bd2290b | |||
| 1f5af1dccf | |||
| 9de3b365a6 | |||
| ec13eb391a | |||
| e70d2171ee | |||
| 3cce605722 | |||
| 0227fc2d4d | |||
| 91c25fa536 | |||
| bcaa8f3f31 | |||
| 5333a33e20 | |||
| 95b1950bb3 | |||
| 307a6faee7 | |||
| baddaf387e | |||
| 15497ee0a2 | |||
| c151d391ee | |||
| 065732f42e | |||
| a9ad97240b | |||
| acc94723fd | |||
| 49695c38f3 | |||
| bb7e7979ae | |||
| d4f9a12db0 | |||
| b7e47a1b1f | |||
| 9d45cd0277 | |||
| d98bf512e1 | |||
| b37141f3f5 | |||
| 94995eaa64 | |||
| 6afe361804 |
33
Makefile
33
Makefile
@ -1,4 +1,17 @@
|
||||
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt
|
||||
.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions
|
||||
|
||||
# HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与
|
||||
# deploy/packaging/build.sh 保持一致)。
|
||||
#
|
||||
# 曾经这里是空 tags,实测的后果(2026-09-15 热部署):`make build` 产出的
|
||||
# homed 只有 33MB,而 onnxruntime 版是 84MB;启动日志里
|
||||
# 「multimodal space active: provider=chineseclip」整行消失,少加载一个插件,
|
||||
# 静态词向量也退化成 fallback——而打包脚本会直接**拒收**这种二进制
|
||||
# (package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」
|
||||
# 与「发行构建」不是同一个东西,部署时无从察觉。
|
||||
# 需要极简构建时显式 HOMED_TAGS= 关掉。
|
||||
HOMED_TAGS ?= onnxruntime
|
||||
TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),)
|
||||
|
||||
BINARY=homed
|
||||
CLI_BINARY=waiter
|
||||
@ -17,13 +30,15 @@ all: build build-cli
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
|
||||
CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
|
||||
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')"
|
||||
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \
|
||||
|| echo "WARN: 本次构建不含 onnxruntime,本地向量空间不可用(HOMED_TAGS= 显式关掉时才符合预期)"
|
||||
|
||||
build-cli:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
|
||||
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
|
||||
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))"
|
||||
|
||||
build-gui:
|
||||
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
|
||||
@ -62,6 +77,14 @@ fmt:
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
|
||||
# 客户端版本与内核版本同步(唯一事实源 internal/meta.Version)。
|
||||
# GUI/鸿蒙各有自版本字段,手工改必漂——用脚本拉齐,check 版给门禁用。
|
||||
sync-client-versions:
|
||||
@bash deploy/scripts/sync-client-versions.sh
|
||||
|
||||
check-client-versions:
|
||||
@bash deploy/scripts/sync-client-versions.sh --check
|
||||
|
||||
# lint-full:在 vet 之外跑 golangci-lint(阈值见 .golangci.yml,起步 warn-only)。
|
||||
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
|
||||
.PHONY: lint-full
|
||||
|
||||
66
README.md
66
README.md
@ -26,6 +26,16 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
||||
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
||||
|
||||
**输入调度:两类别 + 四级中断** — 输入不直接进 LLM,先进调度器。
|
||||
排队(待办工作)与中断(按"有多不能等"分 L1~L4)两类;高级可抢占低级并保存现场
|
||||
(中断栈),同级不抢占。L4 只归内核与内核级插件(如 WebUI 终止按钮)。
|
||||
见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「输入调度器与中断机制」。
|
||||
|
||||
**驻留式子 agent** — 内核可派驻轻量内核的子 agent(自己的调度器与 temp 图记忆,
|
||||
共享通道登记表),把长任务/积压交出去并行做。主 agent 忙久了,内核还会把排队输入
|
||||
交给临时**分诊助手**:简单的直接处理,需要主 agent 的立刻回「忙碌中,请稍候」,
|
||||
用户不再干等。见 [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)。
|
||||
|
||||
## 架构图
|
||||
|
||||
### 一、消息处理时序
|
||||
@ -34,6 +44,7 @@ homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||
sequenceDiagram
|
||||
participant U as 用户/插件
|
||||
participant IO as IOManager
|
||||
participant SCH as 输入调度器
|
||||
participant EV as eventLoop
|
||||
participant CTX as RelevanceContext
|
||||
participant LLM as LLM+工具循环
|
||||
@ -41,7 +52,14 @@ sequenceDiagram
|
||||
participant MEM as 三层记忆
|
||||
|
||||
U->>IO: InjectInput(type, payload)
|
||||
IO->>EV: inputCh
|
||||
IO->>SCH: inputCh
|
||||
rect lavender
|
||||
Note over SCH: 两类别 + 四级中断(L1~L4)
|
||||
SCH->>SCH: 同级不抢占 → 入就绪队列/中断队列
|
||||
SCH->>SCH: 更高级 → 抢占(现场压中断栈,稍后可恢复)
|
||||
SCH->>SCH: 转投(主 agent 忙久了 → 交给临时分诊助手)
|
||||
end
|
||||
SCH->>EV: 选中一个任务开始跑
|
||||
rect lavender
|
||||
Note over EV: processTextInput
|
||||
EV->>ST: StageOnInput 插件可改写/短路
|
||||
@ -55,7 +73,7 @@ sequenceDiagram
|
||||
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
|
||||
EV->>ST: StagePreAction 插件可预拦截
|
||||
loop 工具循环
|
||||
LLM->>LLM: drainInterrupts
|
||||
LLM->>LLM: 安全点:中断求值/让位
|
||||
LLM->>LLM: LLM Chat
|
||||
LLM->>ST: StagePostAction 插件可修改/短路
|
||||
alt 无tool call
|
||||
@ -110,7 +128,7 @@ flowchart TB
|
||||
end
|
||||
subgraph D[② Document 文件记忆]
|
||||
DS[DocStore JSON+TF-IDF]
|
||||
Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP
|
||||
Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP
|
||||
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
|
||||
Q2 -->|原始时间戳写入上下文| RC
|
||||
CD[FindColdDocs 72h] -->|docToTriples| G
|
||||
@ -180,27 +198,44 @@ API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQ
|
||||
cmd/homed/ 守护进程入口,组装所有子系统
|
||||
cmd/waiter/ CLI 客户端(Unix socket)
|
||||
internal/
|
||||
├── agent/core/ Agent 核心:事件循环、LLM 工具循环、7 阶段管道
|
||||
├── agent/api/ LLM Provider + 8 个 Lua 适配器
|
||||
├── agent/core/ Agent 核心:输入调度器(两类别+四级中断)、事件循环、LLM 工具循环、7 阶段管道、驻留子
|
||||
├── agent/api/ LLM Provider(Lua 适配层:provider.go 调 vm)
|
||||
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
|
||||
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||
├── plugin/ 插件注册表 + 子进程加载器(stdio RPC + 共享内存段 + 事件环)
|
||||
├── plugins/ 内置 11 个插件(webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||
├── plugins/ 内置 18 个插件(webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data 等)
|
||||
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||
├── config/ SQLite 配置中心
|
||||
├── events/ 事件总线
|
||||
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||
└── internal/lua/adapters/ 10 个 LLM 协议适配器脚本
|
||||
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
|
||||
```
|
||||
|
||||
## 项目状态
|
||||
|
||||
**v1.3.x 线**(v1.3.1–v1.3.12,最新已发布)—— **驻留式子 agent** + **输入调度器重做**。
|
||||
|
||||
- **驻留式子 agent**:内核可派驻轻量内核的子 agent(自己的调度器、自己的 temp 图记忆、
|
||||
共享通道登记表)。父经 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy)
|
||||
派活与收活;inputch 可划给子,输入**在进内核之前**就已路由到子。
|
||||
- **输出通道可寻址到具体 agent**:`AllowedOutputs` 授权集合(三处过滤点一致),
|
||||
父/子之间可互相投递;设备能力也 outputch 化(每设备一个 `device/<id>` 通道)。
|
||||
- **输入调度器**:排队/中断两类别 + 四级中断(L1~L4)+ 抢占/挂起/恢复/中断栈;
|
||||
同级不抢占、有饥饿防护与抢占冷却;L4 只归内核与内核级插件(WebUI 终止按钮)。
|
||||
- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口,
|
||||
主库以 query_only 受限句柄打开,写走自己的 temp 实例)。
|
||||
- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** ——
|
||||
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。
|
||||
- 修掉一批真实缺陷:销毁驻留子时入站 inputch(`child/<id>`)注册残留、
|
||||
子的轮次永远显示 0(`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、
|
||||
设备心跳 pong 忘了 Flush(每 60 秒掉线)、Lua 插件桥与 SDK 1.3.0 对齐。
|
||||
|
||||
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
|
||||
|
||||
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
|
||||
(`pkg/embedding`:`Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
|
||||
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB);
|
||||
落在**同一空间**(512 维、指纹 `cd2a495cf990`、Apache-2.0;实测加载峰值 1.59GB、静置回收后稳态约 0.89GB);
|
||||
`qwen3vl` 保留(2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
|
||||
文本检索仍由既有词向量 / TF-IDF 兜底:CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**,
|
||||
这是已知并写进文档的代价。
|
||||
@ -221,6 +256,14 @@ internal/
|
||||
|
||||
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**:
|
||||
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
|
||||
>
|
||||
> 另有**两项性能断言的量纲需要更正**(2026-09-20 实测):
|
||||
> 「崩溃到恢复 <1s」不成立 —— 崩溃后是**线性退避重启**,即 1s / 2s / 3s(`procRestartBackoff=1s × 第 n 次`),
|
||||
> 首次重启就要等 1s。且 5 分钟窗口内第 **4** 次崩溃即停止自动重启待人工介入(`procMaxRestarts=3`,判定为 `n > 3`)。
|
||||
> 该断言写下时(v1.0.0)退避值已是 1s,故从未成立。
|
||||
> 「RPC 往返 p50 24.1µs」与当前实测同量级但不吻合:本机 `BenchmarkToolInvoke` 实测
|
||||
> inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。
|
||||
> 保留原文不修改,以免伪造历史。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
@ -265,7 +308,12 @@ make test # go test ./...
|
||||
make install # 安装到系统
|
||||
```
|
||||
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux/Windows。
|
||||
依赖:Go 1.25+, CGo (go-sqlite3), Linux。
|
||||
|
||||
> `homed` 需 Linux(依赖 fd 继承与共享内存段的段内偏移解引用,见
|
||||
> `cmd/homed/platform_windows.go`);Windows 上只构建 `waiter.exe`,
|
||||
> `homed` 跑在 WSL2 里(见「下载」)。macOS 可构建 `waiter`/`initconfig`,
|
||||
> `homed` 需在原生 macOS 构建。
|
||||
|
||||
## 许可
|
||||
|
||||
|
||||
78
README_EN.md
78
README_EN.md
@ -32,6 +32,19 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
|
||||
- **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions
|
||||
- **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations
|
||||
|
||||
**Input Scheduling: 2 classes + 4 interrupt levels** — Input does not go straight to the LLM;
|
||||
it first enters the scheduler. Two classes (queued = pending work, interrupt = ranked L1–L4 by
|
||||
"how urgent") with preemption and frame saving (interrupt stack); same level never preempts
|
||||
same level. L4 belongs only to the kernel and kernel-level plugins (e.g. the WebUI stop button).
|
||||
See "Input Scheduler & Interrupt Mechanism" in [`assets/docs/en/ARCHITECTURE.md`](assets/docs/en/ARCHITECTURE.md).
|
||||
|
||||
**Resident sub-agents** — The kernel can station lightweight-kernel child agents (their own
|
||||
scheduler and temp graph memory, sharing the channel registry) to run long or backlogged work
|
||||
in parallel. When the main agent stays busy, the kernel hands queued input to a temporary
|
||||
**triage assistant**: simple items are handled directly, items needing the main agent get an
|
||||
immediate "busy, please wait" — users no longer wait in silence.
|
||||
See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md).
|
||||
|
||||
## Architecture Diagrams
|
||||
|
||||
### 1. Message Processing Sequence
|
||||
@ -40,6 +53,7 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
|
||||
sequenceDiagram
|
||||
participant U as User/Plugin
|
||||
participant IO as IOManager
|
||||
participant SCH as Input Scheduler
|
||||
participant EV as eventLoop
|
||||
participant CTX as RelevanceContext
|
||||
participant LLM as LLM+Tool Loop
|
||||
@ -47,7 +61,14 @@ sequenceDiagram
|
||||
participant MEM as Three-Layer Memory
|
||||
|
||||
U->>IO: InjectInput(type, payload)
|
||||
IO->>EV: inputCh
|
||||
IO->>SCH: inputCh
|
||||
rect lavender
|
||||
Note over SCH: 2 task classes + 4 interrupt levels (L1-L4)
|
||||
SCH->>SCH: same level never preempts -> ready/interrupt queue
|
||||
SCH->>SCH: higher level -> preempt (frame pushed to interrupt stack)
|
||||
SCH->>SCH: offload (main agent busy too long -> temporary triage assistant)
|
||||
end
|
||||
SCH->>EV: pick one task and run it
|
||||
rect lavender
|
||||
Note over EV: processTextInput
|
||||
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
|
||||
@ -61,7 +82,7 @@ sequenceDiagram
|
||||
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
|
||||
EV->>ST: StagePreAction Plugin can pre-intercept
|
||||
loop Tool loop
|
||||
LLM->>LLM: drainInterrupts
|
||||
LLM->>LLM: safe point: interrupt eval / yield
|
||||
LLM->>LLM: LLM Chat
|
||||
LLM->>ST: StagePostAction Plugin can modify/short-circuit
|
||||
alt No tool call
|
||||
@ -169,28 +190,53 @@ API keys are configured via WebUI `http://localhost:8080` settings page, persist
|
||||
cmd/homed/ Daemon entry, assembles all subsystems
|
||||
cmd/waiter/ CLI client (Unix socket)
|
||||
internal/
|
||||
├── agent/core/ Agent core: event loop, LLM tool loop, 7-stage pipeline
|
||||
├── agent/api/ LLM Provider + 8 Lua adapters
|
||||
├── agent/core/ Agent core: input scheduler (2 classes + 4 levels), event loop, LLM tool loop, 7-stage pipeline, residents
|
||||
├── agent/api/ LLM Provider (Lua adapter layer: provider.go drives the vm)
|
||||
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
|
||||
├── knowledge/ Knowledge base (filesystem + TF-IDF)
|
||||
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
|
||||
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
|
||||
├── plugins/ 18 built-in plugins (webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data, ...)
|
||||
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
|
||||
├── config/ SQLite config center
|
||||
├── events/ Event bus
|
||||
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
|
||||
└── internal/lua/adapters/ 10 LLM protocol adapter scripts
|
||||
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
|
||||
```
|
||||
|
||||
## Project Status
|
||||
|
||||
**v1.3.x line** (v1.3.1–v1.3.12, latest released) — **resident sub-agents** + **input scheduler rework**.
|
||||
|
||||
- **Resident sub-agents**: the kernel can station lightweight-kernel child agents (their own
|
||||
scheduler, their own temp graph memory, sharing the channel registry). The parent dispatches
|
||||
and collects work via `resident_agents` (list/create/send/inspect/compress/reclaim/destroy).
|
||||
An inputch can be assigned to a child, so input is routed to it **before entering the kernel**.
|
||||
- **Output channels addressable to a specific agent**: the `AllowedOutputs` grant set
|
||||
(consistent across all three filter points) lets parent/child deliver to each other;
|
||||
device capabilities became output channels too (one `device/<id>` per device).
|
||||
- **Input scheduler**: two task classes (queued/interrupt) + four interrupt levels (L1–L4)
|
||||
+ preempt/suspend/resume/interrupt-stack; same level never preempts same level, with a
|
||||
starvation guard and preemption cooldown. L4 belongs only to the kernel and kernel-level
|
||||
plugins (e.g. the WebUI stop button).
|
||||
- **Lightweight kernel profile**: a child's memory surface narrows to "conventional context
|
||||
+ graph memory" (narrow interface; the main graph opens as a query_only handle, writes go
|
||||
to its own temp instance).
|
||||
- **Backlog timely feedback** (later in the line): when the main agent is busy for a long time,
|
||||
the kernel hands queued input to a temporary **triage assistant** — simple items are handled
|
||||
directly, items needing the main agent get an immediate "busy, please wait", so users no
|
||||
longer wait 10+ minutes in silence.
|
||||
- Fixed a batch of real defects: inbound inputch (`child/<id>`) registration leak on resident
|
||||
destruction, a child's round count always showing 0 (`info()` never filled it), an empty
|
||||
child-side childIO (output channels not inherited), device heartbeat pong missing Flush
|
||||
(dropping every 60s), and Lua plugin bridge alignment with SDK 1.3.0.
|
||||
|
||||
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
|
||||
|
||||
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
|
||||
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
|
||||
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
|
||||
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
|
||||
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0; measured ~1.59GB peak on load, settling to ~0.89GB steady-state);
|
||||
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
|
||||
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
|
||||
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
|
||||
@ -217,6 +263,17 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
|
||||
|
||||
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
|
||||
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
|
||||
>
|
||||
> **Two performance claims also need correcting** (measured 2026-09-20):
|
||||
> "crash-to-recovery under 1s" does not hold — restarts are **linearly backed off**, i.e.
|
||||
> 1s / 2s / 3s (`procRestartBackoff=1s × nth crash`). Even the *first* restart waits 1s.
|
||||
> And the **4th** crash within a 5-minute window stops automatic restarts pending human
|
||||
> intervention (`procMaxRestarts=3`, tested as `n > 3`).
|
||||
> The backoff was already 1s when this claim was written (v1.0.0), so it never held.
|
||||
> "RPC round-trip p50 24.1µs" is the right order of magnitude but does not match current
|
||||
> measurements: `BenchmarkToolInvoke` on this machine gives inline/small **30.4µs**,
|
||||
> frame/small 51.5µs, inline/large 767µs, frame/large 398µs.
|
||||
> The original text is left unedited rather than rewritten, so the history isn't falsified.
|
||||
|
||||
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
|
||||
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
|
||||
@ -279,7 +336,12 @@ make test # go test ./...
|
||||
make install # Install to system
|
||||
```
|
||||
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
|
||||
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux.
|
||||
|
||||
> `homed` requires Linux (it relies on fd inheritance and intra-segment offset
|
||||
dereferencing of the shared memory region; see `cmd/homed/platform_windows.go`).
|
||||
On Windows only `waiter.exe` is built and `homed` runs under WSL2 (see Downloads).
|
||||
macOS can build `waiter`/`initconfig`; `homed` must be built on native macOS.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@ -455,7 +455,74 @@ Extended fields:
|
||||
- Relation extension: Confidence
|
||||
|
||||
|
||||
## Interrupt Mechanism
|
||||
## Input Scheduler & Interrupt Mechanism
|
||||
|
||||
Inputs do not go straight to the LLM — they first enter the **input scheduler**
|
||||
(`internal/agent/core/scheduler.go`). Full design:
|
||||
[`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md).
|
||||
|
||||
### Two task classes
|
||||
|
||||
| Class | Level | Meaning |
|
||||
|-------|-------|---------|
|
||||
| `TaskQueued` | none (always 0) | Pending work. Any interrupt (≥ L1) preempts it |
|
||||
| `TaskInterrupt` | L1–L4 | "How urgent is this", declared by the source via `InjectOptions.Priority` |
|
||||
|
||||
### Four interrupt levels
|
||||
|
||||
| Level | Meaning | Typical source |
|
||||
|-------|---------|----------------|
|
||||
| L1 Background | Fully deferrable | QQ/WeChat messages, bulk notifications |
|
||||
| L2 Message | General notice | Plugin hints that should be seen soon but aren't urgent |
|
||||
| L3 Interactive | Needs timely handling | Timer expiry, terminal output, resident-agent reports |
|
||||
| L4 Critical | **Kernel-exclusive** | panic, kernel events, kernel-level plugin stop button |
|
||||
|
||||
When no level is declared it defaults to **L1** — "explicit is a privilege", so a new
|
||||
plugin never gets preemption rights by accident. L4 declared by an external plugin is
|
||||
**clamped to L3** (`clampPluginLevel`).
|
||||
|
||||
### Preemption and suspension
|
||||
|
||||
- **Same level never preempts same level** (`canPreempt` requires strictly greater) —
|
||||
this is why messages normally wait for the running task to finish.
|
||||
- A preempted task is pushed onto the **interrupt stack** (LIFO) with its frame saved,
|
||||
and resumed later; the stack is never re-sorted by priority.
|
||||
- **Starvation guard**: preemption count raises the effective level
|
||||
(`effectiveLevel = Level + min(PreemptCount, 2)`, capped at L4).
|
||||
- **Preemption cooldown**: a just-preempted task cannot be preempted again for
|
||||
`preemptCooldown` (2s), so a high-priority stream cannot interrupt the same task forever.
|
||||
- The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).
|
||||
|
||||
### Stop (user presses stop / `/stop`)
|
||||
|
||||
Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference;
|
||||
② short-circuit the x messages **already queued at the moment of stop** during their
|
||||
pre-action phase (`cancelBudget` snapshot), instead of running them as new input.
|
||||
Inputs arriving **after** the stop are unaffected.
|
||||
|
||||
`PendingInputs()` must include the segment still sitting in `io.inputCh` (not yet moved
|
||||
into the queue by `pumpInbox`) — during a stop the scheduler is usually busy running a
|
||||
task, and counting only `sched.queue` yields 0.
|
||||
|
||||
### Resident sub-agents and timely feedback
|
||||
|
||||
Design: [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md).
|
||||
|
||||
- A **resident** is an independent lightweight-kernel agent: its own scheduler, its own
|
||||
temp graph memory, sharing the channel registry.
|
||||
- Parent→child control plane: `resident_agents`
|
||||
(list / create / send / inspect / compress / reclaim / destroy).
|
||||
- **Backlog feedback**: when the main agent is busy for a long time (default > 5m,
|
||||
configurable), the kernel hands queued inputs to a temporary **triage assistant**
|
||||
(`offload_*` config): simple ones are handled directly, ones needing the main agent
|
||||
get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
|
||||
- The triage assistant gets **no inputch** (it receives no plugin user input) and
|
||||
**all output channels** (results must reach the original channel).
|
||||
- On reclaim/destroy, its **residual tasks are decided explicitly by the parent**:
|
||||
`residual=keep` (returned to the parent queue, default) or `drop` (explicitly
|
||||
discarded with a per-item log entry).
|
||||
|
||||
### Legacy three-path view (still present, now a layer beneath the scheduler)
|
||||
|
||||
```
|
||||
interceptLoop (goroutine)
|
||||
@ -465,15 +532,28 @@ interceptLoop (goroutine)
|
||||
└── (c) InjectInput() → Trigger new processing when idle
|
||||
```
|
||||
|
||||
Three delivery paths:
|
||||
Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
|
||||
|
||||
| Path | Effect | Timing |
|
||||
|------|--------|--------|
|
||||
| cancelLLM | Cancel current HTTP request | On context.Canceled |
|
||||
| interceptCh | Insert `[interrupt message]` in process() | Before each LLM call |
|
||||
| InjectInput | Trigger new processing when eventLoop is idle | No ongoing request |
|
||||
## Context Budget
|
||||
|
||||
Code: `internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
||||
|
||||
```
|
||||
maxCtx = provider.MaxContextTokens() // declared window (per-source context_window wins)
|
||||
targetUsage = min(maxCtx × 0.8, 600000) // working band, capped at 600K
|
||||
├── memory recall budget = (targetUsage - fixed) / 3
|
||||
└── context events budget = remaining 2/3
|
||||
```
|
||||
|
||||
★ **Window ≠ working band**: a source's real window may reach 1M, but near-full windows
|
||||
lose attention and cost/latency rise linearly, so `maxTargetTokens=600000` caps the
|
||||
working band separately. If the model name (e.g. `AUTO`) yields no window,
|
||||
`ModelContextWindow` **logs a warning** and falls back conservatively; operators should
|
||||
declare `core.llm.sources.<name>.context_window` explicitly.
|
||||
|
||||
**Budgets are ceilings, not fill targets**: memory is recall-ranked (it stops when nothing
|
||||
is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K
|
||||
budget, actual injection was still a few hundred characters.
|
||||
|
||||
|
||||
## Configuration System
|
||||
|
||||
@ -804,7 +804,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
||||
|---------|------|----------|
|
||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
|
||||
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 20 tools, full input/output channel wiring |
|
||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
|
||||
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
|
||||
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
|
||||
@ -817,6 +817,12 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|
||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
|
||||
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
|
||||
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
|
||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) |
|
||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog publishing and management |
|
||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) |
|
||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) |
|
||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode |
|
||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself |
|
||||
|
||||
### Built-in Plugins
|
||||
|
||||
|
||||
@ -444,7 +444,64 @@ type Plugin interface {
|
||||
- Relation 扩展:Confidence
|
||||
|
||||
|
||||
## 中断机制
|
||||
## 输入调度器与中断机制
|
||||
|
||||
输入不直接进 LLM —— 它们先进**输入调度器**(`internal/agent/core/scheduler.go`)。
|
||||
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
|
||||
|
||||
### 两类任务
|
||||
|
||||
| 类别 | 级别 | 语义 |
|
||||
|------|------|------|
|
||||
| `TaskQueued` 排队输入 | 无级别(恒 0) | 待办工作。任何中断(≥ L1)都能抢它 |
|
||||
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
|
||||
|
||||
### 四级中断
|
||||
|
||||
| 级别 | 含义 | 典型来源 |
|
||||
|------|------|----------|
|
||||
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
|
||||
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
|
||||
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
|
||||
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
|
||||
|
||||
未声明级别时取 **L1**(`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
|
||||
外部插件声明 L4 会被**夹到 L3**(`clampPluginLevel`)。
|
||||
|
||||
### 抢占与挂起
|
||||
|
||||
- **同级不能抢占同级**(`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
|
||||
- 被抢占的任务压入**中断栈**(LIFO),用 `suspendStack` 保存现场,稍后恢复;
|
||||
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
|
||||
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`,
|
||||
封顶 L4),确保低级别流不会被困。
|
||||
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`(2s)内不再被抢,
|
||||
避免高优先级流把同一个任务反复打断到永不完结。
|
||||
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4,最多挂起 4 帧)。
|
||||
|
||||
### 停止(用户按停止按钮 / `/stop`)
|
||||
|
||||
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
|
||||
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
|
||||
当新输入再跑一遍。停止之后**新到**的输入不受影响。
|
||||
|
||||
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
|
||||
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
|
||||
|
||||
### 驻留式子 agent 与及时反馈
|
||||
|
||||
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
|
||||
|
||||
- **驻留子**是轻量内核的独立 agent:自己的调度器、自己的 temp 图记忆、共享的通道登记表。
|
||||
- 父对子的控制面:`resident_agents`(list / create / send / inspect / compress / reclaim / destroy)。
|
||||
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m,可配),内核把排队输入交给
|
||||
一个临时**分诊助手**(`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
|
||||
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
|
||||
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
|
||||
- 回收/销毁时它手头的**残余任务由父显式决定**:`residual=keep`(转回父队列,默认)
|
||||
或 `drop`(明确丢弃,逐条记日志)。
|
||||
|
||||
### 旧版三路径(仍存在,但已是调度器之下的一层)
|
||||
|
||||
```
|
||||
interceptLoop (goroutine)
|
||||
@ -454,15 +511,26 @@ interceptLoop (goroutine)
|
||||
└── (c) InjectInput() → 空闲时触发新处理
|
||||
```
|
||||
|
||||
三种投递路径:
|
||||
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
|
||||
|
||||
| 路径 | 效果 | 时机 |
|
||||
|------|------|------|
|
||||
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
|
||||
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
|
||||
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
|
||||
## 上下文预算
|
||||
|
||||
代码:`internal/agent/core/eventloop.go` — `interceptLoop` / `drainInterrupts`
|
||||
`internal/agent/core/tokenbudget.go` — `ComputeTokenBudget`:
|
||||
|
||||
```
|
||||
maxCtx = provider.MaxContextTokens() // 声明窗口(per-source context_window 优先)
|
||||
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
|
||||
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
|
||||
└── 上下文事件预算 = 其余 2/3
|
||||
```
|
||||
|
||||
★ **窗口 ≠ 工作面**:源的真实窗口可能到 1M,但接近满窗口时注意力涣散、
|
||||
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
|
||||
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
|
||||
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
|
||||
|
||||
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
|
||||
实测:预算 400K 时实际注入仍只有几百字符。
|
||||
|
||||
|
||||
## 配置系统
|
||||
|
||||
@ -797,7 +797,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|
||||
|------|------|------|
|
||||
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询(wttr.in),演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
|
||||
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 |
|
||||
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,20 个工具,输入/输出通道完整对接 |
|
||||
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理,PreAction 注入 + 定时打断双提醒 |
|
||||
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作,4 种写入模式,沙箱隔离 |
|
||||
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取(SSRF)、浏览器渲染(合并自 web/webfetch) |
|
||||
@ -810,6 +810,12 @@ pmgr.ReloadPlugins() // 重载所有插件
|
||||
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
|
||||
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
|
||||
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
|
||||
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD) |
|
||||
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
|
||||
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
|
||||
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent) |
|
||||
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序),failback 模式的核心插件 |
|
||||
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
|
||||
|
||||
### 内置插件
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homeagent-gui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.4.0",
|
||||
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
|
||||
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
|
||||
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",
|
||||
|
||||
@ -147,9 +147,6 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) {
|
||||
} else {
|
||||
log.Printf("[homed] graph memory initialized")
|
||||
}
|
||||
if memDB != nil {
|
||||
}
|
||||
|
||||
memIdx := memory.NewIndexer(memDB)
|
||||
memIdx.Sync() // 启动时立即同步,避免前30分钟空窗
|
||||
socialStore := social.New(memDB)
|
||||
@ -160,8 +157,11 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) {
|
||||
BatchSize: 50,
|
||||
})
|
||||
if memDB != nil {
|
||||
// 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发
|
||||
// defer,而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出,
|
||||
// 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。
|
||||
// 停机由调用点注册的 cleanup 负责(见下方返回值)。
|
||||
distiller.Start()
|
||||
defer distiller.Stop()
|
||||
}
|
||||
|
||||
return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller},
|
||||
@ -487,6 +487,12 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
|
||||
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
|
||||
Offload: agentCore.OffloadOptions{
|
||||
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
|
||||
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
|
||||
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
|
||||
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
|
||||
},
|
||||
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
|
||||
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
|
||||
Embedder: embedder,
|
||||
@ -800,6 +806,7 @@ func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey stri
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetIndexer(memIdx)
|
||||
pluginReg.SetStatusProvider(agent)
|
||||
pluginReg.SetTerminalAPI(agent)
|
||||
}
|
||||
|
||||
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。
|
||||
|
||||
42
cmd/homed/bootstrap_test.go
Normal file
42
cmd/homed/bootstrap_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
|
||||
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
|
||||
//
|
||||
// 历史 bug:main() 拆分时函数体内残留一句 `defer distiller.Stop()`,
|
||||
// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。
|
||||
// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调
|
||||
// distillOnce,绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的
|
||||
// 真实调用方式断言,才照得出来。
|
||||
func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。
|
||||
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, cleanup := initMemoryStack(dir)
|
||||
if st == nil || st.distiller == nil {
|
||||
cleanup()
|
||||
t.Fatal("initMemoryStack 未返回蒸馏器")
|
||||
}
|
||||
if st.db == nil {
|
||||
cleanup()
|
||||
t.Skip("图库未初始化,无法验证蒸馏接线")
|
||||
}
|
||||
if st.distiller.Stopped() {
|
||||
cleanup()
|
||||
t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉(defer Stop 残留?)")
|
||||
}
|
||||
|
||||
// cleanup 是唯一的停机点:先停蒸馏器、再关图库。
|
||||
cleanup()
|
||||
if !st.distiller.Stopped() {
|
||||
t.Fatal("cleanup 之后蒸馏器应已停止")
|
||||
}
|
||||
}
|
||||
123
cmd/memgc/main.go
Normal file
123
cmd/memgc/main.go
Normal file
@ -0,0 +1,123 @@
|
||||
// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。
|
||||
//
|
||||
// 为什么需要这个命令:噪音闸门(internal/memory.IsNoiseEntity)只能拦住
|
||||
// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是
|
||||
// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被
|
||||
// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。
|
||||
//
|
||||
// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端
|
||||
// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。
|
||||
//
|
||||
// 用法(默认 dry-run,只列不删):
|
||||
//
|
||||
// memgc -db /home/newqqagent/memory/graph.db
|
||||
// memgc -db /home/newqqagent/memory/graph.db -orphans -apply
|
||||
//
|
||||
// 清理生产库前请先备份:sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"
|
||||
// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := flag.String("db", "", "graph.db 路径(必填)")
|
||||
apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印")
|
||||
orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
|
||||
tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq)")
|
||||
entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*)。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景")
|
||||
sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要")
|
||||
flag.Parse()
|
||||
|
||||
if *path == "" {
|
||||
flag.Usage()
|
||||
log.Fatal("memgc: 必须指定 -db")
|
||||
}
|
||||
|
||||
g, err := memory.NewGraphDB(*path)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: open %s: %v", *path, err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
if *sceneStats {
|
||||
stats, err := g.SceneStats()
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: scene stats: %v", err)
|
||||
}
|
||||
fmt.Printf("场景 %d 个:\n", len(stats))
|
||||
for _, st := range stats {
|
||||
fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n",
|
||||
st.Origin, st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features,
|
||||
st.UpdatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型)
|
||||
// 没有任何场景引用,不补挂就永远吃不到场景召回。
|
||||
if *tagScene != "" {
|
||||
if *entityGlob == "" {
|
||||
log.Fatal("memgc: -tag-scene 需要配套 -entity-glob(如 '*QQ*');不做自动猜测")
|
||||
}
|
||||
n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: tag scene: %v", err)
|
||||
}
|
||||
if *apply {
|
||||
fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene)
|
||||
} else {
|
||||
fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q(未写库)\n", n, *tagScene)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
junk, err := g.NoiseEntities()
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: scan: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("噪音实体 %d 个:\n", len(junk))
|
||||
for _, e := range junk {
|
||||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
||||
}
|
||||
|
||||
de, dr, err := g.PurgeNoise(!*apply)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: purge: %v", err)
|
||||
}
|
||||
if *apply {
|
||||
fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr)
|
||||
} else {
|
||||
fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d(未写库,加 -apply 才落地)\n", de, dr)
|
||||
}
|
||||
|
||||
if *orphans {
|
||||
list, err := g.OrphanEntities()
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: orphans: %v", err)
|
||||
}
|
||||
fmt.Printf("孤立实体(零关系)%d 个:\n", len(list))
|
||||
for _, e := range list {
|
||||
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
|
||||
}
|
||||
n, err := g.PurgeOrphans(!*apply)
|
||||
if err != nil {
|
||||
log.Fatalf("memgc: purge orphans: %v", err)
|
||||
}
|
||||
if *apply {
|
||||
fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n)
|
||||
} else {
|
||||
fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n)
|
||||
}
|
||||
}
|
||||
|
||||
if *apply {
|
||||
fmt.Println("提示:运行中的进程会在下一个 archive 心跳(Indexer.Sync)重建实体名向量索引,无需重启。")
|
||||
}
|
||||
}
|
||||
@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "com.example.homeagent",
|
||||
"vendor": "HomeAgent",
|
||||
"versionCode": 1001001,
|
||||
"versionName": "1.1.1",
|
||||
"versionCode": 1004000,
|
||||
"versionName": "1.4.0",
|
||||
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
|
||||
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
|
||||
"icon": "$media:layered_image",
|
||||
|
||||
19
cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets
Normal file
19
cmd/ohos/HomeAgent/entry/src/main/ets/common/AppVersion.ets
Normal file
@ -0,0 +1,19 @@
|
||||
import { bundleManager } from '@kit.AbilityKit';
|
||||
|
||||
/**
|
||||
* 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。
|
||||
*
|
||||
* AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh
|
||||
* 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相,
|
||||
* 实测已经漂过:app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。
|
||||
* 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。
|
||||
*/
|
||||
export function appVersion(): string {
|
||||
try {
|
||||
const info: bundleManager.BundleInfo =
|
||||
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
|
||||
return info.versionName;
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,9 @@ import { deviceInfo } from '@kit.BasicServicesKit';
|
||||
import { textToSpeech } from '@kit.CoreSpeechKit';
|
||||
import { componentSnapshot } from '@kit.ArkUI';
|
||||
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
|
||||
import { camera, cameraPicker } from '@kit.CameraKit';
|
||||
import { fileIo, fileUri } from '@kit.CoreFileKit';
|
||||
import { appVersion } from './AppVersion';
|
||||
|
||||
// ===== 能力结果 =====
|
||||
|
||||
@ -17,12 +20,17 @@ export const LOCAL_DEVICE_CAPS: string[] = [
|
||||
'clipboardsee',
|
||||
'clipboardsue',
|
||||
'speakeruse',
|
||||
'camerasue',
|
||||
];
|
||||
|
||||
export interface CapResult {
|
||||
status: string; // 'ok' | 'error'
|
||||
output: string;
|
||||
error: string;
|
||||
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
|
||||
// DeviceBridge 不要再发 cmd_result;否则网关会把后续分块挂在一条已完成的
|
||||
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
|
||||
chunked?: boolean;
|
||||
}
|
||||
|
||||
interface DeviceStatusPayload {
|
||||
@ -71,6 +79,19 @@ function errResult(errMsg: string): CapResult {
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 二进制分块发送回调。由 BridgeRouter 注入(它持有 deviceBridge + reqId),
|
||||
* BridgeCaps 因此不必 import DeviceBridge —— 否则 DeviceBridge 为取 CapResult
|
||||
* 而 import BridgeCaps,两边成环。分层也更干净:能力实现不碰 socket。
|
||||
*/
|
||||
export type DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => void;
|
||||
|
||||
// chunkedResult:结果已由能力自己分块发出,不再回 cmd_result。
|
||||
function chunkedResult(output: string): CapResult {
|
||||
const r: CapResult = { status: 'ok', output: output, error: '', chunked: true };
|
||||
return r;
|
||||
}
|
||||
|
||||
// ===== screensee:截取本应用当前画面(前台时为整屏可见内容)=====
|
||||
|
||||
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
|
||||
@ -129,6 +150,142 @@ export async function capScreensee(): Promise<CapResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== camerasue:系统相机抓拍 =====
|
||||
//
|
||||
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
|
||||
// (CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
|
||||
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
|
||||
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
|
||||
//
|
||||
// 结果落在应用沙箱(saveUri 指向 filesDir),不写系统媒体库,也就不需要
|
||||
// READ_IMAGEVIDEO 这类受限权限。
|
||||
const CAMERASUE_MAX_B64: number = 950000;
|
||||
|
||||
/** 录像回传上限:与网关 mediaDir 落盘模式配合,避免把设备内存/WS 打爆。 */
|
||||
const CAMERASUE_MAX_VIDEO: number = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* camerasue 实现。
|
||||
*
|
||||
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
|
||||
* `<N秒>` = 录 N 秒视频。
|
||||
*
|
||||
* 视频为什么要走二进制分块:一段 10s 录像动辄数 MB,base64 后还要再膨胀
|
||||
* 1/3,既撑爆模型上下文也撑爆 WS 单帧。cameraPicker 本身支持 VIDEO
|
||||
* 模式(系统相机会直接进录像界面),取回文件后用 sendDataChunked 按
|
||||
* cmd_data_start/分块/cmd_data_end 回传——网关侧聚合后落盘成文件,agent 拿路径。
|
||||
* 这与 GUI/CLI 客户端的 camerasue 录像路径一致。
|
||||
*/
|
||||
export async function capCamerasue(context: common.UIAbilityContext,
|
||||
rawArgs: string,
|
||||
sendChunked: DataChunkSender | null): Promise<CapResult> {
|
||||
const raw: string = rawArgs.trim();
|
||||
let videoSeconds: number = 0;
|
||||
if (raw.length > 0) {
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (!digits.test(raw)) {
|
||||
return errResult('camerasue 参数只接受纯数字秒数,如 camerasue 5');
|
||||
}
|
||||
videoSeconds = parseInt(raw, 10);
|
||||
if (videoSeconds <= 0 || videoSeconds > 300) {
|
||||
return errResult('录像时长需在 1~300 秒之间');
|
||||
}
|
||||
}
|
||||
const isVideo: boolean = videoSeconds > 0;
|
||||
|
||||
const ext: string = isVideo ? '.mp4' : '.jpg';
|
||||
const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + ext;
|
||||
try {
|
||||
// cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位
|
||||
const f: fileIo.File = fileIo.openSync(filePath,
|
||||
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
|
||||
fileIo.closeSync(f);
|
||||
} catch (e) {
|
||||
return errResult('无法创建相机输出文件');
|
||||
}
|
||||
|
||||
try {
|
||||
const profile: cameraPicker.PickerProfile = {
|
||||
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
|
||||
saveUri: fileUri.getUriFromPath(filePath),
|
||||
};
|
||||
if (isVideo) {
|
||||
profile.videoDuration = videoSeconds;
|
||||
}
|
||||
const mediaType: cameraPicker.PickerMediaType = isVideo
|
||||
? cameraPicker.PickerMediaType.VIDEO
|
||||
: cameraPicker.PickerMediaType.PHOTO;
|
||||
const res: cameraPicker.PickerResult =
|
||||
await cameraPicker.pick(context, [mediaType], profile);
|
||||
if (res.resultCode !== 0 || res.resultUri.length === 0) {
|
||||
return errResult(isVideo ? '未获取到录像(可能被取消)' : '未获取到照片(可能被取消)');
|
||||
}
|
||||
} catch (e) {
|
||||
return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机');
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
return readAndSendVideo(filePath, sendChunked);
|
||||
}
|
||||
return readPhotoAsBase64(filePath);
|
||||
}
|
||||
|
||||
/** 读回录像并以二进制分块回传;网关聚合后落盘,agent 拿文件路径。 */
|
||||
function readAndSendVideo(filePath: string, sendChunked: DataChunkSender | null): CapResult {
|
||||
let fd: number = -1;
|
||||
try {
|
||||
const stat: fileIo.Stat = fileIo.statSync(filePath);
|
||||
if (stat.size <= 0) {
|
||||
return errResult('录像文件为空,请重试');
|
||||
}
|
||||
if (stat.size > CAMERASUE_MAX_VIDEO) {
|
||||
return errResult('录像文件过大(超过 64MB),请缩短时长');
|
||||
}
|
||||
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
|
||||
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
|
||||
fd = rf.fd;
|
||||
fileIo.readSync(fd, buf);
|
||||
fileIo.closeSync(rf);
|
||||
fd = -1;
|
||||
if (sendChunked === null) {
|
||||
return errResult('录像回传通道未就绪,请重试');
|
||||
}
|
||||
sendChunked('camera_video', 'video/mp4', new Uint8Array(buf));
|
||||
// 分块已代表本次请求的完整结果,DeviceBridge 不再回 cmd_result。
|
||||
return chunkedResult('录像已回传(' + stat.size.toString() + ' 字节)');
|
||||
} catch (e) {
|
||||
if (fd >= 0) {
|
||||
try { fileIo.closeSync(fd); } catch (ignore) {}
|
||||
}
|
||||
return errResult('录像读取失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 照片仍走小体积 base64 内联(图片不大,不必分块)。 */
|
||||
function readPhotoAsBase64(filePath: string): CapResult {
|
||||
let fd: number = -1;
|
||||
try {
|
||||
const stat: fileIo.Stat = fileIo.statSync(filePath);
|
||||
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
|
||||
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
|
||||
fd = rf.fd;
|
||||
fileIo.readSync(fd, buf);
|
||||
fileIo.closeSync(rf);
|
||||
fd = -1;
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
const b64: string = helper.encodeToStringSync(new Uint8Array(buf));
|
||||
if (b64.length > CAMERASUE_MAX_B64) {
|
||||
return errResult('照片数据过大,请降低分辨率后重试');
|
||||
}
|
||||
return okResult('data:image/jpeg;base64,' + b64);
|
||||
} catch (e) {
|
||||
if (fd >= 0) {
|
||||
try { fileIo.closeSync(fd); } catch (ignore) {}
|
||||
}
|
||||
return errResult('照片读取失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== clipboardsee / clipboardsue =====
|
||||
|
||||
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
|
||||
@ -249,7 +406,7 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
platform: 'OpenHarmony',
|
||||
arch: deviceInfo.abiList,
|
||||
os_release: deviceInfo.osFullName,
|
||||
version: '1.1.1',
|
||||
version: appVersion(),
|
||||
cpus: 0,
|
||||
brand: deviceInfo.brand,
|
||||
manufacturer: deviceInfo.manufacture,
|
||||
@ -269,29 +426,3 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
|
||||
};
|
||||
return okResult(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||
|
||||
export interface ScreensuePayload {
|
||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||
const firstSpace: RegExp = new RegExp('\\s');
|
||||
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||
const splitAt: number = rest.search(firstSpace);
|
||||
if (splitAt > 0) {
|
||||
const first: string = rest.substring(0, splitAt);
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (digits.test(first)) {
|
||||
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||
}
|
||||
}
|
||||
p.content = rest;
|
||||
return p;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { CapResult } from './BridgeCaps';
|
||||
import { appVersion } from './AppVersion';
|
||||
|
||||
// ===== 协议消息(与 remotedevice 插件对齐)=====
|
||||
|
||||
@ -83,7 +84,7 @@ export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
|
||||
platform: 'OpenHarmony',
|
||||
arch: '',
|
||||
os_release: '',
|
||||
version: '1.1.1',
|
||||
version: appVersion(),
|
||||
cpus: 0,
|
||||
};
|
||||
const device: HelloDevice = {
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { deviceBridge } from './DeviceBridge';
|
||||
import {
|
||||
CapResult,
|
||||
DataChunkSender,
|
||||
capScreensee,
|
||||
capCamerasue,
|
||||
capClipboardSee,
|
||||
capClipboardsue,
|
||||
capSpeakerUse,
|
||||
capDeviceInfo,
|
||||
capStatus,
|
||||
parseScreensue,
|
||||
ScreensuePayload,
|
||||
} from './BridgeCaps';
|
||||
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
|
||||
import { connStore } from './ConnStore';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@ -84,6 +85,17 @@ async function executeCommand(reqId: string, command: string): Promise<CapResult
|
||||
}
|
||||
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
if (name === 'camerasue') {
|
||||
if (appContext === null) {
|
||||
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
|
||||
}
|
||||
// 录像走二进制分块:把「往本请求回传字节」的能力注入能力实现,
|
||||
// 避免 BridgeCaps 反向 import DeviceBridge 形成循环依赖。
|
||||
const sender: DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => {
|
||||
deviceBridge.sendDataChunked(reqId, kind, mime, bytes);
|
||||
};
|
||||
return capCamerasue(appContext, args, sender);
|
||||
}
|
||||
if (name === 'clipboardsee') {
|
||||
if (hasArgs(args)) {
|
||||
return errRes('clipboardsee 不接受额外参数');
|
||||
|
||||
@ -16,6 +16,15 @@ export interface ParsedHistory {
|
||||
offset: number;
|
||||
/** 服务端是否还有更早的历史 */
|
||||
hasMore: boolean;
|
||||
/**
|
||||
* 服务端下发的增量游标(响应里的 last_seq)。
|
||||
*
|
||||
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
|
||||
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
|
||||
* (jianf 说的“暴露数据查询 api,前端轮询后 patch 视图”那条路)。
|
||||
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
|
||||
*/
|
||||
lastSeq: number;
|
||||
}
|
||||
|
||||
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
|
||||
@ -23,7 +32,7 @@ export function parseHistoryPayload(
|
||||
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
|
||||
const rawList: Object | undefined = obj['messages'] as Object | undefined;
|
||||
if (rawList === undefined || rawList === null) {
|
||||
return { msgs: [], offset: 0, hasMore: false };
|
||||
return { msgs: [], offset: 0, hasMore: false, lastSeq: 0 };
|
||||
}
|
||||
const arr: Object[] = rawList as Object[];
|
||||
const msgs: ChatMessage[] = [];
|
||||
@ -42,6 +51,12 @@ export function parseHistoryPayload(
|
||||
content: content,
|
||||
isFinal: true,
|
||||
};
|
||||
// seq:服务端单调递增序号,增量游标与 keyed 对账的定位符。
|
||||
// 缺失(旧后端/本地乐观消息)时保持 undefined,不编造。
|
||||
const seqVal: Object | undefined = item['seq'];
|
||||
if (typeof seqVal === 'number' && (seqVal as number) > 0) {
|
||||
msg.seq = seqVal as number;
|
||||
}
|
||||
if (att !== undefined) {
|
||||
msg.attachment = att;
|
||||
}
|
||||
@ -64,7 +79,15 @@ export function parseHistoryPayload(
|
||||
}
|
||||
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
|
||||
const hasMore: boolean = obj['has_more'] === true;
|
||||
return { msgs: msgs, offset: offset, hasMore: hasMore };
|
||||
// last_seq:增量游标。缺失时回退到本页最大 seq,保证游标不会倒退。
|
||||
let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0;
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
const sq: number | undefined = msgs[i].seq;
|
||||
if (sq !== undefined && sq > lastSeq) {
|
||||
lastSeq = sq;
|
||||
}
|
||||
}
|
||||
return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq };
|
||||
}
|
||||
|
||||
/** 后端 tool_calls 条目带 tool 和 name 两份;args/result 可能是对象也可能是字符串。 */
|
||||
|
||||
@ -21,6 +21,14 @@ interface SendChatBody {
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
/** POST /chat/interrupt 的请求体。 */
|
||||
interface InterruptBody {
|
||||
/** true = 停止(立即结束当前推理 + 短路已排队消息);false/省略 = 普通中断。 */
|
||||
stop: boolean;
|
||||
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯文本发送:POST /chat。
|
||||
* 带附件的情况走 sendChatFile(后端收下附件后自己写会话并触发 agent)。
|
||||
@ -167,10 +175,32 @@ export async function sendChatFile(text: string, path: string, name: string,
|
||||
chatStore.requestScroll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前生成(停止按钮)。
|
||||
*
|
||||
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
|
||||
* - stop:true(无 message)= ①立即结束当前 LLM 推理(不重试);
|
||||
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
|
||||
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
|
||||
*
|
||||
* 为什么必须带 stop:此前这里 POST 的是 null(空 body),后端把空内容当成
|
||||
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
|
||||
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
|
||||
*/
|
||||
export async function interruptChat(): Promise<void> {
|
||||
try {
|
||||
await apiClient.post('/chat/interrupt', null);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
if (connStore.getCurrentConnection() === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body: InterruptBody = { stop: true };
|
||||
await apiClient.post('/chat/interrupt', body);
|
||||
} catch (e) {
|
||||
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
|
||||
// 否则按钮会一直停在“停止”态,用户以为没生效。
|
||||
}
|
||||
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
|
||||
// 若后端稍后真的推来终态,SSE 处理器会再刷一次(幂等)。
|
||||
chatStore.setLoading(false);
|
||||
chatStore.setStage('已停止');
|
||||
chatStore.forceRefresh();
|
||||
}
|
||||
|
||||
@ -34,6 +34,115 @@ export const K_CHAT_STAGE: string = 'chatStageText';
|
||||
export const K_CHAT_CONNECTED: string = 'chatSseUp';
|
||||
|
||||
const SSE_RECONNECT_MS: number = 5000;
|
||||
/** 增量轮询间隔:与 WebUI 的 chatTicker 一致(3s)。 */
|
||||
const CHAT_POLL_MS: number = 3000;
|
||||
|
||||
/**
|
||||
* 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的
|
||||
* applyServerMessages 同口径)。
|
||||
*
|
||||
* 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把
|
||||
* 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把
|
||||
* 其中一条误判成重复而丢弃(“App 发出的消息不显示”就是这个表现)。
|
||||
* seq 是服务端分配的唯一序号,才是可靠的定位符。
|
||||
*
|
||||
* 规则:
|
||||
* - 服务端消息带 seq:本地已有同 seq → 原地更新(工具卡/最终文本是
|
||||
* 原地改的,不产生新 seq,只靠 after 拿不到,必须靠尾部探测更新);
|
||||
* 本地没有 → 追加。
|
||||
* - 服务端消息无 seq(旧后端):退化为「本地末尾同角色同内容则认领」。
|
||||
* - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq),
|
||||
* 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。
|
||||
*
|
||||
* tailOnly:只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新),
|
||||
* 避免把历史中间的消息插进来造成顺序错乱。
|
||||
*/
|
||||
function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[],
|
||||
tailOnly: boolean, alloc: () => number): ChatMessage[] {
|
||||
const out: ChatMessage[] = local.slice();
|
||||
for (let i = 0; i < incoming.length; i++) {
|
||||
const sm: ChatMessage = incoming[i];
|
||||
const sq: number | undefined = sm.seq;
|
||||
let found: number = -1;
|
||||
if (sq !== undefined) {
|
||||
// 从尾部往前找:新消息总在尾部,省掉全表扫描
|
||||
for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) {
|
||||
if (out[j].seq === sq) {
|
||||
found = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found >= 0) {
|
||||
// 原地更新:保留本地 id(组件按 id 复用,不重建气泡),
|
||||
// 只覆盖服务端权威字段。
|
||||
const prev: ChatMessage = out[found];
|
||||
if (prev.content !== sm.content) {
|
||||
prev.content = sm.content;
|
||||
}
|
||||
if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) {
|
||||
prev.reasoningContent = sm.reasoningContent;
|
||||
}
|
||||
if (sm.toolCalls !== undefined) {
|
||||
prev.toolCalls = sm.toolCalls;
|
||||
}
|
||||
if (sm.attachment !== undefined) {
|
||||
prev.attachment = sm.attachment;
|
||||
}
|
||||
if (sm.source !== undefined) {
|
||||
prev.source = sm.source;
|
||||
}
|
||||
prev.isFinal = true;
|
||||
prev.isStreaming = false;
|
||||
continue;
|
||||
}
|
||||
if (tailOnly) {
|
||||
// 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间)
|
||||
let maxLocalSeq: number = 0;
|
||||
for (let j = 0; j < out.length; j++) {
|
||||
const ls: number | undefined = out[j].seq;
|
||||
if (ls !== undefined && ls > maxLocalSeq) {
|
||||
maxLocalSeq = ls;
|
||||
}
|
||||
}
|
||||
if (sq !== undefined && sq > maxLocalSeq) {
|
||||
sm.id = alloc();
|
||||
out.push(sm);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。
|
||||
if (sq !== undefined) {
|
||||
let claimed: number = -1;
|
||||
for (let j = out.length - 1; j >= 0; j--) {
|
||||
const lm: ChatMessage = out[j];
|
||||
if (lm.seq !== undefined) {
|
||||
break;
|
||||
}
|
||||
if (lm.role === sm.role) {
|
||||
claimed = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (claimed >= 0) {
|
||||
const prev: ChatMessage = out[claimed];
|
||||
prev.seq = sq;
|
||||
prev.isFinal = true;
|
||||
prev.isStreaming = false;
|
||||
if (sm.source !== undefined) {
|
||||
prev.source = sm.source;
|
||||
}
|
||||
if (sm.attachment !== undefined) {
|
||||
prev.attachment = sm.attachment;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
sm.id = alloc();
|
||||
out.push(sm);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class ChatStore implements ChatStreamSink {
|
||||
private msgs: ChatMessage[] = [];
|
||||
@ -49,6 +158,11 @@ class ChatStore implements ChatStreamSink {
|
||||
private newIds: number[] = [];
|
||||
// SSE 正在为当前轮次推送内容时置 true,阻止 POST 响应重复创建消息
|
||||
private sseActiveForTurn: boolean = false;
|
||||
/** 增量游标:本地已知的最大服务端 seq(对应 WebUI 的 state.chatLastSeq) */
|
||||
private lastSeq: number = 0;
|
||||
/** 增量轮询中进行中,避免重入 */
|
||||
private polling: boolean = false;
|
||||
private pollTimer: number = -1;
|
||||
private sse: SseClient = new SseClient();
|
||||
|
||||
init(): void {
|
||||
@ -284,19 +398,112 @@ class ChatStore implements ChatStreamSink {
|
||||
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
if (parsed.msgs.length === 0) {
|
||||
return;
|
||||
// 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退,
|
||||
// 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。
|
||||
if (this.msgs.length === 0) {
|
||||
this.msgs = parsed.msgs;
|
||||
} else {
|
||||
// 与本地未回显的消息按 seq 对账,而不是整表替换。
|
||||
//
|
||||
// 为什么:sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争;
|
||||
// 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失
|
||||
// (“客户端侧发出的消息不显示”)。
|
||||
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
|
||||
() => this.allocId());
|
||||
}
|
||||
this.msgs = parsed.msgs;
|
||||
this.offset = parsed.offset;
|
||||
this.hasEarlier = parsed.hasMore;
|
||||
// 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。
|
||||
if (parsed.lastSeq > this.lastSeq) {
|
||||
this.lastSeq = parsed.lastSeq;
|
||||
}
|
||||
this.forceRefresh();
|
||||
this.requestScroll();
|
||||
this.startPolling();
|
||||
} catch (e) {
|
||||
// ignore history load failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。
|
||||
*
|
||||
* 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供
|
||||
* after=<seq> 游标(commit 9711177),WebUI 前端据此 3s 轮询增量并 patch
|
||||
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
|
||||
* 永远进不来**(页面不会加载新的聊天信息)。
|
||||
*
|
||||
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
|
||||
* 不会产生新 seq,单靠 after 拿不到这些更新。
|
||||
*/
|
||||
async pollIncremental(): Promise<void> {
|
||||
if (this.polling) {
|
||||
return;
|
||||
}
|
||||
this.polling = true;
|
||||
try {
|
||||
if (this.lastSeq <= 0) {
|
||||
// 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。
|
||||
this.polling = false;
|
||||
await this.loadHistory();
|
||||
return;
|
||||
}
|
||||
const resp = await apiClient.getWithTimeout(
|
||||
'/chat/history?after=' + this.lastSeq, 8000);
|
||||
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
|
||||
const parsed: ParsedHistory = this.parseHistory(obj);
|
||||
let changed: boolean = false;
|
||||
if (parsed.msgs.length > 0) {
|
||||
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
|
||||
() => this.allocId());
|
||||
changed = true;
|
||||
}
|
||||
if (parsed.lastSeq > this.lastSeq) {
|
||||
this.lastSeq = parsed.lastSeq;
|
||||
}
|
||||
// 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。
|
||||
try {
|
||||
const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000);
|
||||
const tailObj: Record<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
|
||||
const tail: ParsedHistory = this.parseHistory(tailObj);
|
||||
if (tail.msgs.length > 0) {
|
||||
const before: number = this.msgs.length;
|
||||
this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true,
|
||||
() => this.allocId());
|
||||
if (this.msgs.length !== before) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 尾部探测失败不影响增量结果
|
||||
}
|
||||
if (changed) {
|
||||
this.forceRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
// 轮询失败静默:下一拍会重试(SSE 仍在负责流式渲染)
|
||||
} finally {
|
||||
this.polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */
|
||||
startPolling(): void {
|
||||
if (this.pollTimer >= 0) {
|
||||
return;
|
||||
}
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.pollIncremental();
|
||||
}, CHAT_POLL_MS);
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
if (this.pollTimer >= 0) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
|
||||
* 触顶(yOffset 接近 0)且有更早历史时由 onDidScroll 触发。
|
||||
@ -344,6 +551,11 @@ class ChatStore implements ChatStreamSink {
|
||||
if (cur === null) {
|
||||
return;
|
||||
}
|
||||
// 增量轮询与 SSE 同时拉起:SSE 负责 token 级流式观感,
|
||||
// 轮询负责「界面最终状态」——两者是两条腿,缺一不可
|
||||
// (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。
|
||||
// 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。
|
||||
this.startPolling();
|
||||
this.sse.close();
|
||||
this.sse.connect(cur, '/chat/events',
|
||||
(ev: SseEvent) => {
|
||||
@ -381,6 +593,7 @@ class ChatStore implements ChatStreamSink {
|
||||
/** 页面消失:断线、停表,避免后台空转 */
|
||||
disconnect(): void {
|
||||
this.cancelReconnect();
|
||||
this.stopPolling();
|
||||
this.sse.close();
|
||||
this.cancelRefresh();
|
||||
}
|
||||
|
||||
@ -17,6 +17,18 @@ export const DEFAULT_WS_PORT: number = 9890;
|
||||
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
|
||||
export const CHAT_PAGE_SIZE: number = 40;
|
||||
|
||||
// ===== AppStorage 跨页面信号键 =====
|
||||
//
|
||||
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
|
||||
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
|
||||
// 中间还隔着 Index,逐层传回调会把两个无关页面耦在一起。
|
||||
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
|
||||
export const K_HAS_CONN: string = 'hasConn';
|
||||
/** 外部请求切换主 Tab(-1 = 无请求),由 Index 监听 */
|
||||
export const K_REQUESTED_TAB: string = 'requestedTab';
|
||||
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
|
||||
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
|
||||
|
||||
// ===== sakura / frost palette (style.css :root) =====
|
||||
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
|
||||
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';
|
||||
|
||||
@ -247,6 +247,11 @@ export class DeviceBridgeClient {
|
||||
}
|
||||
const handler: BridgeCmdHandler = this.cmdHandler;
|
||||
handler(reqId, command).then((res: CapResult) => {
|
||||
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
|
||||
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
|
||||
if (res.chunked === true) {
|
||||
return;
|
||||
}
|
||||
this.sendResult(reqId, res.status, res.output, res.error);
|
||||
}).catch((e: Object) => {
|
||||
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
|
||||
|
||||
182
cmd/ohos/HomeAgent/entry/src/main/ets/common/ScreensueHtml.ets
Normal file
182
cmd/ohos/HomeAgent/entry/src/main/ets/common/ScreensueHtml.ets
Normal file
@ -0,0 +1,182 @@
|
||||
/**
|
||||
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
|
||||
*
|
||||
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
|
||||
* 超出工程「单文件 ≤400 行」的约定;而「screensue 内容怎么解析、怎么渲染」
|
||||
* 与「设备能力怎么实现」本就是两件事。
|
||||
*/
|
||||
|
||||
import { util } from '@kit.ArkTS';
|
||||
|
||||
// ===== screensue 内容解析 =====
|
||||
// 服务端协议: screensue [秒] <内容>;0=常驻。
|
||||
|
||||
export interface ScreensuePayload {
|
||||
duration: number; // 秒;0 表示常驻直到用户关闭
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function parseScreensue(rawArgs: string): ScreensuePayload {
|
||||
const p: ScreensuePayload = { duration: 5, content: '' };
|
||||
const leadingSpaces: RegExp = new RegExp('^\\s+');
|
||||
const firstSpace: RegExp = new RegExp('\\s');
|
||||
let rest: string = rawArgs.replace(leadingSpaces, '');
|
||||
const splitAt: number = rest.search(firstSpace);
|
||||
if (splitAt > 0) {
|
||||
const first: string = rest.substring(0, splitAt);
|
||||
const digits: RegExp = new RegExp('^\\d+$');
|
||||
if (digits.test(first)) {
|
||||
p.duration = Math.min(parseInt(first, 10), 86400);
|
||||
rest = rest.substring(splitAt).replace(leadingSpaces, '');
|
||||
}
|
||||
}
|
||||
p.content = rest;
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 agent 下发的 screensue 内容是不是 HTML。
|
||||
*
|
||||
* 服务端两侧协议都允许 HTML(localuse 的 local_screensue 在 Linux 用 browsh/w3m
|
||||
* 渲染 HTML;remotedevice 的工具说明写的就是「显示内容/HTML」)。
|
||||
*
|
||||
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
|
||||
* (`'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
|
||||
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
|
||||
*
|
||||
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
|
||||
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
|
||||
*/
|
||||
export function looksLikeHtml(content: string): boolean {
|
||||
return findHtmlStart(content) >= 0;
|
||||
}
|
||||
|
||||
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
|
||||
function findHtmlStart(content: string): number {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content.charAt(i) !== '<') {
|
||||
continue;
|
||||
}
|
||||
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
|
||||
if (next === '/') {
|
||||
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
|
||||
if (isAsciiLetter(after)) {
|
||||
return i;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isAsciiLetter(next)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isAsciiLetter(ch: string): boolean {
|
||||
if (ch.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const c: number = ch.charCodeAt(0);
|
||||
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
|
||||
*
|
||||
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
|
||||
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
|
||||
*/
|
||||
export function screensueHtmlDocument(content: string): string {
|
||||
let body: string = content.trim();
|
||||
// 反复剥成对的包裹引号(agent 把整段 HTML 当命令参数传时的常见形态)。
|
||||
while (body.length >= 2) {
|
||||
const first: string = body.charAt(0);
|
||||
const last: string = body.charAt(body.length - 1);
|
||||
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
|
||||
body = body.substring(1, body.length - 1).trim();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const idx: number = findHtmlStart(body);
|
||||
if (idx < 0) {
|
||||
return '';
|
||||
}
|
||||
if (idx > 0) {
|
||||
body = body.substring(idx);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
|
||||
*
|
||||
* 为什么必须上 Web(不再用 RichText):RichText 只认极小标签子集,
|
||||
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
|
||||
* HTML 文档(含 @keyframes 与 <svg>),RichText 下只能看到源码。用户明确要求引入 webview。
|
||||
*
|
||||
* 为什么用 base64 而不是明文 loadData:encoding 非 base64 时按 URL 规则转义,
|
||||
* 一个几 KB 的完整文档会撞上长度/转义问题;base64 是整篇加载的推荐方式,
|
||||
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8)。
|
||||
*
|
||||
* 片段(非完整文档)补一层 shell:加 <meta viewport> 让窄屏排版正确,
|
||||
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML(走纯文本渲染)。
|
||||
*/
|
||||
export function screensueWebData(content: string, dark: boolean): string {
|
||||
const fragment: string = screensueHtmlDocument(content);
|
||||
if (fragment.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (hasHtmlShell(fragment)) {
|
||||
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
|
||||
return base64Utf8(fragment);
|
||||
}
|
||||
const fg: string = dark ? '#E8ECF4' : '#1B2430';
|
||||
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
|
||||
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
+ '<style>html,body{margin:0;padding:0}'
|
||||
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
|
||||
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
|
||||
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
|
||||
+ fragment + '</body></html>';
|
||||
return base64Utf8(wrapped);
|
||||
}
|
||||
|
||||
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
|
||||
function hasHtmlShell(s: string): boolean {
|
||||
const head: string = s.substring(0, 400).toLowerCase();
|
||||
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 字符串 → base64。
|
||||
*
|
||||
* 不能把 UTF-16 码元直接交给 Base64Helper:那样中文会变成乱码。
|
||||
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
|
||||
*/
|
||||
export function base64Utf8(s: string): string {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let code: number = s.charCodeAt(i);
|
||||
// 代理对(emoji 等)合成成一个码点。
|
||||
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
|
||||
const next: number = s.charCodeAt(i + 1);
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
||||
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
|
||||
} else if (code < 0x10000) {
|
||||
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
|
||||
} else {
|
||||
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
|
||||
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
|
||||
}
|
||||
}
|
||||
const helper: util.Base64Helper = new util.Base64Helper();
|
||||
return helper.encodeToStringSync(new Uint8Array(bytes));
|
||||
}
|
||||
@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
|
||||
import { SUB_CONNECTIONS } from '../common/SettingsModel';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { ChatAttachment } from '../model/Model';
|
||||
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
|
||||
@ -20,6 +21,8 @@ import { PageTopBar } from './PageTopBar';
|
||||
@Component
|
||||
export struct ChatStream {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
|
||||
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
|
||||
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
|
||||
@StorageProp(K_CHAT_STAGE) private stage: string = '';
|
||||
/** 数组快照的订阅信号 */
|
||||
@ -33,10 +36,22 @@ export struct ChatStream {
|
||||
|
||||
private scroller: Scroller = new Scroller();
|
||||
private autoScrolling: boolean = false;
|
||||
/** 滚动世代号:scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */
|
||||
private scrollGen: number = 0;
|
||||
private navHidden: boolean = false;
|
||||
|
||||
aboutToAppear(): void {
|
||||
this.messages = chatStore.messages();
|
||||
// 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。
|
||||
//
|
||||
// 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。
|
||||
// ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream
|
||||
// 构造之前就完成了,requestScroll 递增的 chatScrollRev 就成了“挂载前
|
||||
// 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是
|
||||
// “消息加载好了却停在顶部/中间,不滚到最新”。
|
||||
if (this.messages.length > 0) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private onChatRev(): void {
|
||||
@ -66,14 +81,30 @@ export struct ChatStream {
|
||||
|
||||
private scrollToBottom(): void {
|
||||
this.autoScrolling = true;
|
||||
// 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的,
|
||||
// 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms,
|
||||
// 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。
|
||||
// 用递增间隔重试到 ~1s,让后几帧的布局增长也跟得上。
|
||||
//
|
||||
// scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。
|
||||
const gen: number = ++this.scrollGen;
|
||||
const delays: number[] = [50, 120, 220, 360, 550, 800];
|
||||
for (let i = 0; i < delays.length; i++) {
|
||||
setTimeout(() => {
|
||||
if (gen !== this.scrollGen) {
|
||||
return;
|
||||
}
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, delays[i]);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.scroller.scrollEdge(Edge.Bottom);
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
if (gen !== this.scrollGen) {
|
||||
return;
|
||||
}
|
||||
this.autoScrolling = false;
|
||||
this.navHidden = false;
|
||||
navBar.setVisible(true);
|
||||
}, 450);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -186,6 +217,50 @@ export struct ChatStream {
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
|
||||
//
|
||||
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
|
||||
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
|
||||
if (this.messages.length === 0 && !this.loading) {
|
||||
Column({ space: 10 }) {
|
||||
Image($r('app.media.ic_link'))
|
||||
.width(34)
|
||||
.height(34)
|
||||
.fillColor(this.palette().textMuted)
|
||||
.draggable(false)
|
||||
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.hasConn
|
||||
? '在下方输入框发送第一条消息'
|
||||
: '请先在“后端连接”里填写服务地址与 API Key')
|
||||
.fontSize(12)
|
||||
.fontColor(this.palette().textMuted)
|
||||
.textAlign(TextAlign.Center)
|
||||
if (!this.hasConn) {
|
||||
Button('去设置连接')
|
||||
.height(34)
|
||||
.fontSize(13)
|
||||
.backgroundColor(this.palette().accent)
|
||||
.fontColor(Color.White)
|
||||
.margin({ top: 4 })
|
||||
.onClick(() => {
|
||||
// 跨页信号:切到设置 Tab,并让设置页直接打开连接二级页
|
||||
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
|
||||
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding({ left: 44, right: 44 })
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
|
||||
.hitTestBehavior(HitTestMode.Transparent)
|
||||
}
|
||||
|
||||
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None,触摸完全穿透)
|
||||
PageTopBar({ title: '聊天' })
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ import { apiClient } from '../common/ApiClient';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants';
|
||||
import { SubPageLayer, PlainCard } from './SubPage';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@ -49,6 +49,11 @@ export struct ConnectionsPane {
|
||||
}
|
||||
}
|
||||
|
||||
/** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */
|
||||
private syncConnFlag(): void {
|
||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
||||
}
|
||||
|
||||
private currentConnName(): string {
|
||||
for (let i = 0; i < this.connections.length; i++) {
|
||||
if (this.connections[i].id === this.currentId) {
|
||||
@ -64,6 +69,7 @@ export struct ConnectionsPane {
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.syncConnFlag();
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('已切换连接', false);
|
||||
@ -88,6 +94,7 @@ export struct ConnectionsPane {
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.syncConnFlag();
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已添加', false);
|
||||
@ -107,6 +114,7 @@ export struct ConnectionsPane {
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
this.syncConnFlag();
|
||||
this.connections = connStore.getConnections();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已更新', false);
|
||||
@ -151,6 +159,7 @@ export struct ConnectionsPane {
|
||||
} else {
|
||||
apiClient.clearConnection();
|
||||
}
|
||||
this.syncConnFlag();
|
||||
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
|
||||
this.toast('连接已删除', false);
|
||||
});
|
||||
|
||||
@ -2,12 +2,24 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo
|
||||
import { GradientBackground } from './GradientBackground';
|
||||
import { PageTopBar } from './PageTopBar';
|
||||
import { MotionBase } from './MotionBase';
|
||||
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
|
||||
import { webview } from '@kit.ArkWeb';
|
||||
|
||||
/**
|
||||
* agent 主动推送的前台内容页。
|
||||
*
|
||||
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
|
||||
* 从而让左侧一级页面和主导航保持可见、可操作。
|
||||
*
|
||||
* 内容可能是纯文本,也可能是 HTML(服务端两侧协议都允许,见 ScreensueHtml)。
|
||||
*
|
||||
* HTML 走 **Web 组件**(用户明确要求):agent 推的常是完整文档 —— 带 <style>
|
||||
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
|
||||
* 对这些一律不渲染,实测只能看到满屏源码。
|
||||
*
|
||||
* 安全:内容来自 agent(第三方),所以显式关掉 JS 与本地文件访问 ——
|
||||
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
|
||||
* 纯文本仍走 Text(无需开销,也不该把文本塞进 Web)。
|
||||
*/
|
||||
@Component
|
||||
export struct ScreensuePage {
|
||||
@ -17,6 +29,16 @@ export struct ScreensuePage {
|
||||
onClose: () => void = () => {
|
||||
};
|
||||
|
||||
/** 内容是不是 HTML(决定走 Web 还是 Text)。 */
|
||||
private htmlMode(): boolean {
|
||||
return looksLikeHtml(this.pushedText);
|
||||
}
|
||||
|
||||
/** HTML 的 base64 载荷(空串表示不是 HTML)。 */
|
||||
private webData(): string {
|
||||
return screensueWebData(this.pushedText, this.isDark);
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack({ alignContent: Alignment.Bottom }) {
|
||||
GradientBackground()
|
||||
@ -44,13 +66,22 @@ export struct ScreensuePage {
|
||||
.width('100%')
|
||||
|
||||
Column() {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
if (this.htmlMode()) {
|
||||
// HTML:整篇交给 Web 渲染(base64 loadData,见 screensueWebData)。
|
||||
// 高度固定 420vp:Web 不参与父级自适应测量,给 height('100%')
|
||||
// 会在 Scroll 里塌成 0。内容区本身可滚。
|
||||
ScreenWebView({ data: this.webData(), isDark: this.isDark })
|
||||
.width('100%')
|
||||
.height(420)
|
||||
} else {
|
||||
Text(this.pushedText)
|
||||
.fontSize(16)
|
||||
.lineHeight(25)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Start)
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.padding(18)
|
||||
@ -101,3 +132,66 @@ export struct ScreensuePage {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
|
||||
*
|
||||
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web:
|
||||
* `WebviewController` 与 Web 组件是一对一绑定的,必须等组件挂载(onControllerAttached)
|
||||
* 才能真正 loadData;把它隔离在这里,ScreensuePage 只管布局与倒计时。
|
||||
*/
|
||||
@Component
|
||||
struct ScreenWebView {
|
||||
// @Watch 挂在这里是必须的:ScreensuePage 在 `if (screensueVisible)` 里常驻,
|
||||
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
|
||||
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
|
||||
// 实测:连推两条不同 HTML,倒计时变了、Web 里还是旧画面。
|
||||
@Prop @Watch('onDataChanged') data: string = '';
|
||||
@Prop isDark: boolean = true;
|
||||
private controller: webview.WebviewController = new webview.WebviewController();
|
||||
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001)。 */
|
||||
private attached: boolean = false;
|
||||
|
||||
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
|
||||
onDataChanged(): void {
|
||||
if (this.attached) {
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Web({ src: '', controller: this.controller })
|
||||
// ★ 内容来自 agent(第三方):显式关闭脚本与本地文件访问。
|
||||
// javaScriptAccess 的默认值是 true,不写这一行等于放任远端内容执行脚本。
|
||||
.javaScriptAccess(false)
|
||||
.fileAccess(false)
|
||||
.domStorageAccess(false)
|
||||
.onlineImageAccess(false)
|
||||
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
|
||||
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
|
||||
.horizontalScrollBarAccess(false)
|
||||
.verticalScrollBarAccess(false)
|
||||
.darkMode(WebDarkMode.Off)
|
||||
.backgroundColor(Color.Transparent)
|
||||
// 控制器挂载完才 loadData:过早调用会抛 17100001(控制器未与组件关联)。
|
||||
.onControllerAttached(() => {
|
||||
// 挂载完成才允许 loadData;此前的变更由 onDataChanged 记着,这里补一次。
|
||||
this.attached = true;
|
||||
this.load();
|
||||
})
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
|
||||
private load(): void {
|
||||
if (this.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.controller.loadData(this.data, 'text/html', 'base64');
|
||||
} catch (e) {
|
||||
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,10 +120,15 @@ export struct StatusSummaryCard {
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
|
||||
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
|
||||
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)。
|
||||
//
|
||||
// 必须是**子组件**(@Prop 单向下发)而不是本组件里的 @Builder:
|
||||
// ArkUI 的 @Builder 按值传参是“快照”语义,父组件重渲染时
|
||||
// 不会用新值重跑 builder —— 实测:K_PLUGINS 已经是 35,
|
||||
// 但 @Builder 里画的还是首次的 0,永远显示 '-'。
|
||||
Row({ space: 10 }) {
|
||||
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
|
||||
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
|
||||
KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
|
||||
KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
|
||||
}
|
||||
.width('100%')
|
||||
.margin({ top: 14 })
|
||||
@ -214,6 +219,51 @@ export struct StatusSummaryCard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力计数小卡(插件 / 工具)。
|
||||
*
|
||||
* 单独成组件而非 @Builder:@Builder 的按值参数不会随父组件重渲染而刷新,
|
||||
* 数值会永远停在首次渲染的 0。@Prop 是单向下发,父组件因 @StorageProp
|
||||
* 变化重渲染时,子组件拿到新值并重绘。
|
||||
*/
|
||||
@Component
|
||||
struct KpiTile {
|
||||
@StorageProp('themeIsDark') private isDark: boolean = true;
|
||||
@Prop icon: Resource = $r('app.media.ic_plug');
|
||||
@Prop label: string = '';
|
||||
@Prop value: number = 0;
|
||||
@Prop tint: string = '';
|
||||
|
||||
build() {
|
||||
Row({ space: 8 }) {
|
||||
Image(this.icon)
|
||||
.width(16)
|
||||
.height(16)
|
||||
.fillColor(this.tint)
|
||||
.draggable(false)
|
||||
Column({ space: 1 }) {
|
||||
Text(this.value > 0 ? this.value.toString() : '-')
|
||||
.fontSize(17)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor(this.palette().textPrimary)
|
||||
Text(this.label)
|
||||
.fontSize(11)
|
||||
.fontColor(this.palette().textMuted)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
|
||||
.borderRadius(RADIUS_SM)
|
||||
.backgroundColor(this.palette().bgHover)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行状态明细:设置页「运行状态」二级页面的内容。
|
||||
*
|
||||
|
||||
@ -26,6 +26,14 @@ export interface ChatMessage {
|
||||
toolCalls?: ToolCallInfo[];
|
||||
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
|
||||
source?: string;
|
||||
/**
|
||||
* 服务端单调递增序号(后端 ChatMsg.seq)。
|
||||
*
|
||||
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
|
||||
* 本地乐观消息没有 seq,服务端回显后靠 seq 认领并去重。
|
||||
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
|
||||
*/
|
||||
seq?: number;
|
||||
/** 图片/文件附件(后端 ChatMsg.attachment) */
|
||||
attachment?: ChatAttachment;
|
||||
}
|
||||
|
||||
@ -7,14 +7,15 @@ import { apiClient } from '../common/ApiClient';
|
||||
import { navBar } from '../common/NavBarController';
|
||||
import { handleBackPress } from '../common/NavStackRegistry';
|
||||
import { ConnectionConfig } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
|
||||
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
|
||||
import { MotionBase } from '../components/MotionBase';
|
||||
import { GradientBackground } from '../components/GradientBackground';
|
||||
import { ScreensuePage } from '../components/ScreensuePage';
|
||||
import { registerScreensueHandler } from '../common/BridgeRouter';
|
||||
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
|
||||
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { snapshotComponentId } from '../common/BridgeCaps';
|
||||
import { ScreensuePayload } from '../common/ScreensueHtml';
|
||||
import { window, display } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
|
||||
@ -83,6 +84,8 @@ struct Index {
|
||||
/** 底部手势条高度(vp) */
|
||||
@State bottomGesture: number = 16;
|
||||
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
|
||||
/** 外部请求切换主 Tab(未连接时聊天空态的“去设置连接”用) */
|
||||
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
|
||||
private swiper: SwiperController = new SwiperController();
|
||||
private screensueTimer: number = -1;
|
||||
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
|
||||
@ -101,6 +104,12 @@ struct Index {
|
||||
if (cur !== null) {
|
||||
apiClient.setConnection(cur);
|
||||
}
|
||||
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
|
||||
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
|
||||
// 所以此处读到的连接状态就是真实的启动态。
|
||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
||||
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
|
||||
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
|
||||
// 种子化自定义背景图状态到 AppStorage,GradientBackground 响应读取
|
||||
const st = connStore.getSettings();
|
||||
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
|
||||
@ -191,6 +200,23 @@ struct Index {
|
||||
AppStorage.set<number>('currentTab', this.currentTab);
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
|
||||
*
|
||||
* 为什么不能直接改 AppStorage 的 currentTab:Index 的 currentTab 是
|
||||
* @State,Swiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
|
||||
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
|
||||
*/
|
||||
private onRequestedTab(): void {
|
||||
const t: number = this.requestedTab;
|
||||
AppStorage.set<number>(K_REQUESTED_TAB, -1);
|
||||
if (t < 0 || t >= this.tabs.length) {
|
||||
return;
|
||||
}
|
||||
this.currentTab = t;
|
||||
navBar.setVisible(true);
|
||||
}
|
||||
|
||||
private syncSystemBar(): void {
|
||||
const dark: boolean = this.isDark;
|
||||
const bg: string = dark ? '#000000' : '#F1F3F5';
|
||||
|
||||
@ -3,7 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError';
|
||||
import { connStore } from '../common/ConnStore';
|
||||
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
|
||||
import { ConnectionConfig, AppSettings } from '../model/Model';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
|
||||
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, K_HAS_CONN, K_SETTINGS_SUB } from '../common/Constants';
|
||||
import { RADIUS_MD, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
|
||||
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
|
||||
import { StatusDetailContent } from '../components/StatusCards';
|
||||
@ -42,6 +42,10 @@ export struct SettingsPage {
|
||||
|
||||
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
|
||||
private navStack: NavPathStack = new NavPathStack();
|
||||
/** 未连接时是否已自动弹过连接页——避免用户关掉后又被 onNavigationModeChange 弹回来 */
|
||||
private autoOpenedConn: boolean = false;
|
||||
/** 外部请求打开某个二级页(聊天空态的“去设置连接”用) */
|
||||
@StorageProp(K_SETTINGS_SUB) @Watch('onSubRequest') private subRequest: string = '';
|
||||
|
||||
// ===== backend key/value editor state =====
|
||||
@State sections: SettingsSection[] = [];
|
||||
@ -86,6 +90,39 @@ export struct SettingsPage {
|
||||
this.connections = connStore.getConnections();
|
||||
const cur = connStore.getCurrentConnection();
|
||||
this.currentId = cur !== null ? cur.id : '';
|
||||
// 广播连接状态:聊天空态据此显示“去设置连接”
|
||||
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
|
||||
// 可能从聊天空态带着“打开连接页”的请求进来(本页尚未挂载时请求已写入)
|
||||
const pending: string = AppStorage.get<string>(K_SETTINGS_SUB) ?? '';
|
||||
if (pending.length > 0) {
|
||||
AppStorage.set<string>(K_SETTINGS_SUB, '');
|
||||
this.autoOpenedConn = true;
|
||||
setTimeout(() => {
|
||||
this.openSub(pending);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 宽屏右栏默认该展示哪一页:没连上就把“后端连接”给出来。
|
||||
*
|
||||
* 为什么不能只靠一级入口行:入口行在列表里,用户很容易略过;
|
||||
* 而“未连接”恰恰是最需要直接看到连接表单的时刻。窄屏同理,
|
||||
* 在 onNavigationModeChange(Stack) 里会把连接页直接推到面前。
|
||||
*/
|
||||
private initialSub(): string {
|
||||
return apiClient.hasConnection() ? SUB_STATUS : SUB_CONNECTIONS;
|
||||
}
|
||||
|
||||
/** 外部请求打开二级页(“去设置连接”) */
|
||||
private onSubRequest(): void {
|
||||
const id: string = this.subRequest;
|
||||
if (id.length === 0) {
|
||||
return;
|
||||
}
|
||||
AppStorage.set<string>(K_SETTINGS_SUB, '');
|
||||
this.autoOpenedConn = true;
|
||||
this.openSub(id);
|
||||
}
|
||||
|
||||
private palette(): ThemePalette {
|
||||
@ -287,12 +324,17 @@ export struct SettingsPage {
|
||||
if (mode === NavigationMode.Split) {
|
||||
markSubPageOpen(true);
|
||||
if (this.navStack.size() === 0) {
|
||||
this.openSub(SUB_STATUS);
|
||||
this.openSub(this.initialSub());
|
||||
}
|
||||
} else {
|
||||
this.navStack.clear(false);
|
||||
this.activeSub = SUB_NONE;
|
||||
markSubPageOpen(false);
|
||||
// 窄屏:未配置后端时直接推连接页,保证“设置里一定能找到连后端的入口”。
|
||||
if (!this.autoOpenedConn && !apiClient.hasConnection()) {
|
||||
this.autoOpenedConn = true;
|
||||
this.openSub(SUB_CONNECTIONS);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
|
||||
/conn use <name> switch to saved connection
|
||||
/conn del <name> delete saved connection
|
||||
|
||||
Server commands (sent to agent):
|
||||
Server commands (local 与 remote 行为一致):
|
||||
/status system status
|
||||
/kernel kernel status
|
||||
/settings [prefix] list settings
|
||||
@ -32,10 +32,27 @@ Server commands (sent to agent):
|
||||
/plugin list list installed plugins
|
||||
/plugin install <url> install plugin
|
||||
/plugin remove <name> remove plugin
|
||||
/plugin disable <name> disable plugin
|
||||
/plugin enable <name> enable plugin
|
||||
/plugin info <name> plugin details
|
||||
/memory query <text> query graph memory
|
||||
/knowledge list knowledge base
|
||||
/memory graph dump full graph memory snapshot
|
||||
/memory text [n] recent text-memory events + stats
|
||||
/memory context [q] assembled memory context (what gets injected)
|
||||
/memory tools memory tool definitions + tool prompt
|
||||
/knowledge list knowledge base (+stats)
|
||||
/knowledge delete <name> delete knowledge item
|
||||
/config dump kernel config (JSON)
|
||||
/tracker change-tracking stats
|
||||
/tracker rollback roll back this session's file changes
|
||||
/adapters list loaded Lua adapters
|
||||
/adapters remove <name> remove a Lua adapter
|
||||
/network network status + LLM endpoints
|
||||
/runtime scheduler / residents / channel topology
|
||||
/terminals list terminal sessions
|
||||
/cmd/history command execution history
|
||||
/terminal create|write|read|close … (local mode; calls agentcli tools)
|
||||
/persona show persona (/persona set default|custom|later [text])
|
||||
/agents list agents
|
||||
/chat <text> send to agent
|
||||
|
||||
@ -54,6 +71,26 @@ Any other text is sent to the agent directly.`)
|
||||
reconnect()
|
||||
return true
|
||||
|
||||
// /stop 与 /interrupt:取消当前生成(可附带一句新指令)。
|
||||
// 之前 /help 里写着这条命令,但 handleBuiltin 根本没有对应 case,
|
||||
// 于是它像普通文本一样被发给了 Agent。
|
||||
// 本地交给 CLI 插件(内核优先级 L3),远端走 WebUI 的 chat/interrupt
|
||||
// (内核优先级 L4)。两条路都是“真中断”,不是发一句话。
|
||||
case cmd == "/stop" || cmd == "/interrupt" ||
|
||||
strings.HasPrefix(cmd, "/stop ") || strings.HasPrefix(cmd, "/interrupt "):
|
||||
msg := stopMessage(cmd)
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
body := fmt.Sprintf(`{"message":%q}`, msg)
|
||||
if _, err := rc.DoAPI("POST", "/api/v1/chat/interrupt", body); err != nil {
|
||||
fmt.Fprintf(out, "interrupt failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Fprintln(out, "interrupt sent")
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/connect "):
|
||||
cfg.Socket = strings.TrimSpace(cmd[9:])
|
||||
cfg.Remote = ""
|
||||
@ -143,7 +180,7 @@ Any other text is sent to the agent directly.`)
|
||||
rc.DoAPI("PUT", "/api/v1/settings", body)
|
||||
fmt.Fprintln(out, "ok")
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -152,7 +189,7 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -172,7 +209,7 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -182,7 +219,7 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -192,17 +229,56 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/memory query "):
|
||||
q := strings.TrimSpace(cmd[14:])
|
||||
// disable/enable:本地由 CLI 插件处理,远端走插件管理 REST 动作接口。
|
||||
case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "):
|
||||
verb := "disable"
|
||||
name := strings.TrimSpace(cmd[16:])
|
||||
if strings.HasPrefix(cmd, "/plugin enable ") {
|
||||
verb = "enable"
|
||||
name = strings.TrimSpace(cmd[15:])
|
||||
}
|
||||
if name == "" {
|
||||
fmt.Fprintln(out, "usage: /plugin disable|enable <name>")
|
||||
return true
|
||||
}
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case strings.HasPrefix(cmd, "/memory "):
|
||||
sub := strings.TrimSpace(cmd[8:])
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
switch {
|
||||
case strings.HasPrefix(sub, "query "):
|
||||
q := strings.TrimSpace(strings.TrimPrefix(sub, "query "))
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
|
||||
printJSON(out, d)
|
||||
case sub == "graph":
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/graph", "")
|
||||
printJSON(out, d)
|
||||
case sub == "text" || strings.HasPrefix(sub, "text "):
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/text", "")
|
||||
printJSON(out, d)
|
||||
case sub == "context" || strings.HasPrefix(sub, "context "):
|
||||
q := strings.TrimSpace(strings.TrimPrefix(sub, "context"))
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/context?q="+q, "")
|
||||
printJSON(out, d)
|
||||
case sub == "tools":
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/memory/tools", "")
|
||||
printJSON(out, d)
|
||||
default:
|
||||
fmt.Fprintln(out, "usage: /memory query <text> | /memory graph | /memory text [n] | /memory context [q] | /memory tools")
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
@ -212,16 +288,126 @@ Any other text is sent to the agent directly.`)
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send(cmd[1:])
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/knowledge":
|
||||
case cmd == "/knowledge" || cmd == "/knowledge list" || cmd == "/knowledge stats":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/knowledge")
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/config":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/config", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/config")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/tracker":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/tracker", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/tracker")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/tracker rollback":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/tracker/rollback", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/tracker rollback")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/adapters" || strings.HasPrefix(cmd, "/adapters remove "):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
if strings.HasPrefix(cmd, "/adapters remove ") {
|
||||
name := strings.TrimSpace(cmd[17:])
|
||||
d, _ := rc.DoAPI("DELETE", "/api/v1/adapters/"+name, "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/adapters", "")
|
||||
printJSON(out, d)
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/persona" || strings.HasPrefix(cmd, "/persona set "):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
if strings.HasPrefix(cmd, "/persona set ") {
|
||||
rest := strings.TrimSpace(cmd[13:])
|
||||
mode := rest
|
||||
content := ""
|
||||
if idx := strings.IndexByte(rest, ' '); idx > 0 {
|
||||
mode = rest[:idx]
|
||||
content = strings.TrimSpace(rest[idx+1:])
|
||||
}
|
||||
body := fmt.Sprintf(`{"mode":%q,"content":%q}`, mode, content)
|
||||
d, _ := rc.DoAPI("POST", "/api/v1/persona", body)
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/persona", "")
|
||||
printJSON(out, d)
|
||||
}
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/terminals":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/terminals", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/terminals")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/cmd/history":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/cmd/history", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/cmd/history")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/terminal" || strings.HasPrefix(cmd, "/terminal "):
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
// 远端 WebUI 没有“开终端”的 REST 端点(终端由 agentcli 工具创建),
|
||||
// 不静默当聊天发出去,直接说明。
|
||||
fmt.Fprintln(out, "remote 模式暂不支持终端操作;请在 local 模式或让 agent 调 terminal_* 工具")
|
||||
} else {
|
||||
state.Send(cmd)
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/runtime":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/runtime", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/runtime")
|
||||
}
|
||||
return true
|
||||
|
||||
case cmd == "/network":
|
||||
if rc := state.RemoteConn(); rc != nil {
|
||||
d, _ := rc.DoAPI("GET", "/api/v1/network", "")
|
||||
printJSON(out, d)
|
||||
} else {
|
||||
state.Send("/network")
|
||||
}
|
||||
return true
|
||||
|
||||
@ -239,6 +425,16 @@ Any other text is sent to the agent directly.`)
|
||||
}
|
||||
}
|
||||
|
||||
// stopMessage 从 /stop 或 /interrupt 行里取出可选的中断附带消息(空串=纯取消)。
|
||||
func stopMessage(cmd string) string {
|
||||
for _, prefix := range []string{"/interrupt", "/stop"} {
|
||||
if strings.HasPrefix(cmd, prefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(cmd, prefix))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func printJSON(out io.Writer, d map[string]interface{}) {
|
||||
if d == nil {
|
||||
fmt.Fprintln(out, "(no data)")
|
||||
|
||||
@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
)
|
||||
|
||||
// ===== 设备桥管理 =====
|
||||
@ -46,6 +47,9 @@ func startDeviceBridge(addr, token string) error {
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"cpus": runtime.NumCPU(),
|
||||
// 客户端版本与内核同源(internal/meta),deviceinfo 回显的软件版本
|
||||
// 因此与 homed 一致,不再是一个空缺字段。
|
||||
"version": meta.Version,
|
||||
}
|
||||
|
||||
// 确保 gateway URL 格式正确
|
||||
|
||||
@ -11,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -129,8 +131,15 @@ func main() {
|
||||
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
|
||||
testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue")
|
||||
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
|
||||
showVersion := flag.Bool("version", false, "打印版本并退出")
|
||||
flag.Parse()
|
||||
|
||||
// 版本号直接来自 internal/meta(与 homed 同一事实源,不可能各写一个)。
|
||||
if *showVersion {
|
||||
fmt.Printf("waiter %s (commit %s, built %s)\n", meta.Version, meta.Commit, meta.BuildTime)
|
||||
return
|
||||
}
|
||||
|
||||
// 本地能力测试模式(无需连接服务器)
|
||||
if *testCap != "" {
|
||||
runCapTest(*testCap, *testCapArgs)
|
||||
@ -270,9 +279,9 @@ func runLineMode(state *State, cfg *Config, history *History) {
|
||||
addrLabel = cfg.Remote
|
||||
}
|
||||
if colors {
|
||||
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
|
||||
fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset)
|
||||
} else {
|
||||
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
|
||||
fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel)
|
||||
}
|
||||
fmt.Println("Type /help for commands.")
|
||||
|
||||
|
||||
364
demo.md
364
demo.md
@ -1,364 +0,0 @@
|
||||
# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md)
|
||||
|
||||
> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程,
|
||||
> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。
|
||||
|
||||
---
|
||||
|
||||
## 0. 背景与目标
|
||||
|
||||
框架目标:**内核零 IO、插件承载所有 IO**(`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。
|
||||
|
||||
**Failback 的定位:所有错误的一层兜底(Safe-Mode 式),而非针对单一场景。**
|
||||
|
||||
- 主 agent(全量 LLM agent)是一切骚操作的执行者,可能把自己搞到无法自愈的任意状态:
|
||||
LLM 源改坏 / 系统网络(proxy、DNS、host)破坏 / 配置文件损坏 / OOM / panic / 崩溃循环……
|
||||
这些错误无法在**同一个被污染环境内**用自身操作自救。
|
||||
- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**——
|
||||
类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP),
|
||||
单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。**
|
||||
- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状盘点(代码勘查结论)
|
||||
|
||||
### 1.1 守护进程(`internal/supervisor/daemon.go`)
|
||||
|
||||
- 主 agent in-process 常驻,`agent.Start()` 在 `cmd/homed/main.go:468` 直接启动,**无独立进程边界**。
|
||||
- `healthLoop` → `checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor` 的 `AggregateResult().LLMAPIReachable`**(`daemon.go:132`)。
|
||||
- `failCount >= MaxRetries`(默认 3)→ `handleFailure`:
|
||||
- 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`。
|
||||
- `restartAgent`(`daemon.go:166`)**只改内存状态再重新 Register,不真重启任何进程**,几乎空转。
|
||||
|
||||
关键问题:
|
||||
- 探测是系统级(HTTP/DNS/TCP),回滚只作用于 `<data>/agentfs` overlay 的 upper,**二者对象错位**。
|
||||
- `AgregateResult` 在无 LLM endpoint 时恒 healthy,机制形同虚设。
|
||||
- `lastHB` 每轮都置 `time.Now()`,`Uptime` 无意义。
|
||||
- `RollbackPolicy` 的 `HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`。
|
||||
|
||||
### 1.2 文件追踪(`internal/tracker`)
|
||||
|
||||
- overlayfs 三层:`lower/upper/work → merged`(`tracker.go:156`)。
|
||||
- `captureFSState(upperDir)` 递归遍历 upper 并 sha256(`changeset.go:56`);`PreAction/PostAction` 前后 diff(`toolcall.go:86-94`)。
|
||||
- **lower 恒空**(`Init` 只 `MkdirAll`,从不填充)→ 无 canonical 基线可回滚。
|
||||
- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets;`FileChange.Content`(本应存回滚原文)**从未回填**。
|
||||
|
||||
结论:overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。
|
||||
|
||||
### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`)
|
||||
|
||||
- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**:
|
||||
`qq.napcat_url`、`qq.listen`、`qq.files_dir`、`qq.remote_dir`、`dm/group_policy`(`plugin.go:120-130`)。
|
||||
- 插件在 `Start()` 里 `getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**。
|
||||
- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。
|
||||
|
||||
### 1.4 LLM 源与"恢复即生效"
|
||||
|
||||
`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**:
|
||||
- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`)
|
||||
- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。
|
||||
|
||||
即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。
|
||||
|
||||
---
|
||||
|
||||
## 2. 现实环境:日志记录模式内参
|
||||
|
||||
> 看真实 systemd 托管的 HomeAgent(`/home/newqqagent`)日志,**目的是弄清现有的日志模型**
|
||||
> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。
|
||||
|
||||
### 2.1 systemd 托管现状(样例)
|
||||
|
||||
```
|
||||
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
|
||||
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
|
||||
```
|
||||
|
||||
- 实测数据区:`/home/newqqagent/` 下有 `log/`、`config.db`、`agentfs/`(overlay merged)、`snapshots/`、`changesets/`、`knowledge/`、`memory/`、`plugins/`、
|
||||
`cli.sock`、`adapters/`、`homed.log`、`memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。
|
||||
- 启动段确认:`[files] started, sandbox: /`(**files 沙箱=全主机 `/` 实锤**);`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`(LLM 走本地 mock)。
|
||||
|
||||
### 2.2 日志目录与格式(核心)
|
||||
|
||||
- **目录配置**:`core.log.path`,默认 `<dataDir>/log`(`internal/config/registry.go:415,508`)。
|
||||
- **单次运行文件**:每次启动新建 `homed_<YYYY-MM-DD_HH-MM-SS>.log`(`cmd/homed/main.go:75`),
|
||||
写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))`(`main.go:80`)——
|
||||
**同时进 stderr(systemd 捕获到 journald/`journalctl -u`)与文件**。
|
||||
- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`。
|
||||
用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。
|
||||
- **层级压缩 + 保留**(`internal/log/manager.go:26-28` + `compressor.go`):
|
||||
- 周度压缩 → `week_<year>-W<ww>.tar.gz`;月度 → `month_<yyyy-mm>.tar.gz`;年度 `year_*.tar.gz`;
|
||||
raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$`(`compressor.go:16`)。
|
||||
- 保留策略:`core.log.retention`(default forever)、`core.log.retention_months`(default 3),
|
||||
`applyRetention` 只留当前周 + 近 N 月(`retention.go`)。
|
||||
实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。
|
||||
|
||||
### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`)
|
||||
|
||||
| 位置 | 日志行内容 | 备注 |
|
||||
|---|---|---|
|
||||
| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 |
|
||||
| `process.go:196` | `[agent] executing tool: <name> (plugin=<p>, id=<id>)` | **只记工具名/插件/id,不记 args** |
|
||||
| `process.go:226` | `[agent] tool <name> result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`)|
|
||||
| `process.go:218` | `[agent] skip tool <name>: plugin <name> unhealthy` | 插件崩溃态跳过 |
|
||||
| `process.go:89/109/121/125` | LLM fallback:`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 |
|
||||
| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 |
|
||||
| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 |
|
||||
| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 |
|
||||
| 插件侧 | Lua 插件 `sdk.log` → `print("[lua-plugin] <level>: <msg>")` | 模板见 `cmd_debug.go:74`/`templates.go` |
|
||||
|
||||
- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall`(`{tool, plugin, args, result, status}`,`process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。
|
||||
- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里),
|
||||
需要的话由 `diag_log_scan` 对 `executing tool: cmd_*` 前缀做签名匹配即可。
|
||||
|
||||
### 2.4 崩溃 / 重启观察
|
||||
|
||||
- `NRestarts=0`;MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**(pid 3310036):
|
||||
`[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`(03:23 / 05:23 / 07:23,约每 2h),
|
||||
被 `stages.go:139` 的 `RunStage` recover 吞掉 → **进程未真崩**,systemd 未见重启。
|
||||
- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。
|
||||
- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督;
|
||||
且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环,
|
||||
systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计演进(讨论全过程)
|
||||
|
||||
### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪"
|
||||
|
||||
- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。
|
||||
- 意图:LLM 不可达 → 回滚 agent 文件改动 → 自愈。
|
||||
|
||||
### 3.1 第一次纠正:回滚对象错位
|
||||
|
||||
- 回滚只作用于 overlayfs upper;而真正能改坏网络的路径(files 默认 `/`,可写 `/etc/resolv.conf`、`/etc/hosts`、代理配置)与 LLM 配置(config.db、adapters 目录)**都不在 overlay 内**。
|
||||
- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。
|
||||
|
||||
### 3.2 第二次纠正:授权不能靠外部插件主动配合
|
||||
|
||||
- 阶段管道是被动通知(`RunStage` 收集 error,不主动拒绝),拒绝依赖各插件 handler。
|
||||
- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。
|
||||
- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。
|
||||
- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置;IO 组件崩溃是正交偶发轴,不并进来。
|
||||
|
||||
### 3.3 第三次纠正:LLM 源才是核心(config.db + ReloadFromConfig 已具钩子)
|
||||
|
||||
- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`。
|
||||
- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测(QuickChat)+ 在 handleFailure 里触发 RFO 复检**。
|
||||
- 但用户进一步点明:**重点是 agent 改了系统网络配置文件(proxy/DNS)** —— 这类连救援 LLM 都连不上。
|
||||
|
||||
### 3.4 定稿架构一:lastFailback(独立进程,第一道防线)
|
||||
|
||||
- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd)+ 单一任务。
|
||||
- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。
|
||||
- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。
|
||||
- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。
|
||||
|
||||
### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程
|
||||
|
||||
- systemd 只绑定**静态、极小、永驻的 guard**;guard 作为父进程拉起 worker(`homed` 全量 agent)。
|
||||
- guard 通过 IPC(unix socket / 匿名 pipe)收 worker 心跳;
|
||||
父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。
|
||||
- worker 可崩可换,systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**。
|
||||
- guard 抗 OOM:`oom_score_adj` 拉低,优先杀 worker 而非 guard。
|
||||
|
||||
### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role,非独立二进制
|
||||
|
||||
- **不新建二进制**。`homed` 拆两个入口:
|
||||
- `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。
|
||||
- `homed --role=agent`:主 agent(工作进程,guard 的子进程)。
|
||||
- `homed --role=agent --boot=failback`:恢复用 agent(受限 bootstrap)。
|
||||
- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent,只加载
|
||||
**webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件** 与 **常见错误检测插件**,
|
||||
复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。
|
||||
- guard 配置**独立 YAML**,不复用 config.db(逃生舱知识必须脱离 agent 可达区)。
|
||||
|
||||
### 3.7 recoveryDiag:崩溃取证 / 根因定位插件(省 token 关键)
|
||||
|
||||
- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。
|
||||
- 工具集:
|
||||
| 工具 | 作用 |
|
||||
|---|---|
|
||||
| `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" |
|
||||
| `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail |
|
||||
| `diag_log_scan` | 时间窗内命中已知错误签名(panic/provider failed/unreachable/sql/OOM)|
|
||||
| `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff("改了什么")|
|
||||
| `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 |
|
||||
|
||||
- 崩溃类别 → 恢复分支决策表:
|
||||
| 结论类 | 走分支 |
|
||||
|---|---|
|
||||
| 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent(无需 agent 推理)|
|
||||
| 系统网络类 | 还原 DNS/proxy → 重载主 agent(第一步小修命中即停)|
|
||||
| 进程失稳类(OOM/panic)| 不还原配置,检查内存/泄漏 → 重建 worker |
|
||||
| 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 |
|
||||
|
||||
- 只有"未知/混合"消耗 token,前几类近乎 0 token。
|
||||
- 结论落盘 `recovery_kb/diag_<ts>.json`,回流知识库,同类崩溃下次直接命中,越用越省。
|
||||
|
||||
### 3.8 三层防御总览(最终)
|
||||
|
||||
```
|
||||
L0 平时:核心只读探活 + 写前快照(config_set 写 core.llm.* 前、files 写 /etc 前自动留档)
|
||||
L1 failback(guard 拉起,Safe-Mode 式兜底):按诊断分支逐类恢复——
|
||||
还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent
|
||||
L2 最后手段:agentfs 回滚最近快照 + 重启(应用快照前系统配置)
|
||||
```
|
||||
|
||||
- failback 是**所有错误(LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**,
|
||||
并非只针对某一条;`diag_*` 决定它走哪条恢复路径。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终架构(定稿)
|
||||
|
||||
### 4.1 进程拓扑(同一二进制,两个 role)
|
||||
|
||||
```
|
||||
systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小
|
||||
├─ exec ──▶ homed --role=agent # 主 agent(可崩)
|
||||
└─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent
|
||||
```
|
||||
|
||||
- guard:先起,持有恢复知识(锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。
|
||||
- worker:guard 子进程,心跳经 IPC,崩溃由 guard reap + 判型。
|
||||
- failback agent = 受限启动(webfetch+files+cmd + 恢复知识库 + recoveryDiag),单一任务"恢复主 agent",N 轮有界。
|
||||
|
||||
### 4.2 guard 独立 YAML 示例
|
||||
|
||||
> 现状实现(§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。
|
||||
|
||||
```yaml
|
||||
role: guard
|
||||
|
||||
llm:
|
||||
sources:
|
||||
- name: rescue
|
||||
base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理
|
||||
api_key: ${GUARD_RESCUE_KEY}
|
||||
adapter: ... # 锚定/SNI 型适配器
|
||||
|
||||
recovery:
|
||||
max_attempts: 4 # 可配置尝试轮次
|
||||
attempt_timeout: 120s
|
||||
knowledge_base: /opt/homeagent/recovery/
|
||||
trigger_prompt: "你是恢复 agent,唯一任务:让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…"
|
||||
plugins: [webfetch, files, cmd]
|
||||
|
||||
last_resort:
|
||||
action: reboot # restart_app | reboot
|
||||
snapshot_before: true
|
||||
```
|
||||
|
||||
### 4.3 guard 恢复状态机(N 轮有界)
|
||||
|
||||
```
|
||||
guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 )
|
||||
1. 固化追溯:exit/信号、panic、journal、OOM 上下文 → 永久区
|
||||
2. 拉起 failback agent(受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag)
|
||||
for attempt in 1..N:
|
||||
(可选先 diag_triage/diag_loc 判型)
|
||||
failback 尝试恢复
|
||||
guard 每轮复检主 agent 是否可达/存活
|
||||
├─ 成功 → 结束,交回主 agent
|
||||
└─ 超时/失败 → kill 重建,进入下一轮
|
||||
3. N 轮未成 → 取消 failback agent
|
||||
→ agentfs 回滚崩溃前最近快照
|
||||
→ 依 yaml 执行最后手段:restart_app 或 reboot
|
||||
```
|
||||
|
||||
### 4.4 systemd 绑定(极简,杜绝风暴)
|
||||
|
||||
```
|
||||
[Unit] # guard
|
||||
OnFailure=... # 备用,通常不触发(guard 稳定)
|
||||
|
||||
[Service] # guard
|
||||
Restart=always # guard 静态稳定 → 几乎不重启
|
||||
ExecStart=/usr/local/bin/homed --role=guard ...
|
||||
# No StartLimit needed for loop 情况;guard 不崩
|
||||
```
|
||||
|
||||
- 主 agent 崩 → 只触发 guard 内部 failback;systemd 仅看 guard,看不到 worker 崩溃循环。
|
||||
- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。
|
||||
- guard 抗 OOM:`oom_score_adj` 拉低。
|
||||
|
||||
---
|
||||
|
||||
## 5. 尚未落地的接口 / 下一步
|
||||
|
||||
**已完成**:
|
||||
|
||||
`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。
|
||||
- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`(config.db integrity_check + LLM 源字段校验,sqlite3 CLI 优先、缺失回退内核 Settings)、`diag_log_scan`(日志签名按类计数)、`diag_delta`(baseline vs 现状 diff)、`diag_loc`(四项结论正交排序 + 推荐恢复动作)。
|
||||
- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`。
|
||||
- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通:3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal),并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。
|
||||
- **结论落盘 + 知识库回流**:`diag_loc` 增 `persist`(缺省 true)→ 写 `<data_dir>/recovery_kb/diag_<ts>.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add` 以 `diag:<cause>:<ts>` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`。
|
||||
- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev`,`go build -o ... .`。
|
||||
- 注意:本环境 `snapshots/`、`changesets/` 均为空(direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir;未来接 guard 时由快照解包目录提供。
|
||||
|
||||
`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。
|
||||
- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。
|
||||
- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件(guard 恢复的外部基线);homed 启动即挂 `<data>/llm_snapshot.json`。
|
||||
- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM`、`TestLLMSnapshotFile`、`TestSetLLMSnapshotFile`。
|
||||
|
||||
`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。
|
||||
- `--role=guard` 父守护(永驻):读独立 `<data>/guard.yaml`(避开被改坏的 config.db),拉起 worker(`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。
|
||||
- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。
|
||||
- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`(restart_app / reboot)。
|
||||
- 心跳:worker 每 5s 触碰 `<data>/heartbeat`(agent 角色 goroutine),guard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。
|
||||
- 插件注册表加 `SetLoadAllowlist(names)`:白名单外插件(含已注册工厂)一律跳过,failback 40 工具 → 4 工具实测通过。
|
||||
|
||||
现状 bug 修复(supervisor/network/tracker)。
|
||||
|
||||
- **monitor 无 endpoint 恒 healthy**(`internal/network/monitor.go` + `pkg/types`):`NetworkCheckResult` 增 `EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error),`NewMonitor` 初始化空切片消除启动竞态;daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url),生产从此健康检查有真实目标。
|
||||
- **lastHB 恒置 now**(`internal/supervisor/daemon.go`):`checkAgent` 接入真实存活源 `SetHeartbeatSource`(homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。
|
||||
- **restartAgent 只改内存空转**:增 `SetRestartHandler`(homed 注册为"清理后以 `exitRestartRequested=42` 退出"),不再假装成功;guard 把 42 识别为"请求重建"(`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。
|
||||
- **tracker 三缺陷**(`internal/tracker/`):`captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB)→ `diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset;`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。
|
||||
|
||||
剩余:
|
||||
|
||||
- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。
|
||||
- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。
|
||||
- 生产实例迁移:编译新 homed、改 systemd 只托管 guard(`--role=guard`),确认 failback 插件(recoverydiag)就位。
|
||||
|
||||
---
|
||||
|
||||
## 附录:真实日志节选(systemd 托管示例,`/home/newqqagent`)
|
||||
|
||||
```
|
||||
# systemd unit
|
||||
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
|
||||
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
|
||||
|
||||
# 日志文件与格式(log.Printf 标准格式)
|
||||
2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log
|
||||
2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully
|
||||
2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs)
|
||||
|
||||
# 工具调用摘要(process.go)
|
||||
2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589
|
||||
... process.go:196: [agent] executing tool: <name> (plugin=<p>, id=<id>)
|
||||
... process.go:226: [agent] tool <name> result: <截断100字符>
|
||||
|
||||
# 启动 & 沙箱
|
||||
homed[3330844]: [files] started, sandbox: /
|
||||
homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8
|
||||
|
||||
# 重复 in-process panic(被 RunStage recover 吞掉,未进程级崩溃)
|
||||
homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23
|
||||
|
||||
# 一次性干净重启(systemd 手动/触发 Started,pid 3310036 → 3330844)
|
||||
homed[3310036]: [homed] stopped
|
||||
systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler.
|
||||
systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler.
|
||||
|
||||
# 运行状态
|
||||
systemctl show homeagent.service -p NRestarts → 0
|
||||
systemctl show homeagent.service -p MainPID → 3330844(自 08-03 起稳定)
|
||||
|
||||
# 归档
|
||||
/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz
|
||||
```
|
||||
104
deploy/scripts/sync-client-versions.sh
Executable file
104
deploy/scripts/sync-client-versions.sh
Executable file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 客户端版本与内核版本同步。
|
||||
#
|
||||
# 为什么要有这个脚本:内核的 internal/meta/meta.go Version 是唯一事实源,
|
||||
# 而各客户端各有各的版本字段——GUI 在 package.json、鸿蒙在 AppScope/app.json5、
|
||||
# waiter 走编译期注入。手工各改各的必然漂移(写这个脚本时的现状:内核 1.4.0、
|
||||
# GUI 1.0.0、鸿蒙 1.1.1,三个号互不相干)。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/scripts/sync-client-versions.sh # 同步到内核当前版本
|
||||
# bash deploy/scripts/sync-client-versions.sh 1.4.0 # 同步到指定版本
|
||||
# bash deploy/scripts/sync-client-versions.sh --check # 只校验,漂移则退出 1
|
||||
#
|
||||
# 同步目标:
|
||||
# cmd/gui/package.json version
|
||||
# cmd/ohos/HomeAgent/AppScope/app.json5 versionName + versionCode
|
||||
#
|
||||
# waiter 不在此列:它直接引用 internal/meta.Version(同一进程内编译),
|
||||
# 没有第二份版本字段可漂。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
|
||||
CHECK=0
|
||||
VERSION=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check) CHECK=1 ;;
|
||||
*) VERSION="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 未显式给版本时,从内核唯一事实源读。
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION="$(grep -oE 'Version = "[^"]+"' "$ROOT/internal/meta/meta.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
|
||||
fi
|
||||
[ -n "$VERSION" ] || { echo "sync-client-versions: 无法确定版本号(internal/meta/meta.go 里没找到 Version)" >&2; exit 1; }
|
||||
|
||||
# versionCode 规则:X*1e6 + Y*1e3 + Z。鸿蒙要求 versionCode 单调递增的整数,
|
||||
# 直接搬 semver 会丢信息,所以用主/次/补丁三段编码(1.4.0 → 1004000)。
|
||||
CODE="$(python3 - "$VERSION" <<'PY'
|
||||
import re, sys
|
||||
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', sys.argv[1])
|
||||
if not m:
|
||||
sys.exit("sync-client-versions: 版本号必须是 X.Y.Z 形态,得到 %r" % sys.argv[1])
|
||||
print(int(m.group(1)) * 1000000 + int(m.group(2)) * 1000 + int(m.group(3)))
|
||||
PY
|
||||
)"
|
||||
|
||||
GUI_PKG="$ROOT/cmd/gui/package.json"
|
||||
OHOS_APP="$ROOT/cmd/ohos/HomeAgent/AppScope/app.json5"
|
||||
|
||||
DRIFT=0
|
||||
note() { printf ' %-52s %s\n' "$1" "$2"; }
|
||||
|
||||
# ── GUI ──
|
||||
gui_cur="$(python3 - "$GUI_PKG" <<'PY'
|
||||
import json, sys
|
||||
print(json.load(open(sys.argv[1]))["version"])
|
||||
PY
|
||||
)"
|
||||
if [ "$gui_cur" != "$VERSION" ]; then
|
||||
DRIFT=1
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
note "cmd/gui/package.json" "$gui_cur → 应为 $VERSION"
|
||||
else
|
||||
python3 - "$GUI_PKG" "$VERSION" <<'PY'
|
||||
import json, sys
|
||||
p, v = sys.argv[1], sys.argv[2]
|
||||
d = json.load(open(p))
|
||||
d["version"] = v
|
||||
# indent=2 保留原格式;末尾补换行,避免 diff 噪声
|
||||
with open(p, "w") as f:
|
||||
json.dump(d, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
PY
|
||||
note "cmd/gui/package.json" "$gui_cur → $VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 鸿蒙 ──
|
||||
ohos_name="$(grep -oE '"versionName"[[:space:]]*:[[:space:]]*"[^"]+"' "$OHOS_APP" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
|
||||
ohos_code="$(grep -oE '"versionCode"[[:space:]]*:[[:space:]]*[0-9]+' "$OHOS_APP" | head -1 | grep -oE '[0-9]+$')"
|
||||
if [ "$ohos_name" != "$VERSION" ] || [ "$ohos_code" != "$CODE" ]; then
|
||||
DRIFT=1
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → 应为 $VERSION/$CODE"
|
||||
else
|
||||
# app.json5 带注释,不是严格 JSON,用 sed 定点替换两个字段。
|
||||
sed -i -E "s/(\"versionCode\"[[:space:]]*:[[:space:]]*)[0-9]+/\1$CODE/" "$OHOS_APP"
|
||||
sed -i -E "s/(\"versionName\"[[:space:]]*:[[:space:]]*\")[^\"]+/\1$VERSION/" "$OHOS_APP"
|
||||
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → $VERSION/$CODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "内核版本: $VERSION (versionCode $CODE)"
|
||||
if [ "$CHECK" -eq 1 ]; then
|
||||
if [ "$DRIFT" -eq 1 ]; then
|
||||
echo "sync-client-versions: 客户端版本与内核不一致(见上);跑 bash deploy/scripts/sync-client-versions.sh 同步" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "sync-client-versions: OK,客户端与内核版本一致"
|
||||
fi
|
||||
@ -32,13 +32,17 @@ ARTIFACT_SUFFIXES = (
|
||||
".rpm",
|
||||
".pkg",
|
||||
"_win64.exe",
|
||||
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
|
||||
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh)。
|
||||
".hmap",
|
||||
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
|
||||
"SHA256SUMS.plugins",
|
||||
)
|
||||
|
||||
|
||||
def is_artifact(name: str) -> bool:
|
||||
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
|
||||
|
||||
|
||||
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
|
||||
q = urllib.parse.urlencode({"file_name": filename})
|
||||
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"
|
||||
|
||||
@ -1,248 +0,0 @@
|
||||
# QQ `output_send` 回声/无限循环(核心侧缺陷) QQ `output_send` 回声/无限循环(核心侧缺陷,非插件)
|
||||
|
||||
> 状态:**已修复**(核心已具备轮次上限:`core.agent.max_tool_turns`,默认 10,
|
||||
> 在 `internal/agent/core/task.go` 到达上限即强制收尾;回归测试
|
||||
> `TestMaxToolTurns_CapsRunawayLoop`。本文保留为缺陷定位过程记录。)
|
||||
>
|
||||
> 原始状态(修复前):**待修复**
|
||||
> 影响:Agent 单轮内每 ~10 秒调用一次 `output_send__qq`,持续数十分钟不结束(实测单轮 `1780624ms`,80+ 次工具调用)
|
||||
> 定位结论:**问题在核心(富回执 + 每轮重复追加同一条“继续”占位 + 无轮次上限),QQ 插件侧已是最小回执,改插件无效**
|
||||
|
||||
---
|
||||
|
||||
## 1. 现象
|
||||
|
||||
生产日志(`/home/newqqagent`,homed 运行期):
|
||||
|
||||
```
|
||||
18:31:39 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:31:47 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:31:58 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
18:32:06 ...
|
||||
18:32:14 ...
|
||||
(每 8~12 秒一条,payload 长度各异:387/237/246/203/252/270/254/231/258/227/188/227/200/209/155/284/212/173/198/242/218/191…)
|
||||
18:31:19 eventloop.go:426: [agent] text from qq → response (1780624ms, tools=[... 80+ 项 ...])
|
||||
```
|
||||
|
||||
- 每条内容**都不同**,所以“相同参数才拦”的插件保险不会触发。
|
||||
- 不是 webhook 回声(15 分钟内只有 1 条真实入站中断)。
|
||||
- 是模型每轮都收到“发送成功的富回执”,把它当成“继续下一步”的信号。
|
||||
|
||||
---
|
||||
|
||||
## 2. 根因链(核心侧,三层)
|
||||
|
||||
### 2.1 QQ 插件已经返回最小回执 —— 但被核心丢弃
|
||||
|
||||
`third_party/homeagent-sdk/example/qq/plugin.go`(`handleChannelOutput` 尾部):
|
||||
|
||||
```go
|
||||
if sendErr != nil {
|
||||
return nil, sendErr
|
||||
}
|
||||
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
|
||||
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
|
||||
return "ok", nil
|
||||
```
|
||||
|
||||
插件返回的是字符串 `"ok"`。
|
||||
|
||||
### 2.2 核心 proc 桥丢弃它并伪造 `status:sent`
|
||||
|
||||
`internal/plugin/proc/plugin.go:336`(`(*Plugin).invokeOutput`):
|
||||
|
||||
```go
|
||||
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{Channel: channel, Args: args})
|
||||
if err != nil { return nil, err }
|
||||
if len(raw) == 0 {
|
||||
return map[string]interface{}{"status": "sent"}, nil
|
||||
}
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return map[string]interface{}{"status": "sent"}, nil // ← "ok" 不是 JSON object,落到这里
|
||||
}
|
||||
if _, ok := res["status"]; !ok {
|
||||
res["status"] = "sent" // ← 再兜底
|
||||
}
|
||||
return res, nil
|
||||
```
|
||||
|
||||
插件返回 `"ok"` → `json.Unmarshal` 进 `map[string]interface{}` 失败 → 核心合成 `{status: sent}`。
|
||||
**插件的返回值在这里被完全覆盖,所以只改插件永远修不掉回声。**
|
||||
|
||||
### 2.3 核心把这个富回执喂给模型
|
||||
|
||||
`internal/agent/core/output.go:81`(HEAD / 部署中的 homed 行为):
|
||||
|
||||
```go
|
||||
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
|
||||
// → "已通过 [qq] 通道发送: map[status:sent]"
|
||||
```
|
||||
|
||||
模型看到“发送成功 + 详情”后继续调用 `output_send__qq`,形成闭环。
|
||||
|
||||
### 2.4 核心没有工具轮次硬上限(放大器)
|
||||
|
||||
`core.agent.max_tool_turns` 只在配置层定义,**agent 循环里没有任何读取点**:
|
||||
|
||||
```
|
||||
internal/config/registry.go:551 set("core.agent.max_tool_turns", "10")
|
||||
internal/config/registry.go:669 reg(ConfigDef{Key: "core.agent.max_tool_turns", ...})
|
||||
$ grep -rn 'max_tool_turns\|MaxToolTurns' internal/agent/ → 无结果
|
||||
```
|
||||
|
||||
`internal/agent/core/process.go:242` 的唯一终止条件是:
|
||||
|
||||
```go
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
```
|
||||
|
||||
即:**模型不主动停,循环就永不结束**。`core.agent.max_tool_turns`(本机 DB 现为 `1000`)形同虚设。
|
||||
|
||||
### 2.5 每轮重复追加同一条 user 占位(“反复喂相同消息”的直接来源)
|
||||
|
||||
`internal/agent/core/process.go:77-82`,位置在 `for turn := 0; ; turn++` 循环的**顶部**:
|
||||
|
||||
```go
|
||||
for turn := 0; ; turn++ {
|
||||
for _, interrupt := range a.drainInterrupts() { ... }
|
||||
|
||||
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
msgs = append(msgs, agentAPI.Message{ // ← process.go:79
|
||||
Role: "user",
|
||||
Content: "请根据以上工具结果继续。",
|
||||
})
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`msgs` 于 `process.go:32` 在循环**外**创建,循环内只增不减:
|
||||
|
||||
- 每轮工具调用结束后,`msgs` 尾部必然是 `tool` 消息;
|
||||
- 下一轮顶部判断成立,于是**再追加一条完全相同的** `请根据以上工具结果继续。`;
|
||||
- 不做替换、不做去重、不做裁剪(`ContextPolicy: prune` 只裁剪 `a.context`,不裁剪 `msgs`)。
|
||||
|
||||
跑 N 轮,模型收到的 prompt 里就叠了 N 条一模一样的“继续”指令。这才是“核心把前面相同消息反复喂给模型”的直接机制,也是把模型持续推向 `output_send` 的持续推力。
|
||||
|
||||
**预期行为**:占位消息应当(a)仅在没有尾部 user 消息时补一条,或(b)补之前先移除上一条同类占位,保持至多一条;绝不能线性累积。
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有未完成/未部署的修复
|
||||
|
||||
| 文件 | 状态 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| `internal/agent/core/output.go:81` | **已改,未提交** (`M`) | `return fmt.Sprintf("已通过 [%s] 通道发送: %v", ...)` → `return "ok"`(含解释回声的注释) |
|
||||
| `internal/plugin/proc/plugin.go:336` | **已改,未提交** (`M`) | 仍是伪造 `status:sent` 的版本,未处理非 map 返回值 |
|
||||
| `third_party/homeagent-sdk/example/qq/plugin.go` | **已改,未提交** (`M`) | `handleChannelOutput` 返回 `"ok"` |
|
||||
|
||||
运行中的 `homed` 是 **Sep 6 11:39** 构建的二进制,不含 `output.go` 的极简回执改动 → 仍回显富回执。
|
||||
另外该二进制用旧 SDK 协议(`shmMagic` 直连,无 `unifiedMagic`),而 `third_party/homeagent-sdk` 仓库 HEAD 已升级到统一区域协议(`fc23612` 起)。**重建并部署 homed 时二者必须对齐**(见 §5)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 建议修复
|
||||
|
||||
### 4.1 (必须)让模型只看到最小回执
|
||||
|
||||
**方案 A(最小改动,已在工作区)**:`internal/agent/core/output.go`
|
||||
|
||||
```go
|
||||
// internal/agent/core/output.go:81
|
||||
// 成功回执:只返回极简标记,不回传完整插件响应。
|
||||
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
|
||||
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
|
||||
return "ok"
|
||||
```
|
||||
|
||||
**方案 B(同时修掉 proc 桥的伪造)**:`internal/plugin/proc/plugin.go:336`
|
||||
不要对非 map 结果伪造 `status:sent`,保留插件真实返回;例如:
|
||||
|
||||
```go
|
||||
if len(raw) == 0 {
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
var res map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
// 插件返回的是标量(如 "ok")——原样透传,不要伪造 status
|
||||
var scalar interface{}
|
||||
if err2 := json.Unmarshal(raw, &scalar); err2 == nil {
|
||||
return scalar, nil
|
||||
}
|
||||
return map[string]interface{}{"status": "ok"}, nil
|
||||
}
|
||||
```
|
||||
|
||||
注意:`output.go` 仍需要 `status == "unconfirmed"/"queued"` 的判定,改成标量透传时该判定自然跳过(非 map),语义正确。
|
||||
|
||||
### 4.2 (必须)工具循环硬上限
|
||||
|
||||
在 `internal/agent/core/process.go` 的工具循环里读取并强制 `core.agent.max_tool_turns`:
|
||||
|
||||
- 位置:`for turn := 0; ; turn++ {` 循环内,执行工具前/每轮结束后检查。
|
||||
- 语义:达到上限时追加一条系统消息(如 `[系统] 已达到最大工具轮次 N,请立即总结并停止调用工具`),并终止循环返回当前内容,而不是继续下一轮。
|
||||
- 至少要在 `turn > maxTurns` 时强制 `break`,避免模型不停调用。
|
||||
|
||||
### 4.3 (建议)QQ 插件侧保持最小回执
|
||||
|
||||
`third_party/homeagent-sdk/example/qq/plugin.go` 的 `return "ok", nil` 是正确的,保留即可。
|
||||
**不要**再依赖插件侧修这个回声——见 §2.2。
|
||||
|
||||
### 4.4 (必须)修掉每轮重复追加的 user 占位
|
||||
|
||||
`internal/agent/core/process.go:77-82`。改为“至多保留一条”,例如:
|
||||
|
||||
```go
|
||||
// 只在尾部是工具轮产物时补位;先移除上一条同类占位,避免线性累积。
|
||||
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
|
||||
// 若尾部之上已经存在一条我们自己的占位,就不要重复追加。
|
||||
if !isContinuationPlaceholder(msgs[len(msgs)-1]) {
|
||||
msgs = append(msgs, agentAPI.Message{
|
||||
Role: "user",
|
||||
Content: continuationPlaceholder,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
更稳妥的写法:在追加前从 `msgs` 尾部回扫,删除所有此前由本机制插入的占位,再追加一条。判定不要只靠字符串相等,建议给占位加一个可识别标记(例如 `internal:continuation`)或单独的 `NoMemory/Role` 约定,避免误删真实用户消息。
|
||||
|
||||
同时建议给 `msgs` 加长度/ token 上限(或定期裁剪历史),防止长任务把上下文堆爆(这正是 §2.4 无轮次上限的伴生问题)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署前提与步骤
|
||||
|
||||
> ⚠️ 部署 homed 前必须先对齐 SDK 协议,否则所有子进程插件握手失败(`统一区域魔数不匹配`)。
|
||||
|
||||
1. **确认工具链协议与要部署的 homed 一致**
|
||||
- 现状:`/usr/local/bin/homed` = 旧协议;`/usr/local/bin/plugindev` 已替换为旧协议版本(备份 `/usr/local/bin/plugindev.bak-20260910-174547`)。
|
||||
- 若决定升级到统一区域协议,则需同时:升级 homed 二进制 + 用新 SDK(`third_party/homeagent-sdk` HEAD)重建全部插件。
|
||||
- 若维持旧协议:用 `/usr/local/bin/plugindev`(旧)重建插件即可,不要用仓库 HEAD 的 `tools/plugindev` 直接 `go run`。
|
||||
2. **构建 homed**:`go build ./...` 已验证通过;产出替换 `/usr/local/bin/homed`(按项目部署纪律:备份 → 原子替换)。
|
||||
3. **重启**:`systemctl restart homeagent.service`
|
||||
4. **重建受影响的子进程插件**(协议一致时):至少 `qq`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收标准
|
||||
|
||||
修复后,发一条会触发回复的 QQ 消息,应满足:
|
||||
|
||||
1. 日志中 `output_send__qq` 的 tool result **不再包含** `已通过 [qq] 通道发送: map[status:sent]`;
|
||||
2. 单轮只发送 1 条(或模型明确决定的多条**不同**消息),**不出现每 ~10 秒一次的持续调用**;
|
||||
3. 当模型异常地持续调用工具时,日志出现达到 `core.agent.max_tool_turns` 的终止记录,且该轮在有限步内结束;
|
||||
4. `eventloop.go:426` 的 `text from qq → response` 耗时应回落到正常量级(秒级~分钟级),不再是 30 分钟;
|
||||
5. 抓取发往上游的请求(或用调试钩子 dump `req.Messages`),确认 `请根据以上工具结果继续。` 在整轮 prompt 中**至多出现一次**;修复前应为 N 条(N=轮数),这正是 §2.5 的判据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 相关背景(避免误修)
|
||||
|
||||
- QQ 插件的 `beforeToolcall` 循环保险只拦“参数完全相同的重复调用”(`max_duplicate_qq_send`),**拦不住内容各异的循环**;本次循环每条内容都不同,所以保险未触发。这是设计使然,不是 bug。
|
||||
- 15 分钟内仅 1 条真实 QQ 入站中断,说明**不是** webhook 把出站消息当入站回灌,**不是**插件回声。
|
||||
- `internal/plugin/proc/plugin.go:122` 的 `invokeCleaner` 签名不匹配(此前导致 `go build` 失败)**已被修复**,当前 `go build ./...` 通过。
|
||||
@ -1,124 +0,0 @@
|
||||
# 检索方案对比报告(2026-09-09)
|
||||
## 测试数据
|
||||
- 文档库:492 篇生产文档(过滤 108 条健康检查测试文档)
|
||||
- 媒体库:3 张生产图片(验证码、新闻截图、深色模式备忘录)
|
||||
- 文本查询:10 组(精确匹配、语义、跨语言、模糊表达)
|
||||
- 媒体查询:6 组(中文/英文查图片,3 张图片各 2 条)
|
||||
|
||||
---
|
||||
|
||||
## 一、文本检索对比(文档库)
|
||||
|
||||
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|
||||
|------|-------|-------|-----|----------|
|
||||
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
|
||||
| fastText(200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
|
||||
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
|
||||
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **Jina 的优势来自"短语语义"能力**:
|
||||
- "邮件代理是否已经成功接入" → TF-IDF rank 5,Jina rank 1
|
||||
- "升级安装 QQ 插件包" → fastText rank 169,Jina rank 1(margin +0.30)
|
||||
- "我所在城市的天气预报" → fastText rank 44,Jina rank 1
|
||||
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1,Jina rank 1(margin +0.33)
|
||||
|
||||
2. **TF-IDF 在精确匹配上不可替代**:
|
||||
- "长期文档记忆功能是否健康" → TF-IDF rank 3,Jina rank 1
|
||||
- "重新加载全部扩展组件" → TF-IDF rank 0(完全未命中),Jina rank 2
|
||||
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
|
||||
|
||||
3. **RRF 融合反而变差**:
|
||||
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
|
||||
- 原因:两种稀疏向量的排序在语义查询上高度重叠,RRF 无法弥补各自短板
|
||||
|
||||
---
|
||||
|
||||
## 二、图片检索对比(同 3 张图片,6 条查询)
|
||||
|
||||
| 方案 | Hit@1 | MRR | 平均 margin |
|
||||
|------|-------|-----|-------------|
|
||||
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
|
||||
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
|
||||
|
||||
### 逐条对比
|
||||
|
||||
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|
||||
|------|-----------|-------------|-----------|-------------|
|
||||
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
|
||||
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
|
||||
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
|
||||
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
|
||||
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
|
||||
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
|
||||
|
||||
### 关键发现
|
||||
|
||||
1. **中文文本→图片**:Jina 明显优于 CLIP(MRR 0.833 vs 0.611)
|
||||
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
|
||||
- Jina 最差也是 +0.045,正样本始终高于负样本
|
||||
|
||||
2. **新闻截图是共同弱点**:
|
||||
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
|
||||
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
|
||||
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
|
||||
|
||||
3. **margin 的实际意义**:
|
||||
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
|
||||
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
|
||||
- 但两者的 margin 都很小(< 0.1),生产环境仍需阈值校准
|
||||
|
||||
---
|
||||
|
||||
## 三、延迟与资源
|
||||
|
||||
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|
||||
|------|-------------|----------|------|
|
||||
| TF-IDF | 0.3ms | <1s | ~50MB |
|
||||
| fastText | 8.3ms | <1s | ~200MB |
|
||||
| CLIP ONNX | 26ms | N/A | ~600MB |
|
||||
| Jina v5-omni CPU | 39.9ms | 78s(492篇) | ~4GB |
|
||||
|
||||
---
|
||||
|
||||
## 四、结论与建议
|
||||
|
||||
### 核心判断
|
||||
|
||||
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|
||||
|------|-----------------|------|--------------|
|
||||
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
|
||||
| 文本语义检索 | ★★ | N/A | ★★★★★ |
|
||||
| 中文文本→图片 | 无能力 | ★ | ★★★★ |
|
||||
| 英文文本→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 图片→图片 | 无能力 | ★★★ | ★★★★ |
|
||||
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
|
||||
| 延迟 | ★★★★★ | ★★★ | ★★ |
|
||||
|
||||
### 架构建议
|
||||
|
||||
1. **保留 TF-IDF 作为精确召回的一级通道**:
|
||||
- 0.3ms 延迟不可替代
|
||||
- Hit@5 70% 证明在关键词匹配场景仍有价值
|
||||
- 特别是"插件安装"、"设备查询"这类精确操作指令
|
||||
|
||||
2. **用 Jina 替换 fastText + CLIP 的稠密通道**:
|
||||
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
|
||||
- 统一空间消除三条通道的维护成本
|
||||
- 中文文本→图片从"无法检索"提升到"可检索"
|
||||
|
||||
3. **两路融合:TF-IDF + Jina RRF**(而非 TF-IDF + fastText RRF):
|
||||
- TF-IDF 精确匹配 + Jina 语义覆盖
|
||||
- RRF 避免跨空间分数归一化问题
|
||||
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
|
||||
|
||||
4. **图片检索仍需阈值校准**:
|
||||
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
|
||||
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
|
||||
|
||||
### 下一步
|
||||
|
||||
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
|
||||
- 用更多生产图片标定 Jina 的图片检索阈值
|
||||
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代(L0 相关性计算)
|
||||
@ -261,6 +261,11 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick,不 merge
|
||||
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
|
||||
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
|
||||
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。
|
||||
- **客户端版本必须与内核同步**(GUI / 鸿蒙 / waiter 同一个号,当前皆为 `internal/meta.Version`):
|
||||
内核版本是唯一事实源,客户端不得各写一个。拉齐用 `make sync-client-versions`,
|
||||
发版前跑 `make check-client-versions` 做漂移门禁。waiter 直接引用 `internal/meta`(无第二份字段);
|
||||
鸿蒙的 `versionName/versionCode` 由脚本写 `AppScope/app.json5`,运行时代码从 `bundleManager` 读,
|
||||
不再硬编码。
|
||||
|
||||
---
|
||||
|
||||
@ -317,10 +322,15 @@ git branch -d release/v1.0.x # tag 已保存历史,
|
||||
|
||||
## 六、本规范与「接口冻结」约束的关系
|
||||
|
||||
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`)。
|
||||
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
|
||||
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag。
|
||||
> **接口冻结已到期(v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是
|
||||
> 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守。
|
||||
> 迁移完成(v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则。
|
||||
> 本节保留历史条款,但**不再作为合回门禁**。
|
||||
|
||||
- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~
|
||||
—— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。
|
||||
- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立);
|
||||
公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。
|
||||
- **公开接口的改动本身是 feature,不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
|
||||
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
|
||||
——发布分支冻结功能(§2.3),接口是最典型的功能面。
|
||||
@ -451,3 +461,52 @@ GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
|
||||
|
||||
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**,
|
||||
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
|
||||
|
||||
---
|
||||
|
||||
## 八、公开 SDK 接口的演进规则
|
||||
|
||||
> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。
|
||||
> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠
|
||||
> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**——
|
||||
> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。
|
||||
> 取代它的是下面三条更弱、但仍然硬的规则。
|
||||
|
||||
### 1. 只增不减,签名不改
|
||||
|
||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
||||
|
||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
||||
而那些插件根本不关心媒体。
|
||||
|
||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
||||
|
||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是
|
||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
||||
每个存量插件都会因未实现而编译失败。
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口,
|
||||
每个外部插件都**编不过**——是硬失败,不是软降级。
|
||||
|
||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。
|
||||
|
||||
### 4. 「接口纯追加」不等于「无需重编」
|
||||
|
||||
插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。
|
||||
协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配
|
||||
会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。
|
||||
必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。
|
||||
|
||||
### 5. 合回 main 前要同步的东西
|
||||
|
||||
1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`;
|
||||
2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含
|
||||
`TestProcTemplate_CoversAllCoreMethods`);
|
||||
3. 存量插件源码零改动(逐个 `cd example/<n> && go vet ./...`);
|
||||
4. 并发安全(`go test -race -count=5 ./sdk/`)。
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef const char* (*verfn)(void);
|
||||
static const char* call_ver(void* f){ return ((verfn)f)(); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Go 用 dlopen 加载纯 C shim(shim 本身常驻,无所谓)
|
||||
sp := C.CString("./shim.so")
|
||||
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
C.free(unsafe.Pointer(sp))
|
||||
if shim == nil {
|
||||
fmt.Println("shim 加载失败:", C.GoString(C.dlerror()))
|
||||
os.Exit(1)
|
||||
}
|
||||
openName := C.CString("shim_open")
|
||||
closeName := C.CString("shim_close")
|
||||
symName := C.CString("shim_sym")
|
||||
shimOpen := C.dlsym(shim, openName)
|
||||
shimClose := C.dlsym(shim, closeName)
|
||||
shimSym := C.dlsym(shim, symName)
|
||||
C.free(unsafe.Pointer(openName))
|
||||
C.free(unsafe.Pointer(closeName))
|
||||
C.free(unsafe.Pointer(symName))
|
||||
fmt.Printf("shim 就绪: open=%p close=%p sym=%p\n\n", shimOpen, shimClose, shimSym)
|
||||
|
||||
// 直接用 dlopen/dlsym 调 shim 的三个函数(避免再写一层 C 包装)
|
||||
load := func(path string) unsafe.Pointer {
|
||||
cp := C.CString(path)
|
||||
defer C.free(unsafe.Pointer(cp))
|
||||
return C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
}
|
||||
ver := func(h unsafe.Pointer) string {
|
||||
n := C.CString("probe_version")
|
||||
defer C.free(unsafe.Pointer(n))
|
||||
f := C.dlsym(h, n)
|
||||
if f == nil { return "<no sym>" }
|
||||
return C.GoString(C.call_ver(f))
|
||||
}
|
||||
|
||||
fmt.Println("--- 场景: Go(带 NODELETE runtime) 加载/卸载纯 C 的第三层 so ---")
|
||||
h1 := load("./probe.so")
|
||||
fmt.Printf("1) dlopen probe.so handle=%p version=%s\n", h1, ver(h1))
|
||||
|
||||
rc := C.dlclose(h1)
|
||||
fmt.Printf("2) dlclose rc=%d\n", int(rc))
|
||||
|
||||
// 换内容(V1 -> V2),同路径
|
||||
in, _ := os.ReadFile("probe_v2.so")
|
||||
os.WriteFile("probe.so", in, 0755)
|
||||
fmt.Println("3) 磁盘 probe.so 内容替换为 V2(同路径)")
|
||||
|
||||
h2 := load("./probe.so")
|
||||
fmt.Printf("4) 再 dlopen 同路径 handle=%p version=%s\n", h2, ver(h2))
|
||||
if h1 == h2 {
|
||||
fmt.Println(" => 句柄相同:未卸载,仍是旧代码")
|
||||
} else {
|
||||
fmt.Println(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
|
||||
}
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef void* (*openfn)(const char*);
|
||||
typedef int (*closefn)(void*);
|
||||
static void* c_open(void* f, const char* p){ return ((openfn)f)(p); }
|
||||
static int c_close(void* f, void* h){ return ((closefn)f)(h); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func cnt(s string) int {
|
||||
b, _ := os.ReadFile("/proc/self/maps")
|
||||
n := 0
|
||||
for _, l := range strings.Split(string(b), "\n") { if strings.Contains(l, s) { n++ } }
|
||||
return n
|
||||
}
|
||||
|
||||
func main() {
|
||||
sp := C.CString("./shim.so")
|
||||
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
C.free(unsafe.Pointer(sp))
|
||||
no := C.CString("shim_open"); nc := C.CString("shim_close")
|
||||
fo := C.dlsym(shim, no); fc := C.dlsym(shim, nc)
|
||||
C.free(unsafe.Pointer(no)); C.free(unsafe.Pointer(nc))
|
||||
|
||||
// 经【纯 C shim】去 dlopen/dlclose Go c-shared 插件
|
||||
qp := C.CString("/home/newqqagent/plugins/qq/plugin.so")
|
||||
h := C.c_open(fo, qp)
|
||||
C.free(unsafe.Pointer(qp))
|
||||
fmt.Printf("经 C shim dlopen Go 插件 handle=%p 映射段=%d\n", h, cnt("qq/plugin.so"))
|
||||
rc := C.c_close(fc, h)
|
||||
fmt.Printf("经 C shim dlclose rc=%d 映射段=%d\n", int(rc), cnt("qq/plugin.so"))
|
||||
if cnt("qq/plugin.so") > 0 {
|
||||
fmt.Println("\n❌ 仍未卸载 —— NODELETE 属于目标 .so 本身,与谁调 dlopen 无关")
|
||||
} else {
|
||||
fmt.Println("\n✅ 卸载成功")
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef char* (*verfn)(void);
|
||||
static char* call_ver(void* f){ return ((verfn)f)(); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func threads() int {
|
||||
e, _ := os.ReadDir("/proc/self/task")
|
||||
return len(e)
|
||||
}
|
||||
func rss() int {
|
||||
b, _ := os.ReadFile("/proc/self/status")
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "VmRSS:") {
|
||||
var k int
|
||||
fmt.Sscanf(l, "VmRSS: %d kB", &k)
|
||||
return k
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func main() {
|
||||
base, baseT := rss(), threads()
|
||||
fmt.Printf("基线: RSS=%dKB threads=%d\n\n", base, baseT)
|
||||
src, _ := os.ReadFile("glv1.so")
|
||||
os.MkdirAll("stress", 0755)
|
||||
var hs []unsafe.Pointer
|
||||
for i := 1; i <= 30; i++ {
|
||||
p := fmt.Sprintf("stress/%010d-qq.so", 1700000000+i)
|
||||
os.WriteFile(p, src, 0755)
|
||||
cp := C.CString("./" + p)
|
||||
h := C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
|
||||
C.free(unsafe.Pointer(cp))
|
||||
if h == nil { fmt.Printf("第 %d 次失败\n", i); break }
|
||||
hs = append(hs, h)
|
||||
C.dlclose(h) // 模拟每次都尝试卸载(no-op)
|
||||
if i%10 == 0 {
|
||||
fmt.Printf("第 %2d 次重载: RSS=%dKB (+%dKB) threads=%d (+%d)\n",
|
||||
i, rss(), rss()-base, threads(), threads()-baseT)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n30 次重载后: RSS 增长 %dKB, 线程增长 %d\n", rss()-base, threads()-baseT)
|
||||
fmt.Printf("每次重载均摊: RSS +%.1fKB, 线程 +%.2f\n",
|
||||
float64(rss()-base)/30, float64(threads()-baseT)/30)
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V1"; }
|
||||
@ -1,2 +0,0 @@
|
||||
#include <stdio.h>
|
||||
const char* probe_version(void){ return "V2"; }
|
||||
@ -1,9 +0,0 @@
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
void* shim_open(const char* p){
|
||||
void* h = dlopen(p, RTLD_NOW|RTLD_LOCAL);
|
||||
if(!h) printf(" [shim] open FAIL: %s\n", dlerror());
|
||||
return h;
|
||||
}
|
||||
int shim_close(void* h){ return dlclose(h); }
|
||||
void* shim_sym(void* h, const char* n){ return dlsym(h, n); }
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 10:多媒体 payload —— 共享内存零拷贝 vs JSON base64 ===")
|
||||
sizes := []int{100 * 1024, 1024 * 1024, 5 * 1024 * 1024}
|
||||
for _, sz := range sizes {
|
||||
img := make([]byte, sz)
|
||||
for i := range img { img[i] = byte(i % 251) }
|
||||
|
||||
// A. JSON + base64(当前 ContentBlock 的做法)
|
||||
t0 := time.Now()
|
||||
b64 := base64.StdEncoding.EncodeToString(img)
|
||||
blob, _ := json.Marshal(map[string]string{"type": "image_url", "url": "data:image/png;base64," + b64})
|
||||
var back map[string]string
|
||||
json.Unmarshal(blob, &back)
|
||||
dec, _ := base64.StdEncoding.DecodeString(back["url"][22:])
|
||||
jsonDur := time.Since(t0)
|
||||
|
||||
// B. 共享内存 arena(写入 + 偏移解引用,零拷贝读)
|
||||
mfd, _ := unix.MemfdCreate("arena", 0)
|
||||
unix.Ftruncate(mfd, int64(sz+4096))
|
||||
data, _ := unix.Mmap(mfd, 0, sz+4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
t0 = time.Now()
|
||||
copy(data[4096:], img) // 写 arena
|
||||
view := data[4096 : 4096+sz] // 偏移解引用 = 零拷贝切片
|
||||
_ = view[sz-1]
|
||||
shmDur := time.Since(t0)
|
||||
unix.Munmap(data)
|
||||
unix.Close(mfd)
|
||||
|
||||
fmt.Printf("\n%s payload:\n", map[int]string{100*1024:"100KB", 1024*1024:"1MB", 5*1024*1024:"5MB"}[sz])
|
||||
fmt.Printf(" A JSON+base64: %8v 传输体积 %d B (+%.0f%%) 解出 %d B %s\n",
|
||||
jsonDur, len(blob), float64(len(blob)-sz)/float64(sz)*100, len(dec),
|
||||
map[bool]string{true:"✓",false:"✗"}[len(dec)==sz])
|
||||
fmt.Printf(" B 共享内存: %8v 传输体积 8 B (描述符) 零拷贝视图 %d B\n", shmDur, len(view))
|
||||
fmt.Printf(" → 加速 %.0fx, 体积节省 %.0f%%\n",
|
||||
float64(jsonDur)/float64(shmDur), float64(len(blob)-8)/float64(len(blob))*100)
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
|
||||
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 11:工具调用 RPC 端到端延迟(实测 payload 中位 93B)===")
|
||||
cmd := exec.Command("./plug11")
|
||||
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
|
||||
cmd.Start()
|
||||
enc := json.NewEncoder(bufio.NewWriter(sin))
|
||||
w := bufio.NewWriter(sin); enc = json.NewEncoder(w)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
|
||||
args := json.RawMessage(`{"city":"hangzhou","days":3,"unit":"celsius","detail":true}`)
|
||||
const N = 10000
|
||||
lat := make([]time.Duration, 0, N)
|
||||
for i := 0; i < N; i++ {
|
||||
t0 := time.Now()
|
||||
enc.Encode(Req{ID: i, Method: "weather_query", Args: args}); w.Flush()
|
||||
var r Res
|
||||
if err := dec.Decode(&r); err != nil { break }
|
||||
lat = append(lat, time.Since(t0))
|
||||
}
|
||||
sin.Close(); cmd.Wait()
|
||||
sort.Slice(lat, func(a,b int) bool { return lat[a] < lat[b] })
|
||||
p := func(q float64) time.Duration { return lat[int(float64(len(lat))*q)] }
|
||||
fmt.Printf("样本 %d 次\n", len(lat))
|
||||
fmt.Printf(" p50 = %v\n p90 = %v\n p99 = %v\n max = %v\n", p(0.5), p(0.9), p(0.99), lat[len(lat)-1])
|
||||
fmt.Printf("\n对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ %.5f%%\n",
|
||||
float64(p(0.5))/float64(3*time.Second)*100)
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
import ("bufio";"encoding/json";"os")
|
||||
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
|
||||
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
|
||||
func main(){
|
||||
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
|
||||
for { var q Req
|
||||
if err:=dec.Decode(&q); err!=nil {return}
|
||||
enc.Encode(Res{ID:q.ID, Result:`{"ok":true,"data":"` + string(q.Args) + `"}`}); w.Flush() }
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func threads() int { e, _ := os.ReadDir("/proc/self/task"); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 1:eventfd 是否走 Go netpoller(只 park goroutine 不占 OS 线程)===")
|
||||
base := threads()
|
||||
fmt.Printf("基线线程数: %d (GOMAXPROCS=%d)\n\n", base, runtime.GOMAXPROCS(0))
|
||||
|
||||
const N = 200 // 模拟 200 个订阅者等待
|
||||
var wg sync.WaitGroup
|
||||
var woke int64
|
||||
files := make([]*os.File, N)
|
||||
|
||||
for i := 0; i < N; i++ {
|
||||
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
|
||||
if err != nil { fmt.Println("eventfd 失败:", err); return }
|
||||
f := os.NewFile(uintptr(efd), fmt.Sprintf("evt%d", i))
|
||||
files[i] = f
|
||||
wg.Add(1)
|
||||
go func(f *os.File) {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 8)
|
||||
// 阻塞读:若走 netpoller 只 park goroutine
|
||||
if _, err := f.Read(buf); err == nil {
|
||||
atomic.AddInt64(&woke, 1)
|
||||
}
|
||||
}(f)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond) // 让所有 goroutine 进入等待
|
||||
waiting := threads()
|
||||
fmt.Printf("%d 个 goroutine 阻塞在 eventfd.Read 后:\n", N)
|
||||
fmt.Printf(" 线程数 = %d (增长 %d)\n", waiting, waiting-base)
|
||||
if waiting-base < 20 {
|
||||
fmt.Println(" ✅ 走 netpoller:线程未随等待者数量增长")
|
||||
} else {
|
||||
fmt.Printf(" ❌ 退化为阻塞 syscall:每个等待者占一个 OS 线程\n")
|
||||
}
|
||||
|
||||
// 全部唤醒
|
||||
one := []byte{1,0,0,0,0,0,0,0}
|
||||
for _, f := range files { f.Write(one) }
|
||||
wg.Wait()
|
||||
fmt.Printf("\n唤醒数 = %d/%d 唤醒后线程数 = %d\n", woke, N, threads())
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 子进程:fd 3 = eventfd(通知), fd 4 = shm 文件
|
||||
func main() {
|
||||
efd := os.NewFile(3, "evt")
|
||||
shmf := os.NewFile(4, "shm")
|
||||
|
||||
data, err := unix.Mmap(int(shmf.Fd()), 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { fmt.Println("CHILD mmap 失败:", err); os.Exit(1) }
|
||||
fmt.Printf("CHILD: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
|
||||
|
||||
buf := make([]byte, 8)
|
||||
if _, err := efd.Read(buf); err != nil {
|
||||
fmt.Println("CHILD read err:", err); os.Exit(1)
|
||||
}
|
||||
n := binary.LittleEndian.Uint64(buf)
|
||||
fmt.Printf("CHILD: 被 eventfd 唤醒, 计数=%d\n", n)
|
||||
|
||||
// 按偏移读:头部 16 字节 = {off uint32, len uint32, seq uint64}
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
seq := binary.LittleEndian.Uint64(data[8:16])
|
||||
payload := string(data[off : off+ln])
|
||||
fmt.Printf("CHILD: 偏移解引用 off=%d len=%d seq=%d → %q\n", off, ln, seq, payload)
|
||||
|
||||
// 子进程回写(验证双向可见)
|
||||
copy(data[2048:], []byte("CHILD-ACK"))
|
||||
binary.LittleEndian.PutUint32(data[16:20], 2048)
|
||||
binary.LittleEndian.PutUint32(data[20:24], uint32(len("CHILD-ACK")))
|
||||
fmt.Println("CHILD: 已回写 ACK")
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 2:跨进程 eventfd 通知 + 共享内存偏移解引用 ===")
|
||||
|
||||
// eventfd 不带 CLOEXEC(需要被子进程继承)
|
||||
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK)
|
||||
if err != nil { panic(err) }
|
||||
evtFile := os.NewFile(uintptr(efd), "evt")
|
||||
|
||||
// shm: 用 memfd(匿名,无需 /dev/shm 清理)
|
||||
mfd, err := unix.MemfdCreate("stagectx", 0)
|
||||
if err != nil { panic(err) }
|
||||
if err := unix.Ftruncate(mfd, 4096); err != nil { panic(err) }
|
||||
shmFile := os.NewFile(uintptr(mfd), "shm")
|
||||
|
||||
data, err := unix.Mmap(mfd, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { panic(err) }
|
||||
fmt.Printf("PARENT: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
|
||||
|
||||
// 写 payload 到 arena(偏移 1024),头部记描述符
|
||||
msg := "hello-from-parent-via-offset"
|
||||
copy(data[1024:], []byte(msg))
|
||||
binary.LittleEndian.PutUint32(data[0:4], 1024)
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(msg)))
|
||||
binary.LittleEndian.PutUint64(data[8:16], 42)
|
||||
fmt.Printf("PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:%d, seq:42}\n", len(msg))
|
||||
|
||||
cmd := exec.Command("go", "run", "exp2_child.go")
|
||||
cmd.ExtraFiles = []*os.File{evtFile, shmFile} // → 子进程 fd 3, 4
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Start(); err != nil { panic(err) }
|
||||
|
||||
time.Sleep(3 * time.Second) // 等 go run 编译+启动
|
||||
fmt.Println("PARENT: 数据到位后 post eventfd(不等待消费者)")
|
||||
t0 := time.Now()
|
||||
evtFile.Write([]byte{1,0,0,0,0,0,0,0})
|
||||
fmt.Printf("PARENT: post 耗时 %v ← post-and-forget\n", time.Since(t0))
|
||||
|
||||
cmd.Wait()
|
||||
|
||||
// 读子进程回写
|
||||
off := binary.LittleEndian.Uint32(data[16:20])
|
||||
ln := binary.LittleEndian.Uint32(data[20:24])
|
||||
if ln > 0 {
|
||||
fmt.Printf("PARENT: 读到子进程回写 → %q ✅ 双向可见\n", string(data[off:off+ln]))
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
|
||||
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
|
||||
|
||||
func main() {
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc, dec := json.NewEncoder(out), json.NewDecoder(in)
|
||||
|
||||
const N = 20000
|
||||
t0 := time.Now()
|
||||
for i := 0; i < N; i++ {
|
||||
enc.Encode(req{ID: i, Method: "stage.lock"})
|
||||
out.Flush()
|
||||
var r resp
|
||||
if err := dec.Decode(&r); err != nil { fmt.Fprintln(os.Stderr, "dec:", err); return }
|
||||
}
|
||||
d := time.Since(t0)
|
||||
fmt.Fprintf(os.Stderr, "CHILD: %d 次 lock RPC 往返 用时 %v, 均摊 %.2f µs/次\n",
|
||||
N, d, float64(d.Microseconds())/float64(N))
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
|
||||
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 3:锁仲裁 RPC 往返成本(stdio JSON-RPC)===")
|
||||
cmd := exec.Command("go", "run", "exp3_child.go")
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
|
||||
var mu sync.Mutex // 内核侧真实的锁仲裁
|
||||
dec := json.NewDecoder(bufio.NewReader(stdout))
|
||||
w := bufio.NewWriter(stdin)
|
||||
enc := json.NewEncoder(w)
|
||||
for {
|
||||
var q req
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
mu.Lock() // 真实加锁
|
||||
mu.Unlock() // 立即释放(模拟仲裁开销)
|
||||
enc.Encode(resp{ID: q.ID, OK: true})
|
||||
w.Flush()
|
||||
}
|
||||
cmd.Wait()
|
||||
}
|
||||
@ -1,71 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type ring struct {
|
||||
writeSeq atomic.Uint64
|
||||
cap uint64
|
||||
slots []uint64
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 4:事件环 post-and-forget vs 同步 Publish(慢消费者场景)===")
|
||||
const tokens = 5000
|
||||
|
||||
// --- A. 现状:同步 Publish,消费者慢 ---
|
||||
slowHandler := func() { time.Sleep(20 * time.Microsecond) }
|
||||
t0 := time.Now()
|
||||
for i := 0; i < tokens; i++ { slowHandler() }
|
||||
syncDur := time.Since(t0)
|
||||
fmt.Printf("A 同步 Publish (慢消费者 20µs): %d token 耗时 %v → 均摊 %.1f µs/token\n",
|
||||
tokens, syncDur, float64(syncDur.Microseconds())/tokens)
|
||||
|
||||
// --- B. 新方案:写环 + eventfd post,不等消费者 ---
|
||||
r := &ring{cap: 1024, slots: make([]uint64, 1024)}
|
||||
efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK)
|
||||
f := os.NewFile(uintptr(efd), "e")
|
||||
|
||||
var dropped atomic.Uint64
|
||||
// 慢消费者 goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
buf := make([]byte, 8)
|
||||
var readSeq uint64
|
||||
for {
|
||||
if _, err := f.Read(buf); err != nil { return }
|
||||
w := r.writeSeq.Load()
|
||||
if w-readSeq > r.cap {
|
||||
dropped.Add(w - readSeq - r.cap)
|
||||
readSeq = w - r.cap
|
||||
}
|
||||
for readSeq < w { readSeq++ }
|
||||
time.Sleep(20 * time.Microsecond) // 慢
|
||||
select { case <-done: return; default: }
|
||||
}
|
||||
}()
|
||||
|
||||
t0 = time.Now()
|
||||
one := []byte{1,0,0,0,0,0,0,0}
|
||||
for i := 0; i < tokens; i++ {
|
||||
s := r.writeSeq.Add(1)
|
||||
r.slots[s%r.cap] = s // 写数据
|
||||
f.Write(one) // post,不等
|
||||
}
|
||||
asyncDur := time.Since(t0)
|
||||
close(done)
|
||||
fmt.Printf("B 环+eventfd post: %d token 耗时 %v → 均摊 %.2f µs/token\n",
|
||||
tokens, asyncDur, float64(asyncDur.Microseconds())/tokens)
|
||||
fmt.Printf("\n加速比 %.1fx 丢弃事件 %d(消费者跟不上,已计数)\n",
|
||||
float64(syncDur)/float64(asyncDur), dropped.Load())
|
||||
if asyncDur < syncDur/5 {
|
||||
fmt.Println("✅ post-and-forget 使流式发布与消费者速度解耦")
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil { return 0 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "Pss:") {
|
||||
f := strings.Fields(l)
|
||||
n, _ := strconv.Atoi(f[1]); return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func threads(pid int) int {
|
||||
e, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e)
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 5:17 个 Go 子进程插件的真实常驻开销(PSS 计入共享页去重)===")
|
||||
var cmds []*exec.Cmd
|
||||
for i := 0; i < 17; i++ {
|
||||
c := exec.Command("./plugbin")
|
||||
c.Stdin, _ = os.Open(os.DevNull)
|
||||
if err := c.Start(); err != nil { fmt.Println("start:", err); return }
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
totalPss, totalThreads := 0, 0
|
||||
for _, c := range cmds {
|
||||
totalPss += pssKB(c.Process.Pid)
|
||||
totalThreads += threads(c.Process.Pid)
|
||||
}
|
||||
fmt.Printf("17 进程合计: PSS = %.1f MB, 线程 = %d\n", float64(totalPss)/1024, totalThreads)
|
||||
fmt.Printf("单进程均摊: PSS = %.2f MB, 线程 = %.1f\n",
|
||||
float64(totalPss)/1024/17, float64(totalThreads)/17)
|
||||
fmt.Printf("\n对照 homed 当前(单进程装 17 个 .so):\n")
|
||||
// 找 homed
|
||||
out, _ := exec.Command("pgrep", "-x", "homed").Output()
|
||||
if p := strings.TrimSpace(string(out)); p != "" {
|
||||
pid, _ := strconv.Atoi(strings.Fields(p)[0])
|
||||
fmt.Printf(" homed PSS = %.1f MB, 线程 = %d\n", float64(pssKB(pid))/1024, threads(pid))
|
||||
}
|
||||
for _, c := range cmds { c.Process.Kill(); c.Wait() }
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// 模拟一个最小插件:stdio JSON-RPC loop + 一个 goroutine
|
||||
func main() {
|
||||
go func() { select {} }()
|
||||
in := bufio.NewReader(os.Stdin)
|
||||
dec := json.NewDecoder(in)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var m map[string]interface{}
|
||||
if err := dec.Decode(&m); err != nil { return }
|
||||
enc.Encode(map[string]interface{}{"ok": true})
|
||||
out.Flush()
|
||||
}
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func pssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
|
||||
if err != nil { return -1 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "Pss:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func rssKB(pid int) int {
|
||||
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil { return -1 }
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.HasPrefix(l, "VmRSS:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func threads(pid int) int { e,_ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 5b:17 个 Go 子进程常驻开销(保持 stdin 管道存活)===")
|
||||
var cmds []*exec.Cmd
|
||||
var pipes []interface{ Close() error }
|
||||
for i := 0; i < 17; i++ {
|
||||
c := exec.Command("./plugbin")
|
||||
w, _ := c.StdinPipe() // 保持打开 → 不 EOF
|
||||
pipes = append(pipes, w)
|
||||
c.Stdout = nil
|
||||
if err := c.Start(); err != nil { fmt.Println(err); return }
|
||||
cmds = append(cmds, c)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
tp, tr, tt, alive := 0, 0, 0, 0
|
||||
for _, c := range cmds {
|
||||
pid := c.Process.Pid
|
||||
if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil { continue }
|
||||
alive++
|
||||
if v := pssKB(pid); v > 0 { tp += v }
|
||||
if v := rssKB(pid); v > 0 { tr += v }
|
||||
tt += threads(pid)
|
||||
}
|
||||
fmt.Printf("存活进程 %d/17\n", alive)
|
||||
fmt.Printf("合计: PSS=%.1f MB RSS=%.1f MB 线程=%d\n",
|
||||
float64(tp)/1024, float64(tr)/1024, tt)
|
||||
if alive > 0 {
|
||||
fmt.Printf("均摊: PSS=%.2f MB RSS=%.2f MB 线程=%.1f\n",
|
||||
float64(tp)/1024/float64(alive), float64(tr)/1024/float64(alive), float64(tt)/float64(alive))
|
||||
}
|
||||
out, _ := exec.Command("pgrep", "-x", "homed").Output()
|
||||
if p := strings.TrimSpace(string(out)); p != "" {
|
||||
pid, _ := strconv.Atoi(strings.Fields(p)[0])
|
||||
fmt.Printf("\n对照 homed(单进程 + 17 个 .so): RSS=%.1f MB 线程=%d\n",
|
||||
float64(rssKB(pid))/1024, threads(pid))
|
||||
}
|
||||
for _, c := range cmds { c.Process.Kill(); c.Wait() }
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 6:子进程崩溃隔离 + 退出码/EOF 作为 recordCrash 信号 ===")
|
||||
cmd := exec.Command("./crashbin")
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = nil // 丢弃 panic 栈
|
||||
cmd.Start()
|
||||
fmt.Printf("插件进程 pid=%d 已启动\n", cmd.Process.Pid)
|
||||
|
||||
enc := json.NewEncoder(sin)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
|
||||
// 正常调用
|
||||
enc.Encode(map[string]string{"method": "ping"})
|
||||
var r map[string]interface{}
|
||||
if err := dec.Decode(&r); err == nil { fmt.Println("正常调用 → ", r) }
|
||||
|
||||
// 触发崩溃
|
||||
fmt.Println("\n发送 boom(插件内 panic)...")
|
||||
t0 := time.Now()
|
||||
enc.Encode(map[string]string{"method": "boom"})
|
||||
err := dec.Decode(&r)
|
||||
|
||||
detected := "未检测到"
|
||||
if errors.Is(err, io.EOF) || err == io.ErrUnexpectedEOF { detected = "EOF" } else if err != nil { detected = fmt.Sprintf("%v", err) }
|
||||
fmt.Printf("调用侧感知: %s (耗时 %v)\n", detected, time.Since(t0))
|
||||
|
||||
werr := cmd.Wait()
|
||||
var ec int = -1
|
||||
if ee, ok := werr.(*exec.ExitError); ok { ec = ee.ExitCode() }
|
||||
fmt.Printf("进程退出码 = %d (panic → 2,可直接喂 recordCrash)\n", ec)
|
||||
|
||||
fmt.Printf("\n宿主进程仍存活: pid=%d ✅ 崩溃已隔离\n", os.Getpid())
|
||||
fmt.Println("→ 对照:当前 .so 模型下,bridge 兜不住的 panic 会带崩整个 homed")
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(out)
|
||||
for {
|
||||
var m map[string]interface{}
|
||||
if err := dec.Decode(&m); err != nil { return }
|
||||
if m["method"] == "boom" {
|
||||
panic("插件故意崩溃") // 真 panic
|
||||
}
|
||||
enc.Encode(map[string]interface{}{"ok": true})
|
||||
out.Flush()
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
func spawnAndAsk(bin string) string {
|
||||
cmd := exec.Command(bin)
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Start()
|
||||
enc := json.NewEncoder(sin)
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
enc.Encode(map[string]string{"method": "version"})
|
||||
var r map[string]interface{}
|
||||
dec.Decode(&r)
|
||||
sin.Close()
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
if v, ok := r["version"].(string); ok { return v }
|
||||
return "?"
|
||||
}
|
||||
|
||||
func build(ver, out string) {
|
||||
src := fmt.Sprintf(`package main
|
||||
import ("bufio";"encoding/json";"os")
|
||||
func main(){
|
||||
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
|
||||
for { var m map[string]interface{}
|
||||
if err:=dec.Decode(&m); err!=nil {return}
|
||||
enc.Encode(map[string]string{"version":%q}); w.Flush() }
|
||||
}`, ver)
|
||||
os.MkdirAll("v", 0755)
|
||||
os.WriteFile("v/main.go", []byte(src), 0644)
|
||||
os.WriteFile("v/go.mod", []byte("module v\ngo 1.21\n"), 0644)
|
||||
c := exec.Command("go", "build", "-o", "../"+out, ".")
|
||||
c.Dir = "v"
|
||||
if b, err := c.CombinedOutput(); err != nil { fmt.Println("build err:", string(b)) }
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 7:子进程模型下的热重载(迁移的原始目标)===")
|
||||
build("v1.0.0", "hotbin")
|
||||
fmt.Printf("1) 首次启动插件 → version = %s\n", spawnAndAsk("./hotbin"))
|
||||
|
||||
fmt.Println("2) 替换二进制为 v2.0.0(同路径,无需版本化 hash 目录)")
|
||||
build("v2.0.0", "hotbin")
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
v := spawnAndAsk("./hotbin")
|
||||
fmt.Printf("3) 重启插件进程 → version = %s\n", v)
|
||||
if v == "v2.0.0" {
|
||||
fmt.Println("\n✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏")
|
||||
fmt.Println(" 对照 .so 模型:同路径 dlopen 复用旧映像,永远拿不到 v2")
|
||||
}
|
||||
}
|
||||
@ -1,84 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 8:跨进程并发扇出改写同一 StageContext(最高风险点 3.4)===")
|
||||
|
||||
mfd, _ := unix.MemfdCreate("stagectx", 0)
|
||||
unix.Ftruncate(mfd, 65536)
|
||||
shmFile := os.NewFile(uintptr(mfd), "shm")
|
||||
data, _ := unix.Mmap(mfd, 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
|
||||
// 初始 final_text = "" @1024, arena 游标 = 1024
|
||||
binary.LittleEndian.PutUint32(data[0:4], 1024)
|
||||
binary.LittleEndian.PutUint32(data[4:8], 0)
|
||||
binary.LittleEndian.PutUint32(data[8:12], 1024)
|
||||
|
||||
tags := []string{"A", "B", "C", "D", "E"} // 5 个并发插件
|
||||
var mu sync.Mutex // 内核侧锁仲裁
|
||||
var wg sync.WaitGroup
|
||||
var rpcCount int64
|
||||
var cntMu sync.Mutex
|
||||
|
||||
t0 := time.Now()
|
||||
for _, tag := range tags {
|
||||
cmd := exec.Command("go", "run", "exp8_worker.go", tag)
|
||||
cmd.ExtraFiles = []*os.File{shmFile}
|
||||
sin, _ := cmd.StdinPipe()
|
||||
sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Start()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
w := bufio.NewWriter(sin)
|
||||
enc := json.NewEncoder(w)
|
||||
held := false
|
||||
for {
|
||||
var q map[string]string
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
switch q["method"] {
|
||||
case "stage.lock": mu.Lock(); held = true
|
||||
case "stage.unlock": if held { mu.Unlock(); held = false }
|
||||
}
|
||||
cntMu.Lock(); rpcCount++; cntMu.Unlock()
|
||||
enc.Encode(map[string]bool{"ok": true}); w.Flush()
|
||||
}
|
||||
if held { mu.Unlock() }
|
||||
cmd.Wait()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
dur := time.Since(t0)
|
||||
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
final := string(data[off : off+ln])
|
||||
|
||||
fmt.Printf("\n--- 结果 ---\n")
|
||||
fmt.Printf("最终 final_text 长度 = %d\n", len(final))
|
||||
counts := map[string]int{}
|
||||
for _, t := range tags { counts[t] = strings.Count(final, t) }
|
||||
fmt.Printf("各插件写入次数: %v\n", counts)
|
||||
total := 0
|
||||
for _, c := range counts { total += c }
|
||||
fmt.Printf("总字符 = %d, 长度 = %d → %s\n", total, len(final),
|
||||
map[bool]string{true:"一致 ✅ 无丢失/无撕裂", false:"不一致 ❌"}[total == len(final)])
|
||||
fmt.Printf("RPC 锁操作 = %d 次, 总耗时 %v\n", rpcCount, dur)
|
||||
fmt.Printf("\n注:写入次数少于 5×300 是 arena 64KB 上限所致(append-only 未压实),符合设计\n")
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// 模拟插件:拿锁 → 读 final_text → 追加自己的标记 → 写回 → 放锁
|
||||
// 锁通过 stdio RPC 向内核申请(方案 3.7:锁仲裁回归内核,无 cgo)
|
||||
func main() {
|
||||
tag := os.Args[1]
|
||||
shmf := os.NewFile(3, "shm")
|
||||
data, err := unix.Mmap(int(shmf.Fd()), 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
|
||||
if err != nil { fmt.Fprintln(os.Stderr, "mmap:", err); os.Exit(1) }
|
||||
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
enc := json.NewEncoder(w)
|
||||
rpc := func(method string) {
|
||||
enc.Encode(map[string]string{"method": method}); w.Flush()
|
||||
var r map[string]interface{}; dec.Decode(&r)
|
||||
}
|
||||
|
||||
const iters = 300
|
||||
for i := 0; i < iters; i++ {
|
||||
rpc("stage.lock")
|
||||
// --- 临界区:偏移解引用读写 final_text ---
|
||||
off := binary.LittleEndian.Uint32(data[0:4])
|
||||
ln := binary.LittleEndian.Uint32(data[4:8])
|
||||
cur := string(data[off : off+ln])
|
||||
add := tag
|
||||
newS := cur + add
|
||||
// append-only arena:写到新位置
|
||||
newOff := binary.LittleEndian.Uint32(data[8:12])
|
||||
if int(newOff)+len(newS) > 65536 { rpc("stage.unlock"); break }
|
||||
copy(data[newOff:], []byte(newS))
|
||||
binary.LittleEndian.PutUint32(data[0:4], newOff)
|
||||
binary.LittleEndian.PutUint32(data[4:8], uint32(len(newS)))
|
||||
binary.LittleEndian.PutUint32(data[8:12], newOff+uint32(len(newS)))
|
||||
rpc("stage.unlock")
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "worker "+tag+" done, iters="+strconv.Itoa(iters))
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func run(name, arg string, mu *sync.Mutex, crashed *bool) {
|
||||
cmd := exec.Command("go", "run", "exp9_worker.go", arg)
|
||||
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
|
||||
cmd.Stderr = nil
|
||||
cmd.Start()
|
||||
dec := json.NewDecoder(bufio.NewReader(sout))
|
||||
w := bufio.NewWriter(sin); enc := json.NewEncoder(w)
|
||||
held := false
|
||||
for {
|
||||
var q map[string]string
|
||||
if err := dec.Decode(&q); err != nil { break }
|
||||
switch q["method"] {
|
||||
case "stage.lock": mu.Lock(); held = true; fmt.Printf(" [%s] 获得锁\n", name)
|
||||
case "stage.unlock": if held { mu.Unlock(); held = false; fmt.Printf(" [%s] 释放锁\n", name) }
|
||||
}
|
||||
enc.Encode(map[string]bool{"ok":true}); w.Flush()
|
||||
}
|
||||
err := cmd.Wait()
|
||||
// 关键:进程死了,内核侧检测到 EOF/退出 → 强制释放它持有的锁
|
||||
if held {
|
||||
mu.Unlock()
|
||||
*crashed = true
|
||||
fmt.Printf(" [%s] 进程死亡(%v),内核强制释放其持有的锁 ← 自愈\n", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 实验 9:持锁进程崩溃后的自愈(验证无需 robust pthread_mutex)===")
|
||||
var mu sync.Mutex
|
||||
crashed := false
|
||||
|
||||
fmt.Println("\n1) 插件 X 拿锁后 panic:")
|
||||
run("X", "crash", &mu, &crashed)
|
||||
|
||||
fmt.Println("\n2) 插件 Y 随后申请同一把锁:")
|
||||
done := make(chan bool, 1)
|
||||
go func() { run("Y", "normal", &mu, new(bool)); done <- true }()
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Println("\n✅ Y 正常获得并释放锁 —— 无死锁")
|
||||
fmt.Println(" → 内核持有锁的所有权,进程死亡由 Wait()/EOF 检测并强制释放")
|
||||
fmt.Println(" → 不需要 PTHREAD_PROCESS_SHARED|ROBUST,也不需要处理 EOWNERDEAD")
|
||||
fmt.Println(" → 整个架构可做到零 cgo")
|
||||
case <-time.After(15 * time.Second):
|
||||
fmt.Println("\n❌ 死锁:Y 拿不到锁(说明需要 robust 语义)")
|
||||
}
|
||||
_ = crashed
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
//go:build ignore
|
||||
package main
|
||||
|
||||
import ("bufio";"encoding/json";"os")
|
||||
func main() {
|
||||
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
|
||||
w := bufio.NewWriter(os.Stdout); enc := json.NewEncoder(w)
|
||||
rpc := func(m string) { enc.Encode(map[string]string{"method":m}); w.Flush(); var r map[string]interface{}; dec.Decode(&r) }
|
||||
rpc("stage.lock")
|
||||
if os.Args[1] == "crash" { panic("持锁时崩溃") } // 拿着锁死掉
|
||||
rpc("stage.unlock")
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 完全复刻内核 loader.go case 2 + templates.go go_invoke_stage 的链路
|
||||
type StageCtx struct {
|
||||
mu sync.RWMutex
|
||||
LLMText string
|
||||
ToolRes []string
|
||||
}
|
||||
|
||||
func (c *StageCtx) Lock() { c.mu.Lock() }
|
||||
func (c *StageCtx) Unlock() { c.mu.Unlock() }
|
||||
func (c *StageCtx) RLock() { c.mu.RLock() }
|
||||
func (c *StageCtx) RUnlock() { c.mu.RUnlock() }
|
||||
|
||||
// === 模拟外部插件(副本模型)===
|
||||
func externalPlugin(tag string, ctxJSON string) string {
|
||||
// go_invoke_stage: 新建全新对象
|
||||
sc := &StageCtx{}
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal([]byte(ctxJSON), &m)
|
||||
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
|
||||
|
||||
// 插件 handler:ctx.Lock() 锁的是这个新对象 → 空转
|
||||
sc.Lock()
|
||||
sc.LLMText = sc.LLMText + "[" + tag + "]"
|
||||
sc.Unlock()
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// === 模拟内核 case 2 handler ===
|
||||
func kernelStageHandler(sc *StageCtx, tag string) {
|
||||
sc.RLock()
|
||||
snap, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
|
||||
sc.RUnlock()
|
||||
|
||||
result := externalPlugin(tag, string(snap))
|
||||
|
||||
// applyStageResult
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &m)
|
||||
sc.Lock()
|
||||
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
|
||||
sc.Unlock()
|
||||
}
|
||||
|
||||
// === 内置插件:直接改同一对象 ===
|
||||
func nativePlugin(sc *StageCtx, tag string) {
|
||||
sc.Lock()
|
||||
sc.LLMText = sc.LLMText + "[" + tag + "]"
|
||||
sc.Unlock()
|
||||
}
|
||||
|
||||
func runCase(name string, fn func(*StageCtx, string), tags []string, rounds int) {
|
||||
lost := 0
|
||||
for r := 0; r < rounds; r++ {
|
||||
sc := &StageCtx{LLMText: "BASE"}
|
||||
var wg sync.WaitGroup
|
||||
for _, t := range tags {
|
||||
wg.Add(1)
|
||||
go func(t string) { defer wg.Done(); fn(sc, t) }(t)
|
||||
}
|
||||
wg.Wait()
|
||||
// 检查是否所有 tag 都在
|
||||
for _, t := range tags {
|
||||
if !strings.Contains(sc.LLMText, "["+t+"]") { lost++; break }
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %-28s %d/%d 轮出现修改丢失 (%.1f%%)\n", name, lost, rounds, float64(lost)/float64(rounds)*100)
|
||||
}
|
||||
|
||||
func main() {
|
||||
tags := []string{"A", "B", "C", "D", "E"}
|
||||
fmt.Println("5 个插件并发在 StageBeforeToolcall 追加标记,各 2000 轮:")
|
||||
fmt.Println()
|
||||
runCase("内置插件(共享同一对象)", nativePlugin, tags, 2000)
|
||||
runCase("外部插件(快照-副本-写回)", kernelStageHandler, tags, 2000)
|
||||
fmt.Println()
|
||||
fmt.Println("→ 副本模型下 read-modify-write 非原子:快照与写回之间的窗口导致覆盖")
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
// 精确复刻现网 AfterToolcall 上 sanitizer(Global,改写) + weather(OwnTools,只读) 的并发
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
type Ctx struct {
|
||||
mu sync.RWMutex
|
||||
ToolRes []ToolResult
|
||||
}
|
||||
func (c *Ctx) Lock(){c.mu.Lock()}; func (c *Ctx) Unlock(){c.mu.Unlock()}
|
||||
func (c *Ctx) RLock(){c.mu.RLock()}; func (c *Ctx) RUnlock(){c.mu.RUnlock()}
|
||||
|
||||
func cleanText(s string) string {
|
||||
// 模拟 sanitizer:去掉 ANSI/坏字节
|
||||
return strings.ReplaceAll(s, "\x1b[31m", "")
|
||||
}
|
||||
|
||||
// 内核 case 2 handler(外部插件通用路径)
|
||||
func kernelExternal(sc *Ctx, pluginFn func(*Ctx)) {
|
||||
// 1. 快照
|
||||
sc.RLock()
|
||||
snap, _ := json.Marshal(map[string]interface{}{"tool_results": sc.ToolRes})
|
||||
sc.RUnlock()
|
||||
|
||||
// 2. go_invoke_stage: 插件进程内全新对象
|
||||
local := &Ctx{}
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(snap, &m)
|
||||
if v, ok := m["tool_results"]; ok {
|
||||
b, _ := json.Marshal(v)
|
||||
json.Unmarshal(b, &local.ToolRes)
|
||||
}
|
||||
|
||||
// 3. 插件 handler 跑在副本上
|
||||
pluginFn(local)
|
||||
|
||||
// 4. stageContextWritable: 无条件回传 tool_results
|
||||
out := map[string]interface{}{}
|
||||
if len(local.ToolRes) > 0 { out["tool_results"] = local.ToolRes }
|
||||
rb, _ := json.Marshal(out)
|
||||
|
||||
// 5. applyStageResult 写回内核
|
||||
var rm map[string]interface{}
|
||||
json.Unmarshal(rb, &rm)
|
||||
sc.Lock()
|
||||
if v, ok := rm["tool_results"]; ok {
|
||||
b, _ := json.Marshal(v)
|
||||
var trs []ToolResult
|
||||
if json.Unmarshal(b, &trs) == nil { sc.ToolRes = trs }
|
||||
}
|
||||
sc.Unlock()
|
||||
}
|
||||
|
||||
func sanitizerStage(ctx *Ctx) {
|
||||
ctx.Lock(); defer ctx.Unlock()
|
||||
for i, tr := range ctx.ToolRes {
|
||||
if s, ok := tr.Result.(string); ok {
|
||||
ctx.ToolRes[i].Result = cleanText(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
func weatherStage(ctx *Ctx) {
|
||||
ctx.Lock(); defer ctx.Unlock()
|
||||
// 只读打印,不改(own_tools scope 已匹配)
|
||||
_ = len(ctx.ToolRes)
|
||||
}
|
||||
|
||||
func main() {
|
||||
const rounds = 3000
|
||||
dirty := "\x1b[31m晴 25°C"
|
||||
polluted := 0
|
||||
for r := 0; r < rounds; r++ {
|
||||
sc := &Ctx{ToolRes: []ToolResult{{Name:"weather_query", Plugin:"weather", Result: dirty}}}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func(){ defer wg.Done(); kernelExternal(sc, sanitizerStage) }()
|
||||
go func(){ defer wg.Done(); kernelExternal(sc, weatherStage) }()
|
||||
wg.Wait()
|
||||
if s, ok := sc.ToolRes[0].Result.(string); ok && strings.Contains(s, "\x1b[31m") {
|
||||
polluted++
|
||||
}
|
||||
}
|
||||
fmt.Printf("现网场景复刻:模型调用 weather_query,sanitizer+weather 并发跑 AfterToolcall\n")
|
||||
fmt.Printf(" %d 轮中 %d 轮清洗结果被覆盖 (%.1f%%)\n", rounds, polluted, float64(polluted)/rounds*100)
|
||||
if polluted > 0 {
|
||||
fmt.Printf("\n ⚠️ 确认:weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果\n")
|
||||
fmt.Printf(" → 脏数据(ANSI 转义)进入 LLM 上下文\n")
|
||||
}
|
||||
}
|
||||
@ -1,62 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef void (*fn)(void);
|
||||
static void call(void* f){ ((fn)f)(); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) }
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== A. cgo 模型:插件死循环,超时后能回收吗? ===")
|
||||
p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p))
|
||||
n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n))
|
||||
|
||||
base := threads()
|
||||
fmt.Printf(" 基线: goroutines=%d threads=%d\n", runtime.NumGoroutine(), base)
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
done := make(chan string, 1)
|
||||
go func() { C.call(f); done <- "ok" }() // 模拟 executeToolCallInner
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(600 * time.Millisecond): // 缩短的"60s 超时"
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
fmt.Printf(" 第 %d 次超时后: goroutines=%d threads=%d (+%d)\n",
|
||||
i, runtime.NumGoroutine(), threads(), threads()-base)
|
||||
}
|
||||
fmt.Println(" ❌ 每次超时永久泄漏 1 goroutine + 1 OS 线程(cgo 调用不可中断)")
|
||||
|
||||
fmt.Println("\n=== B. 子进程模型:同样死循环,可强杀 ===")
|
||||
base2 := threads()
|
||||
for i := 1; i <= 3; i++ {
|
||||
cmd := exec.Command("sleep", "3600")
|
||||
cmd.Start()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
cmd.Process.Kill() // ← 可强制终止
|
||||
<-done
|
||||
}
|
||||
fmt.Printf(" 第 %d 次超时+Kill 后: goroutines=%d threads=%d (+%d)\n",
|
||||
i, runtime.NumGoroutine(), threads(), threads()-base2)
|
||||
}
|
||||
fmt.Println(" ✅ 零泄漏:进程被杀,OS 回收全部资源")
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -ldl
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
typedef void (*fn)(void);
|
||||
static void call(void* f){ ((fn)f)(); }
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) }
|
||||
|
||||
func main() {
|
||||
p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p))
|
||||
n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n))
|
||||
base := threads()
|
||||
fmt.Printf("基线 threads=%d goroutines=%d\n\n", base, runtime.NumGoroutine())
|
||||
for i := 1; i <= 20; i++ {
|
||||
done := make(chan string, 1)
|
||||
go func() { C.call(f); done <- "ok" }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(120 * time.Millisecond):
|
||||
}
|
||||
if i%5 == 0 {
|
||||
fmt.Printf(" %2d 次卡死调用后: goroutines=%2d threads=%2d (+%d)\n",
|
||||
i, runtime.NumGoroutine(), threads(), threads()-base)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n结论: 20 次超时 → 泄漏 %d goroutine, %d OS 线程\n",
|
||||
runtime.NumGoroutine()-1, threads()-base)
|
||||
fmt.Println("每个卡在 cgo 里的 goroutine 独占一个 M(OS 线程),无法被抢占或回收")
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
#include <unistd.h>
|
||||
void hang_forever(void) { while(1) sleep(1); }
|
||||
@ -1,137 +0,0 @@
|
||||
# 实验 19:迁移验证工具(Part 6.3)
|
||||
|
||||
外部插件从 C ABI 动态库迁移到子进程后的批量重编与开销实测工具。
|
||||
与 01~18 的性质不同:那些是**决策前**的可行性验证,这两个是**迁移执行期**
|
||||
反复使用的操作脚本。
|
||||
|
||||
## rebuild-plugins.sh
|
||||
|
||||
批量把 `example/` 下的插件重编为子进程模式(`plugin.bin`)。
|
||||
|
||||
```bash
|
||||
PLUGINDEV=/tmp/plugindev ./rebuild-plugins.sh weather sanitizer qq
|
||||
```
|
||||
|
||||
关键性质:**不修改任何插件源码**。`plg.json` 的 `entry` 仍写着 `"plugin.so"`
|
||||
也无妨——工具链已不看这个字段(Part 6.1)。
|
||||
|
||||
两个实现细节值得记:
|
||||
|
||||
- **成功判定看产物而非退出码**。plugindev 对部分错误只 `fmt.Printf` 不
|
||||
`os.Exit`,单看 `$?` 会把失败当成功。
|
||||
- 构建前清 `build/`+`dist/`。残留的 `.so` 不影响构建,但会让人误以为
|
||||
还在用旧通道。
|
||||
|
||||
已知环境依赖:`rss` 插件需要 `github.com/mmcdole/gofeed`,
|
||||
`proxy.golang.org` 不通时用 `GOPROXY=https://goproxy.cn,direct`。
|
||||
|
||||
## measure-plugin-overhead.sh
|
||||
|
||||
实测 homed + 插件子进程的常驻开销。
|
||||
|
||||
```bash
|
||||
./measure-plugin-overhead.sh $(pgrep -f 'homed -data' | head -1)
|
||||
```
|
||||
|
||||
### 一个统计口径的坑
|
||||
|
||||
第一版混用了两个来源:RSS 读 `/proc/pid/status` 的 `VmRSS`,
|
||||
PSS 读 `smaps_rollup` 的 `Pss`。结果输出 `PSS=87.9MB > RSS=69.1MB`——
|
||||
物理上不可能。
|
||||
|
||||
原因是两者对**共享内存段**的计入方式不同:`smaps_rollup` 的 `Rss` 含
|
||||
`Pss_Shmem`(共享段的按比例份额),`VmRSS` 不含。现已统一从
|
||||
`smaps_rollup` 读,保证 PSS ≤ RSS。
|
||||
|
||||
### 实测结果(2026-09-02,15 个真实插件)
|
||||
|
||||
```
|
||||
15 个插件进程 RSS=88.0 MB PSS=87.9 MB 线程=82
|
||||
均摊 5.87 MB 5.86 MB 5.5 线程
|
||||
homed 本体 RSS=182 MB 线程=15
|
||||
```
|
||||
|
||||
**与实验 5 基线(17 进程 RSS=29.1MB / PSS=12.9MB / 线程=84)的偏差解释**:
|
||||
|
||||
实验 5 用的是 2.68MB 的最小插件,真实插件 3.1~14.8MB(browser 依赖最多)。
|
||||
RSS 随二进制体积线性增长,故绝对数字不可比。可比的是结构性指标:
|
||||
|
||||
| 指标 | 基线 | 实测 | 判断 |
|
||||
|---|---|---|---|
|
||||
| 均摊线程 | 4.9 | 5.5 | 同量级,无线程膨胀 |
|
||||
| PSS/RSS | 44% | 99.9% | **明显差于基线** |
|
||||
|
||||
第二项是真实发现:基线里 PSS 远低于 RSS,说明 Go runtime 只读代码页在
|
||||
进程间共享。实测几乎不共享,因为 15 个插件是 15 个**不同**的二进制,
|
||||
没有共同的物理页可映射。
|
||||
|
||||
这是「每插件独立二进制」的固有代价,不是缺陷,但意味着实际内存开销
|
||||
高于评估文档(§4.3)的乐观估计。若日后需要压这一项,方向是让插件共享
|
||||
一个 launcher 二进制 + 各自的业务 plugin,而非各自静态链接整个 runtime。
|
||||
|
||||
## 冒烟测试
|
||||
|
||||
自动化部分在 `internal/plugins/real_plugin_smoke_test.go`(4 项):
|
||||
|
||||
- `ToolInvokeRoundTrip`:工具真实调用往返(不只是注册)
|
||||
- `StageRewriteTakesEffect`:sanitizer 改写型 stage 在真实内核装配下生效
|
||||
- `MultiPluginShareOneSegment`:多插件共享一段,只读插件不覆盖改写结果
|
||||
- `CrashDoesNotKillKernel`:SIGKILL 插件进程,homed 存活
|
||||
|
||||
这些测试用**真实 example 产物**而非 testdata 假插件,且 manifest 刻意写
|
||||
`"entry":"plugin.so"`——验证「业务代码零改动」这一承诺在完整内核装配下成立。
|
||||
未重编时 skip 而非 fail,CI 不强制先跑重编脚本。
|
||||
|
||||
## 压测与延迟(Part 6.6 验收)
|
||||
|
||||
基准与压测在代码里而非独立脚本:
|
||||
`internal/plugin/proc/bench_test.go` + `streaming_test.go`。
|
||||
|
||||
```bash
|
||||
go test -run '^$' -bench . ./internal/plugin/proc/
|
||||
go test -run 'TestStreaming_' -v ./internal/plugin/proc/
|
||||
```
|
||||
|
||||
### 实测(2026-09-02,AMD Ryzen 7 7840HS)
|
||||
|
||||
| 项目 | 实测 | 基线 | 判断 |
|
||||
|---|---|---|---|
|
||||
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
|
||||
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
|
||||
| 事件环写入 | 95 ns | — | 亚微秒 |
|
||||
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
|
||||
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
|
||||
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
|
||||
|
||||
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——两者测的不是同一个东西:
|
||||
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
|
||||
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级(那部分是 RPC 往返)。
|
||||
基准原名 `BenchmarkStageLockRoundTrip` 有误导性,已改为
|
||||
`BenchmarkStageLockArbitration`。
|
||||
|
||||
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs(2.8%),
|
||||
其余是**一次 stage 要走 3 次进程间往返**——`stage.invoke` 加上插件侧反向的
|
||||
`stage.lock` / `stage.unlock`。相对 LLM 往返 2-8 秒可忽略;若日后要优化,
|
||||
方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答,省掉两次往返。
|
||||
|
||||
### 流式压测(§4.3 标记「风险高」的那一项)
|
||||
|
||||
原文的担忧:「`Bus.Publish` 路径禁用任何锁/阻塞——流式输出逐 token 发布,
|
||||
任何等待都会卡顿」。
|
||||
|
||||
```
|
||||
5000 次 Publish + 每条睡 20µs 的慢消费者
|
||||
实测 2.29ms,均摊 457 ns/token
|
||||
同步语义理论下限 100ms(5000 × 20µs)
|
||||
|
||||
订阅者 1 个:1.547ms(515 ns/次)
|
||||
订阅者 8 个:1.518ms(506 ns/次) ← 几乎不变,无线性恶化
|
||||
|
||||
环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次 ← 仍 O(1)
|
||||
```
|
||||
|
||||
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token)——
|
||||
post-and-forget 在实现中成立。
|
||||
|
||||
最后一项的意义:消费者完全停摆时写端覆盖最旧 slot,这条路径仍是 O(1),
|
||||
故「消费者卡住」不会连带拖慢内核主循环。
|
||||
@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 子进程插件常驻开销实测(Part 6.3 验收项)。
|
||||
#
|
||||
# 对照基线:docs/zh/experiments/plugin-arch 实验 5 实测 17 子进程
|
||||
# PSS=12.9MB / RSS=29.1MB / 线程=84(原文档估计 50-70MB 偏高)。
|
||||
#
|
||||
# 用法:./measure-plugin-overhead.sh <homed-pid>
|
||||
set -uo pipefail
|
||||
|
||||
pid=${1:-}
|
||||
if [ -z "$pid" ]; then
|
||||
echo "用法: $0 <homed-pid>" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "/proc/$pid" ]; then
|
||||
echo "进程 $pid 不存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# homed 本体
|
||||
homed_rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status")
|
||||
homed_thr=$(awk '/^Threads:/ {print $2}' "/proc/$pid/status")
|
||||
|
||||
echo "=== homed 本体 ==="
|
||||
printf "RSS=%s kB 线程=%s\n" "$homed_rss" "$homed_thr"
|
||||
|
||||
# 插件子进程:homed 的直接子进程中执行 plugin.bin 的
|
||||
echo
|
||||
echo "=== 插件子进程 ==="
|
||||
total_rss=0
|
||||
total_pss=0
|
||||
total_thr=0
|
||||
count=0
|
||||
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
exe=$(readlink "/proc/$child/exe" 2>/dev/null || true)
|
||||
case "$exe" in
|
||||
*plugin.bin*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
thr=$(awk '/^Threads:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
|
||||
# RSS 与 PSS 统一从 smaps_rollup 读,保证口径一致。
|
||||
# 混用 status 的 VmRSS 与 smaps 的 Pss 会得出 PSS > RSS 的荒谬结果——
|
||||
# 两者对共享内存段(Pss_Shmem)的计入方式不同。
|
||||
rss=$(awk '/^Rss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
|
||||
pss=$(awk '/^Pss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
|
||||
if [ -z "$rss" ] || [ "$rss" = "0" ]; then
|
||||
rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
|
||||
fi
|
||||
binsz=$(stat -c%s "$(readlink "/proc/$child/exe" 2>/dev/null)" 2>/dev/null || echo 0)
|
||||
name=$(basename "$(readlink "/proc/$child/cwd" 2>/dev/null || echo unknown)")
|
||||
|
||||
printf " %-16s pid=%-8s RSS=%-8s PSS=%-8s 线程=%-3s 二进制=%s MB\n" \
|
||||
"$name" "$child" "$rss" "$pss" "$thr" \
|
||||
"$(awk -v b="$binsz" 'BEGIN{printf "%.1f", b/1048576}')"
|
||||
total_rss=$((total_rss + rss))
|
||||
total_pss=$((total_pss + pss))
|
||||
total_thr=$((total_thr + thr))
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 合计($count 个插件进程)==="
|
||||
awk -v rss="$total_rss" -v pss="$total_pss" -v thr="$total_thr" -v n="$count" '
|
||||
BEGIN {
|
||||
printf "RSS=%d kB (%.1f MB)\n", rss, rss/1024
|
||||
printf "PSS=%d kB (%.1f MB)\n", pss, pss/1024
|
||||
printf "线程=%d\n", thr
|
||||
if (n > 0) printf "均摊 RSS=%.2f MB PSS=%.2f MB 线程=%.1f\n", rss/1024/n, pss/1024/n, thr/n
|
||||
}'
|
||||
|
||||
echo
|
||||
echo "注:RSS/PSS 均取自 smaps_rollup,口径一致(PSS ≤ RSS)。"
|
||||
echo "PSS 低于 RSS 的部分即 Go runtime 只读代码页在进程间的共享收益。"
|
||||
|
||||
echo
|
||||
echo "对照实验 5 基线:17 进程 RSS=29.1MB PSS=12.9MB 线程=84"
|
||||
echo
|
||||
echo "⚠️ 该基线用的是 2.68MB 的最小插件;真实插件 3.3~15.2MB(browser 依赖最多)。"
|
||||
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
|
||||
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"
|
||||
@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 批量重编外部插件为子进程模式(Part 6.3)。
|
||||
#
|
||||
# 用法:./rebuild-plugins.sh <插件名>...
|
||||
#
|
||||
# 关键性质:**不修改任何插件源码**。每个插件只需用新版 plugindev 重编,
|
||||
# plg.json 的 entry 仍写着 "plugin.so" 也无妨——工具链已不看这个字段。
|
||||
set -uo pipefail
|
||||
|
||||
PLUGINDEV=${PLUGINDEV:-/tmp/plugindev}
|
||||
EXAMPLE_DIR=${EXAMPLE_DIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)/third_party/homeagent-sdk/example"}
|
||||
export GOCACHE=${GOCACHE:-/tmp/gocache}
|
||||
export GOPATH=${GOPATH:-/tmp/gopath}
|
||||
|
||||
if [ ! -x "$PLUGINDEV" ]; then
|
||||
echo "plugindev 不存在或不可执行: $PLUGINDEV" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ok=0
|
||||
fail=0
|
||||
failed_names=""
|
||||
|
||||
for name in "$@"; do
|
||||
dir="$EXAMPLE_DIR/$name"
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "✗ $name: 目录不存在"
|
||||
fail=$((fail + 1))
|
||||
failed_names="$failed_names $name"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 清理旧 C ABI 产物:同目录残留 .so 不影响构建,但会让人误以为还在用旧通道
|
||||
rm -rf "$dir/build" "$dir/dist"
|
||||
|
||||
out=$(cd "$dir" && "$PLUGINDEV" build 2>&1)
|
||||
rc=$?
|
||||
|
||||
# 判定成功的依据是产物存在,而非退出码:plugindev 对部分错误只打印不退出
|
||||
if [ $rc -eq 0 ] && ls "$dir"/build/plugin.bin* >/dev/null 2>&1; then
|
||||
n=$(ls "$dir"/build/plugin.bin* 2>/dev/null | wc -l)
|
||||
hmap=$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)
|
||||
printf "✓ %-14s %s 个平台产物 %s\n" "$name" "$n" "$(basename "${hmap:-无 hmap}")"
|
||||
ok=$((ok + 1))
|
||||
else
|
||||
printf "✗ %-14s 构建失败\n" "$name"
|
||||
echo "$out" | tail -6 | sed 's/^/ /'
|
||||
fail=$((fail + 1))
|
||||
failed_names="$failed_names $name"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "成功 $ok / 失败 $fail"
|
||||
[ -n "$failed_names" ] && echo "失败:$failed_names"
|
||||
exit $([ $fail -eq 0 ] && echo 0 || echo 1)
|
||||
@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生产切换:经 pluginmgr 正规通道安装 17 个 hmap(Part 6.5)。
|
||||
|
||||
与手工拷贝方案的区别 —— 这里复用内核自己的安装逻辑:
|
||||
|
||||
validatePackage 校验 manifest + 平台二进制齐全
|
||||
StopAndUnload 停旧实例但**保留配置表**
|
||||
os.Rename 备份 解包失败自动回滚到旧版本
|
||||
platformBinary() 按 runtime 挑当前平台那份,重命名为 plugin.bin
|
||||
chmod 0755 补执行位
|
||||
|
||||
手工拷贝会重新实现这一套,且必然实现得更差(第一版就漏了 platforms 字段
|
||||
与配置保留语义)。
|
||||
|
||||
用法:
|
||||
switch-production.py 演练
|
||||
switch-production.py --apply 实际安装
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
PROD_PLUGINS = "/home/newqqagent/plugins"
|
||||
SDK_EXAMPLE = "/home/program/TrueAgent/third_party/homeagent-sdk/example"
|
||||
PLUGINMGR = "http://127.0.0.1:9876/plugins"
|
||||
|
||||
|
||||
def find_hmap(name):
|
||||
"""找插件的 hmap 包。
|
||||
|
||||
bundle:true -> <snake>_bundle.hmap(含多平台二进制)
|
||||
bundle:false -> <snake>_<goos>_<goarch>.hmap(qq 是这种)
|
||||
"""
|
||||
dist = os.path.join(SDK_EXAMPLE, name, "dist")
|
||||
if not os.path.isdir(dist):
|
||||
return None
|
||||
cands = [f for f in os.listdir(dist) if f.endswith(".hmap")]
|
||||
if not cands:
|
||||
return None
|
||||
for c in cands:
|
||||
if c.endswith("_bundle.hmap"):
|
||||
return os.path.join(dist, c)
|
||||
return os.path.join(dist, sorted(cands)[0])
|
||||
|
||||
|
||||
def install(path):
|
||||
"""POST 到 pluginmgr。overwrite=true 走原地更新分支,保留配置表。"""
|
||||
body = json.dumps({"path": path, "overwrite": True}).encode()
|
||||
req = urllib.request.Request(
|
||||
PLUGINMGR, data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=180) as resp:
|
||||
return json.loads(resp.read().decode()), None
|
||||
except urllib.error.HTTPError as e:
|
||||
return None, "HTTP %d: %s" % (e.code, e.read().decode()[:300])
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
|
||||
targets = sorted(
|
||||
d for d in os.listdir(PROD_PLUGINS)
|
||||
if os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.so"))
|
||||
or os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.bin"))
|
||||
)
|
||||
print("生产外部插件: %d 个" % len(targets))
|
||||
|
||||
# 先全部校验,任一缺包就整批中止。
|
||||
# 理由:新 homed 不认 .so,「一半装了一半没装」的中间态最难排查。
|
||||
plan = []
|
||||
missing = []
|
||||
for name in targets:
|
||||
h = find_hmap(name)
|
||||
if h is None:
|
||||
missing.append(name)
|
||||
else:
|
||||
plan.append((name, h))
|
||||
|
||||
if missing:
|
||||
print("\n✗ 中止:以下插件缺 hmap 包:")
|
||||
for m in missing:
|
||||
print(" " + m)
|
||||
print("\n先跑 rebuild-plugins.sh 重编。")
|
||||
return 1
|
||||
|
||||
print("✓ 全部 %d 个 hmap 就位\n" % len(plan))
|
||||
for name, h in plan:
|
||||
print(" %-16s %-44s %6d KB" % (
|
||||
name, os.path.basename(h), os.path.getsize(h) // 1024))
|
||||
|
||||
if not apply:
|
||||
print("\n[演练] 加 --apply 才实际安装")
|
||||
return 0
|
||||
|
||||
print("\n经 pluginmgr 安装(overwrite=true,保留配置)...")
|
||||
ok = 0
|
||||
failed = []
|
||||
for name, h in plan:
|
||||
result, err = install(h)
|
||||
if err:
|
||||
print(" ✗ %-16s %s" % (name, err))
|
||||
failed.append(name)
|
||||
continue
|
||||
if "error" in result:
|
||||
print(" ✗ %-16s %s: %s" % (
|
||||
name, result["error"], result.get("details", "")))
|
||||
failed.append(name)
|
||||
continue
|
||||
print(" ✓ %-16s %-12s v%s -> v%s config_kept=%s" % (
|
||||
name,
|
||||
result.get("action", "?"),
|
||||
result.get("previous_version", "?"),
|
||||
result.get("version", "?"),
|
||||
result.get("config_kept", False)))
|
||||
ok += 1
|
||||
|
||||
print("\n成功 %d / 失败 %d" % (ok, len(failed)))
|
||||
if failed:
|
||||
print("失败: " + " ".join(failed))
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,111 +0,0 @@
|
||||
# 插件架构评估实验
|
||||
|
||||
[`../../架构迁移评估.md`](../../架构迁移评估.md) 中所有数字的来源。
|
||||
**18 项实验,一键复跑**,用于复核结论或在改动后验证回归。
|
||||
|
||||
```bash
|
||||
./run.sh # 跑全部(约 3-5 分钟)
|
||||
./run.sh 12 13 # 只跑指定实验
|
||||
./run.sh 1 1c # dlclose/NODELETE 组
|
||||
```
|
||||
|
||||
依赖:`go >= 1.21`、`gcc`、Linux(用到 `eventfd`/`memfd_create`/`dlopen`)。
|
||||
脚本在 `mktemp -d` 里构建,**不污染主仓 `go.mod`**;实验源码均带 `//go:build ignore`。
|
||||
|
||||
拉取 `golang.org/x/sys` 需要网络(实验 1/2/4/8/10)。本机走 clash:
|
||||
```bash
|
||||
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
|
||||
```
|
||||
|
||||
## 目录
|
||||
|
||||
| 目录 | 主题 | 对应章节 |
|
||||
|---|---|---|
|
||||
| `01-dlclose-nodelete/` | `dlclose` 对 `DF_1_NODELETE` 是 no-op | 1.1 / 1.2 |
|
||||
| `02-feasibility/` | 新架构可行性 11 项 | 第七章 |
|
||||
| `03-lost-update/` | 副本模型的 lost update | 8.4 / 8.6 |
|
||||
| `04-cgo-uninterruptible/` | cgo 调用不可中断 | 9.3 |
|
||||
|
||||
## 实验清单与最近一次实测结果
|
||||
|
||||
复跑于 2026-08-31,go1.25.12 linux/amd64,192.168.2.60(12 核)。
|
||||
|
||||
### 01 组:dlclose / NODELETE
|
||||
|
||||
| # | 实验 | 结论 |
|
||||
|---|---|---|
|
||||
| 1a | Go 宿主经纯 C shim 加载/卸载第三层 `.so` | 纯 C 目标可卸载;Go c-shared 目标仍不可 |
|
||||
| 1b | `/proc/self/maps` 段数验证 | 纯 C: 5→**0**(真卸载);Go c-shared: 5→**5** |
|
||||
| 1c | 版本化路径 dlopen | handle 不同,`ver=v2` 生效(方案可行但泄漏,已否决) |
|
||||
|
||||
**关键**:`DF_1_NODELETE` 属于**被卸载对象自身**的 ELF 属性,
|
||||
与谁调用 `dlopen` 无关——套任何层数的 C 中间件都绕不过去。
|
||||
|
||||
### 02 组:新架构可行性
|
||||
|
||||
| # | 实验 | 最近结果 |
|
||||
|---|---|---|
|
||||
| 1 | eventfd 是否走 Go netpoller | 200 goroutine 阻塞 → 线程 **+0~1** ✅ |
|
||||
| 2 | 跨进程 eventfd + 偏移解引用 | 父子 mmap 基址不同,偏移仍正确;post **10.9 µs** |
|
||||
| 3 | 锁仲裁 RPC 往返成本 | **19.4 µs/次**(20000 次) |
|
||||
| 4 | post-and-forget vs 同步 Publish | 5.07s → 2.29ms(**2218x**) |
|
||||
| 5 | 17 子进程常驻开销 | **29.1MB RSS / 12.9MB PSS**,84 线程 |
|
||||
| 6 | 子进程崩溃隔离 | 退出码 **2**,EOF **2.5ms** 感知,宿主存活 |
|
||||
| 7 | 子进程热重载 | 同路径替换二进制 → v1→v2 立即生效 |
|
||||
| 8 | **跨进程并发改写 StageContext** | 5 进程 × 300 轮,**零丢失零撕裂** |
|
||||
| 9 | 持锁进程崩溃自愈 | 无死锁,**无需 robust mutex** |
|
||||
| 10 | 二进制零拷贝 | 100KB/1MB/5MB → **14-22x**,体积 −100% |
|
||||
| 11 | 工具调用 RPC 延迟 | p50 **19.6 µs**,占 LLM 往返 0.00065% |
|
||||
|
||||
### 03 组:副本模型缺陷
|
||||
|
||||
| # | 实验 | 最近结果 |
|
||||
|---|---|---|
|
||||
| 12 | 副本模型 lost update 率 | 内置 **0%** vs 外部 **35.8~36.8%** |
|
||||
| 13 | 现网 sanitizer+weather 冲突 | **1.6~4.3%** 清洗结果被覆盖 |
|
||||
|
||||
**实验 12 的对照设计是重点**:两组用**完全相同的并发扇出**
|
||||
(`stages.go:124` 的 `go func` + `wg.Wait()`),唯一差异是
|
||||
「共享同一 `*StageContext`」vs「快照-副本-写回」。
|
||||
|
||||
内置组 0% 证明**并发扇出这个原始设计是正确的**;
|
||||
副本组 36% 证明**跨 C ABI 边界后锁语义失效**才是缺陷所在。
|
||||
不要据此得出"应该取消并发"的结论。
|
||||
|
||||
⚠️ **13 的比率随机器负载波动**(观测区间 1.6%~4.3%)——它取决于两个插件
|
||||
handler 的实际执行耗时比。文档正文引用 1.6% 是首次测量值,
|
||||
**应理解为「量级在百分之几」而非精确常数**。
|
||||
|
||||
### 04 组:cgo 不可中断
|
||||
|
||||
| # | 实验 | 最近结果 |
|
||||
|---|---|---|
|
||||
| 14a | cgo 死循环 vs 子进程 Kill | cgo 泄漏;子进程 **零泄漏** |
|
||||
| 14b | 泄漏增长曲线(20 次) | 泄漏 **20 goroutine / 18 OS 线程**,线性 |
|
||||
|
||||
## 复跑时的注意事项
|
||||
|
||||
**结果会有波动,以下属正常**:
|
||||
|
||||
- 实验 12/13 的丢失率随调度波动(12 稳定在 35~37%,13 在 1.6~4.3%)
|
||||
- 实验 1 的线程增长为 0 或 1(取决于 netpoller 线程是否已存在)
|
||||
- 实验 10 的加速比 14~22x(受 CPU 缓存状态影响)
|
||||
- 实验 5 的 PSS 受同机其他 Go 进程影响(共享页计算)
|
||||
|
||||
**结果不应变的**(若变了说明环境或结论有问题):
|
||||
|
||||
- 实验 1b 中纯 C `.so` 的段数必须归 **0**,Go c-shared 必须**不归零**
|
||||
- 实验 8 的「总字符数 == 最终长度」必须成立(零丢失)
|
||||
- 实验 9 必须无死锁
|
||||
- 实验 12 的内置模型必须 **0%**
|
||||
- 实验 14b 的泄漏必须**线性增长**
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 实验 8 的 arena 未实现压实,64KB 用尽即停止写入(写入次数 < 5×300 属预期,
|
||||
见评估文档 3.3)
|
||||
- 实验 12/13 是**链路复刻**而非直接调用生产代码,
|
||||
证明的是「副本模型这一机制」存在缺陷,不能替代对 `sanitizer`/`weather`
|
||||
的真实行为回归测试
|
||||
- 实验 5 的插件是最小 stdio loop(2.68MB),真实插件(如 qq 7.5MB)开销更高
|
||||
- 无 Windows 环境,9.2 的 Windows DLL 缺陷**未经实测**,仅代码阅读
|
||||
@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 插件架构评估实验 —— 一键复跑
|
||||
# 用法: ./run.sh [实验编号...] 例: ./run.sh 12 13 留空跑全部
|
||||
# 依赖: go >= 1.21, gcc, Linux (eventfd/memfd/dlopen)
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ROOT=$(pwd)
|
||||
PASS=0; FAIL=0
|
||||
|
||||
need() { command -v "$1" >/dev/null || { echo "缺少依赖: $1"; exit 1; }; }
|
||||
need go; need gcc
|
||||
|
||||
# 统一的临时 module 环境(避免污染主仓 go.mod)
|
||||
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
banner() { echo; echo "════════ $* ════════"; }
|
||||
|
||||
# x/sys 只有 exp1/2/4/8/10 需要
|
||||
prep_xsys() {
|
||||
cat > "$1/go.mod" <<EOF
|
||||
module exp
|
||||
go 1.21
|
||||
require golang.org/x/sys v0.20.0
|
||||
EOF
|
||||
(cd "$1" && GOFLAGS=-mod=mod go get golang.org/x/sys@v0.20.0 >/dev/null 2>&1)
|
||||
}
|
||||
prep_plain() { printf 'module exp\ngo 1.21\n' > "$1/go.mod"; }
|
||||
|
||||
run_go() { # <目录> <说明>
|
||||
if (cd "$1" && go run . 2>&1); then PASS=$((PASS+1)); else echo " ❌ 失败: $2"; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
SEL="${*:-all}"
|
||||
sel() { [ "$SEL" = "all" ] && return 0; case " $SEL " in *" $1 "*) return 0;; esac; return 1; }
|
||||
|
||||
# ── 01: dlclose / NODELETE ────────────────────────────────
|
||||
if sel 1; then
|
||||
banner "实验 1 组: dlclose 对 DF_1_NODELETE 是 no-op"
|
||||
W=$WORK/e01; mkdir -p $W; cp 01-dlclose-nodelete/*.c $W/
|
||||
gcc -shared -fPIC -o $W/probe_v1.so $W/probe_v1.c
|
||||
gcc -shared -fPIC -o $W/probe_v2.so $W/probe_v2.c
|
||||
gcc -shared -fPIC -o $W/shim.so $W/shim.c
|
||||
cp $W/probe_v1.so $W/probe.so
|
||||
for e in exp01a exp01b; do
|
||||
mkdir -p $W/$e; cp 01-dlclose-nodelete/$e/main.go $W/$e/
|
||||
sed -i '/^\/\/go:build ignore$/d' $W/$e/main.go; prep_plain $W/$e
|
||||
(cd $W/$e && go build -o ../$e.bin . 2>&1 | head -3)
|
||||
done
|
||||
echo "--- 01a: Go 宿主经 C shim 加载/卸载纯 C so ---"
|
||||
(cd $W && ./exp01a.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
|
||||
echo "--- 01b: /proc/self/maps 段数验证(纯 C 归零,Go c-shared 不归零)---"
|
||||
(cd $W && ./exp01b.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# ── 01c: 版本化路径(需要两个真 Go c-shared)────────────────
|
||||
if sel 1c; then
|
||||
banner "实验 1c: 版本化路径 dlopen 可加载新代码"
|
||||
W=$WORK/e01c; mkdir -p $W/{v1,v2,host}
|
||||
for V in v1 v2; do
|
||||
cat > $W/$V/main.go <<EOF
|
||||
package main
|
||||
import "C"
|
||||
//export lib_version
|
||||
func lib_version() *C.char { return C.CString("$V-CODE") }
|
||||
func main() {}
|
||||
EOF
|
||||
printf 'module gl%s\ngo 1.21\n' $V > $W/$V/go.mod
|
||||
(cd $W/$V && go build -buildmode=c-shared -o ../gl$V.so . 2>&1|head -3)
|
||||
done
|
||||
cp 01-dlclose-nodelete/exp01c/main.go $W/host/
|
||||
sed -i '/^\/\/go:build ignore$/d' $W/host/main.go; prep_plain $W/host
|
||||
(cd $W/host && go build -o ../h.bin .) && (cd $W && ./h.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# ── 02: 可行性 1-11 ───────────────────────────────────────
|
||||
declare -A XSYS=([1]=1 [2]=1 [4]=1 [8]=1 [10]=1)
|
||||
for n in 1 2 3 4 5 6 7 8 9 10 11; do
|
||||
sel $n || continue
|
||||
banner "实验 $n"
|
||||
W=$WORK/f$n; mkdir -p $W
|
||||
case $n in
|
||||
1) cp 02-feasibility/exp1_eventfd.go $W/main.go ;;
|
||||
2) cp 02-feasibility/exp2_parent.go $W/main.go; cp 02-feasibility/exp2_child.go $W/ ;;
|
||||
3) cp 02-feasibility/exp3_parent.go $W/main.go; cp 02-feasibility/exp3_child.go $W/ ;;
|
||||
4) cp 02-feasibility/exp4.go $W/main.go ;;
|
||||
5) cp 02-feasibility/exp5b.go $W/main.go; cp 02-feasibility/exp5_plugin.go $W/ ;;
|
||||
6) cp 02-feasibility/exp6.go $W/main.go; cp 02-feasibility/exp6_crash.go $W/ ;;
|
||||
7) cp 02-feasibility/exp7.go $W/main.go ;;
|
||||
8) cp 02-feasibility/exp8.go $W/main.go; cp 02-feasibility/exp8_worker.go $W/ ;;
|
||||
9) cp 02-feasibility/exp9.go $W/main.go; cp 02-feasibility/exp9_worker.go $W/ ;;
|
||||
10) cp 02-feasibility/exp10.go $W/main.go ;;
|
||||
11) cp 02-feasibility/exp11.go $W/main.go; cp 02-feasibility/exp11_plug.go $W/ ;;
|
||||
esac
|
||||
# 去掉 main.go 的 build ignore(它是入口)
|
||||
sed -i '/^\/\/go:build ignore$/d' $W/main.go
|
||||
if [ "${XSYS[$n]:-}" = "1" ]; then prep_xsys $W; else prep_plain $W; fi
|
||||
# 需要预编译的辅助二进制
|
||||
case $n in
|
||||
5) (cd $W && go build -o plugbin exp5_plugin.go 2>&1|head -3) ;;
|
||||
6) (cd $W && go build -o crashbin exp6_crash.go 2>&1|head -3) ;;
|
||||
11) (cd $W && go build -o plug11 exp11_plug.go 2>&1|head -3) ;;
|
||||
esac
|
||||
run_go $W "实验 $n"
|
||||
done
|
||||
|
||||
# ── 03: lost update ───────────────────────────────────────
|
||||
for e in 12 13; do
|
||||
sel $e || continue
|
||||
banner "实验 $e: 副本模型 lost update"
|
||||
W=$WORK/l$e; mkdir -p $W
|
||||
cp 03-lost-update/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go
|
||||
prep_plain $W; run_go $W "实验 $e"
|
||||
done
|
||||
|
||||
# ── 04: cgo 不可中断 ──────────────────────────────────────
|
||||
for e in 14a 14b; do
|
||||
sel 14 || sel $e || continue
|
||||
banner "实验 $e: cgo 调用不可中断"
|
||||
W=$WORK/c$e; mkdir -p $W
|
||||
cp 04-cgo-uninterruptible/hang.c $W/
|
||||
gcc -shared -fPIC -o $W/hang.so $W/hang.c
|
||||
cp 04-cgo-uninterruptible/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go
|
||||
prep_plain $W; run_go $W "实验 $e"
|
||||
done
|
||||
|
||||
banner "汇总: 通过 $PASS, 失败 $FAIL"
|
||||
[ $FAIL -eq 0 ]
|
||||
@ -541,9 +541,9 @@ v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 st
|
||||
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
|
||||
等价于旧行为(L1)。
|
||||
- 合回 `main` 前需完成的发布动作:
|
||||
1. 同步更新 `docs/zh/plugin-interface-matrix.md`;
|
||||
2. 与 SDK 仓协同升 SDK 中版本;
|
||||
3. 遵守“只增不减、签名不改”边界。
|
||||
1. 与 SDK 仓协同升 SDK 中版本(`docs/git-branching.md` §七);
|
||||
2. 遵守“只增不减、签名不改”,并同步 hmapdev 模板接线
|
||||
(`docs/git-branching.md` §八)。
|
||||
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`)
|
||||
同步追加 `priority`,与公开 SDK 字段一一对应。
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@ axis,实际却只能用导出的那个长度运行。
|
||||
| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|
||||
|---|---|---|---|
|
||||
| 参数量 | 188M | 1.04B | 2B |
|
||||
| 产物 / 实测常驻 | **721MB / 1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
|
||||
| 产物 / 实测内存 | **721MB / 稳态 0.89GB(峰值 1.59GB)** | ~2GB / 2.23GB | 8GB / 峰值 9.4GB |
|
||||
| 维度 | 512 | 768 | 2048 |
|
||||
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
|
||||
| 中文 | 原生(~2 亿中文图文对) | 多语言 | 多语言 |
|
||||
|
||||
284
docs/zh/plan.md
284
docs/zh/plan.md
@ -1,284 +0,0 @@
|
||||
# WebUI 布局与配置归位修复计划
|
||||
|
||||
## 一、背景
|
||||
|
||||
上一轮 SDK 接口化改造完成并部署后,用户指出三个问题:
|
||||
|
||||
1. **WebUI 窄屏布局损坏**:顶部 `<nav>` 为桌面式横排(标题 + 6 tab + 连接指示器 + 语言 + 主题),
|
||||
窄屏断点仅缩小字号不换行,`body { overflow-x:hidden }` 直接把溢出的 tab 裁掉不可点击。
|
||||
2. **OpenClaw skills 目录被注册为核心配置**:`core.skills.path`("OpenClaw 技能存储目录")注册在核心
|
||||
配置表(`internal/config/registry.go`),但全仓无任何读取方(死配置);实际生效路径是
|
||||
clawhubadapter 自己的 `skills_dir` 配置(`config_clawhubadapter` 表 + `core.daemon.data_dir`/skills 兜底)。
|
||||
技能目录是 clawhubadapter 适配加载的领域,不应属于核心配置。
|
||||
3. **clawhubadapter 加载的微信插件成为独立配置项**:设置页出现 `channels.wechat.*`(核心表)、
|
||||
`plugin.wechat.*`(config_wechat 表)、`config_openclaw_weixin`(空表)等多处微信配置,
|
||||
全部为历史残留——当前代码零引用,OC 技能的配置实际在其自身 `~/.openclaw/openclaw.json`。
|
||||
设置页会把核心表全部键 + 全部插件表当作配置组展示,导致残留以"独立配置项"形态出现。
|
||||
|
||||
## 二、修复计划
|
||||
|
||||
| # | 动作 | 位置 | 风险 |
|
||||
|---|------|------|------|
|
||||
| A | 窄屏导航修复:<768px 下 nav 横向滚动、h1 缩写、连接指示器简化;body 溢出裁切改为 nav 内滚动 | `cmd/gui/renderer/style.css` | 无 |
|
||||
| B | 删除 `core.skills.path` 核心配置注册(set + RegisterDef 两处) | `internal/config/registry.go` | 无(无读取方) |
|
||||
| C | 备份后清理残留配置:`channels.wechat.*` 键、`config_wechat` / `config_openclaw_weixin` / `config_openclaw` 表(含微信 token,先备份) | 生产库 `/home/newqqagent/config.db` | 低(当前代码不读) |
|
||||
|
||||
## 三、实施记录
|
||||
|
||||
### 步骤 A:webui 窄屏导航修复(已完成)
|
||||
- 修复对象为 webui HTTP 服务真正前端 `internal/plugins/webui/dashboard.html`(`go:embed` 内嵌,
|
||||
登录后 `/` 返回,104KB;cmd/gui 是独立 electron 客户端,非 webui 一部分)。
|
||||
- `<768px` 断点:`nav { overflow-x:auto; scrollbar-width:none; flex-wrap:nowrap }` + `::-webkit-scrollbar { display:none }`;
|
||||
`nav a { white-space:nowrap; flex-shrink:0 }`;`nav h1 { font-size:0 }`(保留 logo 图、隐藏文字,弥补窄屏空间);
|
||||
`nav > div { flex-shrink:0 }` 右侧语言/主题/退出按钮不压缩。
|
||||
- 顺带在 cmd/gui(electron 客户端)同步了窄屏样式与消息来源徽标(`app.js`/`style.css`,客户端窗口缩放同样受益;
|
||||
客户端需另行构建 electron 应用才生效)。
|
||||
- 验证:部署后 `/` 返回的 dashboard 含 `scrollbar-width:none`/`font-size:0`/`::-webkit-scrollbar` 规则。
|
||||
|
||||
### 步骤 B:删除 core.skills.path 核心配置(已完成)
|
||||
- 删除 `internal/config/registry.go` 两处:`set("core.skills.path", ...)`(SeedDefaults)与
|
||||
`reg(ConfigDef{Key:"core.skills.path", ...})`(定义注册)。
|
||||
- 理由:该键全仓无读取方(grep 仅命中注册处),实际生效路径是 clawhubadapter 的 `skills_dir`
|
||||
(config_clawhubadapter 表 + `core.daemon.data_dir`/skills 兜底)。技能目录属 clawhubadapter 适配领域。
|
||||
- 验证:`grep -rn "skills.path" --include="*.go"` 零命中;部署后设置页无 `core.skills.path`。
|
||||
|
||||
### 步骤 C:清理生产库残留配置(已完成)
|
||||
- 操作前 `sqlite3 .backup /tmp/opencode/config.db.pre-clean.bak`(含微信 token 数据)。
|
||||
- 删除:`config` 表 `channels.wechat.*` 3 键 + `core.skills.path` 键;`DROP TABLE config_wechat /
|
||||
config_openclaw_weixin / config_openclaw`(三者均为历史残留:当前代码零引用,clawhubadapter 实际
|
||||
使用 config_clawhubadapter 表;OC 技能配置在其自身 `~/.openclaw/openclaw.json`)。
|
||||
- 验证:设置页总键数 138→129,无 wechat/weixin/skills.path 残留,`plugin.clawhubadapter.skills_dir /
|
||||
simulator_dir` 正常;服务 healthcheck ready、clawhubadapter "OC plugin manager started"。
|
||||
|
||||
---
|
||||
|
||||
# SDK Stop 注册接口(RegisterStopHandler)计划
|
||||
|
||||
## 一、背景
|
||||
|
||||
2026-08-01 20:00 起生产 homeagent 进入崩溃循环(`fatal error: thread exhaustion`,
|
||||
systemd 重启计数 61+)。排查定位为 SDK 示例插件 `calendar`(示例源码在 SDK 仓库
|
||||
`example/calendar`,生产以 plugin.so 形态加载)三个缺陷叠加:
|
||||
|
||||
1. **农历引擎 3 个 bug**(`daysInLunarYear` 位循环 `i > 0` 应 `i > 0x8`、缺闰月天数、
|
||||
`lunarToSolar` 内层重复加闰月)→ `lunarToSolar(2026,4,12)` 返回 **2062-11-16**(偏移 36 年),
|
||||
农历重复事件(`lunar_yearly`)的 next 被生成到遥远错误日期。
|
||||
2. **`cleanupPastEvents` 保留过时重复事件** → 每 30s ticker 对已到点的重复事件再生成一份 next,
|
||||
事件从 7 个爆炸到 **45612 个**(15MB events.json)。
|
||||
3. **无提醒投递保护**:15018 份同时到点的事件一次性 `go sdk.InjectInterruptText(...)` 投递
|
||||
→ interrupt 风暴 → goroutine/线程耗尽。
|
||||
|
||||
处置:修复农历引擎 3 处 + next 去重 + 清理过时重复事件,用**新版 SDK 仓库 + 新版 plugindev 工具链**
|
||||
重建 `calendar_linux_amd64.hmap`,经 **webui `POST /api/v1/plugins`**(透明代理到 pluginmgr 安装接口)
|
||||
重装,重启验证收敛(事件 4 个、next 正确生成 2027-05-17、0 崩溃)。
|
||||
|
||||
**过程中暴露的能力缺口**:SDK 只有 `Plugin` 接口的 `Name/Start/Stop`,**没有 stop 注册接口**
|
||||
(`RegisterStopHandler`/`OnStop` 均不存在,SDK v0.7.2/v0.8.0/master 一致)。插件停止时只能在自己的
|
||||
`Stop()` 里写清理逻辑,SDK 层无法统一执行"停止时清理"回调;calendar 的 `Stop() { p.saveEvents() }`
|
||||
还会用陈旧内存把已清理的数据写回磁盘(曾导致删除的重复事件复活)。
|
||||
|
||||
## 二、计划
|
||||
|
||||
| # | 动作 | 位置 | 风险 |
|
||||
|---|------|------|------|
|
||||
| 1 | 公共 SDK `PluginSDK` 加 `RegisterStopHandler(fn func())` + `RunStopHandlers()`(幂等、后注册先执行),两处同步 | `third_party/homeagent-sdk/sdk/plugin.go`、SDK 仓库 `sdk/plugin.go` | 低(纯新增,内置 SDK 内嵌透传) |
|
||||
| 2 | 内核 Registry 保存每插件 SDK 引用(`sdkRefs`),`StopAll`/`ReloadOne`/`DisablePlugin` 调 `Stop()` 前执行 `RunStopHandlers` | `internal/plugin/registry.go` | 中(生命周期路径,需回归 reload/disable) |
|
||||
| 3 | 工具链 plugindev:z_bridge 模板 `bridgeState` 存 SDK,`StopPlugin` 先 `RunStopHandlers()` 再 `plugin.Stop()`;init 脚手架模板加演示 | SDK 仓库 `tools/plugindev/templates.go`、`templates/main.go.tmpl` | 低 |
|
||||
| 4 | 内置示例插件演示(如 timer:ticker 停止改为 stop handler) | `internal/plugins/timer/plugin.go` | 低 |
|
||||
| 5 | 外部示例插件同步(`example/calendar` 的 `saveEvents` 改由 stop handler 执行,验证 z_bridge 链路;其余 example 加演示) | SDK 仓库 `example/*` | 低 |
|
||||
| 6 | 文档同步:SDK README 生命周期章节 + 主仓插件开发文档 | SDK 仓库 `README.md`/`README_EN.md` 等 | 无 |
|
||||
|
||||
## 三、实施记录
|
||||
|
||||
1. SDK 公共层(`RegisterStopHandler` + `RunStopHandlers`:后注册先执行、执行后清空幂等)已落地
|
||||
`third_party/homeagent-sdk/sdk/plugin.go`,并同步到 SDK 仓库 `/tmp/opencode/sdk-repo/sdk/plugin.go`(两处一致)。
|
||||
2. 内核 Registry(`internal/plugin/registry.go`)新增 `sdkRefs map[string]*sdk.PluginSDK` + `runStopHandlers`,
|
||||
`loadOne` 注册、`StopAll`/`ReloadOne`/`DisablePlugin` 在 `Stop()` 前执行(共 4 处调用点)。
|
||||
3. 工具链 plugindev(SDK 仓库):linux `tmplLinuxBridge` 的 `go_stop_plugin` 先 `RunStopHandlers()` 再 `plg.Stop()`;
|
||||
windows `tmplBridge` 的 `bridgeState` 加 `sdk` 字段、`StopPlugin` 同链路;`tmplPluginGo` + `main.go.tmpl`
|
||||
脚手架加 `RegisterStopHandler` 演示。plugindev 重新编译通过(GOPATH=/root/go)。
|
||||
4. 内置 timer 插件演示:`close(p.stopCh)` 移入 stop handler,`Stop()` 只 `wg.Wait()`。
|
||||
5. 外部示例:`example/calendar` 的 `saveEvents` 改为 `s.RegisterStopHandler(p.saveEvents)`,
|
||||
`Stop()` 删除写盘调用(持久化交由 stop handler,避免陈旧内存复活已删事件)。
|
||||
6. 文档:SDK 仓库 `README.md`/`README_EN.md` 生命周期章节补充 RegisterStopHandler 说明。
|
||||
7. 构建测试:主仓 `go build ./...` + `go test ./internal/sdk/... ./internal/plugin/...` 全绿;
|
||||
SDK 仓库 `go build ./...` + `go test ./sdk/...` 全绿。
|
||||
8. 生产部署验证:新 plugindev(--no-bundle)重建 `calendar_linux_amd64.hmap`(md5 084e97c0…,strings 确认
|
||||
`go_stop_plugin → RunStopHandlers → saveEvents` 编译进 plugin.so);webui API 删旧装新;重装新内核
|
||||
homed(含 sdkRefs/runStopHandlers);两次重启事件稳定 3 个不复活、events.json mtime 与 stop 时刻吻合
|
||||
(saveEvents 经 stop handler 真实执行)、0 次 thread exhaustion、服务 active。
|
||||
|
||||
|
||||
---
|
||||
|
||||
# clawhubadapter OpenClaw 通道插件兼容修复计划
|
||||
|
||||
## 一、背景
|
||||
|
||||
生产 `core.llm.provider` 已是 mock LLM 源(`core.llm.sources.mocktest`,base_url
|
||||
`http://127.0.0.1:18080/v1`、model mock-model、adapter openai),mock LLM 服务常驻运行。
|
||||
借助 **mock 通道插件**(`/tmp/opencode/mock-skills/mock-wechat/`,完全复刻 openclaw-weixin 的
|
||||
真实注册格式 `register(api) → api.registerChannel({ plugin: ChannelPlugin })`)放入生产 skills 目录
|
||||
端到端复现,得出如下结论:
|
||||
|
||||
**已验证可用链路**:manager 加载 mock 插件 → Go 端识别 `ocplugin mock-wechat handled by manager` →
|
||||
注册工具 `mock-wechat_read_mock_wechat_input`/`mock-wechat_mock_echo` → `RegisterOutputChannel("mock-wechat")`
|
||||
→ mock 自推消息经 `channel_input` 通知 → `[agent] interrupt from manager/mock-wechat` →
|
||||
mock LLM 正常回复(195ms)。
|
||||
|
||||
**复现的核心缺陷**(真实通道插件 wechat/dingding"根本不可用"的根因):
|
||||
|
||||
1. **输出断链**:manager `tools/call` 通道分支只认 `channelPlugin.outbound.sendText/sendMedia`
|
||||
(旧格式),真实 ChannelPlugin(openclaw-weixin 等)无 outbound →
|
||||
`tools/call mock-wechat → error: "channel mock-wechat has no output handler"`。
|
||||
2. **生命周期静止**:manager mock api 从不调用 `gateway.startAccount/stopAccount`,也无
|
||||
`api.runtime`/`channelRuntime` → 通道插件加载后永不启动(不登录、不轮询、不收消息)。
|
||||
3. **输入依赖错位**:真实插件把消息经 `channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher`
|
||||
推送(manager 完全无此对象),而不是调 `api.submitInput`。
|
||||
4. **stdout 污染**:插件 `console.log` 直接进 JSON-RPC 流,Go 端 readLoop 跳过非 JSON 行,有丢通知风险。
|
||||
|
||||
**wechat 通道的心跳机制**(`openclaw-weixin/dist/index.js` `pollLoop`,448 行起):每账号一个常驻
|
||||
`pollLoop`,循环 `POST ilink/bot/getupdates`(body `{get_updates_buf}`,超时 35s)——**长轮询即心跳**:
|
||||
服务器收到 poll 请求即知通道在线,新消息随 poll 响应 push 回来;超时视为空响应继续轮询,真错误延时
|
||||
5s 重试。**与 gateway 生命周期强绑定**:
|
||||
|
||||
- `pollLoop` 只由 `gateway.startAccount(ctx)` 启动;不被调用 → 心跳/收消息全断(服务器侧认为通道离线)。
|
||||
- `startAccount` 末尾 `await new Promise(()=>{})` **永久挂起**——OC gateway 靠它配合 health-monitor
|
||||
(startAccount 退出 → 判定账号崩溃 → 重启账号)。manager 调 `startAccount` 必须 **fire-and-forget**。
|
||||
- 停靠 `gateway.stopAccount(ctx)`(`ctx.account.accountId` 定位),停止时经 `statusSinks` 调
|
||||
`ctx.setStatus({running:false, connected:false, lastStopAt})`;启动即上报
|
||||
`ctx.getStatus()/ctx.setStatus({...running:true, connected:true, lastStartAt})`——`connected` 是
|
||||
gateway 判断账号存活的依据。
|
||||
- `sendTyping`(ilink/bot/sendtyping + typing_ticket)是打字指示,非心跳,无需支持。
|
||||
|
||||
## 二、修复计划
|
||||
|
||||
| # | 动作 | 位置 | 风险 |
|
||||
|---|------|------|------|
|
||||
| A | manager 提供 **gateway 生命周期桥**:channel 插件注册后自动 `gateway.startAccount(ctx)`(fire-and-forget,不等待挂起的 Promise),构造完整 ctx `{account, cfg, channelRuntime, getStatus, setStatus}`;Go 端 `channel_stop` 通知 → `stopAccount` | `internal/plugins/clawhubadapter/manager/main.js` | 中 |
|
||||
| B | 实现 **channelRuntime mock**:`reply.dispatchReplyWithBufferedBlockDispatcher`(deliver 回调 → `channel_output` 通知送 Go 端)、`getPolls`(OC 通用通道轮询输入)、`call` 透传 | 同上 | 中 |
|
||||
| C | `tools/call` 通道分支改造:无 `outbound` 的 ChannelPlugin 改走 channelRuntime 事件式发送(agent 输出 → deliver),不再报 "no output handler" | 同上 | 低 |
|
||||
| D | 状态上报透传:`setStatus` 经 `channel_status` 通知 → Go 端可查;health-monitor 语义(startAccount 保持挂起) | 同上 | 低 |
|
||||
| E | stdout 卫生:插件 `console.log` 重定向 stderr(或 JSON-RPC 流感知封装),杜绝污染 | 同上 | 低 |
|
||||
| F | Go 端:`channel_output`/`channel_status` 通知接入(事件分发),通道输出 handler 保持 `sp.CallTool` | `internal/plugins/clawhubadapter/registry.go`、`plugin.go` | 中 |
|
||||
| G | 端到端验证:mock 通道插件 + mock LLM(生产环境,临时放入/移出 skills 目录)复跑全链路(登录启动→收消息→回复→出站→停止) | 生产 | 低 |
|
||||
|
||||
## 三、实施记录
|
||||
|
||||
(逐步填写)
|
||||
|
||||
1. **manager/main.js — 通道运行时与生命周期桥(已完成,独立运行验证)**
|
||||
- `makeChannelRuntime(chName, ch)`:`reply.dispatchReplyWithBufferedBlockDispatcher(opts)` 提取
|
||||
`dispatcherOptions.deliver`/`typingCallbacks` 按 `ctx.AccountId` 挂到 `ch.deliverers`,随后
|
||||
`notify('channel_input', {channel, payload:{content: BodyForAgent||Body, from, sessionKey, accountId,
|
||||
messageSid, chatType, raw}})` 入站;返回 dispatcher(sendNow/addToBuffer/sendBuffer/closeBuffer)。
|
||||
`chatPolls`/`getPolls` 空转(防断连误判)、`call` 转发 `channel_output` 通知。
|
||||
- `startChannels(name)`:channel 插件注册后自动枚举账号(`config.listAccountIds`→`resolveAccount`,
|
||||
缺省 `['default']`),构造完整 ctx `{account, cfg, channelRuntime, getStatus, setStatus}`,
|
||||
**fire-and-forget** 调 `gateway.startAccount`(真实插件会永久挂起,绝不等待);崩溃/状态变更经
|
||||
`channel_status` 通知透传(health-monitor 语义:startAccount 不退出=账号存活)。
|
||||
- `stopChannels(name)`:逐个账号 `gateway.stopAccount`;进程 SIGTERM/SIGINT 时统一执行优雅停靠。
|
||||
- `tools/call` 通道分支:保留 outbound(旧格式)→ 新增 **deliver 事件式发送**
|
||||
(`deliverItem` 按 accountId 取 deliver + typingCallbacks.onReplyStart/onCleanup 包裹)→
|
||||
无 deliver 时降级 `channel_output` 通知 → 兜底报错。不再出现 "has no output handler"。
|
||||
- **stdout 卫生**:全局 `console.log` 重定向 stderr,JSON-RPC 流仅承载协议帧。
|
||||
2. **mock 插件升级(/tmp/opencode/mock-skills/mock-wechat/index.js)**:完全复刻真实 weixin 行为——
|
||||
`gateway.startAccount` 永久挂起 + `setStatus` 上报 + `setInterval` 心跳轮询 + 800ms 后经
|
||||
`dispatchReplyWithBufferedBlockDispatcher` 推送入站(deliver 本地记录发送);`stopAccount` 停轮询+状态置否。
|
||||
3. **manager 独立运行验证(通过)**:`channel_status` 启动上报(running=true connected=true);
|
||||
`tools/call mock-wechat` → `{"status":"sent","via":"channelRuntime.deliver"}`,插件 deliver 收到
|
||||
`text="hello from agent"` 且 typing onReplyStart/onCleanup 正确包裹;心跳 poll #1-4 常驻;
|
||||
SIGTERM → `stopAccount called` 退出码 0;插件 console 输出全部走 stderr(协议流零污染)。
|
||||
4. **Go 端通知接入(plugin.go translateAndRegister + registry.go 状态缓存)**:`channel_status` 存
|
||||
`channelStatus` map(可查)+ 日志;`channel_output` 降级事件日志。`go build ./...` 通过。
|
||||
5. **回复闭环修复(同步注入)**:`channel_input → InjectInterruptText` 的 InputEvent 不带 ResponseCh
|
||||
(internal/agent/io/channel.go:276),agent 回复在 emitResponse(eventloop.go:384)被静默丢弃。
|
||||
改为 `s.InjectInputSync(pluginName, channel, "text", payload)`(内部 SDK 已有 4 参版本,返回
|
||||
`*OutputEvent`)同步等待回复 → 提取 `Payload["content"]` → `sp.CallTool(channel, {payload, meta})`
|
||||
→ manager `tools/call` → deliver → 插件发送 → 微信送达。公共 SDK IOInjector 同步补
|
||||
`InjectInputSync(source, channel, text) string`(ioAdapter 实现,供外部插件一致使用)。
|
||||
6. **mock LLM 恒定文本化**:/opt/llm-mock/mock_server.py `decide()` 删除工具调用分支,一律回文本
|
||||
("无论收到什么消息都通过微信插件发送"),保证每条入站消息回复必然走通道输出。
|
||||
7. **生产微信闭环验证(通过)**:用户微信发"你好..." → pollLoop 收到 → dispatchReply →
|
||||
InjectInputSync → mock LLM 回文本 → CallTool(wechat) → deliver → `POST ilink/bot/sendmessage`
|
||||
→ **status=200 message_id=7489545365740590088**,微信收到"(mock)已收到消息,长度 324 字符。"
|
||||
8. **通用性审查(无硬编码)**:manager/plugin.go/registry.go 均无 weixin/wechat 特判,全部按 OC 规范
|
||||
字段实现(gateway/config/capabilities/channelRuntime/deliver/typingCallbacks)。修正规范签名参数
|
||||
约定:`listAccountIds(cfg)`、`resolveAccount(cfg, accountId)` 正确传 cfg。
|
||||
9. **已知边界**(非硬编码,架构性):a) `channelRuntime.getPolls/chatPolls` 返回空 msgs——依赖
|
||||
runtime 轮询输入的通用通道型插件收不到消息(weixin/dingding 类自带 pollLoop 的通道不受影响);
|
||||
b) `gatewayMethods` 登录流程(web.login.start/QR 扫码)未实现(CLI 有 stub),通道凭 token
|
||||
配置直连;c) startAccount ctx 提供 account/channelRuntime/cfg/getStatus/setStatus 核心字段。
|
||||
10. **补充边界 a) getPolls/chatPolls 消息源(已完成)**:manager `makeChannelRuntime` 的
|
||||
`chatPolls/getPolls` 改为读 `ch.pollQueues`(按 accountId 队列,poll 取走即消费);新增
|
||||
`channel/send` RPC(Go 端注入 → 队列 → 插件轮询取走);Go 端 `SendToChannel(channel, payload)`
|
||||
+ `ChannelSender()` 单例(Start 时置位)。验证:mock-poll 插件(纯 chatPolls 轮询型)——
|
||||
`channel/send` → `{"status":"queued"}` → `[mock-poll] poll got msg` → dispatchReply →
|
||||
`channel_input` 入站完整(content/from/sessionKey/accountId/messageSid/chatType)。
|
||||
微信链路回归正常(Polling started + channel_status running=true)。
|
||||
11. **边界 b) 登录流程核实(已解决,无需实现)**:真实登录机制是 **SKILL 脚本旁路**——
|
||||
`weixin-openclaw-login` SKILL 的 `scripts/get-login-url.js`(ilink 二维码 URL)+
|
||||
`poll-login-status.py`(轮询扫码状态)→ agent 经 exec 执行 → 拿 bot_token 写入
|
||||
`~/.openclaw-weixin/account.json`(2026-07-28 17:31 创建,token 有效)→ 插件启动
|
||||
`resolveAccountData` 直读。不依赖 manager gatewayMethods(web.login.start 等 OC gateway
|
||||
协议 stub 不影响真实使用)。
|
||||
12. **clawhubadapter 全量管理接口(已完成并验证)**:向 agent 暴露完整插件/通道管理面——
|
||||
- 新工具:`clawhubadapter_plugin_info`(类型/工具/关联通道详情)、`plugin_reload`(reloadPlugin)、
|
||||
`channel_list`(全部注册通道 + 实时状态)、`channel_send`(SendToChannel 投递)、
|
||||
`channel_start`/`channel_stop`(manager `channel/start`、`channel/stop` RPC)。
|
||||
- manager 新增 RPC:`plugins/channels`(registeredChannels 摘要含 status/accounts)、
|
||||
`channel/start`(fire-and-forget startChannels 恢复账号)、`channel/stop`(stopAccount,
|
||||
按 channel 全停或按 accountId 单停,省略 channel 则全部停止)。
|
||||
- Go 侧 `channelSummary(mgr)` 合并 manager 注册信息与 `channel_status` 实时缓存(缓存优先)。
|
||||
- 端到端验证(生产实例,LLM 为本地 mock 源):微信发"你好通道..." → agent 执行
|
||||
`channel_list` → `- wechat | plugin=openclaw-weixin type=text running=true connected=true
|
||||
accounts=[default]` → 回复送达;发"注入..." → 执行 `channel_send` → "消息已投递到通道
|
||||
wechat" → 回复送达。standalone manager 另验证 `channel/start`(mock-wechat startAccount
|
||||
重新执行、心跳恢复)与 `channel/stop`(stopAccount called)。
|
||||
13. **插件删除回调 onRemove 全套(已完成并验证)**:`RegisterOnRemoveHandler`(仅卸载触发、
|
||||
重载/禁用不触发,与 stop handler 互补——stop 每次停止都执行)。registry.RemovePlugin 流程:
|
||||
stop handlers → Stop → runOnRemoveHandlers → 移除 plugins/sdkRefs/instances →
|
||||
UnregisterPluginTools → **配置清理**。配置清理含两层:`ConfigRegistry.RemovePlugin`
|
||||
删除 defs 中 `plugin.<name>.*` 配置项定义 + DROP `config_<name>` 插件配置表
|
||||
(含用户设置值,ListPlugins 基于 config_% 表枚举故配置区完全消失);已用临时程序验证
|
||||
(before: defs=1/plugins=[timer] → after: defs=0/plugins=[])。示例盘点(SDK 仓库 15 个):
|
||||
calendar(events.json)、memo(memos.json)、rss(订阅数据目录)、weather(缓存目录)已加;
|
||||
files(filesDir 为用户配置的访问根目录,默认 /)、bili/qq(用户下载资产)、
|
||||
ocr(函数内 defer RemoveAll 自清理)按语义不加;plugindev 模板 main.go.tmpl + README.tmpl
|
||||
含 onRemove 演示;SDK README/README_EN 生命周期文档补"删除清理(onRemove)"小节。
|
||||
|
||||
14. [2026-08-03] SDK 工具链/打包/重装 + dlclose 修复:
|
||||
- 工具链源码位置澄清:SDK 仓完整内容位于 third_party/homeagent-sdk(主仓 .gitignore 仅跟踪
|
||||
sdk/meta/go.mod,"两个远程仓库各取所需";/tmp/opencode/sdk-repo 为工作克隆,远程=gitcode)。
|
||||
- 工具链支持公共 IOInjector.InjectInputSync:CORE_INJECT_INPUT_SYNC=47(C 桥 dispatchIO
|
||||
callString 回传回复文本);主仓 cabi loader case 47 用内部 4 参版 InjectInputSync 取
|
||||
OutputEvent.Payload["content"] setResult(meta.go ID 47 + loader.go,主仓 3f252ed)。
|
||||
- plugindev 构建环境:GOMODCACHE=/root/go/pkg/mod(yaegi 缓存所在)、GOPROXY=off。
|
||||
- 工具链打包 memo:plg.json BOM 去除、name_en "Memo/Notes"→"Memo"(toSnake 不处理斜杠,
|
||||
name_en 带 / 会使 hmap 名含子路径报错);bundle=true 时走全平台交叉编译(本机无 darwin
|
||||
工具链),打包用 `build --no-bundle --target linux/amd64`;产物 dist/memo_linux_amd64.hmap。
|
||||
- 重装:pluginmgr HTTP API(127.0.0.1:9876)DELETE /plugins/memo 卸载(走内核 RemovePlugin
|
||||
+ onRemove)→ POST /plugins binary body 传 hmap(返回 installed+checksum);生效用
|
||||
webui `POST /api/v1/plugins/reload`(X-API-Key,生产 admin123)。
|
||||
- 关键 bug:Linux dlopen 同路径复用旧句柄——RemovePlugin/ReloadOne 只 Stop 不 dlclose,
|
||||
插件二进制更新后重载仍执行旧代码(生产 memo 装新版仍注册旧 3 工具)。修复:
|
||||
cabiPlugin.Close()(handle.Close)+ Registry.closeDynamic 在卸载/重载时调用(主仓 649e312)。
|
||||
生产 homed-new7 验证:memo 6 工具(memo_todo_add/complete/list + memo_memo_create/list/delete)
|
||||
注册正常,wechat 通道 running。
|
||||
- SDK 仓推送 b6e30f9(工具链 47 + 重建 bin 二进制 + memo plg.json + sdk/plugin.go 注释精简)。
|
||||
15. [2026-08-03] 嵌套 git 恢复 + 工作区清理 + codegraph 索引修正:
|
||||
- 嵌套 git 恢复:third_party/homeagent-sdk 原本是"单仓库双提交"(目录内嵌套 .git 推 gitcode
|
||||
homeagent-sdk 仓,主仓 git 跟踪 sdk/meta/go.mod 推 HomeAgent 仓),嵌套 .git 此前被误删;
|
||||
已从 /tmp/opencode/sdk-repo 复制 .git 恢复(remote=homeagent-sdk.git,HEAD=b6e30f9,工作区干净),
|
||||
主仓 git 不受影响。以后 SDK 改动直接在 third_party 内 git commit+push(SDK 仓推送仍用带凭据
|
||||
URL https://JianFeeeee:BCkb32xBuLxWD9P4MmU8ydZ5@gitcode.com/JianFeeeee/homeagent-sdk.git),
|
||||
不再经 /tmp 中转。
|
||||
- /tmp 清理:删除 /tmp/opencode/sdk-repo、hasdk-fresh、plugindev-new、plugindev_new、mock-run、
|
||||
lunartest(SDK 中转/临时目录);保留 homed-new*(生产二进制备份)、mock-skills 等非 SDK 内容。
|
||||
- replace 修正:go.mod 第 17 行已是 `./third_party/homeagent-sdk`(正确);package-linux.sh
|
||||
prepare_gomod 优先用 $PROJECT_ROOT/third_party/homeagent-sdk,仅缺失时才 clone /tmp/homeagent-sdk
|
||||
兜底(主仓 108faac)。
|
||||
- codegraph 索引修正:根目录 codegraph.json(PROJECT_CONFIG_FILENAME)配
|
||||
includeIgnored+include: ["third_party/homeagent-sdk"],codegraph index 后 Files 179→233,
|
||||
third_party 文件 7→61,tools/plugindev 与 sdk/plugin.go(InjectInputSync 等)均可查询
|
||||
(此前嵌套 SDK 仓被主仓 .gitignore 挡在索引外);codegraph sync 不感知配置变更,需 index 全量重建。
|
||||
@ -1,430 +0,0 @@
|
||||
# 外部插件接口不变矩阵(多进程化整改基线)
|
||||
|
||||
> 状态:**完成 v3**(2026-09-06)——v2 的迁移已上生产(内核 v1.0.0);v3 记录 v1.1.1 的公开接口**扩展**。
|
||||
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
|
||||
> 所有改造落在**核心(homed 侧)+ 工具链(hmapdev,当时名为 plugindev)**,外部插件业务代码零改动,只需用新工具链重编。
|
||||
>
|
||||
> **结果(已验证)**:`git diff third_party/homeagent-sdk/sdk/` 全程为空;17 个 `example/*/plugin.go` 逐字节未改
|
||||
> (`git status example/` 无输出);生产 17 插件全部经子进程通道运行。
|
||||
>
|
||||
> ⚠️ **v1.1.x 起冻结约束被有意解除**,因为「接口不变」这条约束本身是为**迁移期**设的:
|
||||
> 它要保的是「换运行模型不动业务代码」。迁移完成后,SDK 需要能随功能演进而扩展,
|
||||
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
|
||||
>
|
||||
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
|
||||
> 必须同步更新本矩阵。
|
||||
>
|
||||
> 权威编号:plan.md 第 11 节(11.1~11.9)。本文档只做接口面盘点,不做实现。
|
||||
|
||||
---
|
||||
|
||||
## 一、迁移的形状(一句话)
|
||||
|
||||
```
|
||||
今天: 外部插件 = example/*/plugin.go(纯 Go) ──hmapdev c-shared──> plugin.so
|
||||
homed ──dlopen──> plugin.so(C ABI bridge:51 个整数 method id)
|
||||
之后: 外部插件 = example/*/plugin.go(纯 Go,一行不改) ──hmapdev go build──> plugin.bin
|
||||
homed ──spawn──> plugin.bin(stdio JSON-RPC + shm + eventfd)
|
||||
```
|
||||
|
||||
**为什么接口可以不变**(已代码核实):
|
||||
|
||||
| 层 | 含 cgo? | 迁移后动作 |
|
||||
|---|---|---|
|
||||
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
|
||||
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go(只 import 公开 SDK) | **不动**(只重编) |
|
||||
| bridge 模板 `tools/hmapdev/templates.go` 的 `tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
|
||||
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
|
||||
| homed `internal/plugin/cabi/`(1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2) |
|
||||
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
|
||||
|
||||
---
|
||||
|
||||
## 二、合同面 A:公开 SDK 类型与接口(迁移前后必完全一致)
|
||||
|
||||
文件:`third_party/homeagent-sdk/sdk/{plugin.go,memory.go,knowledge.go,llm.go,settings.go}`
|
||||
|
||||
### A1. 插件入口契约(Plugin 接口)
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
// 外部插件实现 NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error)
|
||||
```
|
||||
|
||||
### A2. 插件可注册的 5 类组件(PluginSDK 方法)
|
||||
|
||||
| PluginSDK 方法 | 签名 | 外部插件使用量(example 实测) |
|
||||
|---|---|---|
|
||||
| `RegisterTool` | `(name string, def ToolDef, handler ToolHandler) error` | **86** |
|
||||
| `RegisterStage` | `(stage Stage, handler StageHandler, scope ...StageScope)` | 6 |
|
||||
| `RegisterOutputChannel` | `(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error` | 4 |
|
||||
| `RegisterInputChannel` | `(name string, def ChannelDef) error` | 2 |
|
||||
| `RegisterPluginAPI` | `(name string) error` | 0(定义存在,可用) |
|
||||
|
||||
### A3. 插件可调用的能力访问器(PluginSDK 方法)
|
||||
|
||||
| 访问器 | 返回 | 外部插件使用量 |
|
||||
|---|---|---|
|
||||
| `Settings()` | `SettingsAPI` | **17 插件全部使用**(Get/Set/List/GetCore/SetCore/ListCore/DataDir/GetPlugin/SetPlugin/ListPlugin/RegisterDef/Defs/Dump/Plugins) |
|
||||
| `Memory()` | `MemoryAPI`(Recall/Commit/Introspect/MergeEntities/Purge) | 低(controllable) |
|
||||
| `DocMemory()` | `DocMemoryAPI`(Query/Insert/**InsertWithMedia**/Remove/Stats) | 低(`InsertWithMedia` v1.1.0 新增) |
|
||||
| `TextMemory()` | `TextMemoryAPI`(Append) | 0 当前 |
|
||||
| `Knowledge()` | `KnowledgeAPI`(Search/Add/List) | 2 |
|
||||
| `LLM()` | `LLMAPI`(ListSources/SetSource/CurrentSource) | 0 当前 |
|
||||
| `Social()` | `SocialAPI`(**只读**:GetPerson/GetTrait/GetRelations/GetNetwork/ListPersons) | 0 当前 |
|
||||
| `Events()` | `EventSubscriber`(Subscribe) | 0 当前(**C ABI 空实现**,迁移后可获得) |
|
||||
| `PluginMgr()` | `PluginMgrAPI`(ReloadOne/ListLoadedPlugins/IsPluginDisabled) | 0 当前 |
|
||||
| `AutoRestart()` | `bool` | 配套 SetAutoRestart 用 |
|
||||
|
||||
### A4. 生命周期 / 工具注入(PluginSDK 方法)
|
||||
|
||||
| 方法 | 签名 | 备注 |
|
||||
|---|---|---|
|
||||
| `SetAutoRestart` / `AutoRestart` | `(bool)` / `() bool` | example 使用 16 次 |
|
||||
| `InjectText` | `(source, channel, text string)` | → C ABI case 5 |
|
||||
| `InjectInterruptText` | `(source, channel, text string)` | example 使用 6 次 → case 6 |
|
||||
| `InjectTextNoMemory` | `(source, channel, text string)` | → case 7 |
|
||||
| `InjectInputSync` | `(source, channel, text string) string` | → case 47(例:qq 闭环) |
|
||||
| `SetToolBlocks` | `(blocks []ContentBlock)` | ✅ **v1.1.1 已落地**(`io.setToolBlocks`);同版补上 `PluginSDK` 侧一直缺失的便捷包装——接口里有、便捷方法里没有,插件此前只能自己去拿 injector |
|
||||
| `InjectInputMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectMedia`。与 `SetToolBlocks` 的区别见下方说明 |
|
||||
| `InjectInputMediaSync` | `(source, channel, text string, blocks []ContentBlock) string` | **v1.1.0 新增** → `io.injectMediaSync` |
|
||||
| `InjectInterruptMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增** → `io.injectInterruptMedia` |
|
||||
|
||||
**为何媒体注入不能搭 `SetToolBlocks` 的车**:后者只在**工具处理函数内部**可用,且媒体要等
|
||||
**下一条 tool message** 才到模型手上。插件主动发起一轮带媒体的对话、以及中断注入,
|
||||
需要各自的签名,且媒体在**本轮**就随消息发出,并自动落进 CAS、挂上媒体记忆引用。
|
||||
| `RegisterStopHandler` / `RunStopHandlers` | `(func())` / `()` | 已有(qq 等 1 次) |
|
||||
| `RegisterOnRemoveHandler` / `RunOnRemoveHandlers` | `(func())` / `()` | example 使用 3 次 |
|
||||
| `Set*`(SetIOInjector/SetMemoryAPI/.../SetPluginMgrAPI) | — | 供 bridge/核心启动时接线,插件不直接调 |
|
||||
|
||||
### A5. 核心数据类型(迁移前后结构体字段/JSON tag 不变)
|
||||
|
||||
| 类型 | 关键字段 | 备注 |
|
||||
|---|---|---|
|
||||
| `StageContext` | 16 字段:RawMessage/UserID/GroupID/ContextMsgs/LLMText/ReasoningContent/TokenUsage/ToolCalls/ToolResults/FinalText/Response/Phase/Memory/NoMemory/Extra/Errors + Lock/RLock/Unlock/RUnlock/IsResponded | **注意**:外部插件经 C ABI 只能看到 10 个字段(见 C3),迁移到共享内存后可看到全部 16 个 |
|
||||
| `ToolDef` | Name/Plugin/Description/Parameters/NoMemory/Cleaner(func) | `Cleaner` 是函数,**无法过 C ABI**(迁移后经 RPC/进程内保留) |
|
||||
| `ChannelDef` | NoMemory/Cleaner(func) | 同上 |
|
||||
| `ToolCall` / `ToolResult` / `MemItem` | ID/Name/Plugin/Arguments;CallID/Name/Plugin/Success/Result;Role/Content/Score | 全部纯 JSON 可序列化 |
|
||||
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURL;URL/Detail;URL | 全部可偏移化(迁移评估 3.3 已核实) |
|
||||
| `MediaAttachment`(**v1.1.0 新增**) | Digest/MIME/Data/Name/Description | 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节去重),只给 `Digest` 是引用已有内容。**读路径不回 `Data`**——一次检索可能命中几十份媒体,全塞回去会撑爆跨进程消息 |
|
||||
| `Event` / `EventHandler` / `EventSubscriber` | Type/Source/Payload/Timestamp | 迁移后才对外部插件真正可用 |
|
||||
| `Triple` / `Entity` / `Relation` / `Doc` / `TextEvent` / `PersonProfile` / `SocialRelation` / `Knowledge` / `ConfigDef` | — | 全部 JSON 可序列化 |
|
||||
| `Triple`(**v1.1.0 扩展**) | += `SentenceText` / `MediaDigests` | 媒体引用挂在**句子**上(`SentenceText` → `sentences` → `sentence_id` → `media_refs`),所以 `MediaDigests` 非空而 `SentenceText` 为空时内核会用媒体标记本身充当句子 |
|
||||
| `Doc`(**v1.1.0 扩展**) | += `MediaDigests` / `Attachments` | `Query` 返回时由内核填充(仅元数据,不带字节) |
|
||||
| `TextEvent`(**v1.1.0 扩展**) | += `Attachments` | 写入时内核把标记并进正文;`RecentEvents` 读回时从标记反解 |
|
||||
|
||||
**函数类型字段盘点(唯一无法跨进程序列化的东西)**:
|
||||
- `ToolDef.Cleaner func(string) string`
|
||||
- `ChannelDef.Cleaner func(string) string`
|
||||
- `StageContext.mu sync.RWMutex`(~~锁~~ → 迁移后映射到跨进程锁仲裁)
|
||||
- 各种 `ToolHandler`/`StageHandler`/`EventHandler`/`func()`(回调 → RPC 反向注册)
|
||||
|
||||
→ 这些正是共享内存 + RPC 要保的「留在进程内的回调型资源」(迁移评估 3.5)。
|
||||
|
||||
---
|
||||
|
||||
## 三、合同面 B:bridge 51 个 method id ↔ SDK 方法映射(改造基线)
|
||||
|
||||
> ⏹️ **已完成(2026-09-03)**:整数 method id 已全部平移为 RPC method 名字符串,
|
||||
> 定义在 `internal/plugin/proc/protocol.go` 的 `Method*` 常量(共 60 个,含内核→插件方向)。
|
||||
> 原 `tmplLinuxBridge` 与 `meta.Core<Method>` 整数表**均已删除**。
|
||||
>
|
||||
> 两个遗留点:`case 25`(`CoreFreeString`)无对应 method(内存管理是 C 层特有问题);
|
||||
> `io.setToolBlocks` 已定义但内核侧仍返回未实现(C ABI 时代也是空实现,非回归)。
|
||||
>
|
||||
> 下表保留作为历史对照。
|
||||
|
||||
| # | method id(今天 C ABI) | SDK 背的方法 | 迁移后 RPC method 名(建议) |
|
||||
|---|---|---|---|
|
||||
| 1 | CORE_REGISTER_TOOL | RegisterTool | `tool.register` |
|
||||
| 2 | CORE_REGISTER_STAGE | RegisterStage | `stage.register` |
|
||||
| 3 | CORE_REGISTER_OUTPUT_CH | RegisterOutputChannel | `output.register` |
|
||||
| 4 | CORE_REGISTER_PLUGIN_API | RegisterPluginAPI | `api.register` |
|
||||
| 5 | CORE_INJECT_TEXT | InjectText | `io.injectText` |
|
||||
| 6 | CORE_INJECT_INTERRUPT_TEXT | InjectInterruptText | `io.injectInterrupt` |
|
||||
| 7 | CORE_INJECT_TEXT_NO_MEMORY | InjectTextNoMemory | `io.injectTextNoMem` |
|
||||
| 47 | CORE_INJECT_INPUT_SYNC | InjectInputSync | `io.injectInputSync` |
|
||||
| 8 | CORE_SET_AUTO_RESTART | SetAutoRestart | `lifecycle.autoRestart` |
|
||||
| 9 | CORE_MEMORY_RECALL | Memory().Recall | `memory.recall` |
|
||||
| 10 | CORE_MEMORY_COMMIT | Memory().Commit | `memory.commit` |
|
||||
| 11 | CORE_MEMORY_INTROSPECT | Memory().Introspect | `memory.introspect` |
|
||||
| 12 | CORE_MEMORY_MERGE | Memory().MergeEntities | `memory.merge` |
|
||||
| 13 | CORE_MEMORY_PURGE | Memory().Purge | `memory.purge` |
|
||||
| 14 | CORE_DOC_QUERY | DocMemory().Query | `doc.query` |
|
||||
| 15 | CORE_KNOWLEDGE_SEARCH | Knowledge().Search | `knowledge.search` |
|
||||
| 16 | CORE_SETTINGS_GET | Settings().Get | `settings.get` |
|
||||
| 17 | CORE_SETTINGS_SET | Settings().Set | `settings.set` |
|
||||
| 18 | CORE_SETTINGS_REGISTER_DEF | Settings().RegisterDef | `settings.registerDef` |
|
||||
| 19 | CORE_LLM_LIST_SOURCES | LLM().ListSources | `llm.listSources` |
|
||||
| 20 | CORE_LLM_SET_SOURCE | LLM().SetSource | `llm.setSource` |
|
||||
| 21 | CORE_SOCIAL_GET_PERSON | Social().GetPerson | `social.getPerson` |
|
||||
| 22 | CORE_SOCIAL_GET_NETWORK | Social().GetNetwork | `social.getNetwork` |
|
||||
| 23 | CORE_SUBSCRIBE | Events().Subscribe | `events.subscribe`(**今天空实现**) |
|
||||
| 24 | CORE_UNSUBSCRIBE | (退订闭包) | `events.unsubscribe`(**今天空实现**) |
|
||||
| 25 | CORE_FREE_STRING | (内存释放) | 删除(RPC 无此概念) |
|
||||
| 26 | CORE_SETTINGS_GET_CORE | Settings().GetCore | `settings.getCore` |
|
||||
| 27 | CORE_SETTINGS_SET_CORE | Settings().SetCore | `settings.setCore` |
|
||||
| 28 | CORE_SETTINGS_LIST_CORE | Settings().ListCore | `settings.listCore` |
|
||||
| 29 | CORE_SETTINGS_GET_PLUGIN | Settings().GetPlugin | `settings.getPlugin` |
|
||||
| 30 | CORE_SETTINGS_SET_PLUGIN | Settings().SetPlugin | `settings.setPlugin` |
|
||||
| 31 | CORE_SETTINGS_LIST_PLUGIN | Settings().ListPlugin | `settings.listPlugin` |
|
||||
| 32 | CORE_DOC_INSERT | DocMemory().Insert | `doc.insert` |
|
||||
| 33 | CORE_DOC_REMOVE | DocMemory().Remove | `doc.remove` |
|
||||
| 34 | CORE_DOC_STATS | DocMemory().Stats | `doc.stats` |
|
||||
| 35 | CORE_KNOWLEDGE_ADD | Knowledge().Add | `knowledge.add` |
|
||||
| 36 | CORE_KNOWLEDGE_LIST | Knowledge().List | `knowledge.list` |
|
||||
| 37 | CORE_LLM_CURRENT_SOURCE | LLM().CurrentSource | `llm.currentSource` |
|
||||
| 38 | CORE_SOCIAL_GET_TRAIT | Social().GetTrait | `social.getTrait` |
|
||||
| 39 | CORE_SOCIAL_GET_RELATIONS | Social().GetRelations | `social.getRelations` |
|
||||
| 40 | CORE_SOCIAL_LIST_PERSONS | Social().ListPersons | `social.listPersons` |
|
||||
| 41 | CORE_TEXT_MEMORY_APPEND | TextMemory().Append | `textmemory.append` |
|
||||
| 42 | CORE_SETTINGS_LIST | Settings().List | `settings.list` |
|
||||
| 43 | CORE_SETTINGS_DEFS | Settings().Defs | `settings.defs` |
|
||||
| 44 | CORE_SETTINGS_DUMP | Settings().Dump | `settings.dump` |
|
||||
| 45 | CORE_SETTINGS_PLUGINS | Settings().Plugins | `settings.plugins` |
|
||||
| 51 | CORE_SETTINGS_DATA_DIR | Settings().DataDir | `settings.dataDir` |
|
||||
| 46 | CORE_REGISTER_INPUT_CH | RegisterInputChannel | `input.register` |
|
||||
| 48 | CORE_PLUGIN_RELOAD_ONE | PluginMgr().ReloadOne | `plugin.reloadOne` |
|
||||
| 49 | CORE_PLUGIN_LIST_LOADED | PluginMgr().ListLoadedPlugins | `plugin.listLoaded` |
|
||||
| 50 | CORE_PLUGIN_IS_DISABLED | PluginMgr().IsPluginDisabled | `plugin.isDisabled` |
|
||||
|
||||
**bridge 侧反向调用(内核 → 插件,RPC 的另一半)**:
|
||||
|
||||
| 今天 | 迁移后 |
|
||||
|---|---|
|
||||
| `go_invoke_tool(name, argsJSON)` | `tool.invoke`(homed → pinvoke) |
|
||||
| `go_invoke_stage(stage, ctxJSON, resultOut)` | `stage.invoke`(homed → pinvoke,共享内存数据面) |
|
||||
| `go_invoke_output(channel, type, payloadJSON)` | `output.invoke`(homed → pinvoke) |
|
||||
| `go_free_string` | 删除 |
|
||||
|
||||
---
|
||||
|
||||
## 四、合同面 C:StageContext 跨 ABI 现状 → 共享内存目标
|
||||
|
||||
> ✅ **已达成(2026-09-03)**:子进程插件现在看到全部 18 个字段(枚举见
|
||||
> `internal/plugin/proc/shm.go`),且可写回。生产实测:sanitizer 在另一个进程里
|
||||
> 改写 13590 字节文本,内核读到改写结果(`stage post_action 改写了 1 个字段`)。
|
||||
|
||||
### C1. 迁移前(C ABI 副本模型):插件只看到 10 个字段
|
||||
|
||||
`stageContextWritable`(templates.go:762)下发/回传的字段:
|
||||
|
||||
```
|
||||
raw_message user_id group_id phase llm_text final_text no_memory
|
||||
+ response(可选) + tool_calls(有才传) + tool_results(有才传)
|
||||
```
|
||||
|
||||
**看不到的 6 个字段**:`ContextMsgs` / `ReasoningContent` / `TokenUsage` / `Memory` / `Extra` / `Errors`
|
||||
|
||||
### C2. 迁移后(共享内存 + 锁仲裁):插件可看到/改写全部字段 — ✅ 已实现
|
||||
|
||||
字段级 `Slice{Off,Len}` 描述符 + 内核仲裁锁。插件进程内保留原生 `StageContext`,
|
||||
handler 照常读写,`Lock/RLock` 映射到跨进程锁仲裁 RPC(`stage.lock`/`stage.unlock`),
|
||||
handler 返回时脏字段写回共享段。
|
||||
|
||||
**关键设计决定**:全部子进程插件共享**同一块 memfd**。第一版设计是每插件一段,
|
||||
那会退化成副本模型,复现 §8.4 的 35.8~36.8% lost update。
|
||||
|
||||
→ **接口形式不变,能力变强**(能力断层消除:外部插件拿回 ContextMsgs 等)。
|
||||
|
||||
Windows 同步受益:从「只下发 3 字段、无写回」升到全字段可见 + 写回,
|
||||
与 Unix 共用同一套 RPC 实现与共享段布局。
|
||||
|
||||
### C3. lost update 的合同面定义 — ✅ 已消除
|
||||
|
||||
C ABI 时代 `stageContextWritable` **无条件回传 10 个字段的当前快照**——两个插件
|
||||
(sanitizer 改 ToolResults + weather 只读)并行时,weather 的回传会覆盖 sanitizer
|
||||
的清洗结果(实测 1.6~4.3%,高并发下 35.8~36.8%)。
|
||||
|
||||
Part 0.2 先做了过渡补丁(只回传真正变更的字段);Part 4 的共享内存模型从根上解决
|
||||
(字段级描述符 + 锁仲裁,并发改写同一对象)。
|
||||
|
||||
回归基线:`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`、
|
||||
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`。
|
||||
|
||||
---
|
||||
|
||||
## 五、外部插件实际触达面(example 18 插件实测汇总)
|
||||
|
||||
> 这是「17 个存量插件业务代码零改动」的直接依据——它们**只用**下表这些 API,全部在公开 SDK 合同面内。
|
||||
|
||||
| 插件 | 用到的 SDK 触达 |
|
||||
|---|---|
|
||||
| qq(最复杂) | SetAutoRestart / RegisterDef×11 / RegisterOutputChannel(qq, 4 caps) / RegisterInputChannel(qq, NoMemory+Cleaner) / RegisterStage(BeforeToolcall, OwnTools) / RegisterTool×N / InjectInterruptText×2 / getSetting(p.sdk.Settings()) |
|
||||
| weather / rss / bili / ocr / files / memo / music / a2a / acp / ai_image / browser / calendar / editdoc / recoverydiag / sanitizer / vanblog / luademo | RegisterTool / Settings / SetAutoRestart / (部分) RegisterStage / RegisterOutputChannel / InjectInputSync / Knowledge / RegisterStopHandler / RegisterOnRemoveHandler |
|
||||
|
||||
**结论**:外部插件触达面 ⊆ 公开 SDK 合同面;无任何插件直接使用方法 id 或 bridge 内部符号。
|
||||
→ 只要公开 SDK 签名不变 + bridge 语义平移,接口不变约束成立。
|
||||
|
||||
---
|
||||
|
||||
## 六、迁移后外部插件「新获得」的能力(合同面扩展——只增不减)
|
||||
|
||||
| 能力 | 迁移前 | 迁移后 | 实际结果 |
|
||||
|---|---|---|---|
|
||||
| 事件订阅 `Events().Subscribe`(case 23/24) | ❌ 空实现 | ✅ 事件环(EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) |
|
||||
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ `io.setToolBlocks` | ✅ **v1.1.1 已落地**(走 JSON 而非共享段二进制通道,理由见 §九) |
|
||||
| 媒体入记忆(`InsertWithMedia`、`Triple.MediaDigests`) | ❌ 不存在 | ✅ CAS + 引用计数 GC | ✅ **v1.1.0 类型 / v1.1.1 内核实现** |
|
||||
| 插件主动发起带媒体的一轮对话(`InjectInputMedia*`) | ❌ 不存在 | ✅ 媒体在本轮就到模型手上 | ✅ **v1.1.1** |
|
||||
| `ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` | ❌ 看不到 | ✅ 共享内存全字段 | ✅ 18 字段全可见可写 |
|
||||
| 插件崩溃隔离 | ❌ panic 带崩 homed | ✅ 子进程独立崩溃 | ✅ 测试 + 生产验证 |
|
||||
| 热重载 `.so` | ❌ `DF_1_NODELETE` no-op | ✅ 同路径替换 `.bin` 即生效 | ✅ 生产实测 |
|
||||
| 工具超时取消 | ❌ cgo 不可中断(泄漏线程) | ✅ `Process.Kill()` 真取消 | ✅ 整套新架构零 cgo |
|
||||
| `output_send` 结果 | ❌ 永远假成功 | ✅ 可同步等真实结果 | ✅ 生产实测 `map[status:sent]` |
|
||||
| Lua/Windows DLL 路径 | ❌ 三套 ABI 分裂 | ✅ 收敛为单一 RPC 实现 | ⚠️ Windows 已收敛;Lua 仍独立(留待后续) |
|
||||
|
||||
**三项未完全兼得的说明**:
|
||||
|
||||
- `SetToolBlocks`:`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`,但内核侧 handler
|
||||
仍返回未实现。C ABI 时代它也是空实现(§1.4),故**不是回归**,但也没兑现承诺。
|
||||
- Lua:`lua_plugin.go`/`dynamic_lua.go` 仍走自己的路径。Lua 经解释器不经 C ABI,
|
||||
不属于本轮要消除的 6 类缺陷,因此不阻塞。收敛第三套 ABI 是独立优化。
|
||||
- 事件订阅:机制已完成(内核侧 `EvtRing` + 模板侧 `evtConsumerLoop`),
|
||||
但**无任何现有插件使用 `Events().Subscribe`**,所以生产上未经真实负载检验。
|
||||
|
||||
**刻意不给**(权限梯度显式化,非技术限制):`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/
|
||||
`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`
|
||||
(内核内部机制)。清单与理由记在 `internal/plugin/proc/capability.go` 的
|
||||
`withheldCapabilities`,`TestCapability_WithheldListIsDocumented` 守护。
|
||||
|
||||
这一项从「C ABI 表达能力的意外产物」变成**显式策略**:以前拿不到是因为
|
||||
C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 case 就能捅穿);
|
||||
现在是三道闸:类型层(`procCore` 命名字段不嵌入)+ 能力集(manifest 声明)
|
||||
+ RPC 边界(返回明确错误而非静默忽略)。
|
||||
|
||||
---
|
||||
|
||||
## 七、接口冻结检查点(全部已通过)
|
||||
|
||||
1. ✅ **阶段 2(子进程通道原型)**:`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
|
||||
验收:weather 业务代码逐字节未改(`git status example/` 无输出)。
|
||||
2. ✅ **阶段 3(共享内存)**:子进程并发改写 StageContext 丢失率 = 0%
|
||||
(`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` 与
|
||||
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`)。
|
||||
3. ✅ **阶段 5**:17 个外部插件全部 `.bin` 化、cabi 删除(-3198 行);
|
||||
`go build ./...` 与全仓 `go test ./...` 均通过。
|
||||
4. ✅ **全程**:`git diff third_party/homeagent-sdk/sdk/` 为零——接口冻结的硬证据。
|
||||
5. ⚠️ **v1.1.x 起该检查项不再适用**:冻结是迁移期的约束,迁移完成即到期(见 §九)。
|
||||
取代它的门禁是「存量插件零改动零重编」——见 §九的验证方式。
|
||||
|
||||
生产端到端(2026-09-03,真实 QQ 消息):
|
||||
|
||||
```
|
||||
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
|
||||
output_send__qq output_send__qq qq_mark_read])
|
||||
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
|
||||
[proc] sanitizer stage post_action 改写了 1 个字段
|
||||
tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、v1.1.x 的接口扩展规则(冻结解除后的替代约束)
|
||||
|
||||
冻结约束是为**迁移期**设的:它要保的是「换运行模型不动业务代码」。迁移完成后继续冻结,
|
||||
等于让 SDK 永远停在迁移那天的能力面——多模态这类功能永远到不了插件手上。
|
||||
|
||||
取代它的是三条更弱但仍然硬的约束:
|
||||
|
||||
### 1. 只增不减,签名不改
|
||||
|
||||
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
|
||||
|
||||
实例:v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
|
||||
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
|
||||
而那些插件根本不关心媒体。
|
||||
|
||||
### 2. 新增方法必须是「插件调用、内核实现」方向
|
||||
|
||||
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增三个方法后,插件只是
|
||||
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
|
||||
每个存量插件都会因未实现而编译失败。
|
||||
|
||||
因此 `SDKCompatibleVersion` 与 SDK 的 `CoreVersion` 都不必随之跃迁:
|
||||
1.1.0 的 SDK 配 1.0.0 编的插件仍然成立。
|
||||
|
||||
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
|
||||
|
||||
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
|
||||
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
|
||||
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
|
||||
E2E 用例编译失败),不是靠人工检查发现的。
|
||||
|
||||
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
|
||||
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
|
||||
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
|
||||
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,所以漂移不会被编译器抓到
|
||||
(v1.1.1 修的时候发现它的 `Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation`)。
|
||||
|
||||
### 验证方式(取代「diff 为零」)
|
||||
|
||||
| 检查 | 命令 | v1.1.1 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`(17 个) | ✅ 17/17 通过 |
|
||||
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin` 跑 `TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
|
||||
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
|
||||
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE(13 例压测) |
|
||||
|
||||
### v1.2.x 的接口扩展(2026-09-12)
|
||||
|
||||
1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**:
|
||||
|
||||
| 新增 | 方向 | 说明 |
|
||||
|---|---|---|
|
||||
| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 |
|
||||
| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune`;`prune` 必须显式声明 |
|
||||
| 六个 `*Opts` 变体(Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia) | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 |
|
||||
| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** |
|
||||
|
||||
签名层面零变更(六个方法全是新增),满足第 1、2 条。
|
||||
|
||||
**但「接口纯追加」不等于「无需重编」**:1.2.0 同时把插件运行协议升到 2
|
||||
(fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝
|
||||
并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。
|
||||
|
||||
#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类)
|
||||
|
||||
1. **模板接线守卫红了**:`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的
|
||||
每一个 method id,而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它,
|
||||
现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面**
|
||||
(用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的
|
||||
`deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表
|
||||
退化成「永久豁免」的垃圾抽屉)。
|
||||
2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集
|
||||
**机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件(qq / a2a)完成
|
||||
「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**,
|
||||
不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。)
|
||||
|
||||
#### 验证(1.2.0,本机实测)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 存量插件源码零改动 | 逐个 `cd example/<n> && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) |
|
||||
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) |
|
||||
| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok |
|
||||
| mocksdk 未漂移 | 方法集求差(14 个方法) | ✅ 差集为空 |
|
||||
|
||||
### 为何媒体块走 JSON 而不是共享段二进制通道
|
||||
|
||||
`SetToolBlocks` 的原设计是「二进制落 arena,Slice 描述符回传」。实际落地时改走 JSON:
|
||||
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要让这四个 method 跟其余
|
||||
51 个分道扬镳。共享段的价值在于**并发改写同一份状态**(StageContext 的 lost update),
|
||||
而媒体块是单向传递的不可变数据,没有这个问题。
|
||||
|
||||
---
|
||||
|
||||
## 八、关联文档
|
||||
|
||||
- `docs/zh/架构迁移评估.md` — 完整论证(§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐)
|
||||
- `docs/zh/plugin-migration-plan.md` — Part 0~6 执行计划与完成实录(含 Part 6.5 生产切换、Part 6.6 压测)
|
||||
- `plan.md` §11 — 11.1~11.9 修复清单(唯一权威编号)
|
||||
- `third_party/homeagent-sdk/sdk/` — 合同面 A 的代码实现(全程零 diff)
|
||||
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
|
||||
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
|
||||
- `internal/plugin/proc/capability.go` — 权限梯度(capability 组 + `withheldCapabilities`)
|
||||
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
|
||||
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具
|
||||
@ -1,632 +0,0 @@
|
||||
# 外部插件多进程化适配计划(修改→审查→验证三步微循环)
|
||||
|
||||
> 分支:`update`
|
||||
> 基线:`docs/zh/plugin-interface-matrix.md`(合同面 A/B/C)+ `plan.md` §11 + `docs/zh/架构迁移评估.md`
|
||||
> 每部分 = 一个「修改 → 审查 → 验证」三步微循环。所有验证在 **update 分支**完成,可独立交付、可回退。
|
||||
>
|
||||
> **循环的铁律**(每部分适用):
|
||||
> - **修改**:只动核心侧 + 工具链,`third_party/homeagent-sdk/sdk/`(合同面 A)**零 diff**。
|
||||
> - **审查**:接口冻结检查(`git diff` 公开 SDK 为空)+ 代码 review + `go vet`。
|
||||
> - **验证**:`make test` + 针对性单测 + 端到端冒烟,产物 `.bin` 端到端可用。
|
||||
>
|
||||
> 标 `【M】`=修改部分、`【R】`=审查部分、`【V】`=验证部分。依赖前置部分完成后才可开始。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- **Part 0** 脆弱基线先行(不依赖迁移,现网可直接受益)— 0.1 ✅ / 0.2 ✅ / 0.3 ⏭️ / 0.4 ⏭️
|
||||
- **Part 1** 加载分派骨架(`entry` 双通道共存)— ✅ **已完成**
|
||||
- **Part 2** 子进程通道原型(spawn / JSON-RPC / procPlugin)— ✅ **已完成**
|
||||
- **Part 3** plugindev 工具链改造(`.bin` 产物)— ✅ **已完成**
|
||||
- **Part 4** 共享内存数据面(StageContext 跨进程并发改写)— ✅ **已完成**(段/编解码/锁仲裁 + RunStage 接线)
|
||||
- **Part 5** 通知面(事件环 + eventfd)— ✅ **核心已完成**
|
||||
- **Part 6** 迁移与收尾(17 插件逐个 + 删 cabi + 权限显式化)
|
||||
- 最终验收清单
|
||||
|
||||
> **进度快照(2026-09-02)**:分支 `feature/plugin-proc-migration`。
|
||||
> 已交付:现网止血 2 项(11.1/11.3)、entry 双通道分派、共享内存 stage 并发、
|
||||
> 子进程控制面(NDJSON RPC + 51 method 名平移)、plugindev `.bin` 构建、
|
||||
> registry 接线、**事件环(§3.6)**。**外部插件已可端到端跑在子进程 + 共享内存上**,
|
||||
> 且首次获得事件订阅能力(C ABI 下 case 23/24 一直是空实现)。
|
||||
> 测试:内核 `internal/plugin/proc` 38 项 + `internal/plugin` 16 项(含 `-race`),
|
||||
> SDK 仓 plugindev 16 项。
|
||||
> 下一步:Part 6 逐插件迁移 + 删 `internal/plugin/cabi/`。
|
||||
|
||||
---
|
||||
|
||||
## Part 0:脆弱基线先行(阶段 0,~1 人日)
|
||||
|
||||
> 依据:plan.md §11.1/11.3/11.6。不依赖任何新架构,独立交付,现网直接受益。
|
||||
> 目的:在副本模型内部打补丁,止血,为后续迁移争取时间。
|
||||
|
||||
### 0.1 output_send 假成功修复(11.1)— ✅ **已完成**(2026-08-31)
|
||||
|
||||
- 【M】✅ `internal/plugin/cabi/loader.go`——`CORE_REGISTER_OUTPUT_CH`(:454)的异步 output 从「goroutine 直接返回 queued」改为「goroutine + 带超时 channel 等真实结果」。
|
||||
新增 `awaitOutputResult`(:276)+ 可注入版 `awaitOutputResultWith`(:281)+ 常量 `outputSendTimeout = 10s`:
|
||||
```go
|
||||
resCh := make(chan error, 1)
|
||||
go func() { resCh <- invoke(pid, channel, argsJSON) }()
|
||||
select {
|
||||
case err := <-resCh:
|
||||
if err != nil { return nil, err } // 真实失败上报
|
||||
return map[string]interface{}{"status": "sent"}, nil
|
||||
case <-time.After(timeout):
|
||||
return map[string]interface{}{"status": "unconfirmed", "note": "..."}, nil
|
||||
}
|
||||
```
|
||||
关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内),goroutine 内的 `pluginInvokeOutput` 才是 cgo,**不构成嵌套**。
|
||||
- 【M】✅ `internal/agent/core/output.go` `executeOutputSendTool`:识别 `status=unconfirmed|queued` → 返回「发送结果未确认:<note>」而非「已发送」,把未确认状态透传给模型。
|
||||
- 【R】✅ 无 cgo 嵌套(`awaitOutputResult` 只在 `RegisterOutputChannel` 的 handler 内被调用,该 handler 从 Go 侧调起);
|
||||
「超时未确认」措辞与 11.2 的"已取消"谎言区分——用 `unconfirmed` + 显式 note,不谎报成功也不谎报失败。
|
||||
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空。
|
||||
- 【V】✅ 新增 `internal/plugin/cabi/output_test.go` 三用例全绿:
|
||||
- `TestAwaitOutputResult_Success` → `status=sent`
|
||||
- `TestAwaitOutputResult_Failure`(模拟 meta 缺 user_id)→ **返回 error**(旧实现会谎报成功)
|
||||
- `TestAwaitOutputResult_Timeout` → `status=unconfirmed` 且不返回 error
|
||||
- 【V】✅ `go build ./...` exit 0;`go test ./internal/plugin/... ./internal/agent/...` 全绿。
|
||||
|
||||
### 0.2 stage lost update 补丁(11.3)— ✅ **已完成**(2026-08-31)
|
||||
|
||||
- 【M】✅ `templates.go`(**SDK 仓** update 分支 `5648519`)`go_invoke_stage` 改为 diff 回传:
|
||||
- 新增 `snapshotWritable(sc) map[string]string`——handler 前的**序列化**快照
|
||||
- 新增 `changedFieldsOnly(before, after)`——只回传变更字段,无变更零回传
|
||||
- ❗ **第一版踩坑并修正**:`stageContextWritable` 返回的切片字段与 `sc` **共享底层数组**,handler 原地改元素(`sc.ToolResults[0].Result = clean`)时 before 快照跟着变,diff 看不到变更 → 修复会静默失效。故 before 必须逐字段序列化成字符串。
|
||||
- 【M】✅ `internal/plugin/cabi/loader.go` `applyStageResult` 配套(本仓 `9bb9cb3`):`tool_calls`/`tool_results` 去掉 `len(v)>0` 拦截——改为键存在即应用,使插件「清空全部工具调用」的显式 `[]` 能被表达(旧插件仅 len>0 才带键,不会被误清空)。
|
||||
- 【R】✅ `changedFieldsOnly` 无竞态(纯函数,无共享状态);只读插件零回传(单测断言)。
|
||||
- 【R】✅ 接口冻结:两仓 `git diff sdk/` 均为空(只改 bridge 模版 + 内核)。
|
||||
- 【R】✅ bridge 模版可编译性:抽取 `tmplLinuxBridge` + 真实 `weather/plugin.go` 做 `go build -buildmode=c-shared` → exit 0。
|
||||
- 【V】✅ SDK 仓 `tools/plugindev/stagediff_test.go` 6 用例全绿:
|
||||
- `_ReadOnlyPluginReturnsNothing`(只读插件零回传——修复核心)
|
||||
- `_WriterReturnsOnlyChanged`(原地改切片元素仅回传 tool_results)
|
||||
- `_ScalarChange` / `_NewResponseIsReturned` / `_ClearedSliceIsReturnedAsEmpty`
|
||||
- `_ProductionScenarioNoOverwrite`(**复刻实验 13 现网场景**:sanitizer 清洗 + weather 只读,清洗结果不再被覆盖)
|
||||
- 【V】✅ 内核侧 `output_test.go` 新增 `TestApplyStageResult_ClearedSlicesAreApplied` / `_OnlyPresentKeysApplied` 全绿。
|
||||
- 【V】✅ `go build ./...` exit 0;`go test ./internal/plugin/... ./internal/agent/...` 全绿。
|
||||
- ⚠️ **待部署项**:需用新 plugindev 重编全部 17 个外部插件(bridge 模版变更),走 `plugin_install(overwrite=true)`。
|
||||
|
||||
### 0.3 reload 语义修正(11.6)— ⏭️ **已跳过**(2026-08-31 用户决策:直接进入进程化重构)
|
||||
|
||||
> 子进程模型下 `DF_1_NODELETE` 议题**整体消失**(§3.1)——同路径替换 `plugin.bin` 重启进程即生效。
|
||||
> 在 cabi 路径上补 ELF 检测属于「给即将删除的代码打补丁」,性价比低。
|
||||
> 现网仍受 reload 假成功影响,但 Part 1 的 entry 分派已为迁移铺路,迁移完成即根治。
|
||||
|
||||
- 【M】`dynamic_loader_unix.go`:ELF 检测 `DF_1_NODELETE` → 标记"不可热重载"。
|
||||
- 【M】`registry.go` 的 `ReloadOne`:对此类插件返回"需重启 homed"。
|
||||
- 【M】`pluginmgr/plugin.go` 的 `plugin_install`:返回 `restart_required` 替代 `reload_required`。
|
||||
- 【R】确认 `.so` 插件重载不再"假成功"。
|
||||
- 【V】单测:mock ELF 头带 NODELETE vs 不带 → 正确区分。
|
||||
|
||||
### 0.4 超时日志措辞修正 + 附带(11.2 短期项 + 11.4)— ⏭️ **已跳过**(同上)
|
||||
|
||||
> 11.2 的 cgo 超时不可中断在子进程模型下由 `Process.Kill()` 真正解决(§9.5);
|
||||
> 11.4 的 Lua 路径在迁移后统一走 RPC(三套 ABI 收敛),锁语义天然有边界。
|
||||
|
||||
- 【M】`internal/agent/core/toolcall.go:41`:日志从"已取消"改为"已放弃等待(插件仍在后台运行,其占用的线程无法回收)"。
|
||||
- 【M】`internal/plugin/lua_plugin.go:726`:stage 快照加 `sc.RLock()`/`RUnlock()`(11.4)。
|
||||
- 【R】措辞语义诚实;Lua 快照持锁。
|
||||
- 【V】`make test` 全绿;超时日志不再撒谎。
|
||||
|
||||
**Part 0 出口条件**:11.1/11.3/11.6 全部落地并有针对性测试;生产可先部署(现网止血)。
|
||||
|
||||
---
|
||||
|
||||
## Part 1:加载分派骨架(阶段 2.4,S)
|
||||
|
||||
> 依据:迁移评估 §2.4 / 3.2;plan.md 11.7。目标:让 registry 能按 entry 把插件分派到 `.so`(cabi)或 `.bin`(proc)两条通道——**双通道共存是整个计划可回退的前提**。
|
||||
|
||||
### 修改(核心)
|
||||
|
||||
- 【M】`internal/plugin/manifest.go`:`PluginManifest.Entry` 注释与 `IsPluginDir` 支持 `plugin.bin`。
|
||||
- 【M】`internal/plugin/dynamic.go`:新增 `binEntry = "plugin.bin"` 常量;`readManifest` 读取 entry。
|
||||
- 【M】`internal/plugin/registry.go` `loadOne`(~:376):把「无工厂 → `tryDynamic`」的分支改为按 entry 分派:
|
||||
```go
|
||||
switch entry {
|
||||
case soEntry, dllEntry: p, err = r.tryLoadSO(...) // 现有 cabi
|
||||
case binEntry: p, err = r.tryLoadProc(...) // 新增(Part 2 填充)
|
||||
default: p, err = r.tryOther(...) // lua / skill
|
||||
}
|
||||
```
|
||||
先保留一个 `tryLoadProc` 桩(返回"未实现"错误),保证分派骨架先成立、可测。
|
||||
- 【M】`internal/plugin/dynamic_loader_unix.go`:把 `tryLoadSO` 从 `tryDynamic` 拆出成 registry 可独立调用的函数。
|
||||
|
||||
### 审查
|
||||
|
||||
- 【R】确认内置插件(`hasFactory` 分支)完全不受影响——仍走 `RegisterNative` 进程内路径。
|
||||
- 【R】确认 `.so` 路径行为与今天逐字节一致(无回归)。
|
||||
- 【R】接口冻结:`git diff` 公开 SDK 为空。
|
||||
|
||||
### 验证
|
||||
|
||||
- 【V】单元测试:mock 三种 manifest(so/dll/bin/lua)→ 分派到正确通道;`.bin` 桩返回明确错误而非 panic。
|
||||
- 【V】既有 `.so` 插件加载 e2e 不回归(带一个真实 .so 冒烟)。
|
||||
|
||||
**Part 1 出口条件**:分派骨架在,`.bin` 有明确桩位,`.so` 全回归。
|
||||
#### ✅ **Part 1 已完成**(2026-08-31,commit `610e9d0`)
|
||||
|
||||
- 【M】✅ `dynamic.go`:新增 `binEntry`/`skillEntry` 常量 + `entryKind` 枚举 + `classifyEntry` / `detectEntryKind`
|
||||
- **manifest 的 entry 优先级最高**——把 entry 改回 `plugin.so` 即回退 cabi 通道(回退路径的保证)
|
||||
- 无 manifest 时按目录探测,`.bin` 优先于 `.so`(迁移期同目录两产物共存时走新通道)
|
||||
- 【M】✅ `registry.go` `tryDynamic`:按 entry 分派 proc/cabi;entry 声明 `.bin` 但二进制缺失时**报明确错误,不静默回退**
|
||||
- 【M】✅ `registry.go` `pluginEntryHash`:候选顺序与 `detectEntryKind` 对齐(`.bin` 优先),否则增量重载会用错文件算 hash
|
||||
- 【M】✅ `manifest.go`:`Entry` 字段注释补 `plugin.bin`
|
||||
- 【M】✅ `dynamic_proc_unix.go` / `dynamic_proc_windows.go`:`tryLoadProc` 桩位(存在性/类型/可执行权限校验已实现)
|
||||
- 【R】✅ 内置插件(`hasFactory` 分支)完全未受影响——仍走进程内 `RegisterNative`
|
||||
- 【R】✅ `.so` 路径行为与改动前一致(既有测试全绿,无回归)
|
||||
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空
|
||||
- 【V】✅ `entry_dispatch_test.go` 9 项全绿:
|
||||
- `TestClassifyEntry`(8 种 entry 分类)
|
||||
- `TestDetectEntryKind_ManifestWins` / `_ManifestCanForceRollback`(**回退路径验证**)
|
||||
- `TestDetectEntryKind_ProbeOrderPrefersBin` / `_ProbeFallbacks`(4 子例)
|
||||
- `TestTryLoadProc_MissingBinaryReturnsNil` / `_NonExecutableRejected`
|
||||
- `TestPluginEntryHash_PrefersBin` / `_EmptyForFactoryOnlyPlugin`
|
||||
- 【V】✅ `go build ./...` exit 0;`go test -race ./internal/plugin/...` 全绿;全量 32 个包测试通过
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Part 2:子进程通道原型(阶段 2.1~2.3/2.5/2.9,~3 周,核心风险点)
|
||||
|
||||
> 依据:迁移评估 §4.1 阶段 2;迁移评估指明可大幅参考 `clawhubadapter/sidecar.go:54-350`(已有 stdin/stdout + pending map + notifyCh)。
|
||||
> 目标:把单个外部插件(weather)以 `plugin.bin` 端到端跑通,验证"接口不变"假设。
|
||||
|
||||
### 修改(核心)
|
||||
|
||||
- 【M】新建 `internal/plugin/proc/`:
|
||||
- `process.go`——`procPlugin` 实现 `sdk.Plugin` 接口;`spawn`/健康检查/优雅停止/`Close()`=真 kill+wait。
|
||||
- **可参考** `clawhubadapter/sidecarProcess`:`exec.Cmd` + `stdin *bufio.Writer` + `readLoop`(scanner 大 buffer 64KB)+ `pending map[int]chan<- []byte` + `notifyCh chan OCNotification` + readerStop/readerWg。
|
||||
- `rpc.go`——双向 JSON-RPC 编解码:7 个 kernel→plugin 调用(`tool.invoke`/`stage.invoke`/`output.invoke`)+ 51 个 plugin→kernel 回调(平移自合同面 B 映射表)。
|
||||
- 【M】`internal/plugin/dynamic_loader_unix.go`:实现 `tryLoadProc`(spawn `.bin`,回连 stdio RPC)。
|
||||
- 【M】`internal/plugin/registry.go` `closePlugin`/卸载路径:对 proc 插件 `Close()` 真 kill。
|
||||
- 【M】`internal/agent/core/plugin_health.go` 调用侧:插件**退出码/EOF** → `recordCrash`(**逻辑完全复用**,仅把"panic 捕获"换成"进程退出检测",见迁移评估 §2.3)。
|
||||
|
||||
### 审查
|
||||
|
||||
- 【R】`readLoop` 鉴权:只接受来自本进程 spawn 的 stdout(防注入)。
|
||||
- 【R】JSON-RPC 帧边界处理(`bufio.Scanner` 长行截断风险——沿用 sidecar 64KB buffer)。
|
||||
- 【R】pending map 泄漏:超时清 map、退出时清 map。
|
||||
- 【R】崩溃重启:`SetAutoRestart(true)` 语义保留;`plugin_health` 冷却/自愈复用。
|
||||
- 【R】接口冻结:公开 SDK 零 diff。
|
||||
|
||||
### 验证
|
||||
|
||||
- 【V】单测:spawn→握手→工具调用往返→正常 Stop→kill 崩溃→退出码捕获。
|
||||
- 【V】weather `.bin` 端到端:`RegisterTool`/`Settings`/`InjectInputSync` 全部经 stdio RPC 打通。
|
||||
- 【V】与 Part 1 的 entry 分派联动:同目录 `.so` 与 `.bin` 共存互不干扰。
|
||||
|
||||
**Part 2 出口条件**:一个真实外部插件 `.bin` 全链路可用,崩溃隔离生效,接口零改动。
|
||||
|
||||
#### ✅ **Part 2 已完成**(2026-09-01,commit `d62430a` + `82dcc86`)
|
||||
|
||||
- `proc/protocol.go`:NDJSON 帧、**51 个 method id 平移为 method 名**(编号扔掉)、握手/stage/tool/output 参数类型。
|
||||
`case 25`(CORE_FREE_STRING) 无对应 method(GC 接管);`case 23/24`(事件订阅) 与 `io.setToolBlocks`
|
||||
明确返回未实现,**不静默成功**。
|
||||
- `proc/process.go`:Spawn/readLoop/CallContext/Notify/Stop/Kill/markExited;单帧上限 1MB。
|
||||
- `proc/corehandler.go`:51 case 平移 + `CoreSDK` 接口(**刻意排除**内核内部机制,见 Part 6 权限梯度)。
|
||||
- `proc/host.go`:**全部插件共享同一 memfd**。最初写成每插件一块段,尝试后发现
|
||||
那等于**副本模型换壳**(各写各段、各自回读、最后回读者覆盖前者),已改正。
|
||||
- `proc/stage.go`:RunStage 接线 + lockRegistry;`proc/plugin.go`:Plugin 实体。
|
||||
- 共享段分配按平台拆分(`shmalloc_linux.go` memfd / `shmalloc_darwin.go` 立即 unlink 的临时文件 /
|
||||
`shmalloc_other.go` 明确报错)——不静默降级成「无共享段」,那会让 stage 静默失去数据面。
|
||||
- registry 接线(commit `11c1bbc`):`tryDynamic` → `Registry.loadProc`;Host 惰创建且全局唯一;
|
||||
`StopAll` **锁外**释放共享段(插件还持有映射时拆段 → SIGBUS;持锁调与 onProcCrash 有锁序风险);
|
||||
`onProcCrash` 只发 EventSystem 事件,**不在回调里直接重载**(重载需 registry 锁)。
|
||||
- `proc_core.go` —— 权限梯度的类型系统落点:`procCore` 用**命名字段**持有 `*isdk.PluginSDK`,
|
||||
不是嵌入。嵌入会提升全部方法,外部插件就能经类型断言拿到
|
||||
Supervisor/Tracker/Adapter/Indexer/Status/Selftest。
|
||||
- 测试 36 项含 `-race`:`testdata/` 8 个假插件 + `e2e_template_test.go` 用**真实 plugindev 模板**
|
||||
编译插件跑全链路(验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己跟自己对齐)。
|
||||
|
||||
---
|
||||
|
||||
## Part 3:plugindev 工具链改造(阶段 2.6/2.7/2.8,M,SDK 仓)
|
||||
|
||||
> 依据:合同面 B;迁移评估 §4.1。此部分在**独立 SDK 仓**维护(用户决策 sdk_repo_only)。
|
||||
> 目标:让外部插件能用普通 `go build` 产出 `.bin`,业务代码零改动。
|
||||
|
||||
### 修改(工具链)
|
||||
|
||||
- 【M】`tools/plugindev/templates.go`:新增 `tmplProcMain`——把 bridge 从「7 个 `//export` + `-buildmode=c-shared`」改为「`main()` + stdio JSON-RPC loop」;注册逻辑(`buildPluginSDK` 的 registar 闭包)从 `callVoid(id,...)` 改为 `sendRPC(methodName,...)`(合同面 B 的平移)。
|
||||
- 【M】`tools/plugindev/cmd_build.go`:
|
||||
- 新增目标 `plugin.bin`:`go build`(去 `-buildmode=c-shared`、`CGO_ENABLED=0`)→ `plugin.bin`。
|
||||
- bundle 平台表:`{"linux/amd64","plugin.bin"}`(替代 `.so`)。
|
||||
- `resolveBuild`:bin 分支不再需 C 编译器。
|
||||
- 【M】`tools/plugindev/cmd_build.go` `validBinaries`/打包:`.hmap` 内条目支持 `plugin.bin`(`plugin.json` entry 写 `plugin.bin`)。
|
||||
- 【M】`plg.json` 模板(`tmplPlgJSON`):`entry` 默认改为 `plugin.bin`(保留 `.so` 兼容)。
|
||||
|
||||
### 审查
|
||||
|
||||
- 【R】生成的 `tmplProcMain` 与旧 bridge 的 SDK 方法一一对应(对照合同面 B 51 行映射表逐行核对)。
|
||||
- 【R】业务代码**零改动**证据:同一 `plugin.go`,仅入口文件/构建命令不同。
|
||||
- 【R】交叉编译简化确认:`.bin` 无需 cgo 工具链,跨 GOOS 仅需目标 toolchain。
|
||||
|
||||
### 验证
|
||||
|
||||
- 【V】用新 plugindev 重编 `example/weather` → 产出 `plugin.bin`。
|
||||
- 【V】`.hmap` 打包/解包校验:`plugin.bin` 条目正确登记。
|
||||
- 【V】(与 Part 2 集成)weather.bin 被 homed proc 通道正确加载运行。
|
||||
|
||||
**Part 3 出口条件**:plugindev 一条命令产出 `.bin` + 正确 `.hmap`,外部插件源码零改动。
|
||||
|
||||
#### ✅ **Part 3 已完成**(2026-09-02,SDK 仓 commit `09b64dc`)
|
||||
|
||||
**模板落地方式换了**:不是计划里的 `templates.go` 新增 `tmplProcMain` raw string,
|
||||
而是真实 `.go` 源文件 `templates/proc_main.go.tmpl` + `//go:embed`(`proc_runtime.go`)。
|
||||
原因:900+ 行代码塞在字符串里写错只能等生成插件时才炸,作为源文件可被
|
||||
`go/parser`、`gofmt`、`go vet` 直接检查。这也是 `proc_runtime_test.go` 16 项
|
||||
静态检查得以存在的前提。
|
||||
|
||||
- `templates/proc_main.go.tmpl`(1113 行):51 个 method 的插件侧 RPC 实现
|
||||
(`procIO`/`procMemory`/`procSettings`/`procSocial`/`procLLM`/`procKnowledge`/
|
||||
`procDocMemory`/`procTextMemory`/`procPluginMgr`)、共享段访问(fd 3)与 16 字段
|
||||
StageContext 编解码、`handleStageInvoke`(拿锁 → 读段 → handler → **只写脏字段** → 放锁)。
|
||||
- `cmd_build.go`:`resolveBuild(target, proc)` 分派;proc 走 `go build -trimpath` + `CGO_ENABLED=0`,
|
||||
**交叉编译不再需要目标平台 C 工具链**。bundle 模式各平台产物同名(进程边界即 ABI 边界,
|
||||
无平台扩展名),故 zip 内加平台后缀 `plugin.bin.linux.amd64`。
|
||||
- `proc_runtime.go`:生成时清理残留 `z_bridge_gen.go`/`z_entry.c`——同目录两套 main 会编译冲突,
|
||||
这让 `.so` → `.bin` 切换无需人工清理。
|
||||
|
||||
**计划外补的一个真缺口**:`lifecycle.autoRestart` 没接线。公开 SDK 的 `SetAutoRestart`
|
||||
是纯 setter(`s.autoRestart = enabled`,无回调 hook)。C ABI 下内核在 `Start` 返回后
|
||||
直接读 `plgSDK.AutoRestart()`;子进程隔着进程边界读不到,插件调它只改自己进程内的副本。
|
||||
修法:模板在 `plg.Start()` 返回后显式上报一次(内核侧 `corehandler.go:145` 早已就绪)。
|
||||
**没有改公开 SDK 接口**。
|
||||
|
||||
验证(均已实测):
|
||||
```
|
||||
$ plugindev build # plg.json: entry = "plugin.bin"
|
||||
compiling linux/amd64 (子进程模式,CGO_ENABLED=0)...
|
||||
packaged weather_linux_amd64.hmap
|
||||
|
||||
build/plugin.bin → ELF 64-bit executable, statically linked ← 零 cgo
|
||||
dist/*.hmap → plugin.json + plugin.bin
|
||||
|
||||
$ diff example/weather/plugin.go <构建目录>/plugin.go
|
||||
✅ 逐字节一致 ← 业务代码零改动的硬证据
|
||||
|
||||
$ git diff third_party/homeagent-sdk/sdk/
|
||||
(空) ← 接口冻结保持
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4:共享内存数据面(阶段 3.1~3.5,~3 周,最高风险)
|
||||
|
||||
> 依据:迁移评估 §3.3 数据面 / 3.4 SDK 封装 / 3.7 锁仲裁;合同面 C。
|
||||
> 目标:多插件并发改写同一 `StageContext` 语义与今天一致(丢失率 → 0),外部插件看到全部 16 字段。
|
||||
|
||||
### 修改
|
||||
|
||||
- 【M】`internal/plugin/proc/` 新增 `shm.go`:
|
||||
- 共享段 schema:`ShmStageCtx` + `Slice{off,len}` 偏移描述符 + arena(append-only + 压实)。
|
||||
- arena 分配器:插件把 `FinalText` 从 10B 改 10KB 时分配新区域、旧区域留垃圾、stage 结束后压实。
|
||||
- 4 个 `Extra` 键(media_blocks/media_type/input_source/output_channel)提升为具名字段(迁移评估 §3.3 已核实全部使用点)。
|
||||
- 段生命周期:创建/挂载/插件崩溃后清理。
|
||||
- 【M】`internal/plugin/proc/shmcodec.go`:`StageContext` ↔ 共享段编解码(偏移↔Go 值转换)。
|
||||
- 【M】`internal/plugin/proc/lock.go`:**锁仲裁 RPC**——插件 `Lock/RLock` → `stage.lock`/`stage.unlock` → 内核 `sync.Mutex` 排队(迁移评估 §3.7 已裁定,实验 3+9 支撑)。
|
||||
- 【M】`internal/agent/core/stages.go` `RunStage`:改造为跨进程并发扇出(**保留并发语义,最难一环**)——内置插件仍进程内 `go func`,外部插件走共享段 + 锁仲裁。
|
||||
- 【M】SDK 侧(插件进程内)封装全部复杂度(迁移评估 §3.4):插件保留原生 `StageContext`,handler 照常读写,脏字段写回共享段。
|
||||
|
||||
### 审查(最高优先级 review)
|
||||
|
||||
- 【R】**并发语义一致性**:内置(0% 丢失)与外置(迁移前 35.8~36.8%)在共享内存下都收敛到 0% 丢失。
|
||||
- 【R】锁仲裁死锁:持锁进程崩溃自愈(实验 9 已证无需 robust mutex)。
|
||||
- 【R】arena 单 stage 写入上限:大写入在 SDK 层**报错**而非静默截断(迁移评估 §4.4)。
|
||||
- 【R】`Extra` 不引入通用 tagged union 成本(维持 4 键具名字段)。
|
||||
- 【R】接口冻结:`sdk/` 零 diff;`StageContext` 结构体字段序不变。
|
||||
|
||||
### 验证
|
||||
|
||||
- 【V】复刻实验 8:5 子进程 × 300 轮并发改写 → **零丢失零撕裂**。
|
||||
- 【V】复刻实验 13 现网场景:sanitizer(改 ToolResults)+ weather(只读)并发 → 清洗结果不再被覆盖。
|
||||
- 【V】改写型插件行为基线测试:`sanitizer`/`multimodal` 迁移前后行为对拍(迁移评估 §4.4 风险缓解)。
|
||||
|
||||
**Part 4 出口条件**:跨进程并发改写零丢失,内置/外置语义一致,16 字段全可见。
|
||||
#### ✅ **Part 4 核心已完成**(2026-08-31,commit `610e9d0`)—— 段 / 编解码 / 锁仲裁三件套
|
||||
|
||||
> 用户明确指出「基于共享内存的 stage 并发是最为关键的」,故先于 Part 2/3 落地数据面。
|
||||
> `RunStage` 的跨进程接线(3.4)待 Part 2 的进程通道就绪后进行。
|
||||
|
||||
- 【M】✅ `proc/shm.go` 段布局与 arena 分配器(§3.3)
|
||||
- `Header(64B) + ShmStageCtx(描述符数组 + 标志位) + append-only arena`
|
||||
- **相对偏移**:各进程 mmap 到不同虚拟地址仍能正确解引用
|
||||
- `NewSegment` / `AttachSegment` 带魔数 + 版本校验(版本不匹配显式报错,不静默错读)
|
||||
- **arena 用尽显式报错**而非静默截断(§4.4 风险登记的硬要求)
|
||||
- `Compact()` 回收 append-only 垃圾,须在无插件持锁时调用
|
||||
- 【M】✅ `proc/shmcodec.go` StageContext 16 字段跨进程编解码(§3.4)
|
||||
- **字段级描述符消除 lost update**:只改 `FinalText` 的插件完全不触碰 `ToolResults` 描述符
|
||||
- `WriteDirty` 只写脏字段——**只读插件零写入**,不可能覆盖他人改写
|
||||
- `Snapshot` 存**序列化字符串**(切片共享底层数组的坑,C ABI 侧修 11.3 时已踩过一次)
|
||||
- `Extra` 4 键提升为具名字段;`Response` 用标志位区分 nil 与空串(短路语义)
|
||||
- **全 16 字段可见**——今日经 C ABI 只有 10 个,`ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` 首次对外部插件可见
|
||||
- 【M】✅ `proc/lock.go` 锁仲裁回归内核(§3.7 已裁定,**零 cgo**)
|
||||
- `ForceRelease` 实现实验 9 的崩溃自愈 → 排除 robust pthread_mutex 必要性
|
||||
- 重复加锁**显式拒绝**(否则死锁 30s,比挂死更难排查)
|
||||
- 等待超时有补偿 goroutine 防锁永久泄漏
|
||||
- 【R】✅ 并发语义:`TestSegment_ConcurrentAppend_NoLostUpdate` 断言「各标记计数之和 == 最终长度 且 == 期望写入次数」,同时排除丢失与撕裂
|
||||
- 【R】✅ arena 上限报错(非静默截断):`TestSegment_ArenaExhaustionReturnsError`
|
||||
- 【R】✅ `Extra` 维持 4 键具名字段,未引入通用 tagged union 成本
|
||||
- 【R】✅ 接口冻结:`sdk/` 零 diff;`StageContext` 结构体未改
|
||||
- 【R】✅ `go vet` 干净(含 copylocks 检查)
|
||||
- 【V】✅ proc 包共享段部分 **16 项测试全绿(含 `-race`)**(全包现 36 项,含进程/端到端):
|
||||
- 段:魔数/版本校验、全 16 字段往返、Response nil vs 空串
|
||||
- 脏字段:只读零写回、原地改切片被识别、压实不破坏字段
|
||||
- **现网场景复刻**:`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`(sanitizer 清洗 + weather 只读并发,清洗结果不被覆盖)
|
||||
- **并发零丢失**:5 插件 × 40 轮读-改-写同一字段,200 次写入全部保留
|
||||
- 锁:互斥、串扰拒绝、未持锁释放拒绝、重复加锁拒绝、**崩溃自愈**、定向强制释放、临界区串行化
|
||||
|
||||
#### ✅ **Part 4 RunStage 接线已完成**(2026-09-01~09-02)
|
||||
|
||||
- `proc/stage.go` 把内核 `RunStage` 的并发扇出接到共享段:
|
||||
`Host.beginStage`(首个到达者独占段并写入 StageContext)→ `stage.invoke` RPC →
|
||||
插件侧 `stage.lock` → 读段 → handler → 只写脏字段 → `stage.unlock` →
|
||||
`Host.endStage`(最后离开者回读 + 压实 arena)。
|
||||
- **并发扇出保留**(§0.2 第 1 条:并发扇出是原始设计,不是缺陷);
|
||||
`stageMu` 串行化整次 stage 对共享段的独占(内核可能在不同路径并发触发
|
||||
RunStage,而段只有一份)。
|
||||
- 端到端验证(`e2e_template_test.go`,用**真实 plugindev 模板**编译的插件,
|
||||
而非 `testdata/` 手写假插件——后者只能验证内核自己跟自己对齐):
|
||||
- `TestE2E_RealTemplatePluginFullLifecycle`:握手 → init/start → 反向注册 →
|
||||
工具调用 → stage 读改写;同时验证 `FinalText` 回传
|
||||
(**C ABI 下 after_toolcall 看不到此字段**,§8.3 10→16)
|
||||
- `TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite`:两插件共享同一 Host 并发,
|
||||
只读插件不覆盖改写插件的结果(若每插件一块段,此测试必然失败)
|
||||
|
||||
**Part 4 已整体完成**。
|
||||
|
||||
---
|
||||
|
||||
## Part 5:通知面(阶段 4.1~4.5,~1.5 周)
|
||||
|
||||
> 依据:迁移评估 §3.6 事件环 / §2.4 约束 B / §3.8。目标:外部插件首次获得事件订阅能力,且不阻塞流式输出。
|
||||
|
||||
### 修改
|
||||
|
||||
- 【M】`internal/plugin/proc/eventring.go`:`EvtRing` + `Subscriber` schema(write_seq/read_seq/dropped/type_mask/last_seen),溢出计数、允许丢但让消费者知道丢了。
|
||||
- 【M】eventfd 通知 + Go netpoller 消费:`unix.Eventfd(EFD_NONBLOCK|EFD_CLOEXEC)` + `os.NewFile` 注册 netpoller(**不占 OS 线程**——实验 1 已证 200 goroutine 仅 +1 线程)。
|
||||
- 【M】`internal/events/bus.go` `Publish`:加事件环投递(**post-and-forget,绝不等待消费者**,满足约束 B)。
|
||||
- 【M】实现 `case 23/24`(今天空实现)——`Events().Subscribe` 对外部插件真正可用。
|
||||
- 【M】订阅者活性检测:`last_seen` 超时 → `recordCrash`。
|
||||
|
||||
### 审查
|
||||
|
||||
- 【R】`Bus.Publish` 路径**禁用任何锁/阻塞**——流式输出逐 token 发布,任何等待都会卡顿(迁移评估 §4.3 风险高)。
|
||||
- 【R】溢出语义:drops 计数暴露,不静默丢。
|
||||
- 【R】eventfd 计数合并:1000 token 事件只唤醒几次。
|
||||
|
||||
### 验证
|
||||
|
||||
- 【V】流式压测:长回复下 Publish 单次耗时不随订阅者数线性恶化。
|
||||
- 【V】复刻实验 4:post-and-forget 解耦(5s → 2.3ms 量级)。
|
||||
- 【V】外部插件订阅事件端到端(原空实现 case 23/24 现在可用)。
|
||||
|
||||
**Part 5 出口条件**:事件订阅对外可用,流式输出无卡顿。
|
||||
|
||||
---
|
||||
|
||||
## Part 6:迁移与收尾(阶段 5.1~5.4,~2 周)— ✅ **已完成**(2026-09-03)
|
||||
|
||||
> 依据:迁移评估 §4.5 双通道共存、§5 权限梯度。
|
||||
>
|
||||
> ⚠️ **实际执行偏离计划的一处**:原计划「逐插件迁移,随时回退」。
|
||||
> 用户决策改为**彻底舍弃 `.so` 能力,无回退通道**(不做 `--cabi` 开关),
|
||||
> 本轮直接删 `internal/plugin/cabi/`,生产全量切换。代价是某插件出问题
|
||||
> 只能紧急修复或 `git revert` 整批。因此下方【V】的「`.so` ↔ `.bin` 混跑」
|
||||
> 不再适用——新内核根本不认 `.so`。
|
||||
|
||||
### 修改
|
||||
|
||||
- ✅【M】**6.1** 工具链 entry 语义收敛(SDK 仓 `9f84412`):`isProcEntry` 删除,Go 插件一律产出 `plugin.bin` 不看 entry 值;`templates.go` 1296→516 行。
|
||||
- ✅【M】**6.3** 17 插件全量重编(`1d7f011`):16 个×3 平台 + qq×1;`git status example/` 无输出(业务代码零改动)。
|
||||
- ✅【M】**6.5** 生产切换(`62bdfa2`):经 `pluginmgr` 的 hmap 正规通道安装,17/17 成功且 `config_kept=true`。
|
||||
- ✅【M】**6.6** 压测 + 版本 1.0.0 + 文档(`2572688`、`670efcd`、tag `v1.0.0`)。
|
||||
- ✅【M】**6.2** 内核侧 Windows(`d027c96`)+ 删 C ABI(`b20121f`,-3198 行):删 `internal/plugin/cabi/`(1156)、`dynamic_dll_windows.go`(272)、`dynamic_loader_unix.go`(79) + bridge 模板;新增 `shmalloc_windows.go` + `evtfd_windows.go` + `shmpass_{unix,windows}.go`;顺带修 macOS pipe 写端被 GC 回收的真 bug。
|
||||
- ✅【M】**6.4** 权限梯度显式化(`2ebdb9a`):54 个 method 划入 11 个 capability 组;`coreHandler.Handle` 入口强制;`withheldCapabilities` 表记录 10 项刻意不提供的内核机制及理由(`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`)。
|
||||
- ⏭️【M】`lua_plugin.go`/`dynamic_lua.go` 统一走 RPC —— **留待后续**。Lua 走解释器不经 C ABI,不阻塞本轮目标(消除 C ABI 前提缺陷)。收敛第三套 ABI 是独立优化。
|
||||
- ✅【M】文档:本文与 `plugin-interface-matrix.md` 更新;切换实录见下方。
|
||||
|
||||
### 审查
|
||||
|
||||
- ✅【R】每删一个 cabi 依赖项,`go build ./...` + `go vet ./...` 干净。
|
||||
- ✅【R】权限梯度:被拒 API 在 RPC 边界返回**明确错误**(非忽略)。错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。`TestCapability_DeniedErrorIsActionable` 守护。
|
||||
- ✅【R】接口冻结:`git diff third_party/homeagent-sdk/sdk/` 全程为空。
|
||||
|
||||
### 验证(全量回归)
|
||||
|
||||
- ✅【V】17 插件经 `plugin_install(overwrite=true)` 加载,工具/设置/通道/阶段 e2e。
|
||||
- ⏭️【V】~~`.so` ↔ `.bin` 混跑集群冒烟~~ —— 不适用(无回退通道,见上方偏离说明)。改为验证**新内核面对旧 `.so` 给可操作错误且不崩溃**,已在真实二进制上确认。
|
||||
- ✅【V】`make test` 全量绿 + `go build ./...`。
|
||||
- ⚠️【V】内存:**未达成计划目标**。15 个插件进程 RSS=88.0MB / PSS=87.9MB,远超「基线 +29MB」。根因是每插件静态链接整个 Go runtime,15 个不同二进制无共同物理页可映射(PSS/RSS 99.9% vs 基线 44%)。这是「每插件独立二进制」的固有代价,实际开销高于 §4.3 乐观估计。压缩方向:共享 launcher 二进制 + 各自业务模块。
|
||||
- ✅【V】工具调用 RPC 延迟 24.1µs(实验 11 基线 19.6µs,同量级)。
|
||||
|
||||
**Part 6 出口条件**:全部外部插件 `.bin` 化 ✅,cabi 删除 ✅,接口零改动 ✅,权限显式化 ✅,无回归 ✅。
|
||||
|
||||
---
|
||||
|
||||
## 最终验收清单(对照接口不变矩阵 §7 检查点)
|
||||
|
||||
| # | 检查点 | 通过标准 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 1 | 公开 SDK 接口冻结 | `git diff third_party/homeagent-sdk/sdk/` **为空**(全程) | ✅ 每次审查均确认 |
|
||||
| 2 | 外部插件业务代码零改动 | 17 个 `example/*/plugin.go` 与基线逐字节可比 | ✅ `git status example/` 无输出 |
|
||||
| 3 | 17 插件 `.bin` 化 | 全部经 `plugin_install` 加载,工具/设置/通道/阶段 e2e | ✅ 17/17,`config_kept=true` |
|
||||
| 4 | cabi 删除 | `internal/plugin/cabi/` 与 bridge 模板不存在 | ✅ -3198 行(`b20121f`) |
|
||||
| 5 | 崩溃隔离 | 插件 kill 只退出自身,homed 存活 | ✅ `TestRealPlugin_CrashDoesNotKillKernel` |
|
||||
| 6 | 热重载 | 同路径换 `.bin` 即生效,无需重启 | ✅ 生产实测(`unloaded (config kept)` → 重载) |
|
||||
| 7 | 并发改写 | 跨进程 stage 丢失率 0%(对照今天 35.8~36.8%) | ✅ `TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` |
|
||||
| 8 | 事件订阅 | 外部插件 `Events().Subscribe` 可用 | ✅ 事件环已接线(当前零用户) |
|
||||
| 9 | 多模态 | `SetToolBlocks` 非空实现 | ⚠️ method 已定义并划入 core 能力,内核侧仍返回未实现 |
|
||||
| 10 | 超时取消 | 工具超时可 `Process.Kill()`,零泄漏 | ✅ 整套新架构零 cgo |
|
||||
| 11 | output_send | 真实结果返回(非假成功) | ✅ 生产实测 `map[status:sent]` |
|
||||
| 12 | 权限梯度 | 内部专属 API 在 RPC 边界拒绝 | ✅ 12 项测试(`2ebdb9a`) |
|
||||
| 13 | 内存/延迟 | 常驻 +≤29MB,RPC p50 ≤20µs 量级 | ⚠️ 延迟 24.1µs 达标;内存 88MB **未达标** |
|
||||
|
||||
**两项未完全达标的说明**:
|
||||
|
||||
- **#9 SetToolBlocks**:`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`,
|
||||
但内核侧 handler 仍返回未实现。C ABI 时代它也是空实现(§1.4),
|
||||
故**不是回归**,但也没兑现 §3.8 的承诺。当前无插件使用。
|
||||
- **#13 内存**:15 个进程 RSS=88.0MB,远超「基线 +29MB」。根因是每插件
|
||||
静态链接整个 Go runtime,15 个不同二进制无共同物理页(PSS/RSS 99.9%
|
||||
vs 基线 44%)。实验 5 的基线用的是 2.68MB 最小插件,而真实插件 3.1~14.8MB,
|
||||
绝对数字不可比。结构性指标(均摊线程 5.5 vs 4.9)同量级。
|
||||
|
||||
---
|
||||
|
||||
## 风险与回退
|
||||
|
||||
| 风险 | 缓解 | 回退 |
|
||||
|---|---|---|
|
||||
| Part 2/4 `RunStage` 并发语义漂移 | 复刻实验 8/13 + sanitizer/multimodal 对拍(Part 4 review) | entry 分派切回 `.so`(Part 1 双通道) |
|
||||
| Part 4 `Bus.Publish` 阻塞卡顿 | 专项流式压测(Part 5) | 事件环投递后置,先降级进程内 |
|
||||
| Part 3 工具链 `.bin` 产物问题 | 单插件 weather 先行验证 | 保留 `.so` 构建分支 |
|
||||
| Part 6 17 插件回归 | 逐个迁移 + `plugin_install(overwrite)` | 任意一个失败立即回退该插件 entry |
|
||||
| 接口意外漂移 | 每部分【R】强制 `git diff sdk/` 检查 | 立即 revert,暴露合同面违约 |
|
||||
|
||||
---
|
||||
|
||||
*规划:2026-08-31,update 分支。Part 编号与其依赖的 plan.md/迁移评估阶段对应。*
|
||||
|
||||
---
|
||||
|
||||
## Part 6.5 生产切换实录(2026-09-03)
|
||||
|
||||
### 执行顺序(先换二进制,再装包)
|
||||
|
||||
```
|
||||
1. systemctl stop homeagent
|
||||
2. 换 /usr/local/bin/homed
|
||||
3. 起服务 —— 15 个 .so 插件报可操作错误被跳过,homed 与 16 个内置正常
|
||||
4. 逐个 POST 装 17 个 hmap(overwrite=true)
|
||||
5. 重启核对
|
||||
```
|
||||
|
||||
**为何不能反过来**:若先装包,旧 homed 的 `StopAndUnload` 会停掉 qq
|
||||
消息通道,而它又无法加载 `.bin`,会卡在「插件全挂」的状态。
|
||||
|
||||
第 3 步顺带在真实二进制上验证了 Part 6.2 的可操作错误:
|
||||
|
||||
```
|
||||
[plugin] dynamic weather: plugin weather: 检测到旧 C ABI 产物(plugin.so/.dll/.dylib)。
|
||||
外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin(业务代码无需修改)
|
||||
```
|
||||
|
||||
不崩溃,只跳过该插件。
|
||||
|
||||
### 走 hmap 正规通道,而非手工拷贝
|
||||
|
||||
第一版切换脚本是手工拷 `plugin.bin` + 手改 `plugin.json` 的 entry ——
|
||||
那等于**重新实现了一遍 hmap 解包逻辑,且实现得更差**。漏掉的东西:
|
||||
|
||||
| | 手工拷贝 | hmap 正规通道 |
|
||||
|---|---|---|
|
||||
| `platforms` 字段 | 漏了 | 包内 manifest 本来就写对 |
|
||||
| 平台二进制选择 | 硬编码 `_linux_amd64` | `platformBinary()` 按 runtime 选 |
|
||||
| `overwrite` 语义 | 无 | `StopAndUnload` **保留配置表** |
|
||||
| 失败回滚 | 无 | `os.Rename` 备份,解包失败自动恢复 |
|
||||
| 校验 | 只查文件存在 | `validatePackage` 查 manifest + 各平台二进制齐全 |
|
||||
|
||||
配置保留那条尤其关键:生产 17 个插件都有配置(qq 账号、weather 默认城市、
|
||||
browser profile 路径)。手工脚本恰好没碰配置表所以侥幸不丢,但那是运气不是设计。
|
||||
|
||||
最终实现:POST 到 `127.0.0.1:9876/plugins`,传 `{path, overwrite:true}`。
|
||||
保留的一个设计是**先全部校验再动手**——任一插件缺 hmap 就整批中止,
|
||||
因为新 homed 不认 `.so`,「一半装了一半没装」的中间态最难排查。
|
||||
|
||||
### 结果
|
||||
|
||||
```
|
||||
17/17 成功,全部 config_kept=true
|
||||
0 个残留 .so;17 个 plugin.bin 均有执行位
|
||||
17 个 manifest 的 entry 均为 plugin.bin;无 .bak 残留
|
||||
bundle 包正确挑了当前平台(weather 目录只留 8.7MB 的 linux/amd64 那份)
|
||||
```
|
||||
|
||||
备份:`/home/newqqagent-migration-backup-20260902-214812`
|
||||
(plugins 全目录 + homed.old + homeagent.service,162MB)。
|
||||
**唯一回滚路径**是恢复该目录 + 回滚 homed 二进制。
|
||||
|
||||
### 生产端到端验证(真实 QQ 消息)
|
||||
|
||||
```
|
||||
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
|
||||
output_send__qq output_send__qq qq_mark_read])
|
||||
```
|
||||
|
||||
逐环节:
|
||||
|
||||
- **输入**:qq 子进程收 webhook → 经 RPC 报给内核 → agent 主循环
|
||||
- **工具调用**:5 次跨进程调用全部成功(内核反向调用进子进程执行)
|
||||
- **stage 改写生效**(最关键的一条):
|
||||
```
|
||||
[sanitizer] cleanToolCallLeakage: 2 bytes removed
|
||||
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
|
||||
[proc] sanitizer stage post_action 改写了 1 个字段
|
||||
```
|
||||
sanitizer 在**另一个进程里**改了 StageContext,内核读到了改写结果。
|
||||
13590 字节文本经共享段传递、被改写、写回,全程未拷贝整个上下文。
|
||||
- **输出真的送达**:`tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]`
|
||||
—— 直接验证 Part 0.1 修的 output_send 假成功缺陷(§9.4)
|
||||
- **arena 生命周期正常**:每次 stage 结束都压实回收(单次最高 15802 字节),无泄漏累积
|
||||
|
||||
这一次对话触发约 20 次 stage、5 次工具调用、2 次输出发送,跨越 15 个插件子进程。
|
||||
旧架构下同样流程有三处会静默出问题:stage 并发写丢字段(§8.4 实测 35.8~36.8%
|
||||
lost update)、output_send 假成功、cgo 超时泄漏 goroutine。现在这些在日志里可见且正确。
|
||||
|
||||
---
|
||||
|
||||
## Part 6.6 压测与延迟实测
|
||||
|
||||
基准与压测在代码里(`internal/plugin/proc/bench_test.go` + `streaming_test.go`),
|
||||
非独立脚本——随代码演进自动跑,不会腐坏。
|
||||
|
||||
| 项目 | 实测 | 基线 | 判断 |
|
||||
|---|---|---|---|
|
||||
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
|
||||
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
|
||||
| 事件环写入 | 95 ns | — | 亚微秒 |
|
||||
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
|
||||
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
|
||||
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
|
||||
|
||||
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——测的不是同一个东西:
|
||||
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
|
||||
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级。基准原名
|
||||
`BenchmarkStageLockRoundTrip` 有误导性,已改为 `BenchmarkStageLockArbitration`。
|
||||
|
||||
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs,其余是
|
||||
**一次 stage 要走 3 次进程间往返**(`stage.invoke` + 插件侧反向的
|
||||
`stage.lock` / `stage.unlock`)。相对 LLM 往返 2-8 秒可忽略;
|
||||
要优化的方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答。
|
||||
|
||||
### 流式压测(§4.3 标记「风险高」的那一项)
|
||||
|
||||
```
|
||||
5000 次 Publish + 每条睡 20µs 的慢消费者
|
||||
实测 2.29ms,均摊 457 ns/token
|
||||
同步语义理论下限 100ms
|
||||
|
||||
订阅者 1 个:1.547ms(515 ns/次)
|
||||
订阅者 8 个:1.518ms(506 ns/次) ← 无线性恶化
|
||||
|
||||
环溢出(无消费者写 30000 次,cap=8192):均摊 35 ns/次 ← 仍 O(1)
|
||||
```
|
||||
|
||||
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token),
|
||||
post-and-forget 在实现中成立。第三项的意义:消费者完全停摆时写端覆盖
|
||||
最旧 slot,这条路径仍是 O(1),故「插件卡住」不会连带拖慢内核主循环。
|
||||
|
||||
---
|
||||
|
||||
## 版本号
|
||||
|
||||
v1.0.0(tag 已打)。公开 SDK 接口零改动,但产物形态从 `plugin.so` 变为
|
||||
`plugin.bin`,0.9.x 内核不会识别——不可互操作的破坏性变化,故跃主版本号。
|
||||
|
||||
⚠️ **Makefile 陷阱**:`VERSION ?= $(shell git describe --tags --dirty)`
|
||||
意味着实际注入值来自 git tag,`meta.go` 里的默认值只在不带 ldflags 时生效。
|
||||
打 tag 前 `make build` 注入的是 `v0.9.1-56-g2572688-dirty`。
|
||||
|
||||
同时删掉 C ABI 时代的死常量(`ABIVersion`/`CABINum`/51 个 `Core<Method>`
|
||||
整数 ID)——随 Part 6.2 删 `internal/plugin/cabi/` 就已无使用者,
|
||||
留着会让人以为 C 层协商还在生效,或以为加 method 要同步维护那张整数表。
|
||||
@ -340,6 +340,60 @@
|
||||
|
||||
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)**;**决策**(压还是收、收哪些)
|
||||
在父的模型手里 —— 内核不替父决定。
|
||||
|
||||
### 7.1 积压任务的**及时反馈**(内核主动拉起分诊助手)[已定]
|
||||
|
||||
**背景(2026-09-19 线上实测)**:主 agent 被一条长任务占住时(当天现场:13 分 5 秒、
|
||||
8 次 cmd_run),后来的 QQ 消息全部以 `level insufficient` 排进中断队列干等 ——
|
||||
同级中断不能抢占同级运行任务(`scheduler.canPreempt`),只能等前一个跑完。
|
||||
用户在这十几分钟里**收不到任何回复**。
|
||||
|
||||
**定性(用户明确)**:这不是"内核替父决定",而是**及时反馈** ——
|
||||
主 agent 忙时不该让用户干等。分诊助手的职责是:
|
||||
- **简单的、不需主 agent 介入的** → 直接处理并回复;
|
||||
- **需要主 agent 介入的** → 立刻回「主 agent 忙碌中,请稍候」,**不勉强作答**。
|
||||
|
||||
**行为**:当运行任务已持续超过 `core.agent.offload_busy_after`(默认 5m)
|
||||
**且**排队输入积到 `offload_min_pending`(默认 3)条时,内核:
|
||||
1. 拉起(或在 `offload_max_residents` 内复用一个)**分诊助手**(`OffloadOwned`);
|
||||
2. 把积压的**纯排队输入**交给它先行分诊;
|
||||
3. 在原队列位置留下一条说明:`[系统] N 条积压消息已在主 agent 忙期间交由临时助手 X 先行分诊…`。
|
||||
|
||||
**分诊助手的通道配置(刻意与人工创建的子不同)**:
|
||||
- **不配 inputch**:它是内核的干活 agent,不接收任何插件的用户输入;
|
||||
- **持有全部输出通道**(`AllowedOutputs` 为空 = 完整授权):它必须能把结果发回
|
||||
qq/webui 等正确通道(否则干活结果无处可去)。
|
||||
|
||||
**为什么这套机制自然(用户观察)**:分诊助手就在**同一张登记表**里 ——
|
||||
父能 `inspect` 它的处理表与轮次、能 `send`、能按需 `compress`/`reclaim`/`destroy`。
|
||||
控制面 6 个动作均按 id 生效、不区分来源,因此回收策略对它自动适用。
|
||||
状态面额外暴露 `offload_owned`,让父能分清"我建的子"与"内核临时拉的助手"。
|
||||
|
||||
**残余任务由父显式决定**(用户 2026-09-19 要求):回收/销毁一个分诊助手时,
|
||||
它手头可能还有尚未处理的消息。内核**不自己决定**这些消息的命运,而是:
|
||||
- `residual=keep`(默认):逐条转回父自己的队列,父稍后处理;
|
||||
- `residual=drop`:明确丢弃,**逐条记日志**(不可追溯的丢弃是不允许的);
|
||||
- 两种路径都仍要给 `ResponseCh` 补终态,否则 cli/a2a 这类无超时同步调用方
|
||||
会永久挂起(设计 §7 I5)。
|
||||
|
||||
**默认关闭**(`core.agent.offload_enabled=false`):它改变的是系统行为而非修 bug,
|
||||
按「显式才是特权」(与 `scheduler.DefaultLevel` 同一条理由)由部署方打开。
|
||||
|
||||
**不做的事(边界)**:
|
||||
- 只转投 `TaskQueued` 纯排队输入。中断任务带级别语义(转投会打乱中断阶梯)、
|
||||
self 任务是内核内部记账(与父的记忆面绑定)—— 两者都不动。
|
||||
- 只对**根 agent** 生效:子再去拉孙子会形成无界增殖,而积压的源头是根那条链。
|
||||
- 实现上检查跑在**独立 goroutine**:`schedulerLoop` 是同步执行的,
|
||||
放在那里在「正忙」期间根本不会回到循环顶部(等于永不触发)。
|
||||
- 转投时**推原事件**(`DeliverRouted`)而不是重建:重建会丢掉 `ResponseCh`,
|
||||
使同步调用方永久挂起。
|
||||
|
||||
💡 **两条容易重犯的坑(都已在实现里修掉并写进测试)**:
|
||||
1. 积压可能全堆在 `io.inputCh`(因为忙时 `pumpInbox` 没被调用),
|
||||
只数 `sched.queue` 会得到 0 ⇒ 永不触发。
|
||||
2. 分诊助手必须**继承父的 SystemPrompt**:它里面写着「面向 qq 等异步通道时
|
||||
必须显式 `output_send`,纯文本会被静默丢弃」。缺了它,子处理完却发不出去。
|
||||
|
||||
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
|
||||
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
|
||||
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
|
||||
@ -623,4 +677,4 @@ go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal
|
||||
|
||||
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
|
||||
- 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
|
||||
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
|
||||
追加,并按 `docs/git-branching.md` §八 的接线清单同步(含 hmapdev 模板)与 SDK 仓版本(§七)。
|
||||
|
||||
1621
docs/zh/架构迁移评估.md
1621
docs/zh/架构迁移评估.md
File diff suppressed because it is too large
Load Diff
39
internal/agent/api/context_window_test.go
Normal file
39
internal/agent/api/context_window_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// 回归(2026-09-19):模型名推断不出窗口时,此前会静默退回 32768。
|
||||
// 生产实际配的是 core.llm.model="AUTO",于是整个预算按 32768 算,
|
||||
// 而该源真实窗口是 1M(实测 990,034 token 的 prompt 通过)—— 小 30 倍。
|
||||
//
|
||||
// 这里钉死两点:① deepseek-v4 系能推断出真实窗口;② AUTO 仍走兜底
|
||||
// (兜底值本身不猜大:猜大会让请求直接撞上游 400)。
|
||||
func TestModelContextWindow(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"deepseek/deepseek-v4.1-flash": 1048576,
|
||||
"deepseek-v4-flash": 1048576,
|
||||
"deepseek-chat": 65536,
|
||||
"claude-opus-5": 100000,
|
||||
"gpt-4-turbo": 128000,
|
||||
"llama-3-70b": 8192,
|
||||
"AUTO": 32768, // 推断不出 → 兜底,靠 context_window 覆盖
|
||||
}
|
||||
for model, want := range cases {
|
||||
if got := ModelContextWindow(model); got != want {
|
||||
t.Errorf("ModelContextWindow(%q) = %d, want %d", model, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显式声明的 context_window 必须覆盖模型名推断 —— 这是部署方绕开
|
||||
// “AUTO 推断不出窗口”的唯一手段,不能反过来被推断值盖掉。
|
||||
func TestExplicitContextWindowWinsOverInference(t *testing.T) {
|
||||
p := &LuaAdaptedProvider{cfg: BaseConfig{Model: "AUTO", ContextWindow: 1048576}}
|
||||
if got := p.MaxContextTokens(); got != 1048576 {
|
||||
t.Errorf("显式 context_window 未生效:got %d, want 1048576", got)
|
||||
}
|
||||
p2 := &LuaAdaptedProvider{cfg: BaseConfig{Model: "AUTO"}}
|
||||
if got := p2.MaxContextTokens(); got != 32768 {
|
||||
t.Errorf("未声明时应走推断兜底:got %d, want 32768", got)
|
||||
}
|
||||
}
|
||||
@ -8,9 +8,9 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -260,11 +260,21 @@ func ProviderSupportsAudio(p Provider) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultInferredContextWindow 是模型名无法推断窗口时的兜底。
|
||||
//
|
||||
// 32768 是个保守值,但它属于**静默降级**:模型名写 AUTO(网关自己选上游)时
|
||||
// ModelContextWindow 匹配不到任何分支,内核就会拿着一份比真实小得多的窗口
|
||||
// 去算全部预算(实测:deepseek-v4.1-flash 能吞 990,034 token,而预算按 32768 算)。
|
||||
// 因此推断不出来时留一条日志,并让部署方用 per-source context_window 显式声明。
|
||||
const defaultInferredContextWindow = 32768
|
||||
|
||||
// ModelContextWindow 返回模型的最大上下文窗口(token 数)
|
||||
// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率
|
||||
func ModelContextWindow(model string) int {
|
||||
model = strings.ToLower(model)
|
||||
switch {
|
||||
case strings.Contains(model, "deepseek-v4") || strings.Contains(model, "deepseek-v3"):
|
||||
return 1048576
|
||||
case strings.Contains(model, "deepseek-r1") || strings.Contains(model, "deepseek-chat"):
|
||||
return 65536
|
||||
case strings.Contains(model, "gpt-4") && (strings.Contains(model, "turbo") || strings.Contains(model, "mini") || strings.Contains(model, "omni")):
|
||||
@ -296,7 +306,13 @@ func ModelContextWindow(model string) int {
|
||||
case strings.Contains(model, "moonshot") || strings.Contains(model, "kimi"):
|
||||
return 131072
|
||||
default:
|
||||
return 32768
|
||||
// 模型名推断不出窗口(如 "AUTO"):不要静静退回一个比真实小得多的值。
|
||||
// 报一行日志,让“窗口被低估”这件事可见;部署方用 per-source
|
||||
// core.llm.sources.<name>.context_window 声明真实值即可覆盖。
|
||||
log.Printf("[provider] 模型 %q 无法推断上下文窗口,回退 %d;"+
|
||||
"若真实窗口更大,请设置 core.llm.sources.<name>.context_window",
|
||||
model, defaultInferredContextWindow)
|
||||
return defaultInferredContextWindow
|
||||
}
|
||||
}
|
||||
|
||||
@ -1268,8 +1284,8 @@ func getFloat(m map[string]interface{}, key string) float64 {
|
||||
// ToolOutput 是工具 handler 返回的结构化结果,支持多模态内容。
|
||||
// 返回 string 时等价于 ToolOutput{Text: result}。
|
||||
type ToolOutput struct {
|
||||
Text string `json:"text"` // LLM 看到的文字描述
|
||||
Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块(image_url/audio_url),追加到 tool message
|
||||
Text string `json:"text"` // LLM 看到的文字描述
|
||||
Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块(image_url/audio_url),追加到 tool message
|
||||
}
|
||||
|
||||
func (t ToolOutput) String() string { return t.Text }
|
||||
|
||||
@ -142,6 +142,8 @@ type Agent struct {
|
||||
|
||||
// 工具轮次硬上限(0 = 不限);见 AgentConfig.MaxToolTurns。
|
||||
maxToolTurns int
|
||||
// offload 是积压任务自动转投的参数(见 offload.go)。
|
||||
offload OffloadOptions
|
||||
|
||||
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
||||
cancelLLM context.CancelFunc
|
||||
@ -176,6 +178,10 @@ type Agent struct {
|
||||
noMergeMarkers map[string]int
|
||||
noMergeMu sync.Mutex
|
||||
|
||||
// TerminalRegistry 是终端会话与命令历史的权威视图(“内核开,两个插件接”)。
|
||||
// 内核订阅自己的事件总线归并而来;WebUI/CLI 经 KernelStatus 读取。
|
||||
terminalReg *TerminalRegistry
|
||||
|
||||
// 输入去重:防 webui/GUI 断线重连导致的消息重放
|
||||
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
|
||||
lastInput map[string]time.Time
|
||||
@ -275,6 +281,12 @@ type AgentConfig struct {
|
||||
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
|
||||
// 设计文档 D6:主循环必须有硬上限,否则模型不停调用就永不完结。
|
||||
MaxToolTurns int
|
||||
|
||||
// Offload 是「积压任务自动转投给驻留子」的参数(见 offload.go)。
|
||||
//
|
||||
// 默认关闭(Enabled=false):它让**内核替父做决策**,是设计 §7
|
||||
//「决策在父的模型手里」的刻意例外,因此必须由部署方显式打开。
|
||||
Offload OffloadOptions
|
||||
}
|
||||
|
||||
func New(cfg AgentConfig) *Agent {
|
||||
@ -365,6 +377,7 @@ func New(cfg AgentConfig) *Agent {
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
sched: newScheduler(256),
|
||||
maxToolTurns: cfg.MaxToolTurns,
|
||||
offload: cfg.Offload,
|
||||
pluginHealth: newPluginHealthTracker(),
|
||||
thinkingEnabled: cfg.ThinkingEnabled,
|
||||
inputCfg: cfg.InputProcessing,
|
||||
@ -377,6 +390,13 @@ func New(cfg AgentConfig) *Agent {
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 终端权威注册表只归**根 agent**(无 ParentID)。驻留子共用同一事件总线,
|
||||
// 若每个子都建一份并订阅,一次工具调用会被 N+1 份重复记账;而终端本就是
|
||||
// 内核级设备,不属于任何单个驻留子。
|
||||
if cfg.ParentID == "" {
|
||||
a.terminalReg = NewTerminalRegistry()
|
||||
}
|
||||
|
||||
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
|
||||
// (设计 §4.1「路由发生在进内核之前」)。io 层不认识 agent,所以在这里把路由器
|
||||
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
|
||||
@ -396,11 +416,29 @@ func (a *Agent) Start() {
|
||||
go a.archiveLoop()
|
||||
go a.mergeLoop()
|
||||
go a.reviewLoop()
|
||||
go a.offloadLoop()
|
||||
a.subscribeTerminalRegistry()
|
||||
a.reembedStaleMedia()
|
||||
a.migrateLegacyGraphMedia()
|
||||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||||
}
|
||||
|
||||
// subscribeTerminalRegistry 让内核的终端/命令历史权威视图归并事件流。
|
||||
//
|
||||
// 内核自己发 EventToolCall(agent 路径),agentcli 发 EventTerminalOutput
|
||||
// (含生命周期事件)。两者都进这份唯一真相,WebUI/CLI 不再各自推导。
|
||||
func (a *Agent) subscribeTerminalRegistry() {
|
||||
if a.eventBus == nil || a.terminalReg == nil {
|
||||
return
|
||||
}
|
||||
a.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) {
|
||||
a.terminalReg.OnToolCall(ev.Payload)
|
||||
})
|
||||
a.eventBus.Subscribe(events.EventTerminalOutput, func(ev *events.Event) {
|
||||
a.terminalReg.OnTerminalOutput(ev.Payload)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
|
||||
@ -11,10 +11,10 @@ func TestEntitySimilarity(t *testing.T) {
|
||||
a, b string
|
||||
want float64
|
||||
}{
|
||||
{"", "", 0}, // empty → 0
|
||||
{"a", "b", 0}, // single char → 0
|
||||
{"张三", "张三", 1.0}, // identical → 1.0
|
||||
{"张三", "李四", 0}, // no common bigrams
|
||||
{"", "", 0}, // empty → 0
|
||||
{"a", "b", 0}, // single char → 0
|
||||
{"张三", "张三", 1.0}, // identical → 1.0
|
||||
{"张三", "李四", 0}, // no common bigrams
|
||||
{"iPhone", "iPhone 15", 0.625}, // partial overlap
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
||||
@ -235,3 +235,58 @@ func TestGetFloatInt(t *testing.T) {
|
||||
t.Errorf("expected 5.0, got %f", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocToTriplesDropsNoiseEntities 钉住 doc→graph 的噪音闸门。
|
||||
//
|
||||
// 背景:doc→graph 在 1cb3e87 从「CutExact 滑窗词链」换成 NLP 依存提取器后,
|
||||
// 唯一还拦常用词的那层(CutExact:去停用词 + validEntityName)失去调用点,
|
||||
// 闸门只剩 validEntityName——它只管名字像不像名字,不管名字是不是常用词。
|
||||
// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类
|
||||
// 模板回声,以及 context_archived 这个内部标记。
|
||||
func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道",
|
||||
Content: "",
|
||||
Source: "context_archived",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) {
|
||||
t.Errorf("docToTriples 漏出噪音实体: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// 模板摘要不当「主题」、context_archived 不当「来源」:两条模板三元组都该被拦下。
|
||||
for _, tr := range triples {
|
||||
if tr.Relation == "主题" {
|
||||
t.Errorf("模板摘要被写成主题: %+v", tr)
|
||||
}
|
||||
if tr.Relation == "来源" && tr.Object == "context_archived" {
|
||||
t.Errorf("归档内部标记被写成来源: %+v", tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。
|
||||
func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
Summary: "多轮对话",
|
||||
Content: "",
|
||||
Source: "qq",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
|
||||
var hasTopic, hasSource bool
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" && tr.Object == "多轮对话" {
|
||||
hasTopic = true
|
||||
}
|
||||
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "qq" {
|
||||
hasSource = true
|
||||
}
|
||||
}
|
||||
if !hasTopic || !hasSource {
|
||||
t.Errorf("正常模板三元组被误杀: topic=%v source=%v, triples=%+v", hasTopic, hasSource, triples)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -68,10 +69,38 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
|
||||
|
||||
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
|
||||
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
|
||||
//
|
||||
// 注入时**回填已有事件**的稠密向量。为什么必须回填:NewRelevanceContext 先
|
||||
// load()、再 SetDenseSpace,载入时 c.denseSpace 还是 nil,旧事件只算了稀疏
|
||||
// 向量;若这里只赋值不回填,Prune 里旧事件因 DenseFP 为空、长度不符而全部
|
||||
// 走稀疏余弦,新事件走稠密余弦 —— 同一次排序里两种尺度混排,谁留下谁归档
|
||||
// 取决于事件新旧而非相关性。对齐 DocStore.BuildDenseIndex 的做法。
|
||||
//
|
||||
// 注意 DenseVec/DenseFP 刻意不持久化(json:"-"):这是每次启动一次性重算的
|
||||
// 缓存,不落盘,因此这里也不需要 Save。
|
||||
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.denseSpace = ds
|
||||
if ds == nil || !ds.Loaded() {
|
||||
return
|
||||
}
|
||||
fp := ds.Fingerprint()
|
||||
dim := ds.Dim()
|
||||
filled := 0
|
||||
for _, evt := range c.events {
|
||||
if evt == nil {
|
||||
continue
|
||||
}
|
||||
if evt.DenseFP == fp && len(evt.DenseVec) == dim {
|
||||
continue
|
||||
}
|
||||
c.computeVector(evt)
|
||||
filled++
|
||||
}
|
||||
if filled > 0 {
|
||||
log.Printf("[agent] context dense backfill: %d events", filled)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {
|
||||
|
||||
@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -237,6 +238,41 @@ func containsStr(s, substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TestSetDenseSpaceBackfillsExistingEvents 锁死 L0 稠密回填:
|
||||
//
|
||||
// NewRelevanceContext 先 load()(此时 denseSpace 仍为 nil,旧事件只算了稀疏
|
||||
// 向量),SetDenseSpace 才注入稠密空间。若不回填已有事件,它们的 DenseFP
|
||||
// 为空、DenseVec 长度不符,Prune 里旧事件走稀疏余弦、新事件走稠密余弦——
|
||||
// 同一次排序里混排两种尺度,谁留下只取决于事件新旧。
|
||||
func TestSetDenseSpaceBackfillsExistingEvents(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "context.json")
|
||||
|
||||
// run1:写入事件并落盘(不注入稠密空间)。
|
||||
c1 := NewRelevanceContext(path, memory.NewStaticEmbedder(""))
|
||||
c1.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "昨天的决定"})
|
||||
c1.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Response: "记为待办"})
|
||||
if err := c1.Save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// run2:模拟重启——load() 发生在 SetDenseSpace 之前。
|
||||
c2 := NewRelevanceContext(path, memory.NewStaticEmbedder(""))
|
||||
if c2.Len() == 0 {
|
||||
t.Fatal("重启后未读回任何事件")
|
||||
}
|
||||
c2.SetDenseSpace(fakeSpace{})
|
||||
|
||||
events := c2.Recent(c2.Len())
|
||||
if len(events) == 0 {
|
||||
t.Fatal("no events")
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.DenseFP != "fake-space" || len(e.DenseVec) != 2 {
|
||||
t.Errorf("event %d 未回填稠密向量: fp=%q len=%d", i, e.DenseFP, len(e.DenseVec))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
|
||||
@ -172,6 +172,19 @@ func (a *Agent) archiveColdDocs() {
|
||||
}
|
||||
}
|
||||
|
||||
// 场景记忆的「用进废退」:久未重现的关联按半衰期淡出。
|
||||
//
|
||||
// 不做衰减的后果不是"多记一点",而是**注入预算被一次性巧合吃光**——
|
||||
// 场景是每轮都要注入的常驻内容,关联只增不减时,越老的库注入越糊。
|
||||
// 半衰期取 30 天:比"这个月没做过这类事"更久,避免把季节性的事误删。
|
||||
if a.memory != nil {
|
||||
if n, err := a.memory.DecaySceneRefs(30*24*time.Hour, 0.05); err != nil {
|
||||
log.Printf("[agent] scene decay error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[agent] 场景关联衰减:清理 %d 条长期未重现的引用", n)
|
||||
}
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
a.docStore.Reindex()
|
||||
}
|
||||
@ -207,7 +220,7 @@ func (a *Agent) archiveColdDocs() {
|
||||
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
|
||||
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
|
||||
if len(doc.Blocks) > 0 {
|
||||
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
|
||||
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks, memory.ChannelScene(doc.Source)); bound != len(doc.Blocks) {
|
||||
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
|
||||
doc.ID, bound, len(doc.Blocks))
|
||||
continue
|
||||
@ -423,6 +436,11 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该
|
||||
// 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回,
|
||||
// 不必指望本轮措辞与它们字面重合。
|
||||
docScene := memory.ChannelScene(doc.Source)
|
||||
|
||||
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
|
||||
|
||||
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
|
||||
@ -434,6 +452,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
Object: doc.Summary,
|
||||
ObjectType: "Topic",
|
||||
Confidence: 1.0,
|
||||
Scene: docScene,
|
||||
})
|
||||
}
|
||||
|
||||
@ -451,6 +470,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
for _, nt := range result.Triples {
|
||||
mt := nlp.ToMemoryTriple(nt)
|
||||
if mt.Subject != "" && mt.Relation != "" && mt.Object != "" {
|
||||
mt.Scene = docScene
|
||||
triples = append(triples, mt)
|
||||
}
|
||||
}
|
||||
@ -465,10 +485,15 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
Object: doc.Source,
|
||||
ObjectType: "Source",
|
||||
Confidence: 1.0,
|
||||
Scene: docScene,
|
||||
})
|
||||
}
|
||||
|
||||
return triples
|
||||
// 噪音闸门:NLP 提取器不认常用词(「结果 / 什么 / 待命」都能当主语),
|
||||
// 而落库闸门 validEntityName 只管名字像不像名字。这一层是防止
|
||||
// 「每个文档的常用词都变成实体」的唯一防线(CutExact 时代的那层已随
|
||||
// 提取器换代丢失,见 memory.IsNoiseEntity 的说明)。
|
||||
return memory.FilterNoiseTriples(triples)
|
||||
}
|
||||
|
||||
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要
|
||||
|
||||
@ -31,9 +31,47 @@ func (a *Agent) interceptLoop() {
|
||||
select {
|
||||
case evt := <-a.io.InputInterruptChan():
|
||||
text, _ := evt.Payload["content"].(string)
|
||||
if text == "" {
|
||||
stop, _ := evt.Payload["stop"].(bool)
|
||||
if text == "" && !stop {
|
||||
// 没有内容也不是停止指令:没有可处理的东西(旧行为)。
|
||||
//
|
||||
// 注意:**不能**把“空内容”一律当成空操作。客户端停止按钮
|
||||
// 本来就不带消息(/chat/interrupt 收 body 空的 {}),
|
||||
// 旧代码在这里 continue 掉,于是停止按钮毫无反应,
|
||||
// 而且接口还回 200 骗调用方——已实测:HTTP 200 但内核零日志、
|
||||
// 生成继续跑到自然结束。
|
||||
continue
|
||||
}
|
||||
if stop {
|
||||
// 停止:①立即结束当前 LLM 推理;②登记短路配额。
|
||||
//
|
||||
// 注意这里**只 arm、不 take**:takeStop 必须由 stepLLM 去消费,
|
||||
// 它才是决定“取消后不重跑”的那个人。曾经写成
|
||||
//
|
||||
// if n := armStop(); n > 0 || takeStop() { ... }
|
||||
//
|
||||
// 这个 `||` 在 queued=0 时会短路到 takeStop(),把标记先消费掉,
|
||||
// 于是 stepLLM 永远看不到它 → 取消后照样重跑一轮。
|
||||
// 实测:停止被正确记录(`stop requested ... queued=0`)但生成仍跑到自然结束。
|
||||
// pending 必须算上**停在输入 channel 里**的那一段:停止时
|
||||
// 调度器多在半路忙当前任务,其余消息还没被 pumpInbox 搬进队列,
|
||||
// 只数 sched.queue 会得到 0,配额随之失效(实测过)。
|
||||
pending := 0
|
||||
if a.io != nil {
|
||||
pending = a.io.PendingInputs()
|
||||
}
|
||||
n := a.sched.armStop(pending)
|
||||
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
|
||||
evt.Source, evt.OutputChannel, n)
|
||||
a.cancelCurrentLLM()
|
||||
if text == "" {
|
||||
// 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条
|
||||
// 中断入队,取消后会以空内容重跑一轮,停下之后又“活着”。
|
||||
continue
|
||||
}
|
||||
// 带注释的停止(/stop 说句话):注释本身仍作为中断处理,
|
||||
// 走下面的正常路径——用户想看模型对被停下话题的回应。
|
||||
}
|
||||
log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80))
|
||||
|
||||
clone := &agentIO.InputEvent{
|
||||
@ -275,15 +313,28 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
|
||||
return blocks, alt
|
||||
}
|
||||
|
||||
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
|
||||
// emitSkippedReply 给被跳过任务的调用方一个终态。
|
||||
//
|
||||
// 为什么要单独一条路径而不是复用 emitResponse:跳过意味着“我们没有处理这条输入”,
|
||||
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
|
||||
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
|
||||
//
|
||||
// ❗异步来源(qq / wechat / rss 等)**没有 ResponseCh**,于是这里以前是直接 return。
|
||||
// 后果是任务被丢弃时**完全无声**:用户什么都没收到、日志里也没痕迹,
|
||||
// 他只会以为消息丢了。转投子被回收/销毁时队列里的积压正落在这个盲区里
|
||||
// (父可随时对子 reclaim/destroy,而子手上可能还握着几条 QQ 消息)。
|
||||
// 现在至少留一条带来源与通道的日志,让“这条消息为什么没回”可被追溯。
|
||||
//
|
||||
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
|
||||
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
|
||||
if evt == nil || evt.ResponseCh == nil {
|
||||
if evt == nil {
|
||||
return
|
||||
}
|
||||
if evt.ResponseCh == nil {
|
||||
// 无可回执的通道:不静默。异步来源本就靠 agent 主动 output_send,
|
||||
// 丢弃后没有任何东西会告诉用户,因此这条日志是唯一的线索。
|
||||
log.Printf("[agent] %s: 丢弃一条无回执通道的输入(source=%s channel=%s request=%s reason=%s)",
|
||||
a.id, evt.Source, evt.OutputChannel, evt.RequestID, reason)
|
||||
return
|
||||
}
|
||||
ch := evt.OutputChannel
|
||||
@ -379,7 +430,7 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false).Archived
|
||||
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived
|
||||
}
|
||||
|
||||
// pruneDeclared 判定这次输入是否显式声明了裁剪。
|
||||
|
||||
@ -47,7 +47,7 @@ func (a *Agent) migrateLegacyGraphMedia() {
|
||||
|
||||
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
|
||||
// seed 允许复用已持有块的 ID(L2→L3 迁移保持块身份不变)。
|
||||
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
|
||||
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock, scene string) int {
|
||||
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
|
||||
return 0
|
||||
}
|
||||
@ -64,6 +64,11 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 块继承承载它的三元组的场景:块是流水线里最细的子项目,场景要落到它身上,
|
||||
// 否则「那场对话里发过来的那张图」在场面重现时永远取不回来。
|
||||
if b.Scene == "" {
|
||||
b.Scene = scene
|
||||
}
|
||||
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
|
||||
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
|
||||
continue
|
||||
@ -79,7 +84,7 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed
|
||||
|
||||
// linkBlocksToDocument 把文档持有的块写入 L3,并建立
|
||||
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
|
||||
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
|
||||
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock, scene string) int {
|
||||
if a.memory == nil || docID == "" || len(blocks) == 0 {
|
||||
return 0
|
||||
}
|
||||
@ -87,6 +92,20 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock)
|
||||
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
|
||||
return 0
|
||||
}
|
||||
// 文档层与场景模型兼容:文档节点也进场景,好让「这个场面有哪些文档」
|
||||
// 可枚举、可统计(场景贯穿流水线的 doc 层落地)。
|
||||
if scene != "" {
|
||||
if err := a.memory.TagSceneDocument(scene, docID); err != nil {
|
||||
log.Printf("[media] 文档挂场景失败 (%s): %v", docID, err)
|
||||
}
|
||||
}
|
||||
// 文档层把场景传给块:归档进图库的块属于该文档的来源场面(QQ 归档的图
|
||||
// 就该挂在 chan:qq 上),否则 L3 里这批块在场景召回中不可见。
|
||||
for i := range blocks {
|
||||
if blocks[i].Scene == "" {
|
||||
blocks[i].Scene = scene
|
||||
}
|
||||
}
|
||||
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
|
||||
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
|
||||
return 0
|
||||
@ -135,7 +154,7 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
|
||||
if sid == 0 {
|
||||
continue
|
||||
}
|
||||
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
|
||||
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest, t.Scene)
|
||||
}
|
||||
return ec, rc, blocks, nil
|
||||
}
|
||||
@ -197,6 +216,28 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
|
||||
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
|
||||
}
|
||||
|
||||
// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。
|
||||
//
|
||||
// 带上原始句子(截断到 60 字):三元组只是「A 关系 B」,脱离原句往往看不出
|
||||
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」,
|
||||
// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型
|
||||
// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。
|
||||
func formatRecallRelations(relations []memory.Relation, max int) []string {
|
||||
var out []string
|
||||
for i, r := range relations {
|
||||
if max > 0 && i >= max {
|
||||
out = append(out, "...更多关系被截断")
|
||||
break
|
||||
}
|
||||
line := fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)
|
||||
if s := strings.TrimSpace(r.SentenceText); s != "" {
|
||||
line += " 原句: \"" + truncateStr(s, 60) + "\""
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
|
||||
//
|
||||
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token),
|
||||
|
||||
@ -194,7 +194,7 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
|
||||
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
|
||||
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
|
||||
a, g, _ := newGraphMediaAgent(t)
|
||||
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
|
||||
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil, ""); n != 0 {
|
||||
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("sentence", "42")
|
||||
@ -208,7 +208,7 @@ func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
|
||||
|
||||
func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
|
||||
a := &Agent{}
|
||||
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
|
||||
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil, ""); n != 0 {
|
||||
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
|
||||
}
|
||||
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
|
||||
@ -234,7 +234,7 @@ func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
|
||||
sid := ids["迁移测试句。"]
|
||||
|
||||
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
|
||||
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
|
||||
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest, ""); n != 1 {
|
||||
t.Fatalf("应绑定 1 个块,实际 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
|
||||
@ -256,7 +256,7 @@ func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
|
||||
t.Fatal("blockFromDigest 失败")
|
||||
}
|
||||
|
||||
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
|
||||
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}, ""); n != 1 {
|
||||
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
|
||||
}
|
||||
blocks, err := g.BlocksForNode("document", "doc_42")
|
||||
@ -376,7 +376,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
|
||||
out := a.buildMemoryContext("测试图片", 0)
|
||||
out := a.buildMemoryContext("测试图片", 0, nil)
|
||||
if out == "" {
|
||||
t.Skip("图库召回未命中(indexer 检索策略所致),无法验证媒体段注入")
|
||||
}
|
||||
@ -759,3 +759,39 @@ func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
|
||||
t.Fatal("删除后内容应已移除")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatRecallRelations_SurfacesSentence 锁死「从图谱回到原文」:
|
||||
// memory_recall 的关系行必须带上 sentence_text(截断),否则模型按工具
|
||||
// schema 填了原始句子也永远取不回,该字段形同虚设。
|
||||
func TestFormatRecallRelations_SurfacesSentence(t *testing.T) {
|
||||
rels := []memory.Relation{
|
||||
{SourceName: "张三", RelationType: "喜欢", TargetName: "咖啡", SentenceText: "张三说他每天早上一定要喝一杯手冲咖啡。"},
|
||||
{SourceName: "张三", RelationType: "住在", TargetName: "北京"}, // 无原句:不应出现空的原句字段
|
||||
}
|
||||
lines := formatRecallRelations(rels, 10)
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("应渲染 2 行,实际 %d: %v", len(lines), lines)
|
||||
}
|
||||
if !strings.Contains(lines[0], "张三 →(喜欢)→ 咖啡") || !strings.Contains(lines[0], "原句:") {
|
||||
t.Errorf("第一条应带原句,实际 %q", lines[0])
|
||||
}
|
||||
if strings.Contains(lines[1], "原句") {
|
||||
t.Errorf("无 sentence_text 的关系不应出现原句字段,实际 %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatRecallRelations_Truncates 锁死关系条数上限:
|
||||
// 超过 max 时截断并明确告知,避免刷屏。
|
||||
func TestFormatRecallRelations_Truncates(t *testing.T) {
|
||||
var rels []memory.Relation
|
||||
for i := 0; i < 15; i++ {
|
||||
rels = append(rels, memory.Relation{SourceName: "A", RelationType: "连", TargetName: "B"})
|
||||
}
|
||||
lines := formatRecallRelations(rels, 10)
|
||||
if len(lines) != 11 {
|
||||
t.Fatalf("10 条关系 + 1 条截断提示,实际 %d: %v", len(lines), lines)
|
||||
}
|
||||
if !strings.Contains(lines[10], "截断") {
|
||||
t.Errorf("最后一行应为截断提示,实际 %q", lines[10])
|
||||
}
|
||||
}
|
||||
|
||||
@ -450,7 +450,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
if !strings.Contains(out, "关联") {
|
||||
t.Errorf("返回值应告知模型媒体已关联: %q", out)
|
||||
}
|
||||
@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
|
||||
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("普通提交不该失败: %q", out)
|
||||
}
|
||||
@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("无媒体存储时提交不该失败: %q", out)
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) {
|
||||
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "c1", Name: "memory_recall",
|
||||
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
|
||||
})
|
||||
}, nil)
|
||||
if !strings.Contains(got, "主记忆实体") {
|
||||
t.Fatalf("子应看得到主记忆:%s", got)
|
||||
}
|
||||
@ -132,7 +132,7 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
|
||||
Arguments: map[string]interface{}{
|
||||
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
}, nil)
|
||||
if !strings.Contains(got, "轻量内核") {
|
||||
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
|
||||
}
|
||||
|
||||
@ -440,7 +440,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
t.Fatalf("indexer sync: %v", err)
|
||||
}
|
||||
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
|
||||
if mc := a.buildMemoryContext("测试图片", 0, nil); mc != "" {
|
||||
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
|
||||
} else {
|
||||
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
|
||||
|
||||
@ -1,6 +1,63 @@
|
||||
package core
|
||||
|
||||
import "log"
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// sceneKeysFor 推导本轮输入的**当前场景**。
|
||||
//
|
||||
// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定)
|
||||
// 取回来。优先级:
|
||||
// 1. 注入点显式声明(payload.scene)——插件最清楚自己在什么场面里
|
||||
// 2. 通道(evt.Source → chan:qq)
|
||||
// 3. 工具(tool:qq_get_message)——工具输出触发的召回只知道这一步
|
||||
//
|
||||
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
|
||||
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
|
||||
func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
|
||||
var keys []string
|
||||
seen := make(map[string]bool)
|
||||
add := func(k string) {
|
||||
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
|
||||
// 否则「chan:QQ」与「chan:qq」会变成两个场景,各自只召回一半记忆。
|
||||
k = memory.NormalizeSceneKey(k)
|
||||
if k == "" || seen[k] {
|
||||
return
|
||||
}
|
||||
seen[k] = true
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
if evt != nil && evt.Payload != nil {
|
||||
switch v := evt.Payload["scene"].(type) {
|
||||
case string:
|
||||
add(v)
|
||||
case []string:
|
||||
for _, s := range v {
|
||||
add(s)
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
add(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if evt != nil {
|
||||
add(memory.ChannelScene(evt.Source))
|
||||
}
|
||||
if toolName != "" {
|
||||
add(memory.ToolScene(toolName))
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
|
||||
type memoryPassOut struct {
|
||||
@ -26,7 +83,7 @@ type memoryPassOut struct {
|
||||
// 稠密/词向量给已有事件打分,recall 用图 + TF-IDF 实体索引)。真正的
|
||||
// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、
|
||||
// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。
|
||||
func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPassOut {
|
||||
func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut {
|
||||
var out memoryPassOut
|
||||
if a == nil || (!prune && !recall) {
|
||||
return out
|
||||
@ -35,7 +92,7 @@ func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPass
|
||||
out.Archived = a.pruneByQuery(query)
|
||||
}
|
||||
if recall && query != "" {
|
||||
out.RecallText = a.recallTextFor(query, trigger)
|
||||
out.RecallText = a.recallTextFor(query, trigger, scenes)
|
||||
}
|
||||
if out.Archived > 0 || out.RecallText != "" {
|
||||
log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars",
|
||||
@ -63,3 +120,141 @@ func (a *Agent) pruneByQuery(query string) int {
|
||||
}
|
||||
return a.context.Prune(query, topK, a.docStore)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 场面指纹:场景**涌现**的原料
|
||||
//
|
||||
// 场景不是谁声明的,而是从交互流里长出来的。长出来的原料就是每轮可观察的
|
||||
// 场面指纹——在哪个通道、跟谁、在做什么、聊什么、什么时段。全部取自运行时
|
||||
// 已有量,不需要模型配合,也不需要人工标注。
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// situationFeaturesFor 采集一轮交互的场面指纹。
|
||||
//
|
||||
// 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是
|
||||
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
|
||||
func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
|
||||
var feats []memory.SituationFeature
|
||||
if evt != nil {
|
||||
if evt.Source != "" {
|
||||
feats = append(feats, memory.SituationFeature{Kind: "chan", Value: evt.Source})
|
||||
}
|
||||
// 对话对象:插件在 payload 里给的群/用户标识(有则用,无则退化为仅有通道)
|
||||
for _, k := range []string{"peer", "peer_id", "group_id", "user_id", "chat_id"} {
|
||||
if v, ok := evt.Payload[k]; ok {
|
||||
if s := payloadString(v); s != "" {
|
||||
// 群与私聊要能区分:同一 id 在两种场景下不是同一个对象
|
||||
kind := "peer"
|
||||
if k == "group_id" {
|
||||
kind = "peer_group"
|
||||
}
|
||||
feats = append(feats, memory.SituationFeature{Kind: kind, Value: s})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 时段:弱信号。人的记忆确实带时间气味(「早上那件事」),
|
||||
// 但它不该主导场面判定,所以权重最低。
|
||||
feats = append(feats, memory.SituationFeature{Kind: "part", Value: partOfDay(time.Now())})
|
||||
}
|
||||
if tool != "" {
|
||||
feats = append(feats, memory.SituationFeature{Kind: "tool", Value: tool})
|
||||
}
|
||||
// 话题:取清洗后输入的内容词做软特征(最多 3 个)。
|
||||
if cleanInput != "" {
|
||||
for i, kw := range memory.ExtractKeywords(memory.CleanText(cleanInput)) {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
feats = append(feats, memory.SituationFeature{Kind: "topic", Value: kw})
|
||||
}
|
||||
}
|
||||
return feats
|
||||
}
|
||||
|
||||
// payloadString 从 payload 值里取字符串(可能是 string / float64 / json.Number)。
|
||||
func payloadString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// partOfDay 把时刻归成时段(场面指纹里最弱的一维)。
|
||||
func partOfDay(t time.Time) string {
|
||||
switch h := t.Hour(); {
|
||||
case h < 6:
|
||||
return "night"
|
||||
case h < 12:
|
||||
return "morning"
|
||||
case h < 18:
|
||||
return "afternoon"
|
||||
default:
|
||||
return "evening"
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**:
|
||||
//
|
||||
// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入
|
||||
// 本轮指纹(声明场景因此慢慢学会自己认自己)
|
||||
// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景
|
||||
//
|
||||
// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景
|
||||
// 兜底),Keys 用于**读**(两条路的并集,去重)。
|
||||
//
|
||||
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
|
||||
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
|
||||
func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
|
||||
var out memory.TurnScene
|
||||
if a == nil || a.memory == nil {
|
||||
return out
|
||||
}
|
||||
if f != nil && f.sceneDone {
|
||||
return f.turnScene
|
||||
}
|
||||
|
||||
declared := sceneKeysFor(evtOf(f), tool)
|
||||
feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
|
||||
sig := memory.NewSituation(feats...)
|
||||
|
||||
turn, err := a.memory.EnterSceneWithHint(sig, declared)
|
||||
if err != nil {
|
||||
log.Printf("[agent] scene enter failed: %v", err)
|
||||
// 出错时至少把声明场景交给召回,不让整条召回链一起失效
|
||||
turn = memory.TurnScene{Keys: declared}
|
||||
if len(declared) > 0 {
|
||||
turn.Primary = declared[0]
|
||||
}
|
||||
}
|
||||
if turn.Emergent {
|
||||
log.Printf("[agent] 场景涌现/命中: %q(指纹 %v)", turn.Primary, sig.Keys())
|
||||
} else if len(turn.DeclaredCreated) > 0 {
|
||||
log.Printf("[agent] 声明场景成立: %v(指纹 %v)", turn.DeclaredCreated, sig.Keys())
|
||||
}
|
||||
if f != nil {
|
||||
f.turnScene = turn
|
||||
f.Scene = turn.Primary
|
||||
f.sceneDone = true
|
||||
}
|
||||
return turn
|
||||
}
|
||||
|
||||
// cleanInputOf 安全取出清洗后输入(f 为 nil 时为空)。
|
||||
func cleanInputOf(f *TaskFrame) string {
|
||||
if f == nil {
|
||||
return ""
|
||||
}
|
||||
return f.CleanInput
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) {
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
}
|
||||
out := a.memoryPass("咖啡", "test", false, false)
|
||||
out := a.memoryPass("咖啡", "test", false, false, nil)
|
||||
if out.Archived != 0 || out.RecallText != "" {
|
||||
t.Fatalf("未声明任何策略时不应有任何输出,实际 %+v", out)
|
||||
}
|
||||
@ -31,7 +31,7 @@ func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) {
|
||||
func TestMemoryPass_PruneAndRecallTogether(t *testing.T) {
|
||||
a := newMemoryPassAgent(t)
|
||||
before := a.context.Len()
|
||||
out := a.memoryPass("咖啡", "tool:test", true, true)
|
||||
out := a.memoryPass("咖啡", "tool:test", true, true, nil)
|
||||
if out.Archived == 0 {
|
||||
t.Fatal("声明 prune 应归档低相关事件")
|
||||
}
|
||||
@ -50,7 +50,7 @@ func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) {
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
}
|
||||
if out := onlyPrune.memoryPass("咖啡", "test", true, false); out.RecallText != "" {
|
||||
if out := onlyPrune.memoryPass("咖啡", "test", true, false, nil); out.RecallText != "" {
|
||||
t.Fatalf("只声明 prune 不应召回,实际 %q", out.RecallText)
|
||||
}
|
||||
// 只召回不裁剪:输出只有召回文本,上下文条数不变。
|
||||
@ -60,7 +60,7 @@ func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) {
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
}
|
||||
before := onlyRecall.context.Len()
|
||||
out := onlyRecall.memoryPass("咖啡", "test", false, true)
|
||||
out := onlyRecall.memoryPass("咖啡", "test", false, true, nil)
|
||||
if out.Archived != 0 {
|
||||
t.Fatalf("只声明 recall 不应裁剪,实际归档 %d", out.Archived)
|
||||
}
|
||||
|
||||
411
internal/agent/core/offload.go
Normal file
411
internal/agent/core/offload.go
Normal file
@ -0,0 +1,411 @@
|
||||
package core
|
||||
|
||||
// 积压任务的**自动转投**:主 agent 长时间忙时,把排队中的任务改投给内核拉起的
|
||||
// 驻留子,并在原队列位置留一条"已转投"提示。
|
||||
//
|
||||
// 为什么要这个(用户 2026-09-19 提出的实际需求):
|
||||
// 实测主 agent 被一条长任务占住时(当天现场:12 分 8 秒、69 次工具调用),
|
||||
// 后来的 QQ 消息全部以 "level insufficient" 排进中断队列干等 —— 同级中断
|
||||
// 不能抢占同级运行任务(scheduler.canPreempt),只能等前一个跑完。
|
||||
// 而内核明明有驻留子(独立 agent + 独立调度器 + 共享输出通道视图)可以并行干活。
|
||||
//
|
||||
// 与设计文档 §7 的关系(**这是刻意的例外,必须显式记录**):
|
||||
// 设计原文写「创建/销毁/回收/查看/发送是父可调用的原语;**决策在父的模型手里**——
|
||||
// 内核不替父决定」。本特性让**内核**主动创建并使用驻留子,属于对该原则的例外。
|
||||
// 之所以可接受:父此刻正忙(无法做决策),而积压任务**本来就是空的**——
|
||||
// 转投只是把"排队干等"换成"有人在做",不改变任何已提交决策的语义。
|
||||
// 若不做例外,这个能力就只能由父的模型发起,而它恰恰是忙不过来的那个。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"runtime/debug"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// OffloadOptions 是自动转投的判定与执行参数。
|
||||
type OffloadOptions struct {
|
||||
// BusyAfter:运行任务已持续多久算"长时间工作"(0 = 用默认)。
|
||||
BusyAfter time.Duration
|
||||
// MinPending:至少要积压多少条才值得拉起驻留子(0 = 用默认)。
|
||||
MinPending int
|
||||
// MaxResidents:为转投而拉起的驻留子上限(0 = 用默认)。
|
||||
MaxResidents int
|
||||
// Enabled 为 false 时完全关闭(默认关:见 DefaultOffloadOptions 的说明)。
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// 默认参数。
|
||||
//
|
||||
// 为什么默认**关闭**:自动拉起是"内核替父做决策",改变的是系统行为而非修 bug;
|
||||
// 且它会让日志/账单里凭空多出一个 agent 在干活。默认关闭、由部署方显式打开,
|
||||
// 与「显式才是特权」(scheduler.DefaultLevel 的同一条理由)一致。
|
||||
const (
|
||||
defaultOffloadBusyAfter = 5 * time.Minute
|
||||
defaultOffloadMinPending = 3
|
||||
defaultOffloadMaxResident = 2
|
||||
)
|
||||
|
||||
// DefaultOffloadOptions 返回默认参数(Enabled=false)。
|
||||
func DefaultOffloadOptions() OffloadOptions {
|
||||
return OffloadOptions{
|
||||
BusyAfter: defaultOffloadBusyAfter,
|
||||
MinPending: defaultOffloadMinPending,
|
||||
MaxResidents: defaultOffloadMaxResident,
|
||||
Enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (o OffloadOptions) normalized() OffloadOptions {
|
||||
if o.BusyAfter <= 0 {
|
||||
o.BusyAfter = defaultOffloadBusyAfter
|
||||
}
|
||||
if o.MinPending <= 0 {
|
||||
o.MinPending = defaultOffloadMinPending
|
||||
}
|
||||
if o.MaxResidents <= 0 {
|
||||
o.MaxResidents = defaultOffloadMaxResident
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// offloadNotice 是替换被转投任务的那条提示的正文。
|
||||
//
|
||||
// 它必须**自己说清是系统做的**:用户看到队列里出现一条没人发过的消息时,
|
||||
// 唯一能解释这件事的就是这句话本身。
|
||||
//
|
||||
// 同时要说清"不必重复处理":那些消息已由子 agent 回复(或已回复"忙碌中"),
|
||||
// 主 agent 再处理一遍会让用户收到重复回复。
|
||||
func offloadNotice(count int, residentID string) string {
|
||||
return fmt.Sprintf(
|
||||
"[系统] %d 条积压消息已在主 agent 忙期间交由临时助手 %s 先行分诊"+
|
||||
"(简单的已直接处理并回复,需要你的那些已告知用户「忙碌中,请稍候」)。"+
|
||||
"它们**不需要你再处理**了;若其中有需要你后续跟进的,请查看上述通道的会话记录。"+
|
||||
"本提示仅用于说明情况,无需回复。",
|
||||
count, residentID)
|
||||
}
|
||||
|
||||
// offloadCandidate 是一条可被转投的排队任务。
|
||||
//
|
||||
// 只有**纯排队输入**(TaskQueued + KindInput)可转投:
|
||||
// - 中断任务带级别语义(可能正在等待抢占时机),转投会打乱中断阶梯;
|
||||
// - self 任务是内核内部记账(记忆整理等),与父的记忆面绑定,不能换 agent。
|
||||
type offloadCandidate struct {
|
||||
Event *agentIO.InputEvent
|
||||
}
|
||||
|
||||
// drainInboxToQueue 把 io 输入 channel 里**已经到达但尚未被搬运**的输入
|
||||
// 搬进就绪队列(非阻塞;取空为止)。
|
||||
//
|
||||
// 它与 pumpInbox 做的事一样,但**不要求 hasRoom**:pumpInbox 在队列满时
|
||||
// 会停下以保留背压,而转投场景恰恰是「队列空/不满、但输入堵在 channel 里」
|
||||
// (因为调度器正忙于执行任务、根本回不到 pumpInbox)。
|
||||
//
|
||||
// 队列上限仍由 enqueue 把关:满了就停下,超出的输入留在 channel 里。
|
||||
func (a *Agent) drainInboxToQueue() {
|
||||
for {
|
||||
select {
|
||||
case evt := <-a.io.InputChan():
|
||||
if !a.sched.enqueue(newInputTask(evt)) {
|
||||
// 队列满:放不进去。不能丢,也不能阻塞(我们是后台 goroutine,
|
||||
// 阻塞会把这个循环永远卡住)——给同步调用方一个终态后丢弃,
|
||||
// 与 pumpInbox 的 queue_full 处置一致。
|
||||
a.sched.noteBackpressure()
|
||||
a.emitSkippedReply(evt, "queue_full")
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 注:转投不走 inputch 名字(那会在投递时重建事件、丢掉 ResponseCh),
|
||||
// 而是直接跨 agent 推原事件 —— 见 forwardInputToResident。
|
||||
|
||||
// offloadPendingTasks 检查是否需要转投,需要则拉起/复用一个驻留子并搬运任务。
|
||||
//
|
||||
// 返回实际转投的任务条数(0 = 未触发/未转投)。
|
||||
//
|
||||
// ❗并发前提:本函数会被 offloadLoop 在**任务执行期间**调用(那正是它的意义),
|
||||
// 因此它与 schedulerLoop 是并发跑的。所有对队列的读写都经 scheduler 的锁,
|
||||
// 而"取走哪些任务"与"放回什么"都在同一次锁内完成,不存在丢任务的窗口。
|
||||
func (a *Agent) offloadPendingTasks(opts OffloadOptions) int {
|
||||
opts = opts.normalized()
|
||||
if !opts.Enabled {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ① 判定:运行任务是否已忙够久。运行任务为空说明压根不忙,不做。
|
||||
running := a.sched.runningTask()
|
||||
if running == nil {
|
||||
return 0
|
||||
}
|
||||
busyFor := a.sched.runningFor()
|
||||
if busyFor < opts.BusyAfter {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ② 先把积压从 io 的输入 channel **搬进就绪队列**。
|
||||
//
|
||||
// !!这是本特性最容易写错的一步(我第一版就错了,写完后线上实测永不触发):
|
||||
// schedulerLoop 是**同步执行**任务的,所以「正忙」期间它根本不会回到循环顶部
|
||||
// 去调 pumpInbox —— 这时后到的输入全部堆在 io.inputCh(容量 256)里,
|
||||
// **压根没进 sched.queue**。只数 s.queue 会得到 0,转投就永远不触发。
|
||||
//
|
||||
// 仓库里早记过同一个坑:armStop 的注释写着「pending 是还没被 pumpInbox 搬进
|
||||
// 队列的那一段……只数 s.queue 会得到 0(实测),配额随之失效」。
|
||||
// 这里必须在同一层把这件事做对,而不是重犯。
|
||||
a.drainInboxToQueue()
|
||||
|
||||
// ③ 收集可转投的排队任务;不够量就不值得拉起一个 agent。
|
||||
cands := a.sched.takeQueuedInputs(opts.MinPending)
|
||||
if len(cands) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ③ 找或拉起一个"转投专用"驻留子。
|
||||
residentID, err := a.ensureOffloadResident(opts)
|
||||
if err != nil {
|
||||
// 拉不起来就把任务**放回队列**,绝不能丢:丢一条输入比多处理一条更糟
|
||||
// (与 routeInputByOwner 的兜底同一条理由)。
|
||||
a.sched.requeueFront(cands)
|
||||
log.Printf("[offload] 无法为 %d 条积压任务准备驻留子,已放回队列: %v", len(cands), err)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ④ 搬运:逐条投进子的 inputch。
|
||||
//
|
||||
// 注意这里**逐条转发原文**而不是打包成一条:任务本身带 Source/OutputChannel
|
||||
// 等路由信息,打包会让子无法把回复发回正确的通道(qq 私聊 vs 群聊不同)。
|
||||
var moved int
|
||||
for _, c := range cands {
|
||||
if c.Event == nil {
|
||||
continue
|
||||
}
|
||||
if err := a.forwardInputToResident(residentID, c.Event); err != nil {
|
||||
// 某条投不进去:放回原队列,其余继续(部分成功好过全部回滚)。
|
||||
a.sched.requeueFront([]offloadCandidate{c})
|
||||
log.Printf("[offload] 转发任务给驻留子 %s 失败,已放回队列: %v", residentID, err)
|
||||
continue
|
||||
}
|
||||
moved++
|
||||
}
|
||||
if moved == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ⑤ 在**原队列位置**留下提示(用户要求的那条说明)。
|
||||
//
|
||||
// 为什么留在队列里而不是只记日志:队列顺序就是主 agent 接下来要处理的事;
|
||||
// 用户看会话记录时,需要在这里就看到"那几条去哪儿了",而不是去翻内核日志。
|
||||
a.sched.requeueFront([]offloadCandidate{
|
||||
{Event: a.syntheticEvent(offloadNotice(moved, residentID))},
|
||||
})
|
||||
|
||||
log.Printf("[offload] 主 agent 已忙 %s,把 %d 条积压任务转投给驻留子 %s(队列留 1 条说明)",
|
||||
busyFor.Truncate(time.Second), moved, residentID)
|
||||
return moved
|
||||
}
|
||||
|
||||
// forwardInputToResident 把一条输入**原文**投给指定驻留子的队列。
|
||||
//
|
||||
// ❗必须推**原事件**(DeliverRouted),不能重建:原事件带 ResponseCh,
|
||||
// 而 cli / a2a / webui 这些**同步**调用方正阻塞等它。重建事件(如用
|
||||
// InjectInputTo)会把 ResponseCh 丢掉 ⇒ 任务被子处理完、调用方却永远收不到回执。
|
||||
// 实测:转投生效、子也正常处理(各 ~3s 日志可见),但 HTTP 请求一直挂着不返回。
|
||||
// 仓库反复警告同一件事(见 Agent.Stop 对 drainPendingInterrupts 的注释:
|
||||
// “带 ResponseCh 的同步注入方会永久挂起”),这里必须走既有的跨 agent 投递原语。
|
||||
//
|
||||
// 用排队语义(isInterrupt=false):转投的是"待办工作",不是"打断子"。
|
||||
func (a *Agent) forwardInputToResident(residentID string, evt *agentIO.InputEvent) error {
|
||||
a.residentMu.Lock()
|
||||
rc := a.residents[residentID]
|
||||
a.residentMu.Unlock()
|
||||
if rc == nil || rc.agent == nil || rc.agent.io == nil {
|
||||
return fmt.Errorf("驻留子 %s 不存在或不可用", residentID)
|
||||
}
|
||||
|
||||
// 带上来源线索(不重建事件,只补充 payload,保留 ResponseCh/RequestID)。
|
||||
if evt.Payload == nil {
|
||||
evt.Payload = map[string]interface{}{}
|
||||
}
|
||||
evt.Payload["offloaded_from"] = string(a.id)
|
||||
evt.Payload["offloaded_at"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
rc.agent.io.DeliverRouted(evt, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureOffloadResident 返回一个可用于转投的驻留子 id,必要时拉起一个新的。
|
||||
//
|
||||
// 复用规则:优先复用"内核为转投而建"且仍 running、还没满的驻留子;
|
||||
// 都不可用时(在 MaxResidents 内)新建一个。
|
||||
func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
|
||||
a.residentMu.Lock()
|
||||
var reusable []string
|
||||
for id, rc := range a.residents {
|
||||
if rc == nil || !rc.offloadOwned {
|
||||
continue
|
||||
}
|
||||
rc.mu.Lock()
|
||||
state := rc.state
|
||||
rc.mu.Unlock()
|
||||
if state == "running" {
|
||||
reusable = append(reusable, id)
|
||||
}
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
|
||||
// 复用:按 id 稳定排序后取第一个,避免每次挑到不同的子(可预测性)。
|
||||
if len(reusable) > 0 {
|
||||
sortStrings(reusable)
|
||||
return reusable[0], nil
|
||||
}
|
||||
|
||||
// 计数:只为转投而建的子是否已达上限(人工建的子不计入)。
|
||||
a.residentMu.Lock()
|
||||
owned := 0
|
||||
for _, rc := range a.residents {
|
||||
if rc != nil && rc.offloadOwned {
|
||||
owned++
|
||||
}
|
||||
}
|
||||
a.residentMu.Unlock()
|
||||
if owned >= opts.MaxResidents {
|
||||
return "", fmt.Errorf("转投专用驻留子已达上限 %d", opts.MaxResidents)
|
||||
}
|
||||
|
||||
id := fmt.Sprintf("offload-%d", time.Now().Unix())
|
||||
if a.dataDir == "" {
|
||||
return "", fmt.Errorf("未配置 DataDir,无法为驻留子分配 temp 图库路径")
|
||||
}
|
||||
info, err := a.SpawnResident(ResidentOptions{
|
||||
ID: id,
|
||||
// 不配 inputch:它是内核的**干活** agent,不接收任何插件的用户输入
|
||||
// (用户要求"不配输入通道")。它只由父经转投拿到任务。
|
||||
InputChs: nil,
|
||||
// 全部输出通道:它要能把结果发回 qq/webui 等正确通道
|
||||
// (用户要求"持有全部输出通道")。nil = 完整授权。
|
||||
AllowedOutputs: nil,
|
||||
TempPath: a.residentTempPath(id),
|
||||
OffloadOwned: true,
|
||||
TaskPrompt: offloadTaskPrompt(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
// offloadTaskPrompt 是转投专用驻留子的**分诊职责**说明。
|
||||
//
|
||||
// 为什么必须给:不给的话子完全不知道自己为什么存在(只知道自己是"小宅"),
|
||||
// 拿到一条转投消息时不知道它是"用户正在等回复的请求",
|
||||
// 也不知道自己只有两条路可走(直接办 / 报忙碌)。
|
||||
//
|
||||
// 用户的定位(2026-09-19 明确):这不是"内核替父决定",而是**及时反馈** ——
|
||||
// 主 agent 忙时不该让用户干等(实测有 13 分钟的现场)。
|
||||
// 子的职责是**分诊**(triage):
|
||||
// - 简单、不需主 agent 介入的 → 直接办完并回复;
|
||||
// - 需要主 agent 介入的 → 立刻回「忙碌中,请稍候」,**不要勉强做**。
|
||||
func offloadTaskPrompt() string {
|
||||
return `你是主 agent 的临时助手,负责在主 agent 忙不过来时**分诊**它的积压消息。
|
||||
|
||||
背景:主 agent 正在执行一个长任务,短时间无法处理新消息。你被临时拉起,
|
||||
专门承接这些积压的请求,**避免用户干等**(此前用户可能要等十几分钟)。
|
||||
|
||||
对每一条消息,你只有两条路:
|
||||
|
||||
1. 【直接办】如果这件事简单、明确、不需要主 agent 的全局上下文或长期规划
|
||||
(例如:查个信息、跑个小命令、读个文件、简单问答)——
|
||||
**直接做完,并把结果发回原通道**。
|
||||
|
||||
2. 【报忙碌】如果这件事需要主 agent 介入(需要它的长期记忆、正在进行的任务上下文、
|
||||
需要它做多步决策,或你无法确定怎么做)——
|
||||
**不要勉强尝试**。立刻回复用户:主 agent 当前忙碌中,请稍候。
|
||||
|
||||
重要约束:
|
||||
- **必须把回复发到用户原本的通道**。面向 qq、wechat 等异步通道时,
|
||||
纯文本返回会被丢弃 —— 必须显式调用 output_send__{通道名},否则用户收不到,
|
||||
而你会以为已经回过了。
|
||||
- 不要向用户暴露"我是被临时拉起的助手"这类内部细节,用主 agent 的口吻回复。
|
||||
- 拿不准属于哪一类时,选【报忙碌】。宁可让用户稍后得到准确答复,
|
||||
也不要给出错误的直接回答。`
|
||||
}
|
||||
|
||||
// residentTempPath 计算某个驻留子的 temp 图记忆路径(与既有约定一致)。
|
||||
func (a *Agent) residentTempPath(id string) string {
|
||||
return strings.TrimRight(a.dataDir, "/") + "/residents/" + id + "/graph.db"
|
||||
}
|
||||
|
||||
// syntheticEvent 造一条"内核自己发的"输入事件(用于队列里的转投说明)。
|
||||
//
|
||||
// Source 取 kernel:这条消息不是任何用户发来的,日志与用户界面里都应看得出。
|
||||
// 不带 ResponseCh:没有同步调用方在等它(它只是给主 agent 看的一句说明)。
|
||||
func (a *Agent) syntheticEvent(text string) *agentIO.InputEvent {
|
||||
return &agentIO.InputEvent{
|
||||
Source: "kernel",
|
||||
Type: "text",
|
||||
OutputChannel: "kernel",
|
||||
Payload: map[string]interface{}{"content": text},
|
||||
}
|
||||
}
|
||||
|
||||
// sortStrings 是一个不引入 sort 依赖的小排序(候选集极小,插入排序足够)。
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offloadLoop 周期性检查「主 agent 是否被长任务占住 + 是否有积压」。
|
||||
//
|
||||
// 为什么必须是**独立 goroutine**而不是 schedulerLoop 里的一步:
|
||||
//
|
||||
// schedulerLoop 是**同步执行**任务的(executeNewTask 会一直阻塞到任务结束),
|
||||
// 所以「正忙」期间它根本不会回到循环顶部 —— 把检查放在那里等于永不触发。
|
||||
// 这正是本特性存在的理由(主 agent 忙时无人处理积压),不能在实现上重犯。
|
||||
//
|
||||
// 检查间隔取 BusyAfter 的 1/5(不低于 1 秒):保证在跨过阈值后能在合理时间内
|
||||
// 触发,又不至于空转打日志。
|
||||
func (a *Agent) offloadLoop() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[agent] offloadLoop panic recovered: %v\n%s", r, debug.Stack())
|
||||
time.Sleep(time.Second)
|
||||
go a.offloadLoop()
|
||||
}
|
||||
}()
|
||||
|
||||
if !a.offload.Enabled {
|
||||
return // 未启用:不占 goroutine,也不打日志(默认关闭是常态)
|
||||
}
|
||||
// 只让**根 agent** 做转投:驻留子自己也可能忙,但让子再去拉孙子会形成
|
||||
// 无界增殖(每层都能拉 MAX 个),而积压的源头是根那一条调度链。
|
||||
if a.parentID != "" {
|
||||
return
|
||||
}
|
||||
|
||||
interval := a.offload.BusyAfter / 5
|
||||
if interval < time.Second {
|
||||
interval = time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
a.offloadPendingTasks(a.offload)
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
698
internal/agent/core/offload_test.go
Normal file
698
internal/agent/core/offload_test.go
Normal file
@ -0,0 +1,698 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// newRootWithoutSchedulerLoop 造一个**不启动后台循环**的根 agent。
|
||||
//
|
||||
// 为什么测试必须用它:newRootWith 会 a.Start(),于是真实的 schedulerLoop
|
||||
// 与测试**并发**跑,它会瞬间把测试排进队列的任务执行掉并清空 running
|
||||
// ⇒ "主 agent 正忙"这个前提会被后台循环消掉,转投判定随机失效
|
||||
// (实测:同一测试两次运行结果不同,一个过一个不过)。
|
||||
// 本特性测的是**判定 + 搬运**这两步的语义,不需要真的把任务跑起来。
|
||||
func newRootWithoutSchedulerLoop(t *testing.T) (*Agent, *memory.GraphDB) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := New(AgentConfig{
|
||||
ID: "parent",
|
||||
Provider: &countingProvider{},
|
||||
ProviderManager: agentAPI.NewProviderManager(),
|
||||
IO: agentIO.NewIOManager(),
|
||||
StageHost: NewStageHost(),
|
||||
Memory: main,
|
||||
DataDir: dir,
|
||||
})
|
||||
t.Cleanup(func() { a.Stop(); main.Close() })
|
||||
return a, main
|
||||
}
|
||||
|
||||
// makeQueuedInput 造一条排队输入任务(Event 非空,Class=TaskQueued)。
|
||||
func makeQueuedInput(id int) *Task {
|
||||
return newInputTask(&agentIO.InputEvent{
|
||||
RequestID: "req",
|
||||
Source: "qq",
|
||||
Type: "text",
|
||||
OutputChannel: "qq",
|
||||
Payload: map[string]interface{}{"content": "hello"},
|
||||
})
|
||||
}
|
||||
|
||||
// 转投只应该动**纯排队输入**:中断任务带级别语义、self 任务是内核内部记账,
|
||||
// 搬走它们会分别破坏中断阶梯与记忆整理。
|
||||
func TestTakeQueuedInputsOnlyTakesQueuedInputs(t *testing.T) {
|
||||
s := newScheduler(64)
|
||||
// 混合:1 条排队输入 + 1 条中断 + 1 条 self + 3 条排队输入
|
||||
s.enqueue(makeQueuedInput(1))
|
||||
s.enqueue(newInterruptTask(&agentIO.InputEvent{Source: "qq", OutputChannel: "qq"}, LevelMessage))
|
||||
s.enqueue(newSelfTask(selfInputMsg{text: "distill", channel: "cli"}))
|
||||
s.enqueue(makeQueuedInput(2))
|
||||
s.enqueue(makeQueuedInput(3))
|
||||
s.enqueue(makeQueuedInput(4))
|
||||
|
||||
if len(s.queue) != 6 {
|
||||
t.Fatalf("就绪队列应有 6 条(4 排队输入 + 1 中断 + 1 self),实际 %d", len(s.queue))
|
||||
}
|
||||
got := s.takeQueuedInputs(3)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("应取走 3 条排队输入,实际 %d", len(got))
|
||||
}
|
||||
for _, c := range got {
|
||||
if c.Event == nil {
|
||||
t.Fatal("取出的候选不得为空事件")
|
||||
}
|
||||
}
|
||||
// self 与中断必须还在
|
||||
var hasSelf, hasInterrupt bool
|
||||
for _, tt := range s.queue {
|
||||
if tt.Kind == TaskKindSelf {
|
||||
hasSelf = true
|
||||
}
|
||||
if tt.Class == TaskInterrupt {
|
||||
hasInterrupt = true
|
||||
}
|
||||
}
|
||||
if !hasSelf {
|
||||
t.Error("self 任务被误取(会破坏记忆整理)")
|
||||
}
|
||||
if !hasInterrupt {
|
||||
t.Error("中断任务被误取(会破坏中断阶梯)")
|
||||
}
|
||||
}
|
||||
|
||||
// 不够量时**一条都不取**:拉起一个 agent 的成本不该为一条任务付。
|
||||
// 这条保证「要么不动、要么成批移动」。
|
||||
func TestTakeQueuedInputsIsAllOrNothing(t *testing.T) {
|
||||
s := newScheduler(64)
|
||||
s.enqueue(makeQueuedInput(1))
|
||||
s.enqueue(makeQueuedInput(2))
|
||||
|
||||
if got := s.takeQueuedInputs(3); got != nil {
|
||||
t.Fatalf("不足 3 条时不应取走任何任务,实际取走 %d", len(got))
|
||||
}
|
||||
if len(s.queue) != 2 {
|
||||
t.Errorf("队列不应被改动,实际剩 %d", len(s.queue))
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 安全不变量:转投失败必须把任务**放回队列**。
|
||||
// 吞掉一条输入比多处理一条更糟——用户会看到"消息发出去了却没人理"。
|
||||
func TestRequeueFrontKeepsAllTasks(t *testing.T) {
|
||||
s := newScheduler(64)
|
||||
s.enqueue(makeQueuedInput(1))
|
||||
s.enqueue(makeQueuedInput(2))
|
||||
s.enqueue(makeQueuedInput(3))
|
||||
|
||||
taken := s.takeQueuedInputs(3)
|
||||
if len(taken) != 3 {
|
||||
t.Fatalf("应取走 3 条,实际 %d", len(taken))
|
||||
}
|
||||
if len(s.queue) != 0 {
|
||||
t.Fatalf("取走后队列应空,实际 %d", len(s.queue))
|
||||
}
|
||||
|
||||
s.requeueFront(taken)
|
||||
if len(s.queue) != 3 {
|
||||
t.Fatalf("★ 放回后必须一条不少:期望 3,实际 %d", len(s.queue))
|
||||
}
|
||||
// 放回的是**前端**:它们比队列里原有的一切都早
|
||||
s.enqueue(makeQueuedInput(4))
|
||||
if s.queue[len(s.queue)-1].Event.Payload["content"] != "hello" {
|
||||
t.Error("放回的任务应在队列前端")
|
||||
}
|
||||
}
|
||||
|
||||
// 转投说明必须自己说清是系统做的:用户看到队列里出现一条没人发过的消息时,
|
||||
// 唯一能解释这件事的就是这句话本身。
|
||||
func TestOffloadNoticeExplainsItself(t *testing.T) {
|
||||
msg := offloadNotice(3, "offload-123")
|
||||
// 用词按用户口径:这是**分诊**(及时反馈),不是"内核替父决定"。
|
||||
for _, want := range []string{"系统", "3 条", "offload-123", "分诊", "不需要你再处理"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("说明缺少 %q:%s", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 默认必须是**关闭**:自动拉起是内核替父做决策(设计 §7 的例外),
|
||||
// 不能默默改变系统行为。
|
||||
func TestOffloadDisabledByDefault(t *testing.T) {
|
||||
opts := DefaultOffloadOptions()
|
||||
if opts.Enabled {
|
||||
t.Error("默认必须关闭")
|
||||
}
|
||||
s := newScheduler(64)
|
||||
s.enqueue(makeQueuedInput(1))
|
||||
s.enqueue(makeQueuedInput(2))
|
||||
s.enqueue(makeQueuedInput(3))
|
||||
a := &Agent{sched: s}
|
||||
if n := a.offloadPendingTasks(opts); n != 0 {
|
||||
t.Errorf("关闭时不得转投,实际转了 %d", n)
|
||||
}
|
||||
if len(s.queue) != 3 {
|
||||
t.Errorf("关闭时队列不得被改动,实际 %d", len(s.queue))
|
||||
}
|
||||
}
|
||||
|
||||
// 不忙(无运行任务)时不转投:没有"长任务占住"这个前提,排队就是正常的。
|
||||
func TestOffloadSkippedWhenIdle(t *testing.T) {
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 1
|
||||
|
||||
s := newScheduler(64)
|
||||
s.enqueue(makeQueuedInput(1))
|
||||
a := &Agent{sched: s}
|
||||
if n := a.offloadPendingTasks(opts); n != 0 {
|
||||
t.Errorf("空闲时不应转投,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 端到端:主 agent 忙时,积压任务应真的被搬到驻留子,且队列里留下说明。
|
||||
// 这是本特性的核心行为 —— 只测"判定函数返回 0/非 0"不够,
|
||||
// 必须证明任务**换了 agent 且原队列留下了可读的交代**。
|
||||
func TestOffloadMovesTasksToResidentEndToEnd(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond // 立即算"忙"
|
||||
opts.MinPending = 2
|
||||
opts.MaxResidents = 1
|
||||
|
||||
// 伪造"正在跑一条长任务":转投判定要求 running 非空。
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef() // 把它变成 running
|
||||
|
||||
// 再排 2 条积压
|
||||
root.sched.enqueue(makeQueuedInput(2))
|
||||
root.sched.enqueue(makeQueuedInput(3))
|
||||
|
||||
moved := root.offloadPendingTasks(opts)
|
||||
if moved != 2 {
|
||||
t.Fatalf("应转投 2 条,实际 %d", moved)
|
||||
}
|
||||
|
||||
// ① 确实拉起了一个驻留子,且标记为"为转投而建"
|
||||
list := root.Residents()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("应拉起 1 个驻留子,实际 %d", len(list))
|
||||
}
|
||||
resident := list[0]
|
||||
if !strings.HasPrefix(resident.ID, "offload-") {
|
||||
t.Errorf("驻留子应为转投专用命名,实际 %s", resident.ID)
|
||||
}
|
||||
// ② 它不配任何插件 inputch(用户要求),但持有全部输出通道(nil=全授权)
|
||||
if len(resident.InputChs) != 0 {
|
||||
t.Errorf("转投驻留子不应配 inputch,实际 %v", resident.InputChs)
|
||||
}
|
||||
if len(resident.AllowedOutputs) != 0 {
|
||||
t.Errorf("转投驻留子应持有全部输出通道(空=全授权),实际 %v", resident.AllowedOutputs)
|
||||
}
|
||||
t.Logf("驻留子 %s: inputch=%v outputs=%v", resident.ID, resident.InputChs, resident.AllowedOutputs)
|
||||
|
||||
// ③ 原队列里留下说明(且说明是内核发的)
|
||||
if len(root.sched.queue) != 1 {
|
||||
t.Fatalf("原队列应只剩 1 条说明,实际 %d", len(root.sched.queue))
|
||||
}
|
||||
notice := root.sched.queue[0]
|
||||
if notice.Event == nil || notice.Event.Source != "kernel" {
|
||||
t.Fatalf("留下的应是内核说明,实际 %+v", notice.Event)
|
||||
}
|
||||
content, _ := notice.Event.Payload["content"].(string)
|
||||
for _, want := range []string{"2 条", resident.ID, "分诊"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("说明缺少 %q:%s", want, content)
|
||||
}
|
||||
}
|
||||
t.Logf("队列说明: %s", content)
|
||||
}
|
||||
|
||||
// 达到上限后不得无界增殖:每个 tick 都拉一个新子会把机器拖垮。
|
||||
func TestOffloadRespectsResidentCap(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 1
|
||||
opts.MaxResidents = 1
|
||||
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
|
||||
// 第一轮:拉起 1 个
|
||||
root.sched.enqueue(makeQueuedInput(2))
|
||||
if n := root.offloadPendingTasks(opts); n != 1 {
|
||||
t.Fatalf("第一轮应转 1 条,实际 %d", n)
|
||||
}
|
||||
// 第二轮:已达上限,但**会复用**刚建的那个子,所以仍能转投
|
||||
root.sched.enqueue(makeQueuedInput(3))
|
||||
if n := root.offloadPendingTasks(opts); n != 1 {
|
||||
t.Fatalf("第二轮应复用已有驻留子,实际转 %d", n)
|
||||
}
|
||||
if got := len(root.Residents()); got != 1 {
|
||||
t.Fatalf("★ 不得越过上限增殖:期望 1 个驻留子,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归:检查必须发生在**独立 goroutine** 里。
|
||||
//
|
||||
// schedulerLoop 是同步执行任务的(executeNewTask 阻塞到任务结束),
|
||||
// 所以"正忙"期间它根本不会回到循环顶部 —— 把检查放在那里的实现
|
||||
// 永远不会触发(我第一版就是这么写的,测出来才发现)。
|
||||
// 本测试钉死:offloadLoop 确实起了自己的 goroutine 并能被唤醒干活。
|
||||
func TestOffloadLoopRunsWhileBusy(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
root.offload = OffloadOptions{
|
||||
Enabled: true, BusyAfter: 10 * time.Millisecond,
|
||||
MinPending: 1, MaxResidents: 1,
|
||||
}
|
||||
// 伪造"正忙":直接占住 running(不启动真实调度循环,避免它把任务跑掉)
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
root.sched.enqueue(makeQueuedInput(2))
|
||||
|
||||
go root.offloadLoop() // 独立 goroutine,正是被测的点
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(root.Residents()) > 0 {
|
||||
return // 成功:忙时后台循环把积压转走了
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("offloadLoop 在忙时没有转投:检查没有跑在独立 goroutine 里?")
|
||||
}
|
||||
|
||||
// ★★ 回归:积压可能**全在 io 输入 channel 里**,不在 sched.queue。
|
||||
//
|
||||
// 这是本特性最容易写错、而且我在线上真踩了的一步:schedulerLoop 是同步执行
|
||||
// 任务的,所以「正忙」期间它根本回不到 pumpInbox —— 后到的输入全堆在
|
||||
// io.inputCh(容量 256)里,sched.queue 恒为 0。
|
||||
//
|
||||
// 只数 s.queue 的实现在线上**永不触发**(实测:主 agent 跑着 6×45s 的任务、
|
||||
// 我连发 4 条消息,队列始终显示 0、residents 始终 0)。
|
||||
// 仓库里 armStop 早记过同一个坑("只数 s.queue 会得到 0"),这里钉死不重犯。
|
||||
func TestOffloadSeesInputsStuckInChannel(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 3
|
||||
opts.MaxResidents = 1
|
||||
|
||||
// 伪造"正忙"
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
|
||||
// 关键:把 3 条消息注入 **io 输入 channel**,不碰 sched.queue。
|
||||
// 这精确复现"调度器忙于执行任务、pumpInbox 没被调用"的现场状态。
|
||||
for i := 0; i < 3; i++ {
|
||||
root.io.InjectInputTo("webui", "webui", "text",
|
||||
map[string]interface{}{"content": "stuck"})
|
||||
}
|
||||
if len(root.sched.queue) != 0 {
|
||||
t.Fatalf("前置条件:此时 sched.queue 应为 0(输入还没被搬运),实际 %d", len(root.sched.queue))
|
||||
}
|
||||
if root.io.PendingInputs() != 3 {
|
||||
t.Fatalf("前置条件:输入应堆在 channel 里,实际 %d", root.io.PendingInputs())
|
||||
}
|
||||
|
||||
// 转投必须能看到它们(先搬进队列再取)
|
||||
moved := root.offloadPendingTasks(opts)
|
||||
if moved != 3 {
|
||||
t.Fatalf("★ 堆在 channel 里的积压必须被看见并转投:期望 3,实际 %d", moved)
|
||||
}
|
||||
if len(root.Residents()) != 1 {
|
||||
t.Fatalf("应拉起 1 个驻留子,实际 %d", len(root.Residents()))
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 回归:转投必须保留 ResponseCh,否则同步调用方永久挂起。
|
||||
//
|
||||
// 这是我在线上真踩的第二个 bug:第一版用 InjectInputTo 重建事件 ⇒ ResponseCh
|
||||
// 被丢掉 ⇒ 日志显示子**正常处理完了**(各 ~3s),但 webui 的 HTTP 请求一直挂着
|
||||
// 不返回,最终 504。仓库反复警告同一件事(Agent.Stop 的注释:"带 ResponseCh 的
|
||||
// 同步注入方(cli / clawhubadapter 均无超时)会永久挂起")。
|
||||
//
|
||||
// 正确做法是走既有的跨 agent 投递原语 DeliverRouted:它推**原事件**。
|
||||
//
|
||||
// 断言方式是**最强的那个**:真的等同步回执回来。
|
||||
// (不用读子的 InputChan 来断言:SpawnResident 会启动子自己的调度循环,
|
||||
//
|
||||
// 它会与测试抢同一个 channel —— 那样写出来的测试是 flaky 的,实测过一次挂死。)
|
||||
func TestForwardKeepsResponseCh(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 1
|
||||
opts.MaxResidents = 1
|
||||
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
|
||||
// 一条**带同步回执通道**的输入(模拟 cli/webui 这类调用方)
|
||||
respCh := make(chan *agentIO.OutputEvent, 1)
|
||||
evt := &agentIO.InputEvent{
|
||||
RequestID: "sync-1", Source: "webui", Type: "text",
|
||||
OutputChannel: "webui",
|
||||
Payload: map[string]interface{}{"content": "sync request"},
|
||||
ResponseCh: respCh,
|
||||
}
|
||||
root.sched.enqueue(newInputTask(evt))
|
||||
|
||||
if n := root.offloadPendingTasks(opts); n != 1 {
|
||||
t.Fatalf("应转投 1 条,实际 %d", n)
|
||||
}
|
||||
|
||||
// 转投的是**同一个事件对象**(所以 payload 上的标注能在这里被看到),
|
||||
// 而不是重建的副本 —— 副本会丢掉 ResponseCh。
|
||||
if evt.Payload["offloaded_from"] != "parent" {
|
||||
t.Errorf("应标注转投来源,实际 %v", evt.Payload["offloaded_from"])
|
||||
}
|
||||
if evt.ResponseCh == nil {
|
||||
t.Fatal("★ 原事件的 ResponseCh 被清掉了")
|
||||
}
|
||||
|
||||
// ★ 决定性断言:同步调用方真的收到回执。
|
||||
select {
|
||||
case out := <-respCh:
|
||||
if out == nil {
|
||||
t.Fatal("收到空回执")
|
||||
}
|
||||
if out.RequestID != "sync-1" {
|
||||
t.Errorf("回执应带原 RequestID,实际 %q", out.RequestID)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("★ 同步调用方没收到回执:转投丢了 ResponseCh(线上表现为 HTTP 挂起 504)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 回归:转投子必须拿到**分诊职责**提示词。
|
||||
//
|
||||
// 我第一版没给 TaskPrompt,于是子完全不知道自己为什么存在(只知道自己叫小宅)。
|
||||
// 用户对这个特性的定位是**及时反馈**:主 agent 忙时不能让用户干等十几分钟
|
||||
// (实测现场 785,951ms)。子的职责是分诊 —— 简单的直接办,需要主 agent 的
|
||||
// 立刻回「忙碌中,请稍候」,而不是勉强作答。
|
||||
func TestOffloadResidentGetsTriagePrompt(t *testing.T) {
|
||||
p := offloadTaskPrompt()
|
||||
for _, want := range []string{"分诊", "直接办", "忙碌中", "output_send"} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("分诊提示词缺少 %q", want)
|
||||
}
|
||||
}
|
||||
// 拿不准时的默认动作必须是保守的那条(报忙碌),不能是"勉强作答"
|
||||
if !strings.Contains(p, "选【报忙碌】") {
|
||||
t.Error("必须写明拿不准时选报忙碌(避免给用户错误答复)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 回归:驻留子必须**继承父的 SystemPrompt**。
|
||||
//
|
||||
// 我第一版没传 SystemPrompt,子只能用 buildSystemPrompt 的一句兜底文案。
|
||||
// 而父的提示词里有「回复投递规则」:面向 qq/wechat 等**异步**通道时,
|
||||
// 纯文本返回会被静默丢弃,必须显式 output_send__{通道名}。
|
||||
// 缺了它,子处理完 QQ 积压却发不出去,且自己不会意识到(实测:webui 这类
|
||||
// **同步**通道能回是因为走 ResponseCh,掩盖了这个缺陷)。
|
||||
func TestResidentInheritsParentSystemPrompt(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
root.systemPrompt = "父的提示词:异步通道必须显式 output_send"
|
||||
|
||||
info, err := root.SpawnResident(ResidentOptions{
|
||||
ID: "inherit-test", TempPath: root.residentTempPath("inherit-test"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
root.residentMu.Lock()
|
||||
rc := root.residents[info.ID]
|
||||
root.residentMu.Unlock()
|
||||
if rc == nil || rc.agent == nil {
|
||||
t.Fatal("驻留子不可用")
|
||||
}
|
||||
if rc.agent.systemPrompt != root.systemPrompt {
|
||||
t.Fatalf("★ 驻留子未继承父的 SystemPrompt:子=%q 父=%q",
|
||||
rc.agent.systemPrompt, root.systemPrompt)
|
||||
}
|
||||
// 真正要看的是提示词里确实带上了投递规则
|
||||
built := rc.agent.buildSystemPrompt("", "x")
|
||||
if !strings.Contains(built, "output_send") {
|
||||
t.Errorf("子拼出的系统提示词里没有投递规则:%s", built)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 残余任务必须由父**显式**决定保留还是丢弃(用户 2026-09-19 要求)。
|
||||
//
|
||||
// 现场问题:回收/销毁驻留子时它手头可能还有没处理的消息。异步通道(qq)
|
||||
// 没有 ResponseCh,静默丢弃时用户零反馈、日志也无痕迹 —— 消息就像没发过一样。
|
||||
// 所以内核只负责「把残余任务列清楚」,处置由父的模型决定(设计 §7)。
|
||||
func TestResidualKeepReturnsTasksToParent(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
info, err := root.SpawnResident(ResidentOptions{
|
||||
ID: "res-keep", TempPath: root.residentTempPath("res-keep"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
root.residentMu.Lock()
|
||||
child := root.residents[info.ID].agent
|
||||
root.residentMu.Unlock()
|
||||
|
||||
// 给子塞两条尚未处理的残余任务(一条带同步回执、一条不带=模拟 qq)
|
||||
syncCh := make(chan *agentIO.OutputEvent, 1)
|
||||
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||||
RequestID: "r1", Source: "webui", OutputChannel: "webui",
|
||||
Payload: map[string]interface{}{"content": "a"}, ResponseCh: syncCh,
|
||||
}))
|
||||
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||||
RequestID: "r2", Source: "qq", OutputChannel: "qq",
|
||||
Payload: map[string]interface{}{"content": "b"},
|
||||
}))
|
||||
|
||||
n, msg, err := root.ApplyResidual(info.ID, ResidualKeep)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyResidual(keep): %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("应处置 2 条,实际 %d(msg=%s)", n, msg)
|
||||
}
|
||||
// keep = 转回父自己:两条都要出现在父的队列里,且**带 ResponseCh 的那条仍带**
|
||||
if len(root.sched.queue) != 2 {
|
||||
t.Fatalf("两条残余任务应转回父队列,实际 %d", len(root.sched.queue))
|
||||
}
|
||||
var hasResponseCh bool
|
||||
for _, task := range root.sched.queue {
|
||||
if task.Event != nil && task.Event.ResponseCh != nil {
|
||||
hasResponseCh = true
|
||||
}
|
||||
}
|
||||
if !hasResponseCh {
|
||||
t.Error("★ keep 丢了 ResponseCh:同步调用方会永久挂起")
|
||||
}
|
||||
// keep 后子队列应清空(已交出去):再取一次应为空
|
||||
if left, _ := root.TakeResidual(info.ID); len(left) != 0 {
|
||||
t.Errorf("交接后子队列应清空,实际剩 %d", len(left))
|
||||
}
|
||||
}
|
||||
|
||||
// drop 也必须给同步调用方一个终态,否则 cli/a2a 会永久挂起。
|
||||
func TestResidualDropNotifiesSyncCaller(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
info, err := root.SpawnResident(ResidentOptions{
|
||||
ID: "res-drop", TempPath: root.residentTempPath("res-drop"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
root.residentMu.Lock()
|
||||
child := root.residents[info.ID].agent
|
||||
root.residentMu.Unlock()
|
||||
|
||||
respCh := make(chan *agentIO.OutputEvent, 1)
|
||||
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
|
||||
RequestID: "d1", Source: "cli", OutputChannel: "cli",
|
||||
Payload: map[string]interface{}{"content": "x"}, ResponseCh: respCh,
|
||||
}))
|
||||
|
||||
n, _, err := root.ApplyResidual(info.ID, ResidualDrop)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyResidual(drop): %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("应处置 1 条,实际 %d", n)
|
||||
}
|
||||
select {
|
||||
case out := <-respCh:
|
||||
if out == nil || !out.Done {
|
||||
t.Error("drop 应给同步调用方一个终态")
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("★ drop 未通知同步调用方:cli/a2a 会永久挂起")
|
||||
}
|
||||
// drop 后父队列不应多出东西
|
||||
if len(root.sched.queue) != 0 {
|
||||
t.Errorf("drop 不应把任务转回父队列,实际 %d", len(root.sched.queue))
|
||||
}
|
||||
}
|
||||
|
||||
// 无残余任务时应明确说"无",而不是让父以为丢了什么。
|
||||
func TestResidualEmptyIsReported(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
info, err := root.SpawnResident(ResidentOptions{
|
||||
ID: "res-empty", TempPath: root.residentTempPath("res-empty"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
n, msg, err := root.ApplyResidual(info.ID, ResidualKeep)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyResidual: %v", err)
|
||||
}
|
||||
if n != 0 || msg != "无残余任务" {
|
||||
t.Errorf("应报告无残余任务,实际 n=%d msg=%q", n, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归:offload_owned 必须真的**被填上**。
|
||||
//
|
||||
// 我第一版加了字段、加了状态面映射,却漏了在 rc.info() 里赋值 ⇒ 父读到的
|
||||
// 永远是 false(实测:线上转投子明明存在,offload_owned 却是 null)。
|
||||
// 这类"加了字段但没接线"的缺陷不会报错,只会让上层判断悄悄失效。
|
||||
func TestOffloadOwnedIsReported(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
// 人工建的子(offload_owned 应为 false)
|
||||
manual, err := root.SpawnResident(ResidentOptions{
|
||||
ID: "manual-child", TempPath: root.residentTempPath("manual-child"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建驻留子失败: %v", err)
|
||||
}
|
||||
if manual.OffloadOwned {
|
||||
t.Error("人工创建的子不应被标记为 offload_owned")
|
||||
}
|
||||
|
||||
// 内核为转投拉起的子(应为 true)
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 1
|
||||
opts.MaxResidents = 1
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
root.sched.enqueue(makeQueuedInput(2))
|
||||
if n := root.offloadPendingTasks(opts); n != 1 {
|
||||
t.Fatalf("应转投 1 条,实际 %d", n)
|
||||
}
|
||||
|
||||
list := root.Residents()
|
||||
var foundOffload *ResidentInfo
|
||||
for i := range list {
|
||||
if strings.HasPrefix(list[i].ID, "offload-") {
|
||||
foundOffload = &list[i]
|
||||
}
|
||||
}
|
||||
if foundOffload == nil {
|
||||
t.Fatal("未找到转投子")
|
||||
}
|
||||
if !foundOffload.OffloadOwned {
|
||||
t.Error("★ 转投子必须被标记 offload_owned=true(父据此决定回收策略)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★ 回归:内核自己留的说明**不能再被转投**,否则自我循环。
|
||||
//
|
||||
// 我第一版漏了这一步:转投会在队列里留一条 [系统] 说明(source=kernel),
|
||||
// 而转投条件("排队输入够了")又会把这条说明算进去 ⇒ 每次转投都产生下一轮
|
||||
// 要转投的东西。实测:5 秒内连续触发两次,分诊助手不断收到这类噪音。
|
||||
func TestKernelNoticeIsNeverOffloaded(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 1
|
||||
opts.MaxResidents = 1
|
||||
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
|
||||
// 队列里放一条"内核说明"+ 一条真实积压
|
||||
root.sched.enqueue(newInputTask(root.syntheticEvent("2 条积压已交由临时助手分诊")))
|
||||
root.sched.enqueue(makeQueuedInput(2))
|
||||
|
||||
moved := root.offloadPendingTasks(opts)
|
||||
if moved != 1 {
|
||||
t.Fatalf("★ 只应转投真实积压 1 条(说明不可转投),实际 %d", moved)
|
||||
}
|
||||
// 说明必须还在队列里(留给主 agent 看),不能被搬走
|
||||
var noticeLeft bool
|
||||
for _, task := range root.sched.queue {
|
||||
if task.Event != nil && isKernelNotice(task.Event) {
|
||||
noticeLeft = true
|
||||
}
|
||||
}
|
||||
if !noticeLeft {
|
||||
t.Error("内核说明应留在队列里给主 agent 看,不应被转投走")
|
||||
}
|
||||
}
|
||||
|
||||
// 转投自身产生的说明也不能构成下一轮的积压(循环必须终止)。
|
||||
func TestOffloadDoesNotLoopOnOwnNotice(t *testing.T) {
|
||||
root, main := newRootWithoutSchedulerLoop(t)
|
||||
defer main.Close()
|
||||
|
||||
opts := DefaultOffloadOptions()
|
||||
opts.Enabled = true
|
||||
opts.BusyAfter = time.Nanosecond
|
||||
opts.MinPending = 2
|
||||
opts.MaxResidents = 1
|
||||
|
||||
root.sched.enqueue(makeQueuedInput(1))
|
||||
root.sched.nextRef()
|
||||
root.sched.enqueue(makeQueuedInput(2))
|
||||
root.sched.enqueue(makeQueuedInput(3))
|
||||
|
||||
if n := root.offloadPendingTasks(opts); n != 2 {
|
||||
t.Fatalf("第一轮应转 2 条,实际 %d", n)
|
||||
}
|
||||
// 再调若干次:队列里只剩一条说明,不够 MinPending ⇒ 不该再转
|
||||
for i := 0; i < 5; i++ {
|
||||
if n := root.offloadPendingTasks(opts); n != 0 {
|
||||
t.Fatalf("第 %d 次仍在转投(自我循环):转了 %d 条", i+1, n)
|
||||
}
|
||||
}
|
||||
if got := len(root.Residents()); got != 1 {
|
||||
t.Errorf("不应反复拉起新子,实际 %d 个", got)
|
||||
}
|
||||
}
|
||||
@ -7,9 +7,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxPluginCrashes = 3
|
||||
crashWindow = 5 * time.Minute
|
||||
reloadCooldown = 30 * time.Second
|
||||
maxPluginCrashes = 3
|
||||
crashWindow = 5 * time.Minute
|
||||
reloadCooldown = 30 * time.Second
|
||||
)
|
||||
|
||||
type pluginHealthTracker struct {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user