From 7213edd181feee5dbde7632216ac4973fbd6d0d1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 19 Sep 2026 19:14:55 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=85=A8=E9=9D=A2=E6=8C=89=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E6=BA=90=E7=A0=81=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= =?UTF-8?q?=20+=20=E5=88=A0=E9=99=A4=E5=B7=B2=E8=BF=87=E6=97=B6=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 删除(内容已落地/已被替换,保留只会误导) - demo.md ................... failback 与 recoverydiag 均已实现,0 引用 - docs/defect-qq-output-send-loop.md .. 已修复(本身也标了「已修复」),0 引用 - docs/embedding-comparison.md ....... 一次性选型报告,仅被 agent 产物引用 - docs/zh/plan.md ............ 描述的旧 nav 布局已重写、死配置已清,全部完成 - docs/zh/plugin-migration-plan.md ... 迁移已上生产,纯过程稿(Part 0~6 全完成) ## 更新(按当前源码核对) - assets/docs/{zh,en}/ARCHITECTURE.md(README 指向的用户文档,最重要): 把只讲 cancel/intercept 的旧「中断机制」章节重写为「输入调度器与中断机制」—— 补上两类别 + 四级中断(L1~L4,默认 L1、外部插件 L4 夹到 L3)+ 抢占/挂起/中断栈 + 饥饿防护(PreemptCount 提升,封顶 L4)+ 抢占冷却(2s)+ 停止语义(cancelBudget) + 驻留子/分诊助手/残余任务;新增「上下文预算」章节(窗口 ≠ 工作面,600K 封顶, 预算是上限非填充目标)。中英章节数现已对齐(各 13 节)。 - assets/docs/{zh,en}/PLUGIN_DEV.md:插件示例表补 6 个缺失项 (acp/deepsearch/plugindev/recoverydiag/vanblog/vikunja);qq 工具数 17 → 20(实测)。 - README.md / README_EN.md:补 v1.3.x 线(此前只到 v1.2.0,而 1.3.x 已发布 12 个 patch)—— 驻留式子 agent、输出通道寻址、输入调度器、轻量内核 profile、积压及时反馈。 - plan.md:开头两个「⚠️ 紧急/正在持续污染」是过期告警(实测 残留 = 0), 改为「已解决」并加文档定位说明;§13 仍是活跃路线图故保留。 - docs/zh/plugin-interface-matrix.md + 两处源码注释:清理指向已删文档的断链。 全仓 md 断链检查:仅剩 1 处,位于 third_party 的 oh_modules(第三方依赖,非本项目)。 --- README.md | 17 + README_EN.md | 25 + assets/docs/en/ARCHITECTURE.md | 96 +++- assets/docs/en/PLUGIN_DEV.md | 8 +- assets/docs/zh/ARCHITECTURE.md | 84 +++- assets/docs/zh/PLUGIN_DEV.md | 8 +- demo.md | 364 -------------- docs/defect-qq-output-send-loop.md | 248 ---------- docs/embedding-comparison.md | 124 ----- docs/zh/plan.md | 284 ----------- docs/zh/plugin-interface-matrix.md | 1 - docs/zh/plugin-migration-plan.md | 632 ------------------------- internal/plugin/dynamic_proc.go | 2 +- internal/plugin/entry_dispatch_test.go | 2 +- plan.md | 22 +- 15 files changed, 238 insertions(+), 1679 deletions(-) delete mode 100644 demo.md delete mode 100644 docs/defect-qq-output-send-loop.md delete mode 100644 docs/embedding-comparison.md delete mode 100644 docs/zh/plan.md delete mode 100644 docs/zh/plugin-migration-plan.md diff --git a/README.md b/README.md index 8ed8944..6f36635 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,23 @@ internal/ ## 项目状态 +**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/` 通道)。 +- **输入调度器**:排队/中断两类别 + 四级中断(L1~L4)+ 抢占/挂起/恢复/中断栈; + 同级不抢占、有饥饿防护与抢占冷却;L4 只归内核与内核级插件(WebUI 终止按钮)。 +- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口, + 主库以 query_only 受限句柄打开,写走自己的 temp 实例)。 +- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** —— + 简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。 +- 修掉一批真实缺陷:销毁驻留子时入站 inputch(`child/`)注册残留、 + 子的轮次永远显示 0(`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、 + 设备心跳 pong 忘了 Flush(每 60 秒掉线)、Lua 插件桥与 SDK 1.3.0 对齐。 + **v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。 - **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI diff --git a/README_EN.md b/README_EN.md index dd3afe7..c9541d7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -184,6 +184,31 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/ ## 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/` 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/`) 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. diff --git a/assets/docs/en/ARCHITECTURE.md b/assets/docs/en/ARCHITECTURE.md index 6bd40b2..f8feede 100644 --- a/assets/docs/en/ARCHITECTURE.md +++ b/assets/docs/en/ARCHITECTURE.md @@ -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..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 diff --git a/assets/docs/en/PLUGIN_DEV.md b/assets/docs/en/PLUGIN_DEV.md index a025335..d135b16 100644 --- a/assets/docs/en/PLUGIN_DEV.md +++ b/assets/docs/en/PLUGIN_DEV.md @@ -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 diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 8e9f771..258bacb 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -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..context_window` 显式声明。 + +**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。 +实测:预算 400K 时实际注入仍只有几百字符。 ## 配置系统 diff --git a/assets/docs/zh/PLUGIN_DEV.md b/assets/docs/zh/PLUGIN_DEV.md index 856e0a5..1004df4 100644 --- a/assets/docs/zh/PLUGIN_DEV.md +++ b/assets/docs/zh/PLUGIN_DEV.md @@ -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 自助开发插件 | ### 内置插件 diff --git a/demo.md b/demo.md deleted file mode 100644 index eea61db..0000000 --- a/demo.md +++ /dev/null @@ -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),回滚只作用于 `/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`,默认 `/log`(`internal/config/registry.go:415,508`)。 -- **单次运行文件**:每次启动新建 `homed_.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_-W.tar.gz`;月度 → `month_.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: (plugin=

, id=)` | **只记工具名/插件/id,不记 args** | -| `process.go:226` | `[agent] tool result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`)| -| `process.go:218` | `[agent] skip tool : plugin 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] : ")` | 模板见 `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_.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)→ 写 `/recovery_kb/diag_.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add` 以 `diag::` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `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 启动即挂 `/llm_snapshot.json`。 -- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM`、`TestLLMSnapshotFile`、`TestSetLLMSnapshotFile`。 - -`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。 -- `--role=guard` 父守护(永驻):读独立 `/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 触碰 `/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: (plugin=

, id=) -... process.go:226: [agent] tool 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 -``` \ No newline at end of file diff --git a/docs/defect-qq-output-send-loop.md b/docs/defect-qq-output-send-loop.md deleted file mode 100644 index c9d624d..0000000 --- a/docs/defect-qq-output-send-loop.md +++ /dev/null @@ -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 ./...` 通过。 diff --git a/docs/embedding-comparison.md b/docs/embedding-comparison.md deleted file mode 100644 index fd1a36e..0000000 --- a/docs/embedding-comparison.md +++ /dev/null @@ -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 相关性计算) diff --git a/docs/zh/plan.md b/docs/zh/plan.md deleted file mode 100644 index 2aed4f6..0000000 --- a/docs/zh/plan.md +++ /dev/null @@ -1,284 +0,0 @@ -# WebUI 布局与配置归位修复计划 - -## 一、背景 - -上一轮 SDK 接口化改造完成并部署后,用户指出三个问题: - -1. **WebUI 窄屏布局损坏**:顶部 `