v4 architecture: pipeline stages, SDK, event bus, LLM-driven memory consolidation

- SDK PluginAPI (internal/plugin/sdk/): RegisterTool/RegisterStage/Subscribe/Publish
- EventBus (internal/events/): system-level pub/sub with wildcard support
- StageHost (internal/agent/core/stages.go): 7-stage message pipeline
- Agent core: on_input/pre_action/post_action/before_toolcall/after_toolcall/before_output/after_output
- Plugin Registry: SDK plugin registration and tool routing
- GraphDB.MergeEntities: entity consolidation with relation redirection
- memory_merge tool: allows LLM to merge similar entities
- Consolidation task: heartbeat detects conflicts, enqueues via IO for LLM decision
- _consolidation_ internal channel for system-level memory maintenance
- Comprehensive documentation: ARCHITECTURE.md, PLAN.md, DESIGN.md, README.md
- 54 tests across all packages, all passing
This commit is contained in:
root
2026-07-03 08:04:39 +08:00
parent 304c3ae294
commit 3e3c6a24d2
20 changed files with 2318 additions and 632 deletions

247
DESIGN.md
View File

@ -1,240 +1,19 @@
# HomeAgent 架构设计 v3
# HomeAgent 架构设计 v4
## 一、核心理念
完整架构文档参见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
24 小时陪伴用户、随时待命的智能管家。**单会话·单 Agent**,身份不漂移。
## 核心原则
### 设计原则
- **所有输入走 IO 抽象层(中断模式)**,不直调 agent 方法
- **所有 LLM 调用走 Provider 接口**,不直连 API
- **DeepSeek v4 flash** 为默认 LLMthinking 模式关闭
- **人格固定**personal.md记忆分层管理防止性格突变
- **知识独立于记忆**agent 主动学习
- **插件 = 容器**IO 通道是插件的内嵌组件
- **核心零 IO** — 无任何硬编码 IO 能力,所有 IO 来自插件
- **输出是工具调用** — Agent 必须显式 `output_send` 才能通信
- **三通道插件** — 工具 (RegisterTool)、阶段 (RegisterStage)、事件 (Subscribe/Publish)
- **阶段管道** — 7 个 hook 点让插件干预消息处理流:`on_input``pre_action``post_action``before_toolcall`/`after_toolcall``before_output``after_output`
- **三层记忆** — Context (内存) → Document (JSON+向量) → Graph (SQLite)
- **知识独立** — 独立 TF-IDF 向量索引,不与记忆耦合
---
## 二、核心抽象
### IO 抽象层(唯一输入路径)
## 快速启动
```bash
make build # 编译
make run # 编译并启动(数据 /tmp/homeagent
```
外部设备 (Mic/Camera/OneBot/GPIO/HTTP)
IOManager
├─ Device 接口(每个设备实现)
│ ├─ Name() / Type() / Description()
│ ├─ Tools() → 给 LLM 的 Function Calling 工具
│ ├─ Execute() → 工具调用分发
│ ├─ Start() / Stop()
│ └─ OutputCapabilities() → 输出能力声明
├─ InputEvent{Source, Type, Payload} → inputCh
├─ OutputEvent{Target, Type, Payload} → outputCh
└─ 路由表: 输入源 → 默认输出通道
```
**输出通道能力声明**`OutputCapability` 位掩码):
| 能力 | 说明 |
|------|------|
| CapText | 文本输出 |
| CapFile | 文件传输 |
| CapImage | 图片输出 |
| CapAudio | 音频输出 |
| CapStructured | 结构化数据JSON/卡片) |
**Agent 可调用通道工具**
- `output_list_channels` — 查看所有可用通道及其能力
- `output_set_channel` — 切换当前回复的输出通道
- `output_send` — 通过指定通道异步发送消息(校验能力)
### API 抽象层(唯一输出路径)
```
Agent Core → Provider.Chat() → LLM API
┌───────┴───────┐
▼ ▼
OpenAIProvider LuaAdapter
(DeepSeek API) (格式转换)
```
### 插件系统OpenClaw 兼容)
```
Plugin容器
├─ 元数据: name, version, author, description
├─ IOConfig可选: 声明 IO 端口
├─ Device可选: 原生 Go IO 设备实现
└─ Tools: 给 LLM 的工具定义
```
插件来源:
- `plugins/` 目录热加载agent 通过 `plgreload` 工具显式控制重载
- 有原生工厂注册的(如 QQ/OneBot→ 构造原生设备
- 纯 SKILL.md → 用 PluginDevice 包装为 IO 设备
- 原子化替换:新设备 Start → 原子换路由 → 旧设备 Stop
---
## 三、记忆体系(三层)
```
用户输入 → Context内存, 30条, TF-IDF排序
│ 每次响应后裁剪最不相关的
DocumentJSON + TF-IDF向量, 冷72h→图
│ 心跳蒸馏30min
GraphSQLite三元组, 定期重整+同义合并)
```
| 层 | 存储 | 容量 | 裁剪策略 |
|---|---|---|---|
| Layer 1: 上下文 | `RelevanceContext`(内存环形缓冲) | 30 条 | TF-IDF 余弦相似度排序,低分→文档 |
| Layer 2: 文档 | `document.Store`JSON 文件 + 向量索引) | 无上限 | 72h 未访问 + ≤2 次命中→图 |
| Layer 3: 图 | `memory.GraphDB`SQLite 三元组) | 无上限 | 定期重整 + bigram Jaccard 同义合并 |
**注入策略**
- 只注入**图索引**(实体名+类型+提及数)到 prompt不注入全文
- agent 通过 `memory_recall` 主动查询详情
- 文档摘要按相关性注入前 3 条
---
## 四、知识体系
独立于记忆agent 主动学习。
```
knowledge/
smart_home/content.md
cooking/content.md
TF-IDF 向量索引(字符 bigram
knowledge_search / knowledge_create / knowledge_list
```
---
## 五、人格内核
```personal.md``` → 加载一次 → 固定在 system prompt 最前 → 永不漂移。
---
## 六、OneBot QQ 通道
```
go-cqhttp / LagrangeOneBot 前端)
│ Reverse WebSocket
OneBot Clientinternal/onebot/
├─ 事件循环 → Event → IO InputEvent
├─ Action 调用 → send_private_msg / send_group_msg / ...
└─ 自动重连 + 心跳检测
Device 注册 → IOManager → Agent
```
---
## 七、数据流
```
用户消息 → IO InputEvent → eventLoop
├─ 1. 追加到 RelevanceContext
├─ 2. 构建 prompt:
│ personal.md + 图索引 + 文档摘要 + 上下文 + 工具
├─ 3. 工具循环(最多 10 轮)
│ LLM → tool_calls → Execute → 结果 → LLM → ...
├─ 4. 追加响应到上下文
├─ 5. 相关性裁剪Prune → 不相关的归档到文档)
├─ 6. OutputEvent → 输出通道
└─ 7. 记忆候选 → TextMemory(JSONL) + Distiller → GraphDB
心跳30min:
├─ Indexer.Sync() — 图→向量
├─ DocStore.Reindex() — 文档向量重建
├─ 冷文档→图归化
└─ 图同义合并
插件重载plgreload:
├─ 扫描 plugins/ 目录
├─ 加载新插件Start 新设备
├─ 原子替换 IOManager 设备表 + 路由表
└─ Stop 旧设备
```
---
## 八、配置
```yaml
daemon:
listen_addr: ":8080"
data_dir: "/var/lib/homeagent"
heartbeat_interval: 15s
llm:
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
api_key: "${DEEPSEEK_API_KEY}"
temperature: 0.7
max_tokens: 4096
```
---
## 九、关键文件
```
cmd/homed/main.go — 入口:组装所有子系统
internal/agent/core/agent.go — Agent 核心:事件循环、工具循环、心跳
internal/agent/core/context.go — RelevanceContextTF-IDF 上下文管理
internal/agent/personal.go — 人格加载
internal/agent/api/provider.go — Provider 接口 + DeepSeek 实现
internal/agent/io/channel.go — IO 抽象层Device/InputEvent/OutputEvent/路由
internal/api/handler.go — HTTP API 端点
internal/knowledge/knowledge.go — 知识系统
internal/memory/graph.go — SQLite 图数据库
internal/memory/indexer.go — 图索引器
internal/memory/vector/store.go — TF-IDF 向量存储
internal/memory/document/doc.go — 文档记忆
internal/memory/text/text.go — 文本记忆JSONL
internal/memory/pipeline/ — 蒸馏器
internal/onebot/ — OneBot V11 协议实现
internal/plugin/plugin.go — 插件系统
internal/tracker/tracker.go — 变更追踪
internal/supervisor/daemon.go — 守护进程
internal/lua/vm.go — Lua 适配器 VM
plugins/ — 插件目录
qq/SKILL.md — QQ 插件 SKILL.md
qq/skill.json — QQ 插件 JSON 元数据
config/config.go — 配置加载
pkg/types/ — 类型定义
DESIGN.md — 本架构文档
```
---
## 十、与旧设计的核心区别
| 维度 | 旧设计 (v1) | 当前设计 (v3) |
|------|-------------|---------------|
| 上下文裁剪 | 固定 FIFO 20 条 | TF-IDF 相关性排序 top-K |
| 图记忆注入 | 关键词 LIKE 查询 | 向量搜索实体名,只注索引 |
| 文档→图 | 无 | 72h 冷文档自动归化 |
| 图重整 | 无 | 向量同步 + bigram Jaccard 同义合并 |
| 知识系统 | 无 | `knowledge/` 目录 + TF-IDF 向量 |
| 人格 | 无 | `personal.md` 固定注入 |
| 向量引擎 | 无 | 自研 TF-IDF + 倒排索引(字符 bigram |
| 部署 | Docker 容器 + 快照 | 单二进制 + overlayfs 追踪 |
| Agent 模型 | 多 Agent 编排 | 单 Agent + 工具循环 |
| 输出通道 | 无 | 能力声明 + 路由 + 校验 |
| 插件系统 | 无 | OpenClaw SKILL.md + 原生工厂 |
| QQ 通道 | 无 | OneBot V11 Reverse WS |

132
PLAN.md Normal file
View File

@ -0,0 +1,132 @@
# HomeAgent 实施计划
## 已完成
### Phase 0 — 核心基础设施 ✅
| 任务 | 文件 | 状态 |
|------|------|------|
| SDK 接口定义 | `internal/plugin/sdk/api.go` | ✅ |
| PluginAPIRegisterTool/RegisterStage/Subscribe/Publish | `internal/plugin/sdk/api.go` | ✅ |
| 插件内部 EventBus | `internal/plugin/sdk/bus.go` | ✅ |
| 系统 EventBus | `internal/events/bus.go` | ✅ |
| StageHost 编排器 | `internal/agent/core/stages.go` | ✅ |
| Agent 阶段注入7 个 hook 点) | `internal/agent/core/agent.go` | ✅ |
| 插件注册表 SDK 支持 | `internal/plugin/plugin.go` | ✅ |
| main.go 接入 EventBus + StageHost | `cmd/homed/main.go` | ✅ |
| 架构文档 v4 | `docs/ARCHITECTURE.md` | ✅ |
---
## 待实施
### Phase 1 — 插件 SDK 迁移(当前)
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 1.1 | SDK 添加 `ToolDef` 参数描述支持 | `RegisterTool` 接受 `ToolDef` 结构体(含 parameters而非纯 handler | high |
| 1.2 | StageHost 收集完整 ToolDef | 目前只传 name需传完整 description + parameters 给 LLM | high |
| 1.3 | Registry.AddPluginAPI 自动构建 StageHost | 替代手动 `syncFromRegistry` | high |
| 1.4 | 添加 `before_toolcall` deny 机制的测试 | 确保 `StageContext.Response` 在工具级别生效 | medium |
| 1.5 | 添加 `on_input` 改写消息的测试 | `stageCtx.RawMessage` 在阶段后被正确使用 | medium |
### Phase 2 — 迁移 WebUI 到 SDK 模式
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 2.1 | WebUI 改为通过 `PluginAPI` 注册 | 不再依赖 `Device` 接口 | high |
| 2.2 | WebUI 通过 `Subscribe(EventAll)` 获取所有事件 | 取代 OutputChan 监听 | high |
| 2.3 | WebUI 注册 `output_send` 工具 | 通过 `RegisterTool` 暴露给 LLM | high |
| 2.4 | 删除 `internal/api/plugin.go` 的 Device 包装 | 不再需要 `Device` 适配器 | medium |
| 2.5 | Handler 改为通过 EventBus 获取 IOManager 引用 | 减少直接依赖 | low |
### Phase 3 — 迁移 QQ/OneBot 到 SDK 模式
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 3.1 | OneBot 插件改为 `PluginAPI.RegisterTool` | 注册 `qq_send_private_msg` 等工具 | high |
| 3.2 | OneBot 接管后通过 `Publish(raw_input)` 发布事件 | 取代 IOManager.InjectInput | high |
| 3.3 | OneBot 注册阶段钩子 | 可接入群聊特定的 `pre_action` 逻辑 | medium |
| 3.4 | 删除 `internal/onebot/device.go` 的 Device 包装 | SDK 模式原生支持 | medium |
### Phase 4 — 迁移 OutputBus 到 SDK
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 4.1 | 创建 `internal/outputbus/` 插件 | 管理 `output_send`/`output_list_channels` | high |
| 4.2 | 通过 `RegisterTool` 注册输出工具 | LLM 可直接调用 | high |
| 4.3 | 通过 `RegisterStage(before_output)` 拦截最终文本 | 渠道适配 | medium |
| 4.4 | Agent 内置的 output_* 工具改为委托给 outputbus | 解耦核心 | medium |
### Phase 5 — 清理旧组件
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 5.1 | 删除 `Device` 接口定义 | 全部迁移后移除 | high |
| 5.2 | 删除 `IOManager.ExecuteTool` | 工具路由走 StageHost | high |
| 5.3 | 删除 `IOManager.EmitOutput`/`EmitOutputTo` | 走 EventBus | medium |
| 5.4 | 删除 `IOManager.AtomicSwapDevices` | 不再需要设备热替换 | medium |
| 5.5 | 删除 `PluginDevice` 包装器 | SDK 模式替代 | medium |
| 5.6 | 删除 `internal/onebot/device.go` | 已迁移到 SDK | high |
| 5.7 | 删除 `internal/api/plugin.go` | 已迁移到 SDK | medium |
| 5.8 | 精简 `cmd/homed/main.go` | 移除设备相关初始化 | medium |
### Phase 6 — 进程隔离
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 6.1 | 实现 Unix Socket JSON-RPC 传输层 | 进程隔离模式 | low |
| 6.2 | `sdk.Run()` 自动检测 in-process/external | 开发 vs 生产 | low |
| 6.3 | 插件进程管理(启动/停止/健康检查) | Supervisor 扩展 | low |
### Phase 7 — 增强功能
| # | 任务 | 说明 | 优先级 |
|---|------|------|--------|
| 7.1 | WebUI D3.js 力导向图记忆星图 | 已有 API `GET /api/v1/memory/star` | low |
| 7.2 | Model Context Protocol (MCP) 支持 | 标准工具协议 | low |
| 7.3 | 多 Agent 支持 | 每个 Agent 独立上下文 | low |
| 7.4 | Python 插件 SDK | 扩展生态 | low |
---
## 文件最终结构Phase 5 完成后)
```
HomeAgent/
├── cmd/homed/main.go — 入口
├── internal/
│ ├── agent/
│ │ ├── core/
│ │ │ ├── agent.go — Agent 核心
│ │ │ ├── context.go — 相关性上下文
│ │ │ └── stages.go — StageHost
│ │ └── api/
│ │ └── provider.go — LLM Provider
│ ├── events/
│ │ └── bus.go — 系统事件总线
│ ├── plugin/
│ │ └── sdk/
│ │ ├── api.go — PluginAPI
│ │ └── bus.go — 插件 EventBus
│ ├── memory/ — 三层记忆
│ ├── knowledge/ — 知识库
│ ├── tracker/ — 变更追踪
│ ├── supervisor/ — 守护进程
│ └── plugins/ — 插件实现
│ ├── webui/ — HTTP API + 仪表盘
│ ├── onebot/ — QQ 通道
│ └── outputbus/ — 输出通道管理
├── docs/
│ └── ARCHITECTURE.md — 架构文档
├── DESIGN.md
├── PLAN.md
└── README.md
```
## 设计原则
1. **核心零 IO** — Core 不依赖任何插件、设备、通道实现
2. **三通道标准** — 所有插件通过 Tool/Stage/Event 与核心交互
3. **增量迁移** — 每阶段保持向后兼容,旧组件与新 SDK 并行运行
4. **测试覆盖** — 每阶段提交前确保全部测试通过

120
README.md
View File

@ -1,26 +1,29 @@
# HomeAgent
单二进制 24/7 智能家庭管家。基于 NextAgent 认知解耦架构 + TrulyMEM 自主图记忆
单二进制 24/7 智能管家。**核心零 IO**,所有输入输出通过插件,插件通过三通道与核心交互:工具、阶段钩子、事件订阅
## 架构
```
IO 抽象层(唯一输入路径
┌─────────────────────────────────────────────────────┐
│ Microphone Camera GPIO HTTP OneBot-QQ Plugins │
│ 所有外部输入 → InputEvent → inputCh │
└──────────────────────┬──────────────────────────────┘
┌──────────────────────▼──────────────────────────────┐
│ Agent Core(编排器)
三层记忆注入 → Provider.Chat() → 工具执行 → 输出
└──────────────────────┬──────────────────────────────┘
┌──────────────────────▼──────────────────────────────┐
API 抽象层(唯一输出路径)
Provider: OpenAI / Ollama / Lua 适配
│ DeepSeek v4 flash默认
└─────────────────────────────────────────────────────┘
外部QQ / HTTP / 硬件...
│ 通过插件注册
IOManager.InjectInput()
┌─────────────────────────────────────────────────┐
│ Agent Core
│ on_input → Context → Memory Recall │
→ pre_action → [LLM ↔ 工具循环] → before_output
│ → output_send → after_output │
内置:三层记忆 + 知识库 + Child Agent
└─────────────────────────────────────────────────┘
├── Stage Pipeline7 阶段,插件可拦截/改写)
├── Tool System插件注册工具给 LLM
└── Event Bus插件订阅系统事件
```
## 快速开始
@ -32,62 +35,49 @@ make build
依赖Go 1.19+、CGogo-sqlite3
## 记忆体系(三层)
## 阶段管道
| 层 | 存储 | 容量 | 裁剪策略 |
```
on_input → pre_action → post_action ↔ before_toolcall/after_toolcall → before_output → after_output
↑_______________|
循环
```
| 阶段 | 插件可做 |
|---|---|
| `on_input` | 鉴权、拉黑、改写、短路 |
| `pre_action` | 注入 context 消息 |
| `post_action` | 审查/改写 LLM 输出、增删工具 |
| `before_toolcall` | 拒绝、改参、审计 |
| `after_toolcall` | 脱敏、改写结果 |
| `before_output` | 改写最终文本、加格式 |
| `after_output` | 记录/统计 |
## 三层记忆
| 层 | 存储 | 容量 | 裁剪 |
|---|---|---|---|
| 上下文 | 内存 | 30 条 | TF-IDF 相关性排序,淘汰→文档 |
| 文档 | JSON + TF-IDF 向量 | 无上限 | 72h 冷访问→图数据库 |
| | SQLite 三元组 | 无上限 | 定期重整+同义合并 |
| Context | 内存 TF-IDF | 30 条 | 余弦相似度排序→文档 |
| Document | JSON + 向量索引 | ∞ | 72h 冷→图 |
| Graph | SQLite 三元组 | | 定期重整+同义合并 |
## 插件系统OpenClaw 兼容)
## 插件三通道
插件 = 容器,内嵌 IO 通道为组件。`plugins/` 目录热插拔agent 通过 `plgreload` 工具控制重载。
```
plugins/
├── qq/ # OneBot QQ 通道插件
│ ├── SKILL.md # 技能描述 + IO 端口声明
│ └── skill.json # 元数据 + WS 连接配置
```
### 示例 QQ 插件 `plugins/qq/SKILL.md`
```markdown
# QQ 通知插件
io_type: io
io_input_route: qq
io_output_route: qq
io_output_caps: text,file,image
## qq_send_private_msg
发送 QQ 私聊消息
- user_id: 目标 QQ 号
- message: 消息内容(支持 CQ 码)
```
| 通道 | 方向 | 用途 |
|---|---|---|
| `RegisterTool` | 插件→LLM | Agent 调用插件功能 |
| `RegisterStage` | 核心→插件 | 干预消息处理流 |
| `Subscribe/Publish` | 双向 | 审计/日志/通知 |
## 核心命令
```bash
make build # 编译主二进制
make run # 编译启动(数据 /tmp/homeagent
make install # 安装到系统
make test # 运行测试
make build # 编译
make run # 编译+启动
make test # 测试
make install # 系统安装
```
## 配置
## 完整文档
`/etc/homeagent/config.yaml`(默认 `config/config.yaml`
```yaml
daemon:
listen_addr: ":8080"
data_dir: "/var/lib/homeagent"
llm:
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
```
## 许可证
MIT
详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。

View File

@ -3,7 +3,6 @@ package main
import (
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
@ -16,6 +15,7 @@ import (
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
"gitcode.com/JianFeeeee/HomeAgent/internal/api"
"gitcode.com/JianFeeeee/HomeAgent/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
@ -227,6 +227,15 @@ func main() {
log.Printf("[homed] knowledge store active with %d items", len(ks.List()))
}
// === Event Bus (系统事件总线) ===
evBus := events.NewBus()
log.Printf("[homed] event bus initialized")
// === Stage Host (阶段管道编排) ===
stageHost := agentCore.NewStageHost()
stageHost.SyncFromRegistry(pluginReg)
log.Printf("[homed] stage host initialized with %d plugin sdks", pluginReg.SDKPluginCount())
// === Single Agent Core ===
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
@ -259,8 +268,11 @@ func main() {
DocStore: docStore,
Knowledge: ks,
Personality: personality,
PluginReg: pluginReg,
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
PluginReg: pluginReg,
PluginDir: filepath.Join(cfg.Daemon.DataDir, "plugins"),
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
StageHost: stageHost,
EventBus: evBus,
})
agent.Start()
defer agent.Stop()
@ -271,23 +283,14 @@ func main() {
log.Printf("[homed] main agent started, model=%s base=%s", cfg.LLM.Model, cfg.LLM.BaseURL)
// === HTTP API ===
handler := api.NewHandler(sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
server := &http.Server{
Addr: cfg.Daemon.ListenAddr,
Handler: mux,
}
go func() {
log.Printf("[homed] HTTP API listening on %s", cfg.Daemon.ListenAddr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
// === Built-in HTTP API & WebUI Plugin ===
webui := api.NewWebUIPlugin(
"webui", cfg.Daemon.ListenAddr,
sup, memDB, skMgr, luaVM, cfg, iom, textMem, ks, trk,
)
iom.RegisterDevice(webui)
webui.Start()
defer webui.Stop()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
@ -298,7 +301,6 @@ func main() {
trk.Stop()
}
sup.Shutdown()
server.Close()
log.Printf("[homed] stopped")
}

View File

@ -1,284 +1,540 @@
# HomeAgent 架构参考
# HomeAgent 架构设计 v4
> 基于 NextAgent 认知解耦架构 + TrulyMEM 自主图记忆 + OneBot 协议。
## 一、核心理念
## 一、分层架构
24 小时陪伴用户的智能管家。**单会话·单 Agent**,身份不漂移。
### 设计原则
- **核心零 IO** — Core 没有任何硬编码 IO 能力,所有 IO 来自插件
- **所有输出是工具调用** — Agent 必须显式调用 `output_send` 才能通信,推理不自动路由
- **所有 LLM 调用走 Provider 接口** — 不直连 API
- **DeepSeek v4 flash** 为默认 LLMthinking 模式关闭
- **人格固定**personal.md记忆分层管理防止性格突变
- **知识独立于记忆**agent 主动学习
- **插件 = 三通道**:工具、阶段钩子、事件订阅
---
## 二、核心域 vs 插件域
```
┌──────────────────────────────────────────────────────────┐
IO 抽象层(唯一输入路径)
Device(Mic/Speaker/Camera/GPIO/OneBot-QQ/PluginDevice)
所有外部输入 → InputEvent → inputCh
输出通道: 能力声明(text/file/image/audio/structured)
路由表: 输入源 → 默认输出通道
└──────────────────────────┬───────────────────────────────┘
┌──────────────────────────▼───────────────────────────────┐
Agent 操作层(核心编排器)
eventLoop() consume inputCh
├─ RelevanceContextTF-IDF 相关性管理)
│ ├─ 工具循环Provider.Chat → tool_calls → Execute → ...) │
├─ 记忆索引注入(图索引 + 文档摘要)
└─ 心跳蒸馏30min向量同步 + 冷归档 + 同义合并)
└──────────────────────────┬───────────────────────────────┘
┌──────────────────────────▼───────────────────────────────┐
API 抽象层(唯一输出路径)
Provider.Chat() → DeepSeek API / OpenAI / Ollama
│ LuaAdapter 做请求/响应格式转换 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────
核心域 (Core Domain)
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────────┐
│ Provider │ │ Memory │ │Knowledge │ Pipeline Stages │
│ (LLM) │ (T/D/G) │ (TF-IDF) │ 编排器
└──────────┘ └──────────┘ └──────────┘ └───────────────────┘
┌──────────┐ ┌──────────┐ ┌──────────────────────────────────┐
│ │ Relevance│ │ Context │ │ Plugin Host │ │
Tf-Idf │ Persist (调用钩子 + 路由工具)
└──────────┘ └──────────┘ └──────────────────────────────────┘
核心无任何 IO 能力
├──────────────────────────────────────────────────────────────────┤
边界 (Plugin API)
┌──────────────────────────────────────────────────────────────┐
│ │ RegisterTool(name, handler) ← 插件注册工具给 LLM │ │
RegisterStage(stage, handler) ← 插件挂入消息处理阶段
│ │ Subscribe(eventType, handler) ← 插件订阅系统事件 │ │
Publish(event) → 插件发布事件
└──────────────────────────────────────────────────────────────┘
├──────────────────────────────────────────────────────────────────┤
│ 插件域 (Plugin Domain) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ WebUI │ │ QQ │ │OutputBus │ │ 未来插件: …… │ │
│ │ HTTP/WS │ │ OneBot │ │通道管理 │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
│ 所有 IO 都在这里 │
└──────────────────────────────────────────────────────────────────┘
```
**三条核心规则:**
1. **所有外部输入** → 必须通过 `IOManager.InjectInput()` / `InjectText()` 注入
2. **所有 LLM 调用** → 必须通过 `Provider.Chat()` 发出
3. **Agent 不直接操作记忆系统**,只发射 `memory_candidate` 事件,由 Memory Pipeline 异步消费
### 核心职责
- LLM 调用编排Provider → Agent 工具循环)
- 三层记忆管理Context → Document → Graph
- 知识库维护Knowledge Store
- 阶段管道编排Stage Pipeline
- Context 相关性管理TF-IDF 余弦相似度)
- 心跳蒸馏 + 图重整
## 二、子系统详解
### 插件职责
- 接收外部输入WebSocket、HTTP、硬件等
- 提供输出能力(文本发送、文件传输等)
- 干预消息处理流(阶段钩子)
- 观察系统状态(事件订阅)
### 2.1 IO 抽象层(唯一输入路径)
---
所有外部输入必须通过此层进入系统。
## 三、消息处理阶段管道
```
Device(Microphone) ─┐
Device(OneBot-QQ) ─┤
Device(Plugin) ─┤──→ IOManager → InputEvent → inputCh → Agent
Device(GPIO) ─┤
HTTP API ─┘
┌──────────────────────────────────────────┐
│ on_input │
│ 消息到达Agent 未做任何处理 │
│ └→ 插件可鉴权/拉黑/改写/短路回复 │
└──────────┬───────────────────────────────┘
│ 通过
┌──────────▼───────────────────────────────┐
│ 内部Context Append + Memory Recall │
│ + Context 组装 │
└──────────┬───────────────────────────────┘
│ 就绪
┌──────────▼───────────────────────────────┐
│ pre_action │
│ 上下文已就绪,即将调用 LLM │
│ └→ 插件可注入 system 消息 / 修改 context │
└──────────┬───────────────────────────────┘
│ LLM 调用
┌──────────▼───────────────────────────────┐
│ post_action │
│ LLM 返回文本 + 工具调用列表 │
│ └→ 插件可审查/修改文本、增删工具调用 │
└──────────┬───────────────────────────────┘
│ 判断有无工具调用
╱─────────────┴─────────────╲
有工具调用 无工具调用
│ │
┌──────────▼──────────────┐ │
│ before_toolcall │ │
│ 即将执行某个工具调用 │ │
│ └→ 插件可拒绝/放行/ │ │
│ 修改参数/审计 │ │
└──────────┬──────────────┘ │
│ 执行工具 │
┌──────────▼──────────────┐ │
│ after_toolcall │ │
│ 工具执行完毕,准备喂回 │ │
│ └→ 插件可脱敏/改写结果 │ │
└──────────┬──────────────┘ │
│ 回到 post_action 继续循环 │
└──────────────────────────────┘
┌──────────────────────────┘
┌──────────────────────────────────────────┐
│ before_output │
│ 最终文本就绪,即将调用 output_send │
│ └→ 插件可改写回复/添加格式/适配渠道 │
└──────────────────┬───────────────────────┘
│ output_send 调用
┌──────────────────▼───────────────────────┐
│ after_output │
│ 输出完成 │
│ └→ 记录/统计/清理资源 │
└──────────────────────────────────────────┘
```
**核心类型:**
### 7 个阶段总表
| 类型 | 说明 |
|------|------|
| `InputEvent` | Source + Type + Payload — 所有外部输入的标准化格式 |
| `OutputEvent` | Target + Type + Payload + OutputChannel — 输出路由 |
| `Device` | 接口Name/Type/Description/Tools/Execute/Start/Stop/OutputCapabilities |
| `DeviceType` | Input / Output / IO |
| `ToolDef` | Name + Description + Parameters + Handler — 与 LLM 函数调用同构 |
| `OutputCapability` | 位掩码text, file, image, audio, structured |
| 阶段 | 触发时机 | 插件读写权限 | 典型用途 |
|---|---|---|---|
| `on_input` | 消息到 Agent零处理 | 可读写 `raw_message`,可设置 `response` 短路 | 黑名单、限流、自定义指令前缀 |
| `pre_action` | Memory+Context 就绪LLM 调用前 | 可读写 `context_messages`(追加/修改) | 注入 RAG 结果、插入时政 context |
| `post_action` | LLM 返回文本 + 工具调用列表 | 可读写 `llm_text``tool_calls``context_messages` | 敏感词过滤、强制 redirect 工具 |
| `before_toolcall` | 单个工具调用执行前 | 可读写 `tool_call.name``tool_call.args`,设置 `deny=true` 拒绝 | 审计高危操作、OS 命令白名单 |
| `after_toolcall` | 单个工具执行完毕 | 可读写 `tool_result` | 脱敏数据库结果、排序搜索结果 |
| `before_output` | 最终文本就绪output_send 前 | 可读写 `final_text`,可设置 `skip_output=false` | 添加表情/at 前缀、多平台格式适配 |
| `after_output` | output_send 已调用 | 只读 `final_text` | 统计日志、触发后续流程 |
**内置设备:**
### 循环规则
| 设备 | 方向 | 工具 |
|------|------|------|
| Microphone | Input | capture — 录音 |
| Speaker | Output | speak — 语音播放 |
| Camera | Input | capture 拍照 |
| OneBot QQ | IO | send_private/send_group/get_group_member_info/get_group_list |
| PluginDevice | IO | 插件声明工具 |
| GPIO | IO | gpio_write/gpio_read |
`post_action → before_toolcall → after_toolcall → 回到 post_action` 构成**内循环**。Agent 在以下条件退出循环进入 `before_output`
- LLM 返回纯文本(无工具调用)
- `before_toolcall` 拒绝所有剩余工具且 LLM 无可执行工具
- 循环超过 `max_tool_rounds` 上限
**输出通道能力校验:**
- 每个 Device 声明 `OutputCapabilities()` → 位掩码
- `output_send` 工具发送前校验通道是否支持文本
- `output_list_channels` 只列出有输出能力的通道
### 2.2 API 抽象层(唯一输出路径)
### 短路规则
每个阶段插件都可设置 `ctx.Response`,一旦设置管道立即短路到 `after_output`
```
Agent Core → Provider.Chat()
┌───────┴───────┐
▼ ▼
OpenAIProvider LuaAdapter
(DeepSeek API) (格式转换)
on_input → ctx.Response = "hello" → 跳过后面的所有阶段 → after_output
```
| 实现 | 说明 |
|------|------|
| `OpenAIProvider` | 标准 OpenAI API 格式DeepSeek v4 flash 默认 |
| `LuaAdaptedProvider` | 通过 Lua 脚本转换请求/响应的适配 wrapper |
---
Lua 适配器位于 `{dataDir}/adapters/*.lua`,每个适配器返回 `name` + `transform_request` + `transform_response`
### 2.3 Agent 操作层(核心编排器)
## 四、记忆体系(三层递进)
```
eventLoop() → select on inputCh
输入消息
handleInput(evt) → processTextInput(input)
├─ 1. RelevanceContext.Append(input)
├─ 2. 构建 prompt: personal.md + 图索引 + 文档摘要 + 上下文 + 工具
├─ 3. 工具循环(最多 10 轮)
│ LLM → tool_calls → Execute → 结果注入 → 下一轮
├─ 4. 追加响应到上下文
├─ 5. RelevanceContext.Prune() → 低分事件→文档记忆归档
├─ 6. OutputEvent → 输出通道
└─ 7. memory_candidate → TextMemory + Distiller → GraphDB
┌─────────────────────────────────────────────────────┐
│ Layer 1: Context (RelevanceContext)
内存中维护最近 topK 条事件TF-IDF 评分JSON 持久化 │
│ 每次 Append/Prune → save() 防崩溃丢数据 │
keep=30 条活跃,多余 → 归档到 Document │
└────────────────────┬────────────────────────────────┘
│ Prune 时
┌─────────────────────────────────────────────────────┐
Layer 2: Document (document.Store) │
│ 文件系统 JSON + TF-IDF 向量索引 │
│ 冷文档72h 未访问 + access ≤ 2→ 蒸馏到 Graph │
│ 也可以由用户主动 commitdoc_commit 工具) │
└────────────────────┬────────────────────────────────┘
│ reorg 心跳
┌─────────────────────────────────────────────────────┐
│ Layer 3: Graph (GraphDB + Indexer) │
│ SQLite: entities + relations │
│ Entity: name, type, mention_count │
│ Relation: source → target, relation_type, confidence│
│ 搜索: 关键词 → 向量搜索实体 → BFS 遍历邻居 │
│ 蒸馏: 原始记录 → Distiller → 三元组提交 │
└─────────────────────────────────────────────────────┘
```
**人格注入:** personal.md 加载一次,固定在 system prompt 最前,永不漂移。
**工具分发:**
```
executeToolCall(tc)
├─ memory_* → executeMemoryTool
├─ knowledge_* → executeKnowledgeTool
├─ doc_* → executeDocTool
├─ output_* → executeOutputChannel/Send/ListChannels
└─ 其他 → io.ExecuteTool → Device.Execute
```
### 2.4 记忆系统
三层分级,自顶向下逐渐持久化、抽象化:
### 数据流关系
```
Layer 1: 上下文RelevanceContext
内存环形缓冲TF-IDF 余弦相似度排序
每次响应后保留 top 30低分→文档记忆
Layer 2: 文档记忆document.Store
JSON 文件 + TF-IDF 向量索引(字符 bigram
冷文档72h 未访问 + ≤2 次)→ 图数据库
Layer 3: 图数据库memory.GraphDB
SQLite 三元组(实体-关系-实体)
只注入索引(实体名+类型+提及次数)到 prompt
定期重整:向量同步 + bigram Jaccard 同义合并
Context 修剪 → Document 归档 → reorg 心跳 → Graph 消化
Distiller (原始记录 → 三元组)
```
### 2.5 知识系统
### 工具入口Agent 暴露给 LLM
- `memory_recall(query)` → 从 Graph 召回
- `memory_commit(triples)` → 写入 Graph
- `memory_introspect()` → 查看统计
- `doc_query(query)` → 从 Document 搜索
- `doc_commit(title, content)` → 写入 Document
---
## 五、知识体系
独立于记忆agent 主动学习。
```
knowledge/{category}/content.md
knowledge/<name>/
content.md
knowledge.Store
└─ TF-IDF 向量索引 (character bigram)
└─ 独立于 memory 的 vector.Store 实例
└─ Start() 时扫描目录训练索引
└─ Add(name, content) 时增量更新
```
### 工具入口
- `knowledge_search(query)` → 向量搜索
- `knowledge_create(name, content)` → 新增
- `knowledge_list()` → 列出所有
### 为什么独立于 memory
- Memory 是 LLM 的"对话记忆"——谁说过什么、上下文
- Knowledge 是 LLM 的"知识库"——外部注入的固定知识
- 两者 TF-IDF 索引实例隔离,不互相污染
---
## 六、三通道插件交互
```
插件 ──→ 核心 核心 ──→ 插件
──────────────────────────────────────────────────
RegisterTool(name, fn) ──→ buildToolDefs()
executeToolCall() → fn
Tracker 自动包裹 Pre/PostAction
RegisterStage(stage, fn) ──→ runStage() 在对应阶段调用 fn(ctx)
返回后检查 ctx.Response 决定是否短路
Subscribe(eventType, fn) ──→ Publish(event)
所有订阅者收到(观察型)
```
### 通道对比
| 通道 | 方向 | 用途 | 可否拦截 |
|---|---|---|---|
| **工具** (RegisterTool) | 插件→核心→LLM | LLM 主动调用插件功能 | 否 |
| **阶段** (RegisterStage) | 核心→插件 | 核心触发插件干预消息流 | 是response 短路) |
| **事件** (Subscribe/Publish) | 双方向 | 审计/日志/状态通知 | 否 |
---
## 七、Agent 内部完整流程
```
processTextInput(input)
├── on_input stage ────────────── 插件可拦截/改写
├── context.Append(input)
├── context.Prune(input) → 归档到 Document
├── buildMemoryContext() → Indexer.BuildContext → Graph Recall
├── pre_action stage ──────────── 插件可注入 context
├── [循环] process(input)
│ ├── buildSystemPrompt (人格+记忆+技能+上下文)
│ ├── buildToolDefs (内置工具 + 插件工具)
│ ├── provider.Chat() → LLM
│ │
│ ├── post_action stage ─────── 插件可见 LLM 输出 + 工具列表
│ │
│ ├── 有工具调用?
│ │ ├── 每个工具:
│ │ │ ├── before_toolcall stage ── 插件可拒绝/改参
│ │ │ ├── Tracker.PreAction
│ │ │ ├── executeToolCall() ──── 路由到插件或内置
│ │ │ ├── Tracker.PostAction
│ │ │ └── after_toolcall stage ── 插件可改结果
│ │ └── → 回到 post_action (继续循环)
│ │
│ └── 无工具调用 → 退出循环
├── context.Append(response)
├── before_output stage ───────── 插件可改写最终文本
├── Publish(agent_output event)
├── output_send (调用插件注册的 output 工具)
└── after_output stage ────────── 插件只读,做统计/日志
```
---
## 八、记忆整理(心跳 LLM 驱动消歧)
图数据库在长期运行中会积累**同义实体**(如「张三」与「张先生」指同一人)和**矛盾关系**。心跳流程如下:
### 流程
```
心跳 tick (30min)
├── distillContext() — 蒸馏上下文
├── syncGraphToDocs() — 图→文档
└── reorgGraph()
├── Indexer.Sync() — 图→向量(自动)
├── DocStore.Reindex() — 文档重建索引(自动)
├── 冷文档→图归化 — 将冷文档归档为图三元组(自动)
└── 实体冲突检测 → 发现相似实体对
│ 如:「张三」(person, 5次) vs 「张先生」(person, 3次) 相似度 0.75
enqueueConsolidationTask()
│ 通过 IO 层注入 Agent 输入队列
│ channel = "_consolidation_"(内部通道,不对外输出)
Agent 处理 (processConsolidation)
│ 如同普通用户消息,走完整 LLM 工具循环
│ 但输出仅写记忆,不发外部通道
LLM 决策:
├─ 判断为同一实体 → 调用 memory_merge 合并
│ → "已将「张先生」合并到「张三」3 条关系已重定向"
├─ 判断为不同实体 → 回复"跳过"
└─ 不确定 → 回复"待定,需更多上下文"
```
### 关键设计
| 特性 | 说明 |
|---|---|
| **启发式检测LLM 决策** | bigram Jaccard 仅做候选筛选(低门槛 0.5LLM 做最终判断 |
| **走 IO 输入队列** | 不阻塞心跳,不抢占用户输入,享受完整 Agent 上下文 |
| **`_consolidation_` 通道** | 内部专用通道,输出只写记忆层,不被外部插件路由 |
| **`memory_merge` 工具** | LLM 通过此工具执行合并,自动重定向关系 + 累积 mention_count |
| **异步非阻塞** | 整理任务排队在 inputCh 尾部Agent 按序处理,不影响用户体验 |
### 类比
类似人类睡眠时大脑的海马体回放——白天经历的记忆在休息时被自发整理、关联、去重。HomeAgent 的心跳就是它的"睡眠周期",而 LLM 的参与相当于前额叶皮层执行语义判断。类比:
```diff
- 人类: 白天经历 → 海马体暂存 → 睡眠 → 前额叶整理 → 长期记忆
+ Agent: 用户交互 → Context缓存 → 心跳 → LLM 消歧 → GraphDB 存储
```
---
## 九、Child Agent
不走阶段管道,独立轻量 Agent
```
spawn_child(task) → 新建轻量 Agent
├── 独立 system prompt仅有任务描述
├── 仅 output_send 工具
├── 无 persistent memory
├── 无 Graph/Document 访问
├── 上限 5 轮工具循环
└── 销毁时返回结果文本
```
---
## 十、SDK API 定义
### PluginAPI (`internal/plugin/sdk/api.go`)
```go
type PluginAPI struct {
Name string
Version string
}
func NewPluginAPI(name, version string, bus EventBus, mem MemoryAPI, know KnowledgeAPI) *PluginAPI
// 三通道
func (p *PluginAPI) RegisterTool(name string, handler ToolHandler) error
func (p *PluginAPI) RegisterStage(stage Stage, handler StageHandler)
func (p *PluginAPI) Subscribe(eventType EventType, handler EventHandler)
func (p *PluginAPI) Publish(evt *Event)
// 访问子系统的快捷方式
func (p *PluginAPI) Memory() MemoryAPI
func (p *PluginAPI) Knowledge() KnowledgeAPI
```
### 阶段上下文 (`StageContext`)
```go
type StageContext struct {
RawMessage string // 当前输入(可改写 on_input
UserID string
GroupID string
ContextMsgs []map[string]interface{} // 可注入的消息
LLMText string // LLM 返回文本(可改写 post_action
ToolCalls []ToolCall // 工具调用列表(可增删 post_action/before_toolcall
ToolResults []ToolResult // 工具执行结果(可改写 after_toolcall
FinalText string // 最终输出文本(可改写 before_output
Response *string // 设置后短路管道
Phase Stage // 当前阶段
Memory []MemItem // 召回的记忆
Extra map[string]interface{} // 扩展字段
}
```
---
## 十一、事件系统
### 事件类型
| 类型 | 发布时机 | 用途 |
|---|---|---|
| `raw_input` | 消息到达 Agent | 记录输入日志 |
| `agent_output` | 最终输出发送后 | 记录输出日志 |
| `tool_call` | 每个工具调用完成 | 审计工具调用 |
| `reasoning` | LLM 推理文本 | 展示推理过程 |
| `system` | 系统状态变更 | 健康检查、插件变更 |
### Event Bus (`internal/events/bus.go`)
```go
type Bus struct{}
func NewBus() *Bus
func (b *Bus) Publish(event *Event)
func (b *Bus) Subscribe(eventType EventType, handler Handler) func()
```
---
## 十二、数据流全景
```
外部 (QQ/HTTP/硬件)
│ 通过插件
IOManager.InjectInput() → inputCh
TF-IDF 向量索引(字符 bigram
Agent.eventLoop() → handleInput → processTextInput
工具: knowledge_search / knowledge_create / knowledge_list
├── 1. on_input stage插件可拦截
├── 2. Context.Append
├── 3. Memory Recall (Indexer → Graph)
├── 4. pre_action stage插件可注入
├── 5. 工具循环 (最多 10 轮)
│ LLM → post_action → [before_toolcall → 执行 → after_toolcall] → LLM ...
├── 6. Context.Append(response)
├── 7. Prune不相关 → Document
├── 8. before_output stage插件可改写
├── 9. EmitOutput (通过 output_send 到对应通道)
├── 10. after_output stage插件只读
└── 11. memory_candidate → TextMemory + Distiller → GraphDB
心跳30min:
├── distillContext()
├── syncGraphToDocs()
└── reorgGraph()
├── Indexer.Sync() — 图→向量(自动)
├── DocStore.Reindex() — 文档向量重建(自动)
├── 冷文档→图归化(自动)
└── 实体冲突检测 → 走 LLM 消歧(详见第八章)
```
### 2.6 插件系统
---
OpenClaw SKILL.md 兼容。
## 十三、代码结构
```
Plugin容器
├─ 元数据: name, version, author
├─ IOConfig可选: 声明 IO 端口
├─ Device可选: 原生 Go 设备
└─ Tools: LLM 工具定义
Registry:
├─ NativeFactory: "qq" → onebot.NewDevice
├─ SetIOManager: 绑定 IO 管理器
└─ Reload(): 原子化重载
```
**热插拔流程:**
1. 扫描 `plugins/` 目录
2. 原生工厂优先,无工厂则 LoadSKILL.md
3. 新设备 Start预先启动
4. `IOManager.AtomicSwapDevices()` 原子替换设备表 + 路由表
5. 旧设备 Stop后台 goroutine
### 2.7 OneBot QQ 通道
```
OneBot 前端go-cqhttp/Lagrange
│ Reverse WebSocket
OneBot Clientinternal/onebot/
├─ 事件: message/notice/request → IO InputEvent
├─ 动作: send_private_msg / send_group_msg / get_group_info / ...
├─ 自动重连(指数退避 1s→30s
└─ 事件 handler 注入 IO 层
Deviceinternal/onebot/device.go
├─ Tools: qq_send_private_msg, qq_send_group_msg, etc.
├─ Execute → Client.SendAction
└─ Start → Connect + 注册事件 handler
```
### 2.8 变更追踪器
Overlayfs 文件变更追踪:
```
PreAction(tool) → 记录文件 hash
PostAction(tool) → diff → ChangeSet{ID, Tool, Files[]SHA256}
Rollback() → 用 overlayfs 下层恢复
```
健康检查失败 → `tracker.Rollback()`
### 2.9 Supervisor 守护进程
```
Daemon:
├─ SetTracker() → 绑定变更追踪器
├─ RegisterAgent("main") → 注册主 agent
└─ 健康检查周期 → LLM API 可达性
└─ 连续失败 → tracker.Rollback()
```
## 三、配置
```yaml
daemon:
listen_addr: ":8080"
data_dir: "/var/lib/homeagent"
heartbeat_interval: 15s
check_interval: 30s
llm:
provider: "openai"
model: "deepseek-v4-flash"
base_url: "https://api.deepseek.com/v1"
api_key: "${DEEPSEEK_API_KEY}"
temperature: 0.7
max_tokens: 4096
```
## 四、数据目录
```
{dataDir}/
cmd/homed/main.go — 入口:组装所有子系统
internal/
├── agent/
├── core/
│ │ ├── agent.go — Agent 核心:事件循环、工具循环、心跳
│ │ ├── context.go — RelevanceContextTF-IDF 上下文管理
│ │ └── stages.go — StageHost阶段管道编排
├─ api/
│ └── provider.go — Provider 接口 + DeepSeek/Ollama 实现
│ ├── io/
│ │ └── channel.go — IOManager + Device 接口(过渡期保留)
│ └── personal.go — 人格加载
├── api/
│ ├── handler.go — HTTP API 端点
│ └── plugin.go — WebUI Device 包装
├── events/
│ └── bus.go — 系统事件总线 (Publish/Subscribe)
├── memory/
│ ├── graph.db # SQLite 图数据库
│ ├── text/ # JSONL 文本记忆
│ ├── documents/ # JSON 文档记忆
── raw/ # 原始记录(蒸馏后删除)
├── knowledge/ # 知识库
│ └── {name}/content.md
├── plugins/ # 插件
│ └── {name}/
│ ├── SKILL.md
└── skill.json
├── skills/ # 技能
├── adapters/ # Lua 适配器
├── personal/ # 人格
│ └── personal.md
├── changesets/ # 变更追踪记录
── snapshots/ # 快照
│ ├── graph.go SQLite 图数据库
│ ├── indexer.go — 图索引器
│ ├── vector/store.go — TF-IDF 向量存储
── document/doc.go — 文档记忆
│ ├── text/text.go — 文本记忆JSONL
│ └── pipeline/ — 蒸馏器
├── knowledge/
│ └── knowledge.go — 知识系统
├── plugin/
├── plugin.go — 插件注册表 + 旧 Device 兼容层
│ └── sdk/
│ ├── api.go — PluginAPI 定义
│ └── bus.go — 插件内部 EventBus 接口
├── onebot/ — OneBot V11 QQ 协议实现
├── tracker/ — 变更追踪 (overlayfs)
── supervisor/ — 守护进程
├── skill/ — 技能管理器
├── lua/ — Lua 适配器
├── network/ — 网络监控
├── container/ — 容器管理
├── snapshot/ — 快照
├── embed/ — 嵌入
└── tokenizer/ — 分词器
config/
├── config.go — 配置加载
└── config.yaml
pkg/types/ — 类型定义
docs/
└── ARCHITECTURE.md — 本架构文档
```
## 五、HTTP API
---
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/v1/chat/completions` | OpenAI 兼容对话 |
| GET | `/api/v1/knowledge` | 知识查询/列表 |
| POST | `/api/v1/knowledge` | 创建知识 |
| DELETE | `/api/v1/knowledge?name=` | 删除知识 |
| GET | `/api/v1/status` | 系统状态 |
| GET | `/api/v1/config` | 配置查看 |
## 十四、与旧设计 (v3) 的关键区别
## 六、构建与部署
```bash
make build # 编译主二进制
make run # 编译 + 启动(数据 /tmp/homeagent
make install # 安装到系统
make test # 运行测试
```
依赖Go 1.19+ (CGo enabled for go-sqlite3)。单二进制部署。
| 维度 | v3 | v4 |
|------|-----|-----|
| 插件交互 | Device 接口 + IOManager 路由 | 三通道Tool/Stage/Event |
| 消息流编辑 | 无(纯事件推送) | 阶段管道 7 个 hook 点 |
| Event Bus | 无 | `internal/events/bus.go` |
| SDK | 无 | `internal/plugin/sdk/` |
| 核心 IO | IOManager `EmitOutput` 直出 | 全部走 `output_send` 工具 |
| 插件工具路由 | IOManager `ExecuteTool` 链 | StageHost + Registry 双层路由 |

View File

@ -11,10 +11,12 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@ -59,6 +61,10 @@ type Agent struct {
// 当前请求的输出通道mutex 保护process() 内独占)
currentOutputChannel string
// 阶段管道:插件消息流编辑
stageHost *StageHost
eventBus *events.Bus
}
type AgentConfig struct {
@ -78,7 +84,10 @@ type AgentConfig struct {
PluginReg *plugin.Registry
PluginDir string
DistillInterval time.Duration
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
ContextSavePath string // 上下文持久化路径,空则不持久化
StageHost *StageHost
EventBus *events.Bus
}
func New(cfg AgentConfig) *Agent {
@ -100,7 +109,7 @@ func New(cfg AgentConfig) *Agent {
indexer: cfg.Indexer,
skills: cfg.Skills,
tracker: cfg.Tracker,
context: NewRelevanceContext(),
context: NewRelevanceContext(cfg.ContextSavePath),
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
@ -112,6 +121,8 @@ func New(cfg AgentConfig) *Agent {
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
maxContextSize: cfg.MaxContextSize,
stageHost: cfg.StageHost,
eventBus: cfg.EventBus,
}
}
@ -168,13 +179,31 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
a.currentOutputChannel = evt.Source
}
// 记忆整理任务:不路由到外部输出通道
if evt.OutputChannel == "_consolidation_" {
a.processConsolidation(input)
return
}
// === Stage: on_input — 消息到达,插件可拦截 ===
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
a.publishEvent(events.EventRawInput, map[string]interface{}{
"content": input,
"source": evt.Source,
})
if a.runStage(sdk.StageOnInput, stageCtx) {
a.emitResponse(evt, *stageCtx.Response)
return
}
input = stageCtx.RawMessage
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
Input: input,
})
response, toolsUsed, err := a.process(input)
response, toolsUsed, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] process error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
@ -206,6 +235,14 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
}
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
// === Stage: before_output — 最终文本就绪,插件可改写 ===
stageCtx := &sdk.StageContext{
FinalText: response,
Phase: sdk.StageBeforeOutput,
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
response = stageCtx.FinalText
// 读取当前输出通道(可能已被 AI 通过 output_set_channel 切换)
ch := a.currentOutputChannel
if ch == "" {
@ -230,10 +267,19 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
OutputChannel: ch,
}
}
// === Stage: after_output — 输出完成,插件只读 ===
a.publishEvent(events.EventAgentOutput, map[string]interface{}{
"content": response,
"channel": ch,
"source": evt.Source,
})
stageCtx.Phase = sdk.StageAfterOutput
a.runStage(sdk.StageAfterOutput, stageCtx)
}
// process — 内部处理,带工具循环
func (a *Agent) process(input string) (response string, toolsUsed []string, err error) {
// process — 内部处理,带工具循环和阶段管道
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
a.mu.Lock()
defer a.mu.Unlock()
@ -248,6 +294,20 @@ func (a *Agent) process(input string) (response string, toolsUsed []string, err
a.personality != nil && a.personality.Content != "",
a.docStoreSize())
// === Stage: pre_action — 上下文就绪,即将调用 LLM ===
if a.runStage(sdk.StagePreAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
}
if len(stageCtx.ContextMsgs) > 0 {
for _, m := range stageCtx.ContextMsgs {
role, _ := m["role"].(string)
content, _ := m["content"].(string)
if role != "" {
msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
}
}
}
for turn := 0; turn < a.maxTurns; turn++ {
req := &agentAPI.CompletionRequest{
Messages: msgs,
@ -264,6 +324,15 @@ func (a *Agent) process(input string) (response string, toolsUsed []string, err
return "", toolsUsed, fmt.Errorf("provider: %w", err)
}
// === Stage: post_action — LLM 返回,插件可审查/修改 ===
stageCtx.LLMText = resp.Content
stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
if a.runStage(sdk.StagePostAction, stageCtx) {
return *stageCtx.Response, toolsUsed, nil
}
resp.Content = stageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, nil
}
@ -271,16 +340,74 @@ func (a *Agent) process(input string) (response string, toolsUsed []string, err
for _, tc := range resp.ToolCalls {
toolsUsed = append(toolsUsed, tc.Name)
log.Printf("[agent] executing tool: %s (id=%s)", tc.Name, tc.ID)
// === Stage: before_toolcall — 插件可拒绝/改参 ===
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
stageCtx.ToolResults = nil
if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"args": tc.Arguments,
"result": result,
"status": "denied",
})
continue
}
tc.Arguments = stageCtx.ToolCalls[0].Arguments
result := a.executeToolCall(tc)
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
// === Stage: after_toolcall — 插件可改结果 ===
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Success: true, Result: result}}
a.runStage(sdk.StageAfterToolcall, stageCtx)
if len(stageCtx.ToolResults) > 0 {
if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
result = r
}
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"args": tc.Arguments,
"result": result,
"status": "ok",
})
}
}
return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns)
}
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
if tcs == nil {
return nil
}
result := make([]sdk.ToolCall, len(tcs))
for i, tc := range tcs {
result[i] = sdk.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
}
return result
}
func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
if tcs == nil {
return nil
}
result := make([]agentAPI.ToolCall, len(tcs))
for i, tc := range tcs {
result[i] = agentAPI.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
}
return result
}
func (a *Agent) docStoreSize() int {
if a.docStore == nil {
return 0
@ -322,6 +449,15 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
return a.executeOutputListChannels()
case tc.Name == "plgreload":
return a.executePluginReload()
case tc.Name == "spawn_child":
return a.executeSpawnChild(tc)
}
// 插件工具(通过 SDK RegisterTool 注册)
if a.stageHost != nil {
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
return fmt.Sprintf("%v", result)
}
}
if a.tracker != nil {
@ -414,7 +550,19 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("记忆统计: %v", stats)
case "memory_document_query":
return a.executeDocTool(tc)
return a.executeDocTool(tc)
case "memory_merge":
source, _ := tc.Arguments["source"].(string)
target, _ := tc.Arguments["target"].(string)
if source == "" || target == "" {
return "source 和 target 不能为空"
}
count, err := a.memory.MergeEntities(source, target)
if err != nil {
return fmt.Sprintf("合并失败: %v", err)
}
return fmt.Sprintf("已将「%s」合并到「%s」%d 条关系已重定向", source, target, count)
default:
return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
@ -600,12 +748,45 @@ func (a *Agent) buildToolDefs() []interface{} {
}
}
// 插件注册的工具(通过 SDK RegisterTool
if a.stageHost != nil {
for _, td := range a.stageHost.GetToolDefs() {
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": td.Name,
"description": td.Description,
"parameters": td.Parameters,
},
})
}
}
if a.indexer != nil {
for _, td := range a.indexer.GetToolDefinitions() {
tools = append(tools, td)
}
}
// 实体合并工具(心跳检测到冲突时 LLM 使用)
if a.memory != nil {
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "memory_merge",
"description": "合并两个同义实体:将所有关系从 source 重定向到 targetsource 标记为 merged。仅在有明确证据时使用。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
},
"required": []string{"source", "target"},
},
},
})
}
// 知识库工具
if a.knowledge != nil {
tools = append(tools, map[string]interface{}{
@ -709,6 +890,25 @@ func (a *Agent) buildToolDefs() []interface{} {
})
}
// 子任务工具
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "spawn_child",
"description": "创建一个子 Agent 执行独立任务。子 Agent 使用传统上下文(无持久记忆),任务完成即销毁。适用于需要多步推理但不需要写入长期记忆的场景,例如:计算、分析、生成报告草稿等。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"task": map[string]interface{}{
"type": "string",
"description": "要子 Agent 完成的任务描述。请描述清晰、完整,包含所有必要背景。",
},
},
"required": []string{"task"},
},
},
})
// 输出通道工具
tools = append(tools, map[string]interface{}{
"type": "function",
@ -764,6 +964,20 @@ func (a *Agent) buildToolDefs() []interface{} {
return tools
}
// ConsolidationTask 心跳检测到的记忆整理任务,通过 IO 发送给 Agent 让 LLM 决策
type ConsolidationTask struct {
Type string `json:"type"` // "entity_merge", "relation_conflict", "doc_archival"
Reason string `json:"reason"` // 人类可读的描述
Data interface{} `json:"data"` // 任务相关数据
}
// enqueueConsolidationTask 将记忆整理任务注入到 Agent 输入队列
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
msg := fmt.Sprintf("【记忆整理任务】\n类型: %s\n说明: %s", task.Type, task.Reason)
a.io.InjectTextTo("system", "_consolidation_", msg)
log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
}
// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
func (a *Agent) distillLoop() {
if a.docStore == nil && a.memory == nil {
@ -896,34 +1110,85 @@ func (a *Agent) reorgGraph() {
}
}
// 4. 实体向量同义合并
// 4. 实体同义冲突检测 → 交由 LLM 决策
result, err := a.memory.Recall(nil, nil, 1, "")
if err != nil || result == nil || len(result.Entities) < 2 {
return
}
merged := 0
candidates := 0
for i := 0; i < len(result.Entities); i++ {
for j := i + 1; j < len(result.Entities); j++ {
if isSimilarName(result.Entities[i].Name, result.Entities[j].Name) {
if result.Entities[i].MentionCount >= result.Entities[j].MentionCount {
log.Printf("[agent] reorg: merging '%s' → '%s'", result.Entities[j].Name, result.Entities[i].Name)
} else {
log.Printf("[agent] reorg: merging '%s' → '%s'", result.Entities[i].Name, result.Entities[j].Name)
}
merged++
sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
if sim > 0.5 {
candidates++
a.enqueueConsolidationTask(ConsolidationTask{
Type: "entity_merge",
Reason: fmt.Sprintf(
"实体「%s」(类型:%s, 提及%d次) 与「%s」(类型:%s, 提及%d次) 相似度 %.0f%%,可能指代同一事物,请判断是否需要合并",
result.Entities[i].Name, result.Entities[i].Type, result.Entities[i].MentionCount,
result.Entities[j].Name, result.Entities[j].Type, result.Entities[j].MentionCount,
sim*100,
),
Data: map[string]interface{}{
"entity_a": result.Entities[i].Name,
"entity_a_type": result.Entities[i].Type,
"entity_a_mentions": result.Entities[i].MentionCount,
"entity_b": result.Entities[j].Name,
"entity_b_type": result.Entities[j].Type,
"entity_b_mentions": result.Entities[j].MentionCount,
"similarity": sim,
},
})
}
}
}
if merged > 0 {
log.Printf("[agent] graph reorg: merged %d similar entities", merged)
if candidates > 0 {
log.Printf("[agent] graph reorg: %d merge candidates sent for LLM decision", candidates)
} else {
log.Printf("[agent] graph reorg: no merges needed")
log.Printf("[agent] graph reorg: no similar entities found")
}
}
// isSimilarName — 使用字符 bigram Jaccard 相似度判断实体名是否同义
// entitySimilarity 计算两个实体名的相似度(字符 bigram Jaccard
func entitySimilarity(a, b string) float64 {
if a == "" || b == "" {
return 0
}
if a == b {
return 1.0
}
runesA, runesB := []rune(a), []rune(b)
if len(runesA) < 2 || len(runesB) < 2 {
if len(runesA) == len(runesB) && len(runesA) == 1 {
if runesA[0] == runesB[0] {
return 1.0
}
}
return 0
}
setA := make(map[string]bool)
for i := 0; i < len(runesA)-1; i++ {
setA[string(runesA[i:i+2])] = true
}
intersect := 0
for i := 0; i < len(runesB)-1; i++ {
if setA[string(runesB[i:i+2])] {
intersect++
}
}
union := len(setA) + len(runesB) - 1 - intersect
if union <= 0 {
return 0
}
return float64(intersect) / float64(union)
}
// docToTriples 将文档转为图记忆三元组
func docToTriples(doc *document.Doc) []memory.Triple {
var triples []memory.Triple
@ -964,36 +1229,6 @@ func docToTriples(doc *document.Doc) []memory.Triple {
return triples
}
func isSimilarName(a, b string) bool {
if a == b {
return false // 自带跳过
}
runesA, runesB := []rune(a), []rune(b)
if len(runesA) < 2 || len(runesB) < 2 {
return false
}
setA := make(map[string]bool)
for i := 0; i < len(runesA)-1; i++ {
setA[string(runesA[i:i+2])] = true
}
intersect := 0
for i := 0; i < len(runesB)-1; i++ {
if setA[string(runesB[i:i+2])] {
intersect++
}
}
union := len(setA) + len(runesB) - 1 - intersect
if union <= 0 {
return false
}
jaccard := float64(intersect) / float64(union)
return jaccard > 0.5
}
func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []string) {
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
"source": source,
@ -1007,6 +1242,33 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []
// executeOutputChannelTool — AI 切换当前请求的输出通道
// 在 process() 内调用mutex 保护,只有一个请求在执行
// processConsolidation 处理后台记忆整理任务(不发外部输出)
func (a *Agent) processConsolidation(input string) {
start := time.Now()
a.currentOutputChannel = "_consolidation_"
a.context.Append(ContextEvent{
Timestamp: start,
Source: "system",
Input: input,
})
response, toolsUsed, err := a.process(input, &sdk.StageContext{RawMessage: input})
if err != nil {
log.Printf("[agent] consolidation error: %v", err)
return
}
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: input,
Response: response,
ToolsUsed: toolsUsed,
})
_ = a.context.Prune(response, a.maxContextSize, a.docStore)
// 只写入记忆,不发外部输出
a.emitMemoryCandidate("system", input, response, toolsUsed)
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}
func (a *Agent) executeOutputChannelTool(tc agentAPI.ToolCall) string {
channel, _ := tc.Arguments["channel"].(string)
if channel == "" {
@ -1077,6 +1339,118 @@ func (a *Agent) executePluginReload() string {
return msg
}
// executeSpawnChild 创建子 Agent 执行独立任务
// 子 Agent 使用传统上下文(单轮对话),无持久记忆,任务完即销毁
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
task, _ := tc.Arguments["task"].(string)
if task == "" {
return "请提供 task 参数"
}
sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。
请完成以下任务。完成即可,无需保留记忆或查询历史。
任务: %s`, task)
msgs := []agentAPI.Message{
{Role: "system", Content: sysPrompt},
{Role: "user", Content: task},
}
// 子 Agent 无特殊工具,只保留基础 tool 定义(无记忆/知识/文档工具)
childTools := []interface{}{
map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "output_send",
"description": "通过指定输出通道发送消息",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"channel": map[string]interface{}{"type": "string", "description": "输出通道"},
"content": map[string]interface{}{"type": "string", "description": "消息内容"},
},
"required": []string{"channel", "content"},
},
},
},
}
for turn := 0; turn < 5; turn++ {
req := &agentAPI.CompletionRequest{
Messages: msgs,
MaxTokens: 4096,
Tools: childTools,
ToolChoice: "auto",
ExtraBody: map[string]interface{}{
"thinking": map[string]interface{}{"type": "disabled"},
},
}
resp, err := a.provider.Chat(a.ctx, req)
if err != nil {
return fmt.Sprintf("子 Agent 执行失败: %v", err)
}
if len(resp.ToolCalls) == 0 {
return resp.Content
}
for _, ct := range resp.ToolCalls {
var result string
if ct.Name == "output_send" {
channel, _ := ct.Arguments["channel"].(string)
content, _ := ct.Arguments["content"].(string)
if channel != "" && content != "" {
a.io.EmitTextTo("child_agent", channel, content)
result = fmt.Sprintf("已通过 [%s] 通道发送", channel)
} else {
result = "channel 和 content 不能为空"
}
} else {
result = fmt.Sprintf("子 Agent 无法调用工具 %s", ct.Name)
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
}
}
return "子 Agent 执行超时(超过 5 轮)"
}
// runStage — 运行阶段管道,若插件 Response 被设置则返回 true短路
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
if a.stageHost == nil {
return false
}
ctx.Phase = stage
a.stageHost.RunStage(stage, ctx)
return ctx.Response != nil
}
// publishEvent — 发布系统事件
func (a *Agent) publishEvent(evtType events.EventType, payload map[string]interface{}) {
if a.eventBus == nil {
return
}
a.eventBus.Publish(&events.Event{
Type: evtType,
Source: string(a.id),
Payload: payload,
Timestamp: time.Now().Unix(),
})
}
// stageCtxFromInput — 根据输入构建阶段上下文
func (a *Agent) stageCtxFromInput(input, userID, groupID string) *sdk.StageContext {
return &sdk.StageContext{
RawMessage: input,
UserID: userID,
GroupID: groupID,
Phase: sdk.StageOnInput,
Extra: make(map[string]interface{}),
}
}
func getFloat(m map[string]interface{}, key string) float64 {
if v, ok := m[key]; ok {
switch n := v.(type) {

View File

@ -6,21 +6,21 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
func TestIsSimilarName(t *testing.T) {
func TestEntitySimilarity(t *testing.T) {
tests := []struct {
a, b string
want bool
want float64
}{
{"张三", "张三四", false},
{"", "", false},
{"a", "b", false},
{"张三", "李四", false},
{"张三", "张三", false},
{"", "", 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 {
got := isSimilarName(tt.a, tt.b)
got := entitySimilarity(tt.a, tt.b)
if got != tt.want {
t.Errorf("isSimilarName(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want)
t.Errorf("entitySimilarity(%q, %q) = %.3f, want %.3f", tt.a, tt.b, got, tt.want)
}
}
}

View File

@ -1,7 +1,10 @@
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@ -13,26 +16,63 @@ import (
// ContextEvent — 单条上下文事件
type ContextEvent struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
Vector vector.Vector `json:"-"` // 缓存向量,避免重复计算
}
// RelevanceContext — 基于相关性的上下文管理,非固定阈值
type RelevanceContext struct {
mu sync.Mutex
events []*ContextEvent
veczer *vector.TFIDFVectorizer
trained bool
mu sync.Mutex
events []*ContextEvent
veczer *vector.TFIDFVectorizer
trained bool
savePath string // 持久化路径,空则不持久化
}
func NewRelevanceContext() *RelevanceContext {
return &RelevanceContext{
veczer: vector.NewTFIDFVectorizer(2),
func NewRelevanceContext(savePath string) *RelevanceContext {
rc := &RelevanceContext{
veczer: vector.NewTFIDFVectorizer(2),
savePath: savePath,
}
if savePath != "" {
rc.load()
}
return rc
}
// load 从文件恢复上下文事件
func (c *RelevanceContext) load() {
data, err := os.ReadFile(c.savePath)
if err != nil {
return
}
var events []*ContextEvent
if err := json.Unmarshal(data, &events); err != nil {
return
}
for _, evt := range events {
evt.Vector = c.veczer.Vectorize(evt.Input + " " + evt.Response)
}
c.events = events
}
// Save 持久化上下文事件到文件
func (c *RelevanceContext) Save() error {
if c.savePath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(c.savePath), 0755); err != nil {
return err
}
data, err := json.Marshal(c.events)
if err != nil {
return err
}
return os.WriteFile(c.savePath, data, 0644)
}
func (c *RelevanceContext) Append(evt ContextEvent) {
@ -44,6 +84,20 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
// 增量训练向量化器
c.trained = false
c.save()
}
// save 无锁版本Append/Prune 内部持有锁时调用
func (c *RelevanceContext) save() error {
if c.savePath == "" {
return nil
}
data, err := json.Marshal(c.events)
if err != nil {
return err
}
return os.WriteFile(c.savePath, data, 0644)
}
// Prune — 基于当前输入计算每条上下文的相关性,归档最不相关的
@ -114,6 +168,8 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
}
}
c.save()
return archived
}

View File

@ -6,7 +6,7 @@ import (
)
func TestContextAppendAndLen(t *testing.T) {
ctx := NewRelevanceContext()
ctx := NewRelevanceContext("")
if ctx.Len() != 0 {
t.Errorf("new context should be empty, got %d", ctx.Len())
}
@ -18,7 +18,7 @@ func TestContextAppendAndLen(t *testing.T) {
}
func TestContextRecent(t *testing.T) {
ctx := NewRelevanceContext()
ctx := NewRelevanceContext("")
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "a"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "b"})
ctx.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "c"})
@ -33,7 +33,7 @@ func TestContextRecent(t *testing.T) {
}
func TestContextFormat(t *testing.T) {
ctx := NewRelevanceContext()
ctx := NewRelevanceContext("")
f := ctx.Format()
if f != "" {
t.Errorf("empty context should format to empty string, got %q", f)
@ -54,7 +54,7 @@ func TestContextFormat(t *testing.T) {
}
func TestContextPruneKeepsTopK(t *testing.T) {
ctx := NewRelevanceContext()
ctx := NewRelevanceContext("")
for i := 0; i < 10; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
@ -80,7 +80,7 @@ func TestContextPruneKeepsTopK(t *testing.T) {
}
func TestContextPruneWithDocStore(t *testing.T) {
ctx := NewRelevanceContext()
ctx := NewRelevanceContext("")
for i := 0; i < 15; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),
@ -97,7 +97,7 @@ func TestContextPruneWithDocStore(t *testing.T) {
}
func TestContextAppendAfterPrune(t *testing.T) {
ctx := NewRelevanceContext()
ctx := NewRelevanceContext("")
for i := 0; i < 10; i++ {
ctx.Append(ContextEvent{
Timestamp: time.Now(),

View File

@ -0,0 +1,74 @@
package core
import (
"fmt"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
)
type StageHost struct {
plugins []*sdk.PluginAPI
toolDefs []sdk.ToolDef
tools map[string]sdk.ToolHandler
}
func NewStageHost() *StageHost {
return &StageHost{
tools: make(map[string]sdk.ToolHandler),
}
}
func (h *StageHost) RegisterPlugin(api *sdk.PluginAPI) {
h.plugins = append(h.plugins, api)
for name, handler := range api.Tools() {
h.tools[name] = handler
h.toolDefs = append(h.toolDefs, sdk.ToolDef{Name: name})
}
}
// SyncFromRegistry 从插件注册表同步 SDK 插件
func (h *StageHost) SyncFromRegistry(reg *plugin.Registry) {
if reg == nil {
return
}
for _, td := range reg.GetAllSDKToolDefs() {
h.toolDefs = append(h.toolDefs, td)
}
}
func (h *StageHost) GetToolDefs() []sdk.ToolDef {
return h.toolDefs
}
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (interface{}, error) {
if handler, ok := h.tools[name]; ok {
return handler(args)
}
return nil, fmt.Errorf("tool %s not found in any plugin", name)
}
func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
for _, p := range h.plugins {
for _, handler := range p.StageHandlers(stage) {
if err := handler(ctx); err != nil {
return
}
if ctx.Response != nil {
return
}
}
}
}
func (h *StageHost) RunStageAll(stage sdk.Stage, ctx *sdk.StageContext) {
for _, p := range h.plugins {
for _, handler := range p.StageHandlers(stage) {
handler(ctx)
}
}
}
func (h *StageHost) PluginCount() int {
return len(h.plugins)
}

View File

@ -0,0 +1,183 @@
package core
import (
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
)
func TestStageHostRegisterPlugin(t *testing.T) {
host := NewStageHost()
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
host.RegisterPlugin(api)
if host.PluginCount() != 1 {
t.Errorf("expected 1 plugin, got %d", host.PluginCount())
}
defs := host.GetToolDefs()
if len(defs) != 1 {
t.Errorf("expected 1 tool def, got %d", len(defs))
}
if defs[0].Name != "test_tool" {
t.Errorf("expected test_tool, got %s", defs[0].Name)
}
}
func TestStageHostExecuteTool(t *testing.T) {
host := NewStageHost()
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
api.RegisterTool("hello", func(args map[string]interface{}) (interface{}, error) {
return "world", nil
})
host.RegisterPlugin(api)
result, err := host.ExecuteTool("hello", nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.(string) != "world" {
t.Errorf("expected world, got %v", result)
}
_, err = host.ExecuteTool("nonexistent", nil)
if err == nil {
t.Error("expected error for nonexistent tool")
}
}
func TestStageHostRunStage(t *testing.T) {
host := NewStageHost()
api := sdk.NewPluginAPI("test", "1.0.0", nil, nil, nil)
var called bool
api.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
called = true
return nil
})
host.RegisterPlugin(api)
ctx := &sdk.StageContext{RawMessage: "hello"}
host.RunStage(sdk.StageOnInput, ctx)
if !called {
t.Error("stage handler was not called")
}
}
func TestStageHostRunStageShortCircuit(t *testing.T) {
host := NewStageHost()
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
api1.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
resp := "short-circuited"
ctx.Response = &resp
return nil
})
var api2called bool
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
api2.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
api2called = true
return nil
})
host.RegisterPlugin(api1)
host.RegisterPlugin(api2)
ctx := &sdk.StageContext{RawMessage: "hello"}
host.RunStage(sdk.StageOnInput, ctx)
if ctx.Response == nil || *ctx.Response != "short-circuited" {
t.Errorf("expected short-circuited, got %v", ctx.Response)
}
if api2called {
t.Error("api2 should not have been called after short circuit")
}
}
func TestStageHostRunStageAll(t *testing.T) {
host := NewStageHost()
count := 0
api1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
api1.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
count++
return nil
})
api2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
api2.RegisterStage(sdk.StageAfterOutput, func(ctx *sdk.StageContext) error {
count++
return nil
})
host.RegisterPlugin(api1)
host.RegisterPlugin(api2)
host.RunStageAll(sdk.StageAfterOutput, &sdk.StageContext{})
if count != 2 {
t.Errorf("expected 2 handlers called, got %d", count)
}
}
func TestStageHostMultiplePlugins(t *testing.T) {
host := NewStageHost()
p1 := sdk.NewPluginAPI("p1", "1.0.0", nil, nil, nil)
p1.RegisterTool("tool1", func(args map[string]interface{}) (interface{}, error) {
return "from_p1", nil
})
p2 := sdk.NewPluginAPI("p2", "1.0.0", nil, nil, nil)
p2.RegisterTool("tool2", func(args map[string]interface{}) (interface{}, error) {
return "from_p2", nil
})
host.RegisterPlugin(p1)
host.RegisterPlugin(p2)
if host.PluginCount() != 2 {
t.Errorf("expected 2 plugins, got %d", host.PluginCount())
}
r1, _ := host.ExecuteTool("tool1", nil)
if r1.(string) != "from_p1" {
t.Errorf("expected from_p1, got %v", r1)
}
r2, _ := host.ExecuteTool("tool2", nil)
if r2.(string) != "from_p2" {
t.Errorf("expected from_p2, got %v", r2)
}
}
func TestStageHostEmpty(t *testing.T) {
host := NewStageHost()
if host.PluginCount() != 0 {
t.Errorf("expected 0 plugins, got %d", host.PluginCount())
}
defs := host.GetToolDefs()
if len(defs) != 0 {
t.Errorf("expected 0 tool defs, got %d", len(defs))
}
_, err := host.ExecuteTool("anything", nil)
if err == nil {
t.Error("expected error on empty host")
}
// RunStage on empty host should not panic
host.RunStage(sdk.StageOnInput, &sdk.StageContext{})
}

68
internal/api/plugin.go Normal file
View File

@ -0,0 +1,68 @@
package api
import (
"log"
"net/http"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// Plugin 将 HTTP API + WebUI 包装为 IO Device
// 作为 HomeAgent 自带的默认 IO 通道插件
type Plugin struct {
name string
handler *Handler
server *http.Server
addr string
mux *http.ServeMux
}
func NewWebUIPlugin(name, addr string, sup *supervisor.Daemon, mem *memory.GraphDB, sk *skill.Manager,
lua *luaVM.VM, cfg *types.Config, iom *agentIO.IOManager, tm *text.Memory, ks *knowledge.Store, tr *tracker.Tracker) *Plugin {
h := NewHandler(sup, mem, sk, lua, cfg, iom, tm, ks, tr)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return &Plugin{
name: name,
handler: h,
addr: addr,
mux: mux,
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (p *Plugin) Description() string { return "HTTP API & Web Dashboard" }
func (p *Plugin) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText | agentIO.CapStructured }
func (p *Plugin) Tools() []agentIO.ToolDef { return nil }
func (p *Plugin) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return nil, nil
}
func (p *Plugin) Start() error {
p.server = &http.Server{Addr: p.addr, Handler: p.mux}
go func() {
log.Printf("[webui] HTTP server listening on %s", p.addr)
if err := p.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("[webui] server error: %v", err)
}
}()
return nil
}
func (p *Plugin) Stop() error {
if p.server != nil {
return p.server.Close()
}
return nil
}

69
internal/events/bus.go Normal file
View File

@ -0,0 +1,69 @@
package events
import (
"fmt"
"sync"
)
type EventType string
const (
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventSystem EventType = "system"
EventAll EventType = "*"
)
type Event struct {
Type EventType `json:"type"`
Source string `json:"source"`
Payload map[string]interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
}
type Handler func(event *Event)
type Bus struct {
mu sync.RWMutex
subs map[EventType][]Handler
}
func NewBus() *Bus {
return &Bus{
subs: make(map[EventType][]Handler),
}
}
func (b *Bus) Publish(evt *Event) {
b.mu.RLock()
allHandlers := b.subs[EventAll]
typeHandlers := b.subs[evt.Type]
b.mu.RUnlock()
for _, h := range allHandlers {
h(evt)
}
for _, h := range typeHandlers {
h(evt)
}
}
func (b *Bus) Subscribe(eventType EventType, handler Handler) func() {
b.mu.Lock()
b.subs[eventType] = append(b.subs[eventType], handler)
b.mu.Unlock()
return func() {
b.mu.Lock()
defer b.mu.Unlock()
list := b.subs[eventType]
for i, h := range list {
if fmt.Sprintf("%p", h) == fmt.Sprintf("%p", handler) {
b.subs[eventType] = append(list[:i], list[i+1:]...)
break
}
}
}
}

102
internal/events/bus_test.go Normal file
View File

@ -0,0 +1,102 @@
package events
import (
"sync/atomic"
"testing"
"time"
)
func TestBusPublishSubscribe(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe(EventRawInput, func(evt *Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(&Event{
Type: EventRawInput,
Source: "test",
Payload: map[string]interface{}{"content": "hello"},
})
if c := atomic.LoadInt32(&count); c != 1 {
t.Errorf("expected 1, got %d", c)
}
}
func TestBusWildcard(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe(EventAll, func(evt *Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(&Event{Type: EventRawInput, Source: "test"})
bus.Publish(&Event{Type: EventToolCall, Source: "test"})
if c := atomic.LoadInt32(&count); c != 2 {
t.Errorf("expected 2, got %d", c)
}
}
func TestBusUnsubscribe(t *testing.T) {
bus := NewBus()
var count int32
handler := func(evt *Event) {
atomic.AddInt32(&count, 1)
}
unsub := bus.Subscribe(EventRawInput, handler)
bus.Publish(&Event{Type: EventRawInput, Source: "test"})
unsub()
bus.Publish(&Event{Type: EventRawInput, Source: "test"})
if c := atomic.LoadInt32(&count); c != 1 {
t.Errorf("expected 1 after unsub, got %d", c)
}
}
func TestBusNoMatch(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe(EventRawInput, func(evt *Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(&Event{Type: EventAgentOutput, Source: "test"})
if c := atomic.LoadInt32(&count); c != 0 {
t.Errorf("expected 0, got %d", c)
}
}
func TestBusConcurrent(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe(EventAll, func(evt *Event) {
atomic.AddInt32(&count, 1)
})
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
bus.Publish(&Event{Type: EventRawInput, Source: "test"})
}
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("timeout")
}
if c := atomic.LoadInt32(&count); c != 100 {
t.Errorf("expected 100, got %d", c)
}
}

View File

@ -520,6 +520,98 @@ func (g *GraphDB) Introspect() (map[string]interface{}, error) {
}, nil
}
// MergeEntities 合并两个实体:将 sourceName 的所有信息合并到 targetName
// 1. sourceName 的所有关系重新指向 targetName
// 2. targetName 的 mention_count 增加 sourceName 的计数
// 3. sourceName 标记为 merged
// 返回 (关系的重定向数, error)
func (g *GraphDB) MergeEntities(sourceName, targetName string) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
tx, err := g.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
var sourceID, targetID int64
var sourceCount, targetCount int
err = tx.QueryRow("SELECT id, mention_count FROM entities WHERE name = ?", sourceName).Scan(&sourceID, &sourceCount)
if err != nil {
return 0, fmt.Errorf("source entity '%s' not found: %w", sourceName, err)
}
err = tx.QueryRow("SELECT id, mention_count FROM entities WHERE name = ?", targetName).Scan(&targetID, &targetCount)
if err != nil {
return 0, fmt.Errorf("target entity '%s' not found: %w", targetName, err)
}
if sourceID == targetID {
return 0, fmt.Errorf("cannot merge entity with itself")
}
// 重定向 source → target 的关系(作为 source
res, err := tx.Exec(
`UPDATE relations SET source_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE source_id = ? AND status = 'active'`,
targetID, sourceID,
)
if err != nil {
return 0, err
}
redirectedSource, _ := res.RowsAffected()
// 重定向 source → target 的关系(作为 target
res, err = tx.Exec(
`UPDATE relations SET target_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE target_id = ? AND status = 'active'`,
targetID, sourceID,
)
if err != nil {
return 0, err
}
redirectedTarget, _ := res.RowsAffected()
// 删除可能产生的自引用关系
_, err = tx.Exec(
`DELETE FROM relations
WHERE source_id = target_id AND source_id = ?`,
targetID,
)
if err != nil {
return 0, err
}
// 更新 target 的 mention_count
_, err = tx.Exec(
`UPDATE entities SET mention_count = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
targetCount+sourceCount, targetID,
)
if err != nil {
return 0, err
}
// 标记 source 为 merged改名避免 UNIQUE 冲突)
_, err = tx.Exec(
`UPDATE entities SET name = ? || '@merged_' || ?,
mention_count = 0,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
sourceName, time.Now().Format("20060102150405"), sourceID,
)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
total := int(redirectedSource + redirectedTarget)
return total, nil
}
func (g *GraphDB) Archive(days int) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()

View File

@ -214,6 +214,95 @@ func TestIntrospectHotspots(t *testing.T) {
}
}
func TestMergeEntities(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
g.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "编程"},
{Subject: "张三", Relation: "居住", Object: "北京"},
}, "session", 0)
g.Commit([]Triple{
{Subject: "张先生", Relation: "工作", Object: "字节跳动"},
}, "session", 0)
// 合并前:两个实体各有关联
stats, _ := g.Introspect()
if stats["entity_count"].(int) != 5 {
t.Fatalf("expected 5 entities (张三, 编程, 北京, 张先生, 字节跳动), got %d", stats["entity_count"])
}
// 先增加张先生的 mention_count
g.Commit([]Triple{
{Subject: "张先生", Relation: "喜欢", Object: "Go"},
}, "session", 0)
n, err := g.MergeEntities("张先生", "张三")
if err != nil {
t.Fatal(err)
}
if n < 2 {
t.Errorf("expected at least 2 redirected relations, got %d", n)
}
// source 应被改名
result, err := g.Recall(nil, []string{"张先生"}, 1, "")
if err != nil {
t.Fatal(err)
}
if len(result.Entities) > 0 {
t.Error("张先生 should be merged and hidden")
}
// target 的 mention_count 应合并
// 验证 target 还存在seedEntities 精确查找)
result2, err := g.Recall([]string{"张三"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
found := false
for _, e := range result2.Entities {
if e.Name == "张三" {
found = true
if e.MentionCount < 2 {
t.Errorf("expected 张三 mention_count >= 2 after merge, got %d", e.MentionCount)
}
break
}
}
if !found {
t.Error("张三 should still exist after merge")
}
}
func TestMergeEntitiesSelf(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
g.Commit([]Triple{
{Subject: "张三", Relation: "喜欢", Object: "编程"},
}, "session", 0)
_, err := g.MergeEntities("张三", "张三")
if err == nil {
t.Error("expected error when merging entity with itself")
}
}
func TestMergeEntitiesNonexistent(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
_, err := g.MergeEntities("不存在", "张三")
if err == nil {
t.Error("expected error for nonexistent source")
}
}
func TestPlaceholders(t *testing.T) {
if placeholders(0) != "NULL" {
t.Errorf("expected NULL for n=0, got %s", placeholders(0))

View File

@ -12,6 +12,7 @@ import (
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/plugin/sdk"
)
type PluginType string
@ -147,15 +148,90 @@ type Registry struct {
plugins map[string]Plugin
ioMgr *agentIO.IOManager
factories map[string]NativeFactory // 名称匹配的插件使用原生实现
sdkAPIs map[string]*sdkAPI // SDK 插件 API 实例
}
type sdkAPI struct {
api *sdk.PluginAPI
tools map[string]sdk.ToolHandler
stages map[sdk.Stage][]sdk.StageHandler
}
func NewRegistry() *Registry {
return &Registry{
plugins: make(map[string]Plugin),
factories: make(map[string]NativeFactory),
sdkAPIs: make(map[string]*sdkAPI),
}
}
// RegisterPluginAPI 注册一个 SDK 插件 API 实例
func (r *Registry) RegisterPluginAPI(api *sdk.PluginAPI) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.sdkAPIs[api.Name]; ok {
return fmt.Errorf("sdk api %s already registered", api.Name)
}
r.sdkAPIs[api.Name] = &sdkAPI{
api: api,
tools: api.Tools(),
stages: make(map[sdk.Stage][]sdk.StageHandler),
}
for stage := range sdk.AllStages() {
if handlers := api.StageHandlers(stage); len(handlers) > 0 {
r.sdkAPIs[api.Name].stages[stage] = handlers
}
}
log.Printf("[plugin] registered SDK plugin: %s (tools=%d, stages=%d)",
api.Name, len(api.Tools()), len(r.sdkAPIs[api.Name].stages))
return nil
}
// GetAllSDKToolDefs 收集所有 SDK 插件的工具定义
func (r *Registry) GetAllSDKToolDefs() []sdk.ToolDef {
r.mu.RLock()
defer r.mu.RUnlock()
var defs []sdk.ToolDef
for _, sa := range r.sdkAPIs {
for name := range sa.tools {
defs = append(defs, sdk.ToolDef{Name: name})
}
}
return defs
}
// ExecuteSDKTool 执行 SDK 插件工具
func (r *Registry) ExecuteSDKTool(name string, args map[string]interface{}) (interface{}, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, sa := range r.sdkAPIs {
if handler, ok := sa.tools[name]; ok {
return handler(args)
}
}
return nil, fmt.Errorf("sdk tool %s not found", name)
}
// GetStageHandlers 获取所有 SDK 插件在指定阶段的处理器
func (r *Registry) GetStageHandlers(stage sdk.Stage) []sdk.StageHandler {
r.mu.RLock()
defer r.mu.RUnlock()
var handlers []sdk.StageHandler
for _, sa := range r.sdkAPIs {
if h, ok := sa.stages[stage]; ok {
handlers = append(handlers, h...)
}
}
return handlers
}
// SDKPluginCount 返回已注册的 SDK 插件数量
func (r *Registry) SDKPluginCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.sdkAPIs)
}
// RegisterNative 注册内置原生插件工厂。当从 plugins/ 加载插件时,
// 如果插件名称匹配已注册的工厂,优先使用原生设备注册。
// 例如: r.RegisterNative("qq", onebot.NewDeviceFactory)

169
internal/plugin/sdk/api.go Normal file
View File

@ -0,0 +1,169 @@
package sdk
import "fmt"
type Stage string
const (
StageOnInput Stage = "on_input"
StagePreAction Stage = "pre_action"
StagePostAction Stage = "post_action"
StageBeforeToolcall Stage = "before_toolcall"
StageAfterToolcall Stage = "after_toolcall"
StageBeforeOutput Stage = "before_output"
StageAfterOutput Stage = "after_output"
)
type EventType string
const (
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventSystem EventType = "system"
EventAll EventType = "*"
)
type Event struct {
Type EventType `json:"type"`
Source string `json:"source"`
Payload map[string]interface{} `json:"payload"`
Timestamp int64 `json:"timestamp"`
}
type MemItem struct {
Content string `json:"content"`
Score float64 `json:"score"`
Source string `json:"source"`
}
type StageContext struct {
RawMessage string
UserID string
GroupID string
ContextMsgs []map[string]interface{}
LLMText string
ToolCalls []ToolCall
ToolResults []ToolResult
FinalText string
Response *string
Phase Stage
Memory []MemItem
Extra map[string]interface{}
}
type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
type ToolResult struct {
CallID string `json:"call_id"`
Name string `json:"name"`
Success bool `json:"success"`
Result interface{} `json:"result"`
}
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
type MemoryAPI interface {
Recall(query string, topK int) ([]MemItem, error)
Commit(triples []map[string]string) error
Introspect() (map[string]interface{}, error)
}
type KnowledgeAPI interface {
Search(query string, topK int) ([]MemItem, error)
Create(name, content string) error
List() ([]string, error)
}
type EventHandler func(event *Event)
type StageHandler func(ctx *StageContext) error
type ToolHandler func(args map[string]interface{}) (interface{}, error)
type PluginAPI struct {
Name string
Version string
tools map[string]ToolHandler
stages map[Stage][]StageHandler
events map[EventType][]EventHandler
eventBus EventBus
memAPI MemoryAPI
knowAPI KnowledgeAPI
}
func NewPluginAPI(name, version string, bus EventBus, mem MemoryAPI, know KnowledgeAPI) *PluginAPI {
return &PluginAPI{
Name: name,
Version: version,
tools: make(map[string]ToolHandler),
stages: make(map[Stage][]StageHandler),
events: make(map[EventType][]EventHandler),
eventBus: bus,
memAPI: mem,
knowAPI: know,
}
}
func (p *PluginAPI) RegisterTool(name string, handler ToolHandler) error {
if _, ok := p.tools[name]; ok {
return fmt.Errorf("tool %s already registered by plugin %s", name, p.Name)
}
p.tools[name] = handler
if p.eventBus != nil {
p.eventBus.Publish(&Event{
Type: EventSystem,
Source: p.Name,
Payload: map[string]interface{}{"action": "register_tool", "tool": name},
})
}
return nil
}
func (p *PluginAPI) RegisterStage(stage Stage, handler StageHandler) {
p.stages[stage] = append(p.stages[stage], handler)
}
func (p *PluginAPI) Subscribe(eventType EventType, handler EventHandler) {
p.events[eventType] = append(p.events[eventType], handler)
if p.eventBus != nil {
p.eventBus.Subscribe(eventType, handler)
}
}
func (p *PluginAPI) Publish(evt *Event) {
if p.eventBus != nil {
p.eventBus.Publish(evt)
}
}
func (p *PluginAPI) Tools() map[string]ToolHandler {
return p.tools
}
func (p *PluginAPI) StageHandlers(stage Stage) []StageHandler {
return p.stages[stage]
}
func (p *PluginAPI) Memory() MemoryAPI { return p.memAPI }
func (p *PluginAPI) Knowledge() KnowledgeAPI { return p.knowAPI }
func AllStages() map[Stage]bool {
return map[Stage]bool{
StageOnInput: true,
StagePreAction: true,
StagePostAction: true,
StageBeforeToolcall: true,
StageAfterToolcall: true,
StageBeforeOutput: true,
StageAfterOutput: true,
}
}

View File

@ -0,0 +1,42 @@
package sdk
import "fmt"
type EventBus interface {
Publish(event *Event)
Subscribe(eventType EventType, handler EventHandler) func()
}
type InProcessBus struct {
subs map[EventType][]EventHandler
}
func NewInProcessBus() *InProcessBus {
return &InProcessBus{
subs: make(map[EventType][]EventHandler),
}
}
func (b *InProcessBus) Publish(evt *Event) {
for _, h := range b.subs[EventAll] {
h(evt)
}
if evt.Type != EventAll {
for _, h := range b.subs[evt.Type] {
h(evt)
}
}
}
func (b *InProcessBus) Subscribe(eventType EventType, handler EventHandler) func() {
b.subs[eventType] = append(b.subs[eventType], handler)
return func() {
list := b.subs[eventType]
for i, h := range list {
if fmt.Sprintf("%p", h) == fmt.Sprintf("%p", handler) {
b.subs[eventType] = append(list[:i], list[i+1:]...)
break
}
}
}
}

View File

@ -0,0 +1,133 @@
package sdk
import (
"testing"
)
func TestInProcessBus(t *testing.T) {
bus := NewInProcessBus()
var called bool
bus.Subscribe(EventRawInput, func(evt *Event) {
called = true
if evt.Source != "test" {
t.Errorf("expected source test, got %s", evt.Source)
}
})
bus.Publish(&Event{
Type: EventRawInput,
Source: "test",
})
if !called {
t.Error("handler was not called")
}
}
func TestInProcessBusWildcard(t *testing.T) {
bus := NewInProcessBus()
count := 0
bus.Subscribe(EventAll, func(evt *Event) {
count++
})
bus.Publish(&Event{Type: EventRawInput, Source: "s1"})
bus.Publish(&Event{Type: EventToolCall, Source: "s2"})
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
}
func TestPluginAPI(t *testing.T) {
bus := NewInProcessBus()
api := NewPluginAPI("test", "1.0.0", bus, nil, nil)
if api.Name != "test" {
t.Errorf("expected test, got %s", api.Name)
}
if api.Version != "1.0.0" {
t.Errorf("expected 1.0.0, got %s", api.Version)
}
var stageCalled bool
api.RegisterStage(StageOnInput, func(ctx *StageContext) error {
stageCalled = true
if ctx.RawMessage != "hello" {
t.Errorf("expected hello, got %s", ctx.RawMessage)
}
return nil
})
ctx := &StageContext{RawMessage: "hello"}
for _, handler := range api.StageHandlers(StageOnInput) {
handler(ctx)
}
if !stageCalled {
t.Error("stage handler was not called")
}
}
func TestPluginAPITool(t *testing.T) {
api := NewPluginAPI("test", "1.0.0", nil, nil, nil)
err := api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
if err != nil {
t.Fatalf("register tool: %v", err)
}
if _, ok := api.Tools()["test_tool"]; !ok {
t.Error("tool not found")
}
// duplicate registration should fail
err = api.RegisterTool("test_tool", func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
if err == nil {
t.Error("expected error on duplicate tool registration")
}
}
func TestPluginAPIStageShortCircuit(t *testing.T) {
api := NewPluginAPI("test", "1.0.0", nil, nil, nil)
api.RegisterStage(StageOnInput, func(ctx *StageContext) error {
resp := "intercepted"
ctx.Response = &resp
return nil
})
ctx := &StageContext{RawMessage: "hello"}
handlers := api.StageHandlers(StageOnInput)
if len(handlers) != 1 {
t.Fatalf("expected 1 handler, got %d", len(handlers))
}
handlers[0](ctx)
if ctx.Response == nil || *ctx.Response != "intercepted" {
t.Errorf("expected intercepted, got %v", ctx.Response)
}
}
func TestAllStages(t *testing.T) {
stages := AllStages()
expected := []Stage{
StageOnInput, StagePreAction, StagePostAction,
StageBeforeToolcall, StageAfterToolcall,
StageBeforeOutput, StageAfterOutput,
}
for _, s := range expected {
if !stages[s] {
t.Errorf("missing stage: %s", s)
}
}
if len(stages) != len(expected) {
t.Errorf("expected %d stages, got %d", len(expected), len(stages))
}
}