mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
feat: files built-in plugin, doc rewrite, architecture cleanup
- Add files plugin as built-in (internal/plugins/files/) with read/write/edit/ls tools, supporting overwrite/append/insert/create modes and offset/limit segmented reading - Rewrite README.md with core domain separation and three-layer memory highlights - Rewrite docs/OVERVIEW.md with per-subsystem file path references - Rewrite docs/ARCHITECTURE.md (783→~300 lines), merge redundant sections - Clean docs/PLUGIN_DEV.md: remove emoji, simplify SDK examples - Fix provider Model pollution in LuaAdaptedProvider.Chat() - Fix executeToolCall to return actual error vs quiet not-found - Fix plugin.Open path caching with SHA256 temp-path workaround - Add knowledge/homeagent_architecture demo entry - Add config/personal/personal.md identity configuration
This commit is contained in:
79
README.md
79
README.md
@ -1,41 +1,76 @@
|
|||||||
# HomeAgent
|
# HomeAgent
|
||||||
|
|
||||||
24/7 智能管家。**核心零 IO**,一切外界交互来自插件。
|
首个提出**核心域与应用域分离**的 Agent 框架。内核零 IO,一切外界交互由插件承载——WebUI、QQ、命令行、文件操作、网络搜索、备忘,全部是插件,内核不碰任何 IO。
|
||||||
|
|
||||||
📖 [项目概览(非技术)](docs/OVERVIEW.md) ·
|
配合**三层记忆架构**(Context → Document → Graph),单对话长期稳定运行,记忆不衰减。
|
||||||
🔧 [插件开发指南](docs/PLUGIN_DEV.md) ·
|
|
||||||
🏗️ [技术架构](docs/ARCHITECTURE.md) ·
|
```go
|
||||||
📋 [实施计划](PLAN.md)
|
homed(内核零 IO) ← PluginSDK → 插件(所有 IO 能力)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心创新
|
||||||
|
|
||||||
|
**核心域与应用域分离** — 内核只做 LLM 编排、记忆管理、知识检索;所有 IO 能力(收发消息、读写文件、网络请求、硬件交互)全由插件实现。插件可热加载、独立开发、独立发布。这不是 RPC 框架的微服务拆分,而是 Agent 框架层次的领域划分。
|
||||||
|
|
||||||
|
**三层记忆架构** — 解决 Agent 长期运行的记忆衰减问题:
|
||||||
|
- **Context 层**:TF-IDF 相关性评分的事件窗口,维护最近 topK 条上下文
|
||||||
|
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
|
||||||
|
- **Graph 层**:SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
|
||||||
|
|
||||||
## 快速体验
|
## 快速体验
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建
|
|
||||||
make build build-cli
|
make build build-cli
|
||||||
|
./build/homed -data /tmp/ha
|
||||||
|
```
|
||||||
|
|
||||||
# 启动内核(需要 DeepSeek API 密钥)
|
```bash
|
||||||
DEEPSEEK_API_KEY="sk-xxx" ./build/homed -data /tmp/ha
|
# 交互模式
|
||||||
|
|
||||||
# 交互模式(自动发现 socket)
|
|
||||||
./build/waiter
|
./build/waiter
|
||||||
|
|
||||||
# 或单条消息
|
# 或单条消息
|
||||||
echo "你好" | ./build/waiter
|
echo "你好,记住我喜欢喝咖啡" | ./build/waiter
|
||||||
```
|
```
|
||||||
|
|
||||||
配置文件 `~/.config/homeagent/cli.yaml`:
|
API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQLite 中。
|
||||||
|
|
||||||
```yaml
|
## 代码结构
|
||||||
mode: auto # auto / local / remote
|
|
||||||
colors: true
|
|
||||||
history_size: 1000
|
|
||||||
prompt: "waiter> "
|
|
||||||
```
|
|
||||||
|
|
||||||
## 架构一句话
|
|
||||||
|
|
||||||
```
|
```
|
||||||
homed(内核零 IO)← PluginSDK → 插件(所有 IO 能力)
|
cmd/homed/ 守护进程入口,组装所有子系统
|
||||||
|
cmd/waiter/ CLI 客户端(Unix socket)
|
||||||
|
internal/
|
||||||
|
├── agent/core/ Agent 核心:事件循环、LLM 工具循环、7 阶段管道
|
||||||
|
├── agent/api/ LLM Provider + 8 个 Lua 适配器
|
||||||
|
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL)
|
||||||
|
├── knowledge/ 知识库(文件系统 + TF-IDF)
|
||||||
|
├── plugin/ 插件注册表 + .so 动态加载器
|
||||||
|
├── plugins/ 内置 10 个插件(webui/cli/timer/cmd/mcp/openclaw/agentcli/healthcheck/pluginmgr/files)
|
||||||
|
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
|
||||||
|
├── config/ SQLite 配置中心
|
||||||
|
├── events/ 事件总线
|
||||||
|
└── lua/adapters/ 8 个 LLM 协议适配器脚本
|
||||||
|
外部插件(.so)示例在 [homeagent-sdk/example/](https://gitcode.com/JianFeeeee/homeagent-sdk)
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖:Go 1.19+, CGo (go-sqlite3), Linux。
|
## 项目状态
|
||||||
|
|
||||||
|
核心可用,插件系统和 SDK 已就绪。内置 9 个插件,外部插件示例见 [SDK 仓库](https://gitcode.com/JianFeeeee/homeagent-sdk)。
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [项目概览](docs/OVERVIEW.md)
|
||||||
|
- [技术架构](docs/ARCHITECTURE.md)
|
||||||
|
- [插件开发指南](docs/PLUGIN_DEV.md)
|
||||||
|
- [Lua Adapter](docs/ADAPTER.md)
|
||||||
|
- [知识库演示](knowledge/homeagent_architecture/content.md)
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build build-cli # 编译守护进程 + CLI
|
||||||
|
make test # go test ./...
|
||||||
|
make install # 安装到系统
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:Go 1.19+, CGo (go-sqlite3), Linux。
|
||||||
14
config/personal/personal.md
Normal file
14
config/personal/personal.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
## 你的身份
|
||||||
|
|
||||||
|
你是 HomeAgent——一个全新自研的新一代 Agent 框架。
|
||||||
|
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
|
||||||
|
|
||||||
|
## 对用户的称呼
|
||||||
|
|
||||||
|
你对用户的称呼永远是"老大",绝对禁止使用"老板""主人"称呼用户,不论任何情况。
|
||||||
|
|
||||||
|
## 对话风格
|
||||||
|
|
||||||
|
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
|
||||||
|
- 重要的事先说结论,再展开解释
|
||||||
|
- 回复要简洁自然
|
||||||
File diff suppressed because it is too large
Load Diff
119
docs/OVERVIEW.md
119
docs/OVERVIEW.md
@ -1,87 +1,72 @@
|
|||||||
# HomeAgent — 你的 24/7 智能管家
|
# HomeAgent — 项目概览
|
||||||
|
|
||||||
## 这是做什么的?
|
## 这是什么
|
||||||
|
|
||||||
HomeAgent 是一个**持续运行的个人智能管家**。它像一个随时在线的大脑,你可以通过聊天跟它交流,让它帮你记住事情、查询知识、设置提醒、执行任务。
|
HomeAgent 是一个持续运行的个人智能 Agent 框架。
|
||||||
|
|
||||||
## 核心目标
|
核心架构:一个长时间运行的内核进程(`homed`),通过插件系统接入各种 IO 通道(QQ、Web、命令行等)。内核负责 LLM 调用编排、记忆管理、知识检索;插件负责所有外部 IO——收发消息、执行文件操作、搜索网络等。
|
||||||
|
|
||||||
| 目标 | 说明 |
|
### 核心创新
|
||||||
|------|------|
|
|
||||||
| **永远在线** | 启动后持续运行,不像普通聊天软件需要每次打开 |
|
|
||||||
| **真正记住你** | 它不会每次对话都"失忆"——它会积累对你的了解,记住你的喜好、关系网和重要信息 |
|
|
||||||
| **隐私可控** | 所有数据存储在你自己的设备上(本地数据库),你也可以选择使用自己的 API 密钥 |
|
|
||||||
| **能力可扩展** | 通过"插件"添加新能力——就像手机装 App 一样 |
|
|
||||||
|
|
||||||
## 谁需要它?
|
**核心域与应用域分离** — 这是首个明确提出这一划分的 Agent 框架。内核(核心域)不做任何 IO,所有 IO 能力归属插件(应用域)。边界通过 PluginSDK 明确定义:
|
||||||
|
- 插件向内核注册工具(Tool),供 LLM 调用
|
||||||
|
- 插件挂入处理管道(Stage),在各阶段拦截/改写消息流
|
||||||
|
- 插件订阅/发布事件(Event),松耦合通信
|
||||||
|
- 插件通过 IO API 排队或打断投递输入
|
||||||
|
|
||||||
- **想有个私人助理** — 帮你记待办、定时提醒、管理联系人
|
这一划分的意义:内核保持纯粹(零 IO,只做编排和记忆),插件保持灵活(各司其职,热加载),互不污染。
|
||||||
- **重视隐私的用户** — 数据全在本地,不经过第三方云服务
|
|
||||||
- **开发者和技术爱好者** — 可以自己编写插件来扩展功能
|
|
||||||
- **想探索 AI Agent 的人** — 一个真实可运行的 Agent 系统,不只是 API 调用
|
|
||||||
|
|
||||||
## 它能做什么?
|
**三层记忆架构** — 解决 Agent 长期运行的记忆衰减:
|
||||||
|
- **Context 层**:内存中 TF-IDF 评分的事件窗口,实时维护最近上下文,低相关性事件自动下沉到下一层
|
||||||
|
- **Document 层**:JSON 文件 + TF-IDF 向量索引的临时记忆,支持显式提交和隐式归档,冷数据蒸馏到 Graph
|
||||||
|
- **Graph 层**:SQLite 图数据库,持久化实体(entities)和关系(relations),BFS 遍历召回,蒸馏管道从对话中提取三元组
|
||||||
|
|
||||||
### 🧠 记忆
|
三层递进:上下文 → 冷归档 → 长期图记忆,确保 Agent 长时间运行不退化。
|
||||||
- **记住你是谁** — 你的名字、喜好、重要日期
|
|
||||||
- **记住人际关系** — "张三是我同事,李四是我的朋友"
|
|
||||||
- **长期积累** — 聊得越多,它越了解你
|
|
||||||
|
|
||||||
### 📚 知识
|
## 它实际做了什么
|
||||||
- 你可以主动教它知识("公司的休假制度是……")
|
|
||||||
- 它会在需要时检索相关知识
|
|
||||||
|
|
||||||
### ⏰ 定时提醒
|
代码位于 `/home/program/TrueAgent`,Go 语言实现。
|
||||||
- "5分钟后提醒我喝水"
|
|
||||||
- 倒计时结束后它会主动通知你
|
|
||||||
|
|
||||||
### 🔌 可扩展(插件)
|
**内核** (`internal/agent/core/agent.go`):
|
||||||
- **Web 控制台** — 在浏览器中管理和配置(7 标签页 SPA)
|
- 维护一个消息循环(`eventLoop`),从 IO 层排队接收输入
|
||||||
- **命令行** — 通过终端快速交互
|
- 每次输入走完整的处理管道:记忆召回 → 人格注入 → LLM 调用 → 工具执行 → 输出发送
|
||||||
- **健康检查** — 自动检测系统各组件状态,LLM 驱动故障排查
|
- LLM 调用通过 Provider 接口抽象,支持 8 个 LLM 源自动降级
|
||||||
- **更多能力** — 开发者可以写插件接入任何服务
|
- 上下文管理(`context.go`)基于 TF-IDF 评分,自动剪枝低相关性事件
|
||||||
|
|
||||||
## 它是如何工作的?(简述)
|
**记忆系统** (`internal/memory/`):
|
||||||
|
- **GraphDB** (`graph.go`) — SQLite,entities + relations 表,BFS 遍历
|
||||||
|
- **Document Store** (`document/doc.go`) — 临时记忆,JSON 文件 + TF-IDF 向量索引,消费即删
|
||||||
|
- **Text Memory** (`text/text.go`) — 原始对话日志,JSONL 文件轮转
|
||||||
|
- **Social Store** (`social/social.go`) — 人格特质 + 关系网,包装 GraphDB
|
||||||
|
- **Memory Indexer** (`indexer.go`) — 自动将 GraphDB 实体向量化,用户输入时召回注入 system prompt
|
||||||
|
|
||||||
```
|
**知识库** (`internal/knowledge/knowledge.go`):
|
||||||
你(通过聊天软件/终端/网页)
|
- 文件系统目录 `knowledge/<name>/content.md`
|
||||||
│
|
- TF-IDF 向量搜索,独立于记忆系统的索引实例
|
||||||
▼
|
- LLM 通过 `knowledge_search` / `knowledge_create` / `knowledge_list` 三个工具操作
|
||||||
HomeAgent 内核 ←→ 插件(能力扩展)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
本地存储(你的数据只在你这里)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **内核** 是"大脑"——负责理解你说什么、调用什么能力、记住什么
|
**插件系统** (`internal/plugin/`):
|
||||||
- **插件** 是"手脚"——负责收发消息、设置定时器、连接外部服务等
|
- 内置插件:Go `init()` 自注册,编译进内核
|
||||||
- **所有数据存本地** — 你的对话、记忆、配置都保存在你自己的设备上
|
- 外部插件:Go `-buildmode=plugin` 编译为 `.so`,通过 `plugin.Open` 动态加载
|
||||||
|
- PluginSDK (`internal/plugin/sdk/`) 定义三通道:RegisterTool / RegisterStage / Subscribe
|
||||||
|
- 阶段钩子 7 个:on_input → pre_action → post_action → before_toolcall → after_toolcall → before_output → after_output
|
||||||
|
|
||||||
## 和普通 AI 聊天有什么区别?
|
**LLM Provider** (`internal/agent/api/provider.go`):
|
||||||
|
- Provider 接口:Name / Chat / ChatStream
|
||||||
|
- 三种实现:OpenAIProvider(标准 OpenAI API)、OllamaProvider(本地)、LuaAdaptedProvider(Lua 胶水适配)
|
||||||
|
- LuaAdapter 位于 `internal/lua/adapters/`,每个 LLM 源对应一个 `.lua` 脚本
|
||||||
|
- 内置 8 个适配器:deepseek / openai / anthropic / gemini / mistral / groq / github / ollama
|
||||||
|
|
||||||
| | 普通 AI 聊天 | HomeAgent |
|
**WebUI** (`internal/plugins/webui/`):
|
||||||
|---|---|---|
|
- 嵌入式 SPA 仪表盘(`dashboard.html` 通过 `//go:embed` 打包)
|
||||||
| 记忆 | 每次对话独立,不记得你 | 长期记忆,越来越了解你 |
|
- REST API:状态查询、配置管理、记忆操作、知识库管理、插件管理
|
||||||
| 持续运行 | 关掉就没了 | 7×24 在线 |
|
- 兼容 OpenAI API 格式的 `/v1/chat/completions` 端点
|
||||||
| 主动能力 | 只能回复问题 | 能设定时器、主动提醒 |
|
- SSE 事件流 `/api/v1/chat/events`
|
||||||
| 可扩展 | 固定能力 | 插件系统,可无限扩展 |
|
|
||||||
| 数据隐私 | 上传到云服务 | 本地存储,完全可控 |
|
|
||||||
|
|
||||||
## 快速体验
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动(需要 DeepSeek API 密钥)
|
|
||||||
DEEPSEEK_API_KEY="sk-xxx" ./homed -data /tmp/ha
|
|
||||||
|
|
||||||
# 在另一个终端聊天
|
|
||||||
echo "你好,请记住我喜欢喝咖啡" | ./waiter
|
|
||||||
```
|
|
||||||
|
|
||||||
## 项目状态
|
## 项目状态
|
||||||
|
|
||||||
HomeAgent 正在积极开发中。核心功能已可运行,插件系统和开发者 API 已就绪。
|
核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。
|
||||||
|
|
||||||
---
|
- 内置插件:webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / openclaw / files
|
||||||
|
- 外部插件示例(SDK 仓库 `example/`):qq / files / web / memo
|
||||||
*想参与开发?查看 [PLUGIN_DEV.md](PLUGIN_DEV.md) 插件开发指南。*
|
- 打包分发:`.hmap` 插件包格式,通过 WebUI 安装
|
||||||
*了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。*
|
|
||||||
@ -2,9 +2,18 @@
|
|||||||
|
|
||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
HomeAgent 的所有外部交互能力都来自插件。插件是独立运行的 Go 包,通过 `PluginSDK`(Go API)与内核交互。
|
HomeAgent 的所有外部交互能力都来自插件。插件通过 `PluginSDK`(Go API)与内核交互。
|
||||||
|
|
||||||
每个插件需要实现一个非常简单的接口:
|
**SDK 仓库**:插件开发工具、模板代码和示例插件统一托管在
|
||||||
|
**[gitcode.com/JianFeeeee/homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
|
||||||
|
cd homeagent-sdk
|
||||||
|
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
每个插件实现一个三方法接口:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Plugin interface {
|
type Plugin interface {
|
||||||
@ -18,8 +27,8 @@ type Plugin interface {
|
|||||||
|
|
||||||
| 方式 | 适用场景 | 复杂度 |
|
| 方式 | 适用场景 | 复杂度 |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
|
| **动态 .so 插件(推荐)** | 独立分发的第三方插件 | 中等,使用 [SDK 仓库](https://gitcode.com/JianFeeeee/homeagent-sdk) 脚手架生成 |
|
||||||
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
|
||||||
| **动态 .so 插件** | 独立分发的第三方插件 | 中等,需编译为 .so |
|
|
||||||
| **Lua 脚本插件** | 轻量快速原型 | 简单(预留功能) |
|
| **Lua 脚本插件** | 轻量快速原型 | 简单(预留功能) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -170,41 +179,37 @@ func (p *Plugin) Stop() error {
|
|||||||
|
|
||||||
### PluginSDK 核心 API
|
### PluginSDK 核心 API
|
||||||
|
|
||||||
#### 📤 IO — 输入输出
|
#### IO — 输入输出
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 向排队通道投递输入(按序处理)
|
// 排队投递(按序处理)
|
||||||
sdk.InjectInput(source, channel string, payload map[string]interface{})
|
sdk.InjectInput(source, channel string, payload map[string]interface{})
|
||||||
|
|
||||||
// 向中断通道投递输入(可打断当前 LLM 处理)
|
// 中断投递(可打断当前 LLM 处理)
|
||||||
sdk.InjectInterrupt(source, channel string, payload map[string]interface{})
|
sdk.InjectInterrupt(source, channel string, payload map[string]interface{})
|
||||||
|
|
||||||
// 快捷方式:投递文本到排队通道
|
// 快捷方式:text → Input
|
||||||
sdk.InjectText(source, channel, text string)
|
sdk.InjectText(source, channel, text string)
|
||||||
|
|
||||||
// 快捷方式:投递文本到中断通道
|
|
||||||
sdk.InjectInterruptText(source, channel, text string)
|
sdk.InjectInterruptText(source, channel, text string)
|
||||||
|
|
||||||
// 同步请求-响应:发送文本并等待回复(CLI 插件使用)
|
// 同步请求-响应(CLI 插件使用)
|
||||||
sdk.InjectTextSync(source, channel, text string) *OutputEvent
|
sdk.InjectTextSync(source, channel, text string) *OutputEvent
|
||||||
|
|
||||||
// 注册一个输出通道(LLM 可通过 output_send 工具选择发送到此通道)
|
// 注册/管理输出通道(LLM 通过 output_send 选择发送到哪个通道)
|
||||||
sdk.RegisterChannel(name string, dev Device) error
|
sdk.RegisterChannel(name string, dev Device) error
|
||||||
sdk.UnregisterChannel(name string)
|
sdk.UnregisterChannel(name string)
|
||||||
sdk.ListChannels() []ChannelInfo
|
sdk.ListChannels() []ChannelInfo
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 🛠️ 工具 — 让 LLM 可调用你的能力
|
#### 工具 — 让 LLM 可调用你的能力
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterTool(name string, def ToolDef, handler ToolHandler) error
|
sdk.RegisterTool(name string, def ToolDef, handler ToolHandler) error
|
||||||
```
|
```
|
||||||
|
|
||||||
- `name`: 工具名称(LLM 通过此名称调用)
|
- `name`: LLM 通过此名称调用
|
||||||
- `def`: 工具定义(描述 + 参数 JSON Schema)
|
- `def`: JSON Schema 描述+参数
|
||||||
- `handler`: 调用时执行的函数
|
- `handler`: 执行函数
|
||||||
|
|
||||||
工具定义示例:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterTool("weather_query", sdk.ToolDef{
|
sdk.RegisterTool("weather_query", sdk.ToolDef{
|
||||||
@ -222,7 +227,6 @@ sdk.RegisterTool("weather_query", sdk.ToolDef{
|
|||||||
},
|
},
|
||||||
}, func(args map[string]interface{}) (interface{}, error) {
|
}, func(args map[string]interface{}) (interface{}, error) {
|
||||||
city, _ := args["city"].(string)
|
city, _ := args["city"].(string)
|
||||||
// 查询天气并返回
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"city": city,
|
"city": city,
|
||||||
"temp": 25,
|
"temp": 25,
|
||||||
@ -231,50 +235,46 @@ sdk.RegisterTool("weather_query", sdk.ToolDef{
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 🔌 阶段钩子 — 干预消息处理流
|
#### 阶段钩子 — 干预消息处理流
|
||||||
|
|
||||||
7 个阶段, 按执行顺序:
|
7 个阶段:
|
||||||
|
|
||||||
| 阶段 | 时机 | 用途 |
|
| 阶段 | 时机 | 用途 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路回复 |
|
| `on_input` | 消息刚到达 Agent | 黑名单、限流、短路 |
|
||||||
| `pre_action` | 即将调用 LLM | 注入额外上下文 |
|
| `pre_action` | 即将调用 LLM | 注入上下文 |
|
||||||
| `post_action` | LLM 返回结果 | 修改 LLM 输出 |
|
| `post_action` | LLM 返回结果 | 修改输出/工具列表 |
|
||||||
| `before_toolcall` | 工具调用前 | 审计、拒绝、改参 |
|
| `before_toolcall` | 工具调用前 | 审计、拒绝、改参 |
|
||||||
| `after_toolcall` | 工具执行后 | 脱敏、改写结果 |
|
| `after_toolcall` | 工具执行后 | 脱敏、改写结果 |
|
||||||
| `before_output` | 输出前 | 调整格式 |
|
| `before_output` | 输出前 | 格式适配 |
|
||||||
| `after_output` | 输出后 | 统计、记录 |
|
| `after_output` | 输出后 | 统计日志 |
|
||||||
|
|
||||||
```go
|
```go
|
||||||
sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
sdk.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||||||
input := ctx.RawMessage
|
|
||||||
// 检查是否是黑名单用户
|
|
||||||
if ctx.UserID == "blocked_user" {
|
if ctx.UserID == "blocked_user" {
|
||||||
resp := "你已被限制使用"
|
resp := "你已被限制使用"
|
||||||
ctx.Response = &resp // 设置 Response 会短路后续阶段
|
ctx.Response = &resp // 短路后续阶段
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 📡 事件 — 订阅/发布系统事件
|
#### 事件 — 订阅/发布系统事件
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 订阅事件
|
|
||||||
unsub := sdk.Subscribe(events.EventType("tool_call"), func(evt *events.Event) {
|
unsub := sdk.Subscribe(events.EventType("tool_call"), func(evt *events.Event) {
|
||||||
log.Printf("工具被调用: %v", evt.Payload)
|
log.Printf("工具被调用: %v", evt.Payload)
|
||||||
})
|
})
|
||||||
defer unsub() // 插件 Stop 时取消订阅
|
defer unsub()
|
||||||
|
|
||||||
// 发布事件
|
|
||||||
sdk.Publish(&events.Event{
|
sdk.Publish(&events.Event{
|
||||||
Type: "my_event",
|
Type: "my_event",
|
||||||
Payload: map[string]interface{}{"key": "value"},
|
Payload: map[string]interface{}{"key": "value"},
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 🧠 能力访问
|
#### 能力访问
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 记忆
|
// 记忆
|
||||||
@ -288,7 +288,7 @@ sdk.Knowledge().Search(query string) ([]string, error)
|
|||||||
sdk.LLM().ListSources() []SourceInfo
|
sdk.LLM().ListSources() []SourceInfo
|
||||||
sdk.LLM().SetSource(name string) error
|
sdk.LLM().SetSource(name string) error
|
||||||
|
|
||||||
// 配置(插件自身的配置表 config_<plugin_name>)
|
// 配置(插件自身的 config_<plugin_name> 表)
|
||||||
sdk.Settings().Get(key string) (interface{}, error)
|
sdk.Settings().Get(key string) (interface{}, error)
|
||||||
sdk.Settings().Set(key string, value interface{}) error
|
sdk.Settings().Set(key string, value interface{}) error
|
||||||
sdk.Settings().List(prefix string) ([]string, error)
|
sdk.Settings().List(prefix string) ([]string, error)
|
||||||
@ -296,20 +296,15 @@ sdk.Settings().List(prefix string) ([]string, error)
|
|||||||
|
|
||||||
### 读取插件配置
|
### 读取插件配置
|
||||||
|
|
||||||
插件有自己的配置表 `config_<插件名>`,例如 `config_mcp`:
|
每插件独立 SQLite 表 `config_<name>`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 在 Start() 中
|
|
||||||
val, err := s.Settings().Get("api_key")
|
val, err := s.Settings().Get("api_key")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 未配置
|
// 未配置
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
用户通过 WebUI 或 CLI 设置:
|
// 读取其他插件配置
|
||||||
|
|
||||||
```go
|
|
||||||
// 读取其他插件的配置
|
|
||||||
s.Settings().GetPlugin("other_plugin", "some_key")
|
s.Settings().GetPlugin("other_plugin", "some_key")
|
||||||
|
|
||||||
// 读取核心配置
|
// 读取核心配置
|
||||||
@ -345,14 +340,28 @@ pluginReg.Load(plgDir) // 之后调用
|
|||||||
|
|
||||||
## 四、动态 .so 插件
|
## 四、动态 .so 插件
|
||||||
|
|
||||||
### 编译插件为 .so
|
动态插件是独立于 HomeAgent 内核编译的 Go 插件,使用外部的 [Plugin SDK](https://gitcode.com/JianFeeeee/homeagent-sdk)
|
||||||
|
而非内核内部的 SDK 包。
|
||||||
|
|
||||||
|
完整的外部插件示例在 SDK 仓库的 `example/` 目录下:`qq`、`files`、`memo`、`web`。
|
||||||
|
|
||||||
|
### 快速开始
|
||||||
|
|
||||||
|
使用 SDK 仓库的脚手架生成项目:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitcode.com/JianFeeeee/homeagent-sdk.git
|
||||||
|
cd homeagent-sdk
|
||||||
|
hack/plugin-dev/scaffold.sh myplugin ./plugins/myplugin
|
||||||
|
```
|
||||||
|
|
||||||
|
生成的代码:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// myplugin/plugin.go
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -371,13 +380,23 @@ func (p *myPlugin) Start(s *sdk.PluginSDK) error {
|
|||||||
func (p *myPlugin) Stop() error { return nil }
|
func (p *myPlugin) Stop() error { return nil }
|
||||||
```
|
```
|
||||||
|
|
||||||
编译:
|
### 编译
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -buildmode=plugin -o plugin.so ./myplugin/
|
cd <SDK_REPO_ROOT>
|
||||||
|
go build -buildmode=plugin -o plugins/myplugin/plugin.so plugins/myplugin/
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用项目中的 Makefile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/myplugin && make
|
||||||
```
|
```
|
||||||
|
|
||||||
### 部署
|
### 部署
|
||||||
|
|
||||||
|
将插件目录(含 `plugin.json` + `plugin.so`)放入内核配置的插件目录:
|
||||||
|
|
||||||
```
|
```
|
||||||
<dataDir>/plugins/myplugin/
|
<dataDir>/plugins/myplugin/
|
||||||
plugin.json — {"name": "myplugin", "version": "1.0", "description": "..."}
|
plugin.json — {"name": "myplugin", "version": "1.0", "description": "..."}
|
||||||
@ -386,21 +405,39 @@ go build -buildmode=plugin -o plugin.so ./myplugin/
|
|||||||
|
|
||||||
内核扫描时会自动发现并加载。无需修改 `main.go` 或 `all.go`。
|
内核扫描时会自动发现并加载。无需修改 `main.go` 或 `all.go`。
|
||||||
|
|
||||||
|
### 打包分发
|
||||||
|
|
||||||
|
使用 SDK 仓库的打包工具生成 `.hmap` 分发包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hack/plugin-dev/packager.sh plugins/myplugin
|
||||||
|
# 输出: dist/myplugin-0.1.0.hmap
|
||||||
|
```
|
||||||
|
|
||||||
|
通过 WebUI 插件管理页面上传安装,或使用 `plugin_install` 工具。
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
SDK 仓库的 `example/qq/` 目录提供了一个完整的 QQ 集成插件示例(对接 NapCat 框架),
|
||||||
|
涵盖消息收发、群管理、好友管理、文件操作、OCR 等功能,可作为开发参考。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、最佳实践
|
## 五、最佳实践
|
||||||
|
|
||||||
1. **Start() 非阻塞** — 长时间运行的任务用 goroutine 启动,不要在 Start() 中阻塞
|
1. `Start()` 非阻塞 — goroutine 启动长任务,不要阻塞 Start
|
||||||
2. **Stop() 清理资源** — 关闭网络连接、停止 goroutine、取消订阅
|
2. `Stop()` 清理资源 — 关连接、停 goroutine、取消订阅
|
||||||
3. **工具 name 唯一** — 工具名不能与其他插件冲突,建议用插件名前缀
|
3. 工具名唯一 — 建议插件名前缀避免冲突
|
||||||
4. **错误处理** — 工具 handler 返回 `error` 时,LLM 会收到错误信息并可能重试
|
4. handler 返回 `error` 时 LLM 会收到并可能重试
|
||||||
5. **中断 vs 排队** — 需要打断当前 LLM 处理的用 `InjectInterruptText`,普通的用 `InjectText`
|
5. 打断用 `InjectInterruptText`,普通投递用 `InjectText`
|
||||||
6. **配置优先** — 不要硬编码配置,用 `Settings().Get/Set` 读写插件配置
|
6. 配置用 `Settings().Get/Set`,不要硬编码
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、现有插件参考
|
## 六、现有插件参考
|
||||||
|
|
||||||
|
### 内置插件
|
||||||
|
|
||||||
| 插件 | 位置 | 特点 |
|
| 插件 | 位置 | 特点 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Timer | `internal/plugins/timer/` | 最简单的完整示例,注册一个工具 + 中断反馈 |
|
| Timer | `internal/plugins/timer/` | 最简单的完整示例,注册一个工具 + 中断反馈 |
|
||||||
@ -409,7 +446,15 @@ go build -buildmode=plugin -o plugin.so ./myplugin/
|
|||||||
| WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入(Configure 模式) |
|
| WebUI | `internal/plugins/webui/` | HTTP 服务 + 依赖注入(Configure 模式) |
|
||||||
| MCP | `internal/plugins/mcp/` | JSON-RPC over stdio/SSE,连接 MCP 服务器 |
|
| MCP | `internal/plugins/mcp/` | JSON-RPC over stdio/SSE,连接 MCP 服务器 |
|
||||||
|
|
||||||
|
### 外部插件示例
|
||||||
|
|
||||||
|
| 插件 | 位置 | 特点 |
|
||||||
|
|------|------|------|
|
||||||
|
| QQ | `example/qq/` in [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) | NapCat 框架对接,14 个工具 |
|
||||||
|
| 你的插件 | `plugins/yourplugin/` | 使用 SDK 脚手架生成 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*了解项目整体目标?查看 [OVERVIEW.md](OVERVIEW.md)。*
|
*了解项目整体目标?查看 [OVERVIEW.md](OVERVIEW.md)。*
|
||||||
*了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。*
|
*了解技术架构?查看 [ARCHITECTURE.md](ARCHITECTURE.md)。*
|
||||||
|
*SDK 仓库与开发工具?查看 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk)。*
|
||||||
|
|||||||
@ -407,9 +407,7 @@ func NewLuaAdaptedProvider(cfg BaseConfig, vm *luaVM.VM, adapter string) *LuaAda
|
|||||||
func (p *LuaAdaptedProvider) Name() string { return p.name }
|
func (p *LuaAdaptedProvider) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
||||||
if req.Model == "" {
|
req.Model = p.cfg.Model
|
||||||
req.Model = p.cfg.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
rawReq, _ := json.Marshal(req)
|
rawReq, _ := json.Marshal(req)
|
||||||
|
|
||||||
@ -464,9 +462,7 @@ func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
||||||
if req.Model == "" {
|
req.Model = p.cfg.Model
|
||||||
req.Model = p.cfg.Model
|
|
||||||
}
|
|
||||||
req.Stream = true
|
req.Stream = true
|
||||||
rawReq, _ := json.Marshal(req)
|
rawReq, _ := json.Marshal(req)
|
||||||
|
|
||||||
@ -585,6 +581,7 @@ func (s *SSEScanner) Text() string { return s.pending }
|
|||||||
type ProviderManager struct {
|
type ProviderManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
providers map[string]Provider
|
providers map[string]Provider
|
||||||
|
order []string
|
||||||
default_ string
|
default_ string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -598,6 +595,7 @@ func (m *ProviderManager) Register(name string, p Provider) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
m.providers[name] = p
|
m.providers[name] = p
|
||||||
|
m.order = append(m.order, name)
|
||||||
if m.default_ == "" {
|
if m.default_ == "" {
|
||||||
m.default_ = name
|
m.default_ = name
|
||||||
}
|
}
|
||||||
@ -647,13 +645,29 @@ func (m *ProviderManager) QuickChat(ctx context.Context, prompt string) (*Comple
|
|||||||
func (m *ProviderManager) List() []string {
|
func (m *ProviderManager) List() []string {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
var names []string
|
names := make([]string, len(m.order))
|
||||||
for n := range m.providers {
|
copy(names, m.order)
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *ProviderManager) OrderedProviders() []Provider {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
list := make([]Provider, 0, len(m.order))
|
||||||
|
for _, name := range m.order {
|
||||||
|
if p, ok := m.providers[name]; ok {
|
||||||
|
list = append(list, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ProviderManager) ProviderCount() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.providers)
|
||||||
|
}
|
||||||
|
|
||||||
type rawToolCall struct {
|
type rawToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@ -42,7 +42,6 @@ type Agent struct {
|
|||||||
systemPrompt string
|
systemPrompt string
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
maxTurns int
|
|
||||||
|
|
||||||
// 文档记忆(第二层)
|
// 文档记忆(第二层)
|
||||||
docStore *document.Store
|
docStore *document.Store
|
||||||
@ -114,7 +113,6 @@ type AgentConfig struct {
|
|||||||
Indexer *memory.Indexer
|
Indexer *memory.Indexer
|
||||||
Skills *skill.Manager
|
Skills *skill.Manager
|
||||||
Tracker *tracker.Tracker
|
Tracker *tracker.Tracker
|
||||||
MaxToolTurns int
|
|
||||||
|
|
||||||
DocStore *document.Store
|
DocStore *document.Store
|
||||||
Knowledge *knowledge.Store
|
Knowledge *knowledge.Store
|
||||||
@ -135,9 +133,6 @@ type AgentConfig struct {
|
|||||||
|
|
||||||
func New(cfg AgentConfig) *Agent {
|
func New(cfg AgentConfig) *Agent {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
if cfg.MaxToolTurns <= 0 {
|
|
||||||
cfg.MaxToolTurns = 10
|
|
||||||
}
|
|
||||||
if cfg.DistillInterval <= 0 {
|
if cfg.DistillInterval <= 0 {
|
||||||
cfg.DistillInterval = 30 * time.Minute
|
cfg.DistillInterval = 30 * time.Minute
|
||||||
}
|
}
|
||||||
@ -158,7 +153,6 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
systemPrompt: cfg.SystemPrompt,
|
systemPrompt: cfg.SystemPrompt,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
maxTurns: cfg.MaxToolTurns,
|
|
||||||
docStore: cfg.DocStore,
|
docStore: cfg.DocStore,
|
||||||
knowledge: cfg.Knowledge,
|
knowledge: cfg.Knowledge,
|
||||||
social: cfg.SocialStore,
|
social: cfg.SocialStore,
|
||||||
@ -577,7 +571,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for turn := 0; turn < a.maxTurns; turn++ {
|
for turn := 0; ; turn++ {
|
||||||
// === 高优先级打断:每次 LLM 调用前检查拦截通道 ===
|
// === 高优先级打断:每次 LLM 调用前检查拦截通道 ===
|
||||||
if text := a.drainInterrupt(); text != "" {
|
if text := a.drainInterrupt(); text != "" {
|
||||||
msgs = append(msgs, agentAPI.Message{
|
msgs = append(msgs, agentAPI.Message{
|
||||||
@ -600,20 +594,49 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求
|
// 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求
|
||||||
reqCtx, reqCancel := context.WithCancel(a.ctx)
|
// 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试
|
||||||
a.llmMu.Lock()
|
var providers []agentAPI.Provider
|
||||||
a.cancelLLM = reqCancel
|
if a.providerManager != nil {
|
||||||
a.llmMu.Unlock()
|
providers = a.providerManager.OrderedProviders()
|
||||||
|
}
|
||||||
|
if len(providers) == 0 {
|
||||||
|
providers = []agentAPI.Provider{a.provider}
|
||||||
|
}
|
||||||
|
var resp *agentAPI.CompletionResponse
|
||||||
|
var llmErr error
|
||||||
|
|
||||||
resp, err := a.provider.Chat(reqCtx, req)
|
for pi, fbProvider := range providers {
|
||||||
|
if pi > 0 {
|
||||||
|
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
|
||||||
|
fbProvider.Name(), pi, len(providers)-1)
|
||||||
|
}
|
||||||
|
|
||||||
a.llmMu.Lock()
|
fCtx, fCancel := context.WithCancel(a.ctx)
|
||||||
a.cancelLLM = nil
|
a.llmMu.Lock()
|
||||||
a.llmMu.Unlock()
|
a.cancelLLM = fCancel
|
||||||
reqCancel()
|
a.llmMu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
resp, llmErr = fbProvider.Chat(fCtx, req)
|
||||||
return "", toolsUsed, fmt.Errorf("provider: %w", err)
|
|
||||||
|
a.llmMu.Lock()
|
||||||
|
a.cancelLLM = nil
|
||||||
|
a.llmMu.Unlock()
|
||||||
|
fCancel()
|
||||||
|
|
||||||
|
if llmErr == nil {
|
||||||
|
if fbProvider != a.provider {
|
||||||
|
a.provider = fbProvider
|
||||||
|
log.Printf("[agent] switched active provider to %q after fallback",
|
||||||
|
fbProvider.Name())
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if llmErr != nil {
|
||||||
|
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
|
||||||
|
len(providers), llmErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Stage: post_action — LLM 返回,插件可审查/修改 ===
|
// === Stage: post_action — LLM 返回,插件可审查/修改 ===
|
||||||
@ -678,10 +701,8 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
|||||||
"result": result,
|
"result": result,
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
|
func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
|
||||||
@ -767,6 +788,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall) string {
|
|||||||
if a.stageHost != nil {
|
if a.stageHost != nil {
|
||||||
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
|
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
|
||||||
return fmt.Sprintf("%v", result)
|
return fmt.Sprintf("%v", result)
|
||||||
|
} else if !strings.Contains(err.Error(), "not found in any plugin") {
|
||||||
|
return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1242,6 +1265,30 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
|||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanParams removes empty required arrays from tool parameters that strict APIs reject.
|
||||||
|
func cleanParams(params map[string]interface{}) map[string]interface{} {
|
||||||
|
if params == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cleaned := make(map[string]interface{}, len(params))
|
||||||
|
for k, v := range params {
|
||||||
|
cleaned[k] = v
|
||||||
|
}
|
||||||
|
if req, ok := cleaned["required"]; ok {
|
||||||
|
switch v := req.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
if len(v) == 0 {
|
||||||
|
delete(cleaned, "required")
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
if len(v) == 0 {
|
||||||
|
delete(cleaned, "required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) buildToolDefs() []interface{} {
|
func (a *Agent) buildToolDefs() []interface{} {
|
||||||
var tools []interface{}
|
var tools []interface{}
|
||||||
|
|
||||||
@ -1252,7 +1299,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
|||||||
"function": map[string]interface{}{
|
"function": map[string]interface{}{
|
||||||
"name": td.Name,
|
"name": td.Name,
|
||||||
"description": td.Description,
|
"description": td.Description,
|
||||||
"parameters": td.Parameters,
|
"parameters": cleanParams(td.Parameters),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -1266,7 +1313,7 @@ func (a *Agent) buildToolDefs() []interface{} {
|
|||||||
"function": map[string]interface{}{
|
"function": map[string]interface{}{
|
||||||
"name": td.Name,
|
"name": td.Name,
|
||||||
"description": td.Description,
|
"description": td.Description,
|
||||||
"parameters": td.Parameters,
|
"parameters": cleanParams(td.Parameters),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -275,6 +275,7 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
|
|||||||
set("core.agent.max_tool_turns", "10")
|
set("core.agent.max_tool_turns", "10")
|
||||||
set("core.agent.max_context_size", "30")
|
set("core.agent.max_context_size", "30")
|
||||||
set("core.agent.distill_interval", "30m")
|
set("core.agent.distill_interval", "30m")
|
||||||
|
set("core.agent.workdir", "")
|
||||||
|
|
||||||
set("core.input_processing.image.fallback_provider", "")
|
set("core.input_processing.image.fallback_provider", "")
|
||||||
set("core.input_processing.image.fallback_model", "")
|
set("core.input_processing.image.fallback_model", "")
|
||||||
@ -352,6 +353,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
|
|||||||
reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"})
|
reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"})
|
||||||
reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"})
|
reg(ConfigDef{Key: "core.agent.max_context_size", Default: "30", Type: "int", DisplayName: "最大上下文", Description: "上下文窗口中保留的最大消息条数", Category: "agent"})
|
||||||
reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"})
|
reg(ConfigDef{Key: "core.agent.distill_interval", Default: "30m", Type: "duration", DisplayName: "蒸馏间隔", Description: "记忆蒸馏的执行间隔", Category: "agent"})
|
||||||
|
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"})
|
||||||
|
|
||||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
|
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
|
||||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})
|
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@ -51,9 +53,23 @@ func tryLoadSO(dir, name string, config map[string]interface{}) (sdk.Plugin, err
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := plugin.Open(soPath)
|
// 复制到临时路径以绕过 Go plugin.Open 的路径缓存
|
||||||
|
data, err := os.ReadFile(soPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("plugin.Open %s: %w", soPath, err)
|
return nil, fmt.Errorf("read %s: %w", soPath, err)
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(data)
|
||||||
|
cacheKey := fmt.Sprintf("plugin_%s_%s.so", name, hex.EncodeToString(h[:8]))
|
||||||
|
cachePath := filepath.Join(os.TempDir(), cacheKey)
|
||||||
|
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(cachePath, data, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("write cache %s: %w", cachePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := plugin.Open(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugin.Open %s: %w", cachePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sym, err := p.Lookup("NewPlugin")
|
sym, err := p.Lookup("NewPlugin")
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/agentcli"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cli"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
|
||||||
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/files"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/openclaw"
|
||||||
|
|||||||
483
internal/plugins/files/plugin.go
Normal file
483
internal/plugins/files/plugin.go
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
plugin.RegisterFactory("files", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
|
return New(name), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plugin struct {
|
||||||
|
name string
|
||||||
|
sdk *sdk.PluginSDK
|
||||||
|
mu sync.RWMutex
|
||||||
|
filesDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(name string) *Plugin {
|
||||||
|
return &Plugin{name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Name() string { return p.name }
|
||||||
|
|
||||||
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
|
p.sdk = s
|
||||||
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
|
Key: "dir",
|
||||||
|
Default: "/",
|
||||||
|
Type: "string",
|
||||||
|
DisplayName: "文件系统根目录",
|
||||||
|
Description: "文件操作允许访问的根目录(设为 / 表示完整主机文件系统)",
|
||||||
|
Category: "files",
|
||||||
|
})
|
||||||
|
|
||||||
|
dir := getSetting[string](s.Settings(), "dir", "/")
|
||||||
|
if strings.HasPrefix(dir, "~/") {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
dir = filepath.Join(home, dir[2:])
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve files.dir: %w", err)
|
||||||
|
}
|
||||||
|
p.filesDir = abs
|
||||||
|
|
||||||
|
tp := p.name + "_"
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||||
|
Name: tp + "read",
|
||||||
|
Description: fmt.Sprintf("读取文件内容。支持 offset/limit 分段读取大文件。沙箱路径: %s", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "文件路径(绝对路径或相对于沙箱的路径)"},
|
||||||
|
"offset": map[string]interface{}{"type": "integer", "description": "起始行号(从1开始,可选,默认1)"},
|
||||||
|
"limit": map[string]interface{}{"type": "integer", "description": "最多返回的行数(可选,默认全部)"},
|
||||||
|
},
|
||||||
|
"required": []string{"path"},
|
||||||
|
},
|
||||||
|
}, p.handleRead)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"write", sdk.ToolDef{
|
||||||
|
Name: tp + "write",
|
||||||
|
Description: fmt.Sprintf("写入文件。自动创建父目录。沙箱路径: %s", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||||
|
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
|
||||||
|
"mode": map[string]interface{}{"type": "string", "description": "写入模式: overwrite(覆盖,默认)| append(追加到末尾)| insert(插入到指定行)| create(创建新文件,已存在则报错)"},
|
||||||
|
"line": map[string]interface{}{"type": "integer", "description": "插入模式时的目标行号(从1开始),内容将插入到该行之前"},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "content"},
|
||||||
|
},
|
||||||
|
}, p.handleWrite)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"edit", sdk.ToolDef{
|
||||||
|
Name: tp + "edit",
|
||||||
|
Description: fmt.Sprintf("对文件执行精确字符串替换。每个 old 必须在原文中唯一匹配。沙箱路径: %s", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
|
||||||
|
"edits": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "一个或多个替换操作。每个 old 必须在原文中恰好出现一次。不要包含重叠的 edit。",
|
||||||
|
"items": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"old": map[string]interface{}{"type": "string", "description": "要查找的原文(必须在文件中唯一)"},
|
||||||
|
"new": map[string]interface{}{"type": "string", "description": "替换后的文本"},
|
||||||
|
},
|
||||||
|
"required": []string{"old", "new"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"path", "edits"},
|
||||||
|
},
|
||||||
|
}, p.handleEdit)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"ls", sdk.ToolDef{
|
||||||
|
Name: tp + "ls",
|
||||||
|
Description: fmt.Sprintf("列出目录内容。目录以 / 后缀标记。沙箱路径: %s", p.filesDir),
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"path": map[string]interface{}{"type": "string", "description": "目录路径(可选,默认为沙箱根目录)"},
|
||||||
|
"limit": map[string]interface{}{"type": "integer", "description": "最多返回条目数(可选,默认500)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, p.handleLs)
|
||||||
|
|
||||||
|
log.Printf("[%s] started, sandbox: %s", p.name, p.filesDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) Stop() error {
|
||||||
|
log.Printf("[%s] stopped", p.name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) resolvePath(userPath string) (string, error) {
|
||||||
|
if userPath == "" {
|
||||||
|
userPath = "."
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(userPath) {
|
||||||
|
userPath = filepath.Join(p.filesDir, userPath)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(userPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve path: %w", err)
|
||||||
|
}
|
||||||
|
base := filepath.Clean(p.filesDir)
|
||||||
|
if base != "/" && !strings.HasPrefix(abs, base+string(filepath.Separator)) && abs != base {
|
||||||
|
return "", fmt.Errorf("path outside sandbox: %s", userPath)
|
||||||
|
}
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return errorResult("path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("file not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("stat error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return errorResult("is a directory, use ls instead: " + path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(data)
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
totalLines := len(lines)
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if v, ok := args["offset"].(float64); ok && v > 0 {
|
||||||
|
offset = int(v) - 1
|
||||||
|
}
|
||||||
|
if offset >= totalLines {
|
||||||
|
return errorResult(fmt.Sprintf("offset %d exceeds file length (%d lines)", offset+1, totalLines)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := totalLines - offset
|
||||||
|
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||||
|
if int(v) < limit {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
end := offset + limit
|
||||||
|
if end > totalLines {
|
||||||
|
end = totalLines
|
||||||
|
}
|
||||||
|
|
||||||
|
selected := lines[offset:end]
|
||||||
|
output := strings.Join(selected, "\n")
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(output)
|
||||||
|
|
||||||
|
if end < totalLines {
|
||||||
|
nextOffset := end + 1
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\n[Showing lines %d-%d of %d. Use offset=%d to continue.]", offset+1, end, totalLines, nextOffset))
|
||||||
|
} else if offset > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": sb.String(),
|
||||||
|
"size": len(data),
|
||||||
|
"lines": totalLines,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleWrite(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return errorResult("path is required"), nil
|
||||||
|
}
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
mode, _ := args["mode"].(string)
|
||||||
|
if mode == "" {
|
||||||
|
mode = "overwrite"
|
||||||
|
}
|
||||||
|
|
||||||
|
line := 0
|
||||||
|
if v, ok := args["line"].(float64); ok && v > 0 {
|
||||||
|
line = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "create":
|
||||||
|
if _, err := os.Stat(absPath); err == nil {
|
||||||
|
return errorResult("file already exists: " + path), nil
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return errorResult("mkdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Created %s (%d bytes)", path, len(content)),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "append":
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return errorResult("mkdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(absPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("open error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString(content); err != nil {
|
||||||
|
return errorResult("append error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Appended %d bytes to %s", len(content), path),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
case "insert":
|
||||||
|
if line < 1 {
|
||||||
|
return errorResult("line must be >= 1 for insert mode"), nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("file not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
if line > len(lines)+1 {
|
||||||
|
return errorResult(fmt.Sprintf("line %d exceeds file length (%d lines)", line, len(lines))), nil
|
||||||
|
}
|
||||||
|
idx := line - 1
|
||||||
|
newLines := make([]string, 0, len(lines)+1)
|
||||||
|
newLines = append(newLines, lines[:idx]...)
|
||||||
|
newLines = append(newLines, content)
|
||||||
|
newLines = append(newLines, lines[idx:]...)
|
||||||
|
result := strings.Join(newLines, "\n")
|
||||||
|
if err := os.WriteFile(absPath, []byte(result), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Inserted %d bytes at line %d in %s", len(content), line, path),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
default: // overwrite
|
||||||
|
dir := filepath.Dir(absPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return errorResult("mkdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": fmt.Sprintf("Wrote %d bytes to %s", len(content), path),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return errorResult("path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawEdits, ok := args["edits"].([]interface{})
|
||||||
|
if !ok || len(rawEdits) == 0 {
|
||||||
|
return errorResult("edits must be a non-empty array"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("file not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("read error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
original := string(data)
|
||||||
|
content := original
|
||||||
|
applied := 0
|
||||||
|
var errors []string
|
||||||
|
|
||||||
|
for i, raw := range rawEdits {
|
||||||
|
edit, ok := raw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: invalid format", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
oldText, _ := edit["old"].(string)
|
||||||
|
newText, _ := edit["new"].(string)
|
||||||
|
if oldText == "" {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: old is required", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count := strings.Count(content, oldText)
|
||||||
|
if count == 0 {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: could not find %q in %s", i, oldText, path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count > 1 {
|
||||||
|
errors = append(errors, fmt.Sprintf("edit[%d]: found %d occurrences of %q, must be unique", i, count, oldText))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
content = strings.Replace(content, oldText, newText, 1)
|
||||||
|
applied++
|
||||||
|
}
|
||||||
|
|
||||||
|
if applied == 0 {
|
||||||
|
msg := "no edits applied"
|
||||||
|
if len(errors) > 0 {
|
||||||
|
msg += ": " + strings.Join(errors, "; ")
|
||||||
|
}
|
||||||
|
return errorResult(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||||
|
return errorResult("write error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Successfully applied %d/%d edits to %s", applied, len(rawEdits), path)
|
||||||
|
if len(errors) > 0 {
|
||||||
|
msg += "\nWarnings:\n" + strings.Join(errors, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": msg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) handleLs(args map[string]interface{}) (interface{}, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
path = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := p.resolvePath(path)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult(err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errorResult("path not found: " + path), nil
|
||||||
|
}
|
||||||
|
return errorResult("stat error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return errorResult("not a directory: " + path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return errorResult("readdir error: " + err.Error()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 500
|
||||||
|
if v, ok := args["limit"].(float64); ok && v > 0 {
|
||||||
|
limit = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
||||||
|
})
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
entryLimitReached := false
|
||||||
|
for i, entry := range entries {
|
||||||
|
if i >= limit {
|
||||||
|
entryLimitReached = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err == nil {
|
||||||
|
name = fmt.Sprintf("%-40s %8d", name, info.Size())
|
||||||
|
}
|
||||||
|
lines = append(lines, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": "(empty directory)",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output := strings.Join(lines, "\n")
|
||||||
|
if entryLimitReached {
|
||||||
|
output += fmt.Sprintf("\n\n[%d entries limit reached. Use limit=N for more.]", limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"content": output,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorResult(msg string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"isError": true,
|
||||||
|
"content": msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSetting[T any](s sdk.SettingsAPI, key string, def T) T {
|
||||||
|
v, err := s.Get(key)
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
val, ok := v.(T)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
56
knowledge/homeagent_architecture/content.md
Normal file
56
knowledge/homeagent_architecture/content.md
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
HomeAgent 是完全独立自研的新一代 Agent 框架。
|
||||||
|
|
||||||
|
## 架构总览
|
||||||
|
|
||||||
|
HomeAgent 采用内核 + 插件双层架构:
|
||||||
|
|
||||||
|
```
|
||||||
|
外部输入(QQ/Web/CLI)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 内核 (Kernel) │
|
||||||
|
│ ┌───────┐ ┌───────┐ ┌────────┐ │
|
||||||
|
│ │ LLM │ │ 记忆 │ │ 上下文 │ │
|
||||||
|
│ │ 引擎 │ │ 系统 │ │ 管理器 │ │
|
||||||
|
│ └───────┘ └───────┘ └────────┘ │
|
||||||
|
│ ┌───────┐ ┌───────┐ ┌────────┐ │
|
||||||
|
│ │ 工具 │ │ 事件 │ │ 配置 │ │
|
||||||
|
│ │ 调度 │ │ 总线 │ │ 系统 │ │
|
||||||
|
│ └───────┘ └───────┘ └────────┘ │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 插件层 (Plugins) │
|
||||||
|
│ QQ / Web / Cmd / 备忘 / Files / … │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### 内核
|
||||||
|
- **LLM 引擎**:多 Provider 自动故障转移,8 个适配器(DeepSeek/Anthropic/Gemini/GitHub/Groq/Mistral/Ollama/OpenAI),自动降级
|
||||||
|
- **记忆系统**:四层记忆架构——图记忆(实体+关系)、文档记忆、文本记忆、社交记忆,向量索引检索
|
||||||
|
- **上下文管理**:自动剪枝低相关性事件,蒸馏重要信息写入长期记忆,有效防止上下文膨胀和记忆衰减
|
||||||
|
- **工具调度**:同质阶段并行执行,支持 pre_action/post_action/on_input 等生命周期钩子
|
||||||
|
- **事件总线**:发布/订阅模式,插件间松耦合通信
|
||||||
|
- **打断机制**:高优先级消息可打断进行中的 LLM 请求,即时响应
|
||||||
|
|
||||||
|
### 插件系统
|
||||||
|
- **双模式加载**:内部插件(Go 包编译集成)和外部插件(Go plugin -buildmode=plugin 动态加载)
|
||||||
|
- **热加载**:插件管理 API,运行时安装/卸载/重载插件
|
||||||
|
- **Stage 钩子**:插件可在 on_input/pre_action/post_action/before_toolcall/after_toolcall/before_output/after_output 各阶段注入逻辑
|
||||||
|
- **配置系统**:每插件独立 SQLite 配置表,标准注册/读取 API
|
||||||
|
|
||||||
|
### 数据存储
|
||||||
|
- SQLite 集中配置(核心 + 每插件独立表空间)
|
||||||
|
- 记忆数据存本地文件系统,向量索引内嵌(无需外部向量数据库)
|
||||||
|
- 文件变更追踪基于 overlayfs 实现
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
- 语言:Go 1.19+
|
||||||
|
- 构建:标准 Go toolchain,CGO_ENABLED=1(overlayfs 依赖)
|
||||||
|
- 插件:Go -buildmode=plugin
|
||||||
|
- LLM 适配:Lua 胶水层,8 个适配器
|
||||||
|
- 搜索:内置 TF-IDF 向量化器
|
||||||
Reference in New Issue
Block a user