mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-01 06:52:53 +00:00
feat: NoMemory/Cleaner memory system + doc update
- _sdk_local/ removed (moved to standalone sdk repo) - internal/agent/core: NoMemory/Cleaner data-flow breakpoints - internal/memory: clean_text, document store refactor - internal/plugin/registry.go: plugin API alignment - docs: PLUGIN_DEV.md, ARCHITECTURE.md NoMemory/Cleaner docs - plan.md, review.md: status update
This commit is contained in:
@ -1,205 +0,0 @@
|
||||
# HomeAgent SDK
|
||||
|
||||
HomeAgent 插件开发 SDK,用于构建与 HomeAgent 平台交互的智能插件。
|
||||
|
||||
## SDK API 接口
|
||||
|
||||
### Plugin 接口
|
||||
|
||||
插件需实现 `Plugin` 接口:
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
```
|
||||
|
||||
### PluginSDK 方法
|
||||
|
||||
通过 `Start(sdk *PluginSDK)` 注入的 SDK 实例提供以下方法:
|
||||
|
||||
| 分类 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调,scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
|
||||
| 输出通道 | `RegisterOutputChannel(name, caps, desc, handler)` | 注册输出通道,caps 为能力位掩码 |
|
||||
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
|
||||
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
|
||||
| 图记忆 | `Memory()` | 访问图记忆 API(实体-关系存储) |
|
||||
| 文本记忆 | `TextMemory()` | 访问文本记忆 API(时序事件) |
|
||||
| 文档记忆 | `DocMemory()` | 访问文档记忆 API(向量存储) |
|
||||
| 社交图谱 | `Social()` | 访问社交图谱 API(外部插件只读) |
|
||||
| 知识库 | `Knowledge()` | 访问知识库 API |
|
||||
| LLM | `LLM()` | 访问 LLM 提供商管理 API |
|
||||
| 设置 | `Settings()` | 访问设置 API |
|
||||
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
||||
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
||||
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
||||
|
||||
### 阶段钩子
|
||||
|
||||
```go
|
||||
// 全局监听所有插件的阶段事件
|
||||
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||||
|
||||
// 仅监听自己注册的工具的 before_toolcall / after_toolcall
|
||||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||
```
|
||||
|
||||
### 输出通道
|
||||
|
||||
```go
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", handler)
|
||||
```
|
||||
|
||||
handler 接收三个参数:
|
||||
- `payload` (string) — 消息载荷。`type=text` 时直接填文字,`type=file/image` 时填 URL
|
||||
- `meta` (string) — 可选的 JSON 路由元数据(如 `{"group_id":123,"user_id":456}`)
|
||||
- `type` (string) — 载荷类型,枚举值见下
|
||||
|
||||
能力标志位:
|
||||
|
||||
| 标志 | 值 | 说明 |
|
||||
|------|----|------|
|
||||
| `CapText` | 1 | 纯文本输出 |
|
||||
| `CapFile` | 2 | 文件输出 |
|
||||
| `CapImage` | 4 | 图片输出 |
|
||||
| `CapAudio` | 8 | 音频输出 |
|
||||
| `CapStructured` | 16 | 结构化数据输出 |
|
||||
|
||||
type 枚举值:
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `text` | 纯文本 |
|
||||
| `voice` / `audio` | 语音 |
|
||||
| `image` | 图片 |
|
||||
| `file` | 文件 |
|
||||
|
||||
### IOInjector 通道路由
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `InjectText(source, channel, text)` | 注入文本,记入内存,路由到指定通道 |
|
||||
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
||||
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
||||
|
||||
`source` 标识来源,`channel` 指定目标输出通道。
|
||||
|
||||
### Triple 扩展字段
|
||||
|
||||
Triple 数据结构新增字段:
|
||||
|
||||
- `Confidence` — 置信度(0.0~1.0)
|
||||
- `SubjectType` — 主体类型
|
||||
- `ObjectType` — 客体类型
|
||||
|
||||
### New 构造函数
|
||||
|
||||
`New()` 由内核在加载插件时调用,插件开发者无需手动构造 PluginSDK:
|
||||
|
||||
```go
|
||||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||||
```
|
||||
|
||||
插件开发者只需实现 `Plugin` 接口并导出 `NewPlugin()` 入口函数。
|
||||
|
||||
## plugindev 工具链
|
||||
|
||||
`plugindev` 提供插件开发全流程支持:
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `plugindev init` | 初始化插件项目(生成 plg.json、入口模板) |
|
||||
| `plugindev build` | 构建插件,输出 .hmap 包 |
|
||||
| `plugindev clean` | 清理构建产物 |
|
||||
| `plugindev debug` | 本地调试模式运行插件 |
|
||||
|
||||
支持 **Go** 和 **Lua** 两种插件语言。
|
||||
|
||||
### plg.json 清单格式
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"lang": "go",
|
||||
"entry": "main.go",
|
||||
"description": "插件描述",
|
||||
"channels": ["my-channel"],
|
||||
"dependencies": {}
|
||||
}
|
||||
```
|
||||
|
||||
### .hmap 包格式
|
||||
|
||||
`.hmap` 为 ZIP 归档,包含:
|
||||
|
||||
- `plugin.json` — 插件元数据
|
||||
- `plugin.so` — Go 编译产物(Linux)
|
||||
- `plugin.dll` — Go 编译产物(Windows)
|
||||
- `main.lua` — Lua 插件入口(Lua 插件时)
|
||||
|
||||
## 插件生命周期
|
||||
|
||||
### 启动与停止
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — 插件启动,接收 SDK 实例
|
||||
- `Stop() error` — 插件停止,释放资源
|
||||
|
||||
### 自动重启
|
||||
|
||||
```go
|
||||
sdk.SetAutoRestart(true)
|
||||
// 查询状态
|
||||
enabled := sdk.AutoRestart()
|
||||
```
|
||||
|
||||
插件崩溃时平台自动拉起,保障服务可用性。
|
||||
|
||||
## 受限 SDK vs 完整 SDK
|
||||
|
||||
外部插件(第三方分发)使用**受限 SDK**,仅暴露安全子集:
|
||||
|
||||
| 受限 API | 允许操作 |
|
||||
|----------|----------|
|
||||
| `SocialAPI` | 只读:`GetPerson`、`GetTrait`、`GetRelations`、`GetNetwork`、`ListPersons` |
|
||||
| `EventSubscriber` | 仅订阅:`Subscribe`(无 `Publish`) |
|
||||
|
||||
内部插件(平台内置)拥有完整 SDK 访问权限,包括 SocialAPI 写操作和 EventPublisher。
|
||||
|
||||
## 示例插件
|
||||
|
||||
| 插件 | 说明 |
|
||||
|------|------|
|
||||
| a2a | Agent-to-Agent 协议通信 |
|
||||
| bili | Bilibili 视频下载 |
|
||||
| browser | 网络搜索、网页抓取、浏览器渲染(合并自 web/webfetch) |
|
||||
| editdoc | 文档编辑 |
|
||||
| files | 文件管理 |
|
||||
| memo | 备忘录/记忆 |
|
||||
| ocr | 光学字符识别 |
|
||||
| qq | QQ 消息集成 |
|
||||
| sanitizer | 内容清洗/安全过滤 |
|
||||
|
||||
## 构建与安装
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
输出 `.hmap` 包到项目目录。
|
||||
|
||||
### 安装
|
||||
|
||||
通过 pluginmgr HTTP API 安装:
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
```
|
||||
|
||||
或手动将 `.hmap` 放入插件目录后重启平台。
|
||||
@ -1,206 +0,0 @@
|
||||
# HomeAgent SDK
|
||||
|
||||
Plugin development SDK for building intelligent plugins that interact with the HomeAgent platform.
|
||||
|
||||
## SDK API Surface
|
||||
|
||||
### Plugin Interface
|
||||
|
||||
Plugins implement the `Plugin` interface:
|
||||
|
||||
```go
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
```
|
||||
|
||||
### PluginSDK Methods
|
||||
|
||||
The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
||||
|
||||
| Category | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| Stage Hooks | `RegisterStage(stage, handler, scope...)` | Register stage callback; scope: `StageScopeGlobal` (all, default) or `StageScopeOwnTools` (own tools only) |
|
||||
| Output Channel | `RegisterOutputChannel(name, caps, desc, handler)` | Register output channel with capability bitmask |
|
||||
| Tool Registration | `RegisterTool(name, def, handler)` | Register a tool for LLM invocation |
|
||||
| Plugin API | `RegisterPluginAPI(name)` | Register plugin API for inter-plugin access |
|
||||
| Graph Memory | `Memory()` | Access graph memory API (entity-relation store) |
|
||||
| Text Memory | `TextMemory()` | Access text memory API (chronological events) |
|
||||
| Doc Memory | `DocMemory()` | Access document memory API (vector store) |
|
||||
| Social Graph | `Social()` | Access social graph API (read-only for external plugins) |
|
||||
| Knowledge | `Knowledge()` | Access knowledge base API |
|
||||
| LLM | `LLM()` | Access LLM provider manager API |
|
||||
| Settings | `Settings()` | Access settings API |
|
||||
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||||
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||||
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||||
|
||||
### Stage Hooks
|
||||
|
||||
```go
|
||||
// Listen to all stage events globally
|
||||
sdk.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil })
|
||||
|
||||
// Listen only to this plugin's own tool calls (before_toolcall / after_toolcall only)
|
||||
sdk.RegisterStage(StageBeforeToolcall, myHandler, StageScopeOwnTools)
|
||||
```
|
||||
|
||||
### Output Channels
|
||||
|
||||
```go
|
||||
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "channel description", handler)
|
||||
```
|
||||
|
||||
The handler receives three arguments:
|
||||
- `payload` (string) — message content. For `type=text` it's plain text, for `type=file/image` it's a URL
|
||||
- `meta` (string) — optional JSON routing metadata (e.g. `{"group_id":123,"user_id":456}`)
|
||||
- `type` (string) — content type enum (see below)
|
||||
|
||||
Capability flags:
|
||||
|
||||
| Flag | Value | Description |
|
||||
|------|-------|-------------|
|
||||
| `CapText` | 1 | Plain text output |
|
||||
| `CapFile` | 2 | File output |
|
||||
| `CapImage` | 4 | Image output |
|
||||
| `CapAudio` | 8 | Audio output |
|
||||
| `CapStructured` | 16 | Structured data output |
|
||||
|
||||
Type enum values:
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `text` | plain text |
|
||||
| `voice` / `audio` | audio/voice |
|
||||
| `image` | image |
|
||||
| `file` | file |
|
||||
|
||||
### IOInjector Channel Routing
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `InjectText(source, channel, text)` | Inject text, record to memory, route to specified channel |
|
||||
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||||
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||||
|
||||
`source` identifies the origin, `channel` specifies the target output channel.
|
||||
|
||||
### Triple Extended Fields
|
||||
|
||||
The Triple data structure includes additional fields:
|
||||
|
||||
- `Confidence` — confidence score (0.0–1.0)
|
||||
- `SubjectType` — subject type
|
||||
- `ObjectType` — object type
|
||||
|
||||
### New Constructor
|
||||
|
||||
`New()` is called by the kernel when loading a plugin. Plugin developers do not need to construct PluginSDK manually:
|
||||
|
||||
```go
|
||||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK
|
||||
```
|
||||
|
||||
Plugin developers only need to implement the `Plugin` interface and export a `NewPlugin()` entry function.
|
||||
|
||||
## plugindev Toolchain
|
||||
|
||||
`plugindev` provides full development workflow support:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `plugindev init` | Initialize plugin project (generates plg.json, entry template) |
|
||||
| `plugindev build` | Build plugin, output .hmap package |
|
||||
| `plugindev clean` | Clean build artifacts |
|
||||
| `plugindev debug` | Run plugin in local debug mode |
|
||||
|
||||
Supports both **Go** and **Lua** plugin languages.
|
||||
|
||||
### plg.json Manifest Format
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-plugin",
|
||||
"version": "1.0.0",
|
||||
"lang": "go",
|
||||
"entry": "main.go",
|
||||
"description": "Plugin description",
|
||||
"channels": ["my-channel"],
|
||||
"dependencies": {}
|
||||
}
|
||||
```
|
||||
|
||||
### .hmap Package Format
|
||||
|
||||
`.hmap` is a ZIP archive containing:
|
||||
|
||||
- `plugin.json` — plugin metadata
|
||||
- `plugin.so` — Go compiled artifact (Linux)
|
||||
- `plugin.dll` — Go compiled artifact (Windows)
|
||||
- `main.lua` — Lua plugin entry (for Lua plugins)
|
||||
|
||||
## Plugin Lifecycle
|
||||
|
||||
### Start & Stop
|
||||
|
||||
- `Start(sdk *PluginSDK) error` — Plugin startup, receives SDK instance
|
||||
- `Stop() error` — Plugin shutdown, release resources
|
||||
|
||||
### Auto-Restart
|
||||
|
||||
```go
|
||||
sdk.SetAutoRestart(true)
|
||||
// Query state
|
||||
enabled := sdk.AutoRestart()
|
||||
```
|
||||
|
||||
The platform automatically restarts the plugin on crash, ensuring service availability.
|
||||
|
||||
## Restricted SDK vs Full SDK
|
||||
|
||||
External plugins (third-party distribution) use a **restricted SDK** that only exposes a safe subset:
|
||||
|
||||
| Restricted API | Allowed Operations |
|
||||
|----------------|-------------------|
|
||||
| `SocialAPI` | Read-only: `GetPerson`, `GetTrait`, `GetRelations`, `GetNetwork`, `ListPersons` |
|
||||
| `EventSubscriber` | Subscribe-only: `Subscribe` (no `Publish`) |
|
||||
|
||||
Internal plugins (platform built-in) have full SDK access including SocialAPI write operations and EventPublisher.
|
||||
|
||||
## Example Plugins
|
||||
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| a2a | Agent-to-Agent protocol communication |
|
||||
| bili | Bilibili data fetching |
|
||||
| editdoc | Document editing |
|
||||
| files | File management |
|
||||
| memo | Memo/notes |
|
||||
| ocr | Optical character recognition |
|
||||
| qq | QQ messaging integration |
|
||||
| sanitizer | Content sanitization/safety filtering |
|
||||
| web | Web browsing and interaction |
|
||||
| webfetch | Web content fetching |
|
||||
|
||||
## Building & Installing
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
plugindev build
|
||||
```
|
||||
|
||||
Outputs a `.hmap` package to the project directory.
|
||||
|
||||
### Install
|
||||
|
||||
Via pluginmgr HTTP API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://<host>:<port>/api/plugins/install \
|
||||
-F "package=@my-plugin.hmap"
|
||||
```
|
||||
|
||||
Or manually place the `.hmap` in the plugin directory and restart the platform.
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"name": "files",
|
||||
"name_zh": "文件系统",
|
||||
"name_en": "File System",
|
||||
"version": "1.0.0",
|
||||
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
|
||||
"author": "HomeAgent",
|
||||
"entry": "plugin.so",
|
||||
"tags": ["files", "filesystem"],
|
||||
"targets": "linux/amd64"
|
||||
}
|
||||
@ -1,483 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
sdk *sdk.PluginSDK
|
||||
mu sync.RWMutex
|
||||
filesDir string
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
p.sdk = s
|
||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||
Key: "plugin.files.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
|
||||
os.MkdirAll(p.filesDir, 0755)
|
||||
|
||||
tp := p.name + "_"
|
||||
|
||||
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||
Name: tp + "read",
|
||||
Description: fmt.Sprintf("Read file contents within the sandbox directory (%s). Supports offset/limit for large files.", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
|
||||
"offset": map[string]interface{}{"type": "integer", "description": "Starting line number (1-indexed, optional)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "Max lines to return (optional)"},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
}, p.handleRead)
|
||||
|
||||
s.RegisterTool(tp+"write", sdk.ToolDef{
|
||||
Name: tp + "write",
|
||||
Description: fmt.Sprintf("Write content to a file. Creates parent directories automatically. Sandbox: %s", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "File path"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "Write mode: overwrite (default) | append | insert | create"},
|
||||
"line": map[string]interface{}{"type": "integer", "description": "Line number for insert mode (1-indexed)"},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
},
|
||||
}, p.handleWrite)
|
||||
|
||||
s.RegisterTool(tp+"edit", sdk.ToolDef{
|
||||
Name: tp + "edit",
|
||||
Description: fmt.Sprintf("Apply exact string replacements to a file within the sandbox (%s). All edits are matched against the original file content.", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "File path relative to sandbox or absolute"},
|
||||
"edits": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "One or more targeted replacements. Each old must match exactly once in the original file. Do not include overlapping edits.",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"old": map[string]interface{}{"type": "string", "description": "Exact text to find (must be unique)"},
|
||||
"new": map[string]interface{}{"type": "string", "description": "Replacement text"},
|
||||
},
|
||||
"required": []string{"old", "new"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "edits"},
|
||||
},
|
||||
}, p.handleEdit)
|
||||
|
||||
s.RegisterTool(tp+"ls", sdk.ToolDef{
|
||||
Name: tp + "ls",
|
||||
Description: fmt.Sprintf("List directory contents within the sandbox (%s). Directories are marked with / suffix.", p.filesDir),
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Directory path (optional, defaults to sandbox root)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "Max entries (optional, default 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
|
||||
}
|
||||
|
||||
// resolvePath resolves user-provided path to an absolute path within filesDir.
|
||||
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
|
||||
}
|
||||
|
||||
// handleRead implements the read tool.
|
||||
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")
|
||||
|
||||
truncated := false
|
||||
if limit < totalLines-offset {
|
||||
truncated = true
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(output)
|
||||
if truncated {
|
||||
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 || end < totalLines {
|
||||
sb.WriteString(fmt.Sprintf("\n\n[%d lines total]", totalLines))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": sb.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleWrite implements the write tool.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// handleEdit implements the edit tool.
|
||||
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
|
||||
}
|
||||
|
||||
// handleLs implements the ls tool.
|
||||
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 += "/"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// errorResult returns a standardized error result.
|
||||
func errorResult(msg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"isError": true,
|
||||
"content": msg,
|
||||
}
|
||||
}
|
||||
|
||||
// getSetting reads a setting with generic type assertion.
|
||||
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
|
||||
}
|
||||
|
||||
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return &Plugin{name: name}, nil
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
module gitcode.com/JianFeeeee/homeagent-sdk
|
||||
|
||||
go 1.25.0
|
||||
@ -1,23 +0,0 @@
|
||||
// Package meta 收集 HomeAgent SDK 的全部元数据。
|
||||
// 版本号应与核心 meta.Version 保持一致。
|
||||
package meta
|
||||
|
||||
var (
|
||||
// Version 是 HomeAgent SDK 版本号。
|
||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
||||
Version = "0.7.1"
|
||||
|
||||
// Commit 是构建时的 Git commit hash。
|
||||
Commit = "unknown"
|
||||
|
||||
// BuildTime 是构建时间。
|
||||
BuildTime = "unknown"
|
||||
|
||||
// SDKName 是 SDK 名称。
|
||||
SDKName = "HomeAgent SDK"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
func FullVersion() string {
|
||||
return SDKName + " v" + Version + " (" + Commit + ")"
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// KnowledgeAPI provides access to the knowledge store.
|
||||
type KnowledgeAPI interface {
|
||||
Search(query string, topK int) ([]*Knowledge, error)
|
||||
Add(name, content string) error
|
||||
List() ([]string, error)
|
||||
}
|
||||
|
||||
// Knowledge represents a knowledge entry.
|
||||
type Knowledge struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// LLMAPI provides access to the LLM provider manager.
|
||||
type LLMAPI interface {
|
||||
ListSources() []string
|
||||
SetSource(name string) error
|
||||
CurrentSource() string
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
package sdk
|
||||
|
||||
// MemoryAPI provides access to the graph memory (entity-relation store).
|
||||
type MemoryAPI interface {
|
||||
Recall(query []string, depth int) ([]Entity, []Relation, error)
|
||||
Commit(triples []Triple) error
|
||||
Introspect() (map[string]interface{}, error)
|
||||
MergeEntities(source, target string) (int, error)
|
||||
Purge(criteria map[string]string, mode string) (int, error)
|
||||
}
|
||||
|
||||
// Entity represents a named entity in the knowledge graph.
|
||||
type Entity struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MentionCount int `json:"mention_count"`
|
||||
}
|
||||
|
||||
// Relation represents a relationship between two entities.
|
||||
type Relation struct {
|
||||
SourceName string `json:"source_name"`
|
||||
TargetName string `json:"target_name"`
|
||||
RelationType string `json:"relation_type"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// Triple represents a subject-relation-object triple for the knowledge graph.
|
||||
type Triple struct {
|
||||
Subject string `json:"subject"`
|
||||
Relation string `json:"relation"`
|
||||
Object string `json:"object"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
SubjectType string `json:"subject_type,omitempty"`
|
||||
ObjectType string `json:"object_type,omitempty"`
|
||||
}
|
||||
|
||||
// TextMemoryAPI provides access to chronological text event storage.
|
||||
type TextMemoryAPI interface {
|
||||
Append(evt TextEvent) error
|
||||
}
|
||||
|
||||
// TextEvent represents a single text memory event.
|
||||
type TextEvent struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// DocMemoryAPI provides access to the document vector store.
|
||||
type DocMemoryAPI interface {
|
||||
Query(text string, topK int) []*Doc
|
||||
Insert(doc *Doc) error
|
||||
Remove(id string)
|
||||
Stats() map[string]interface{}
|
||||
}
|
||||
|
||||
// Doc represents a document in the document store.
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
|
||||
// External plugins can query person traits and social networks but cannot modify them.
|
||||
type SocialAPI interface {
|
||||
GetPerson(name string) (*PersonProfile, error)
|
||||
GetTrait(name, trait string) (string, bool)
|
||||
GetRelations(name string) ([]SocialRelation, error)
|
||||
GetNetwork(name string, depth int) ([]*PersonProfile, error)
|
||||
ListPersons() ([]string, error)
|
||||
}
|
||||
|
||||
// PersonProfile represents a person's complete profile (traits + social relations).
|
||||
type PersonProfile struct {
|
||||
Name string `json:"name"`
|
||||
Traits map[string]string `json:"traits,omitempty"`
|
||||
Relations []SocialRelation `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
// SocialRelation represents a social relationship between two persons.
|
||||
type SocialRelation struct {
|
||||
Person string `json:"person"`
|
||||
Relation string `json:"relation"`
|
||||
}
|
||||
@ -1,356 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"gitcode.com/JianFeeeee/homeagent-sdk/meta"
|
||||
)
|
||||
|
||||
// SDKVersion 是对外暴露的 SDK 版本号。
|
||||
var SDKVersion = meta.Version
|
||||
|
||||
// Plugin is the interface every plugin must implement.
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Start(sdk *PluginSDK) error
|
||||
Stop() error
|
||||
}
|
||||
|
||||
// ToolHandler is a function that handles a tool call.
|
||||
type ToolHandler func(args map[string]interface{}) (interface{}, error)
|
||||
|
||||
// StageHandler is a function that handles a pipeline stage event.
|
||||
type StageHandler func(ctx *StageContext) error
|
||||
|
||||
// Stage represents a point in the message processing pipeline.
|
||||
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"
|
||||
)
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
type StageContext struct {
|
||||
mu sync.RWMutex
|
||||
RawMessage string
|
||||
UserID string
|
||||
GroupID string
|
||||
ContextMsgs []map[string]interface{}
|
||||
LLMText string
|
||||
ReasoningContent string
|
||||
TokenUsage map[string]int
|
||||
ToolCalls []ToolCall
|
||||
ToolResults []ToolResult
|
||||
FinalText string
|
||||
Response *string
|
||||
Phase Stage
|
||||
Memory []MemItem
|
||||
NoMemory bool
|
||||
Extra map[string]interface{}
|
||||
Errors []string // 阶段处理过程中的错误信息
|
||||
}
|
||||
|
||||
func (c *StageContext) RLock() { c.mu.RLock() }
|
||||
func (c *StageContext) RUnlock() { c.mu.RUnlock() }
|
||||
func (c *StageContext) Lock() { c.mu.Lock() }
|
||||
func (c *StageContext) Unlock() { c.mu.Unlock() }
|
||||
func (c *StageContext) IsResponded() bool { c.mu.RLock(); defer c.mu.RUnlock(); return c.Response != nil }
|
||||
|
||||
// MemItem represents a memory item in stage context.
|
||||
type MemItem struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// ToolCall represents a model's request to call a tool.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Arguments map[string]interface{} `json:"arguments"`
|
||||
}
|
||||
|
||||
// ToolResult represents the result of a tool call.
|
||||
type ToolResult struct {
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Result interface{} `json:"result"`
|
||||
}
|
||||
|
||||
// ToolDef describes a tool that the plugin exposes.
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
}
|
||||
|
||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||
// All methods accept (source, channel) where channel is the target output channel
|
||||
// for routing the agent's response.
|
||||
type IOInjector interface {
|
||||
InjectInterruptText(source, channel, text string)
|
||||
InjectText(source, channel, text string)
|
||||
InjectTextNoMemory(source, channel, text string)
|
||||
}
|
||||
|
||||
// EventType identifies the kind of system event.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventRawInput EventType = "raw_input"
|
||||
EventAgentOutput EventType = "agent_output"
|
||||
EventAgentLLMChain EventType = "agent_llm_chain"
|
||||
EventToolCall EventType = "tool_call"
|
||||
EventReasoning EventType = "reasoning"
|
||||
EventStage EventType = "stage"
|
||||
EventSystem EventType = "system"
|
||||
)
|
||||
|
||||
// Event represents a system event published by the kernel.
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// EventHandler processes a system event.
|
||||
type EventHandler func(evt *Event)
|
||||
|
||||
// EventSubscriber allows plugins to subscribe to kernel events.
|
||||
// This is a restricted interface: plugins can subscribe but the kernel
|
||||
// controls which events are delivered.
|
||||
type EventSubscriber interface {
|
||||
Subscribe(eventType EventType, handler EventHandler) func()
|
||||
}
|
||||
|
||||
// StageScope controls which events a stage handler receives.
|
||||
type StageScope int
|
||||
|
||||
const (
|
||||
// StageScopeGlobal receives all stage events (default).
|
||||
StageScopeGlobal StageScope = 0
|
||||
// StageScopeOwnTools only receives events for this plugin's own tool calls
|
||||
// (before_toolcall / after_toolcall only). Other stages degrade to global.
|
||||
StageScopeOwnTools StageScope = 1
|
||||
)
|
||||
|
||||
// ToolRegistrar registers a tool dynamically.
|
||||
type ToolRegistrar func(name string, def ToolDef, handler ToolHandler) error
|
||||
|
||||
// StageRegistrar registers a stage handler.
|
||||
type StageRegistrar func(stage Stage, handler StageHandler)
|
||||
|
||||
// APIRegistrar registers a plugin API for external access.
|
||||
type APIRegistrar func(name string) error
|
||||
|
||||
// OutputChannelRegistrar registers an output channel that the output_send tool can use.
|
||||
type OutputChannelRegistrar func(name string, caps int, desc string, handler ToolHandler) error
|
||||
|
||||
// Output capability flags
|
||||
const (
|
||||
CapText = 1
|
||||
CapFile = 2
|
||||
CapImage = 4
|
||||
CapAudio = 8
|
||||
CapStructured = 16
|
||||
)
|
||||
|
||||
// PluginSDK is the main API surface provided to plugins at runtime.
|
||||
// It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
|
||||
type PluginSDK struct {
|
||||
name string
|
||||
regTool ToolRegistrar
|
||||
regStage StageRegistrar
|
||||
regAPI APIRegistrar
|
||||
regOutput OutputChannelRegistrar
|
||||
io IOInjector
|
||||
mem MemoryAPI
|
||||
textMem TextMemoryAPI
|
||||
docMem DocMemoryAPI
|
||||
know KnowledgeAPI
|
||||
llm LLMAPI
|
||||
sett SettingsAPI
|
||||
social SocialAPI
|
||||
events EventSubscriber
|
||||
|
||||
autoRestart bool
|
||||
textCleaners []func(text string) string
|
||||
}
|
||||
|
||||
// New creates a PluginSDK with the given dependencies.
|
||||
func New(name string, sett SettingsAPI, regTool ToolRegistrar, regStage StageRegistrar, regAPI APIRegistrar, regOutput OutputChannelRegistrar) *PluginSDK {
|
||||
return &PluginSDK{
|
||||
name: name,
|
||||
sett: sett,
|
||||
regTool: regTool,
|
||||
regStage: regStage,
|
||||
regAPI: regAPI,
|
||||
regOutput: regOutput,
|
||||
autoRestart: true,
|
||||
textCleaners: make([]func(text string) string, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// PluginName returns the name of the plugin.
|
||||
func (s *PluginSDK) PluginName() string { return s.name }
|
||||
|
||||
// Settings returns the settings API for reading/writing plugin configuration.
|
||||
func (s *PluginSDK) Settings() SettingsAPI { return s.sett }
|
||||
|
||||
// Memory returns the graph memory API (may be nil if not available).
|
||||
func (s *PluginSDK) Memory() MemoryAPI { return s.mem }
|
||||
|
||||
// TextMemory returns the text memory API (may be nil if not available).
|
||||
func (s *PluginSDK) TextMemory() TextMemoryAPI { return s.textMem }
|
||||
|
||||
// DocMemory returns the document memory API (may be nil if not available).
|
||||
func (s *PluginSDK) DocMemory() DocMemoryAPI { return s.docMem }
|
||||
|
||||
// Knowledge returns the knowledge store API (may be nil if not available).
|
||||
func (s *PluginSDK) Knowledge() KnowledgeAPI { return s.know }
|
||||
|
||||
// LLM returns the LLM provider API (may be nil if not available).
|
||||
func (s *PluginSDK) LLM() LLMAPI { return s.llm }
|
||||
|
||||
// Social returns the social graph API (may be nil if not available).
|
||||
func (s *PluginSDK) Social() SocialAPI { return s.social }
|
||||
|
||||
// Events returns the event subscriber for listening to kernel events (may be nil if not available).
|
||||
func (s *PluginSDK) Events() EventSubscriber { return s.events }
|
||||
|
||||
// RegisterTool registers a tool that the LLM can call.
|
||||
func (s *PluginSDK) RegisterTool(name string, def ToolDef, handler ToolHandler) error {
|
||||
if def.Plugin == "" {
|
||||
def.Plugin = s.name
|
||||
}
|
||||
if s.regTool != nil {
|
||||
return s.regTool(name, def, handler)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterStage registers a handler for a pipeline stage.
|
||||
// scope: StageScopeGlobal (default) — receives all stage events.
|
||||
// StageScopeOwnTools — only before_toolcall/after_toolcall for this plugin's tools.
|
||||
func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope) {
|
||||
if s.regStage == nil {
|
||||
return
|
||||
}
|
||||
sc := StageScopeGlobal
|
||||
if len(scope) > 0 {
|
||||
sc = scope[0]
|
||||
}
|
||||
if sc == StageScopeGlobal {
|
||||
s.regStage(stage, handler)
|
||||
return
|
||||
}
|
||||
// OwnTools scope — only for before_toolcall / after_toolcall
|
||||
if stage != StageBeforeToolcall && stage != StageAfterToolcall {
|
||||
s.regStage(stage, handler)
|
||||
return
|
||||
}
|
||||
s.regStage(stage, func(ctx *StageContext) error {
|
||||
ctx.RLock()
|
||||
match := false
|
||||
switch stage {
|
||||
case StageBeforeToolcall:
|
||||
match = len(ctx.ToolCalls) > 0 && ctx.ToolCalls[0].Plugin == s.name
|
||||
case StageAfterToolcall:
|
||||
match = len(ctx.ToolResults) > 0 && ctx.ToolResults[0].Plugin == s.name
|
||||
}
|
||||
ctx.RUnlock()
|
||||
if !match {
|
||||
return nil
|
||||
}
|
||||
return handler(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterPluginAPI registers this plugin's API for access by other plugins.
|
||||
func (s *PluginSDK) RegisterPluginAPI(name string) error {
|
||||
if s.regAPI != nil {
|
||||
return s.regAPI(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterOutputChannel registers an output channel that the output_send tool can route to.
|
||||
// name: channel name (e.g. "qq", "webui")
|
||||
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
|
||||
// desc: description of the channel, expected meta format, and type enum
|
||||
// handler: receives args map with keys: payload (string), type (string), meta (string|optional)
|
||||
func (s *PluginSDK) RegisterOutputChannel(name string, caps int, desc string, handler ToolHandler) error {
|
||||
if s.regOutput != nil {
|
||||
return s.regOutput(name, caps, desc, handler)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
|
||||
func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar) { s.regOutput = r }
|
||||
|
||||
// SetIOInjector sets the IO injector (called by the core at startup).
|
||||
func (s *PluginSDK) SetIOInjector(io IOInjector) { s.io = io }
|
||||
|
||||
// SetMemoryAPI sets the memory API (called by the core at startup).
|
||||
func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI) { s.mem = mem }
|
||||
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI) { s.textMem = tm }
|
||||
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI) { s.docMem = dm }
|
||||
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI) { s.know = kn }
|
||||
func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
||||
|
||||
// ---- IO Convenience Methods ----
|
||||
|
||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||
if s.io != nil {
|
||||
s.io.InjectInterruptText(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectText injects a text message into the agent pipeline.
|
||||
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||
if s.io != nil {
|
||||
s.io.InjectText(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectTextNoMemory injects a text message without generating memory.
|
||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
if s.io != nil {
|
||||
s.io.InjectTextNoMemory(source, channel, text)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAutoRestart 设置插件是否允许内核自动重启(崩溃后自动重载)。
|
||||
// 默认 true。如果插件有无法恢复的状态(如外部连接),应设为 false。
|
||||
func (s *PluginSDK) SetAutoRestart(enabled bool) { s.autoRestart = enabled }
|
||||
|
||||
// AutoRestart 返回插件是否允许自动重启。
|
||||
func (s *PluginSDK) AutoRestart() bool { return s.autoRestart }
|
||||
|
||||
// RegisterTextCleaner registers a text cleaning function that is applied to
|
||||
// all text before it enters memory. Multiple cleaners can be registered and
|
||||
// are applied in registration order.
|
||||
func (s *PluginSDK) RegisterTextCleaner(cleaner func(text string) string) {
|
||||
s.textCleaners = append(s.textCleaners, cleaner)
|
||||
}
|
||||
|
||||
// TextCleaners returns all registered text cleaning functions.
|
||||
func (s *PluginSDK) TextCleaners() []func(text string) string {
|
||||
return s.textCleaners
|
||||
}
|
||||
@ -1,229 +0,0 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegisterStageGlobalDefault(t *testing.T) {
|
||||
called := false
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
called = true
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "test"}
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil })
|
||||
|
||||
if !called {
|
||||
t.Error("global scope: handler not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageGlobalExplicit(t *testing.T) {
|
||||
called := false
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
called = true
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "test"}
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeGlobal)
|
||||
|
||||
if !called {
|
||||
t.Error("global scope: handler not registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsMatch(t *testing.T) {
|
||||
var registered StageHandler
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
registered = handler
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||
|
||||
if registered == nil {
|
||||
t.Fatal("handler not registered")
|
||||
}
|
||||
|
||||
ctx := &StageContext{}
|
||||
ctx.ToolCalls = []ToolCall{{Plugin: "myplugin", Name: "my_tool"}}
|
||||
ctx.ToolResults = nil
|
||||
|
||||
err := registered(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsSkipOtherPlugin(t *testing.T) {
|
||||
var registered StageHandler
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
registered = handler
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||
|
||||
callCount := 0
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
|
||||
callCount++
|
||||
return nil
|
||||
}, StageScopeOwnTools)
|
||||
|
||||
if registered == nil {
|
||||
t.Fatal("handler not registered")
|
||||
}
|
||||
|
||||
ctx := &StageContext{}
|
||||
ctx.ToolCalls = []ToolCall{{Plugin: "other", Name: "other_tool"}}
|
||||
|
||||
err := registered(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
if callCount != 0 {
|
||||
t.Error("handler should not be called for other plugin's tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsNonToolcallDegrades(t *testing.T) {
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
if stage != StagePreAction {
|
||||
t.Errorf("expected StagePreAction, got %s", stage)
|
||||
}
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "test"}
|
||||
s.RegisterStage(StagePreAction, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsStageBeforeToolcallNoToolCalls(t *testing.T) {
|
||||
var registered StageHandler
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
registered = handler
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||
|
||||
callCount := 0
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error {
|
||||
callCount++
|
||||
return nil
|
||||
}, StageScopeOwnTools)
|
||||
|
||||
if registered == nil {
|
||||
t.Fatal("handler not registered")
|
||||
}
|
||||
|
||||
ctx := &StageContext{}
|
||||
|
||||
err := registered(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
if callCount != 0 {
|
||||
t.Error("handler should not be called when ToolCalls is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsStageAfterToolcallMatch(t *testing.T) {
|
||||
var registered StageHandler
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
registered = handler
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||
|
||||
callCount := 0
|
||||
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
|
||||
callCount++
|
||||
return nil
|
||||
}, StageScopeOwnTools)
|
||||
|
||||
if registered == nil {
|
||||
t.Fatal("handler not registered")
|
||||
}
|
||||
|
||||
ctx := &StageContext{}
|
||||
ctx.ToolResults = []ToolResult{{Plugin: "myplugin", Name: "my_tool"}}
|
||||
|
||||
err := registered(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Error("handler should be called for own plugin's tool result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsStageAfterToolcallSkip(t *testing.T) {
|
||||
var registered StageHandler
|
||||
regStage := func(stage Stage, handler StageHandler) {
|
||||
registered = handler
|
||||
}
|
||||
s := &PluginSDK{regStage: regStage, name: "myplugin"}
|
||||
|
||||
callCount := 0
|
||||
s.RegisterStage(StageAfterToolcall, func(ctx *StageContext) error {
|
||||
callCount++
|
||||
return nil
|
||||
}, StageScopeOwnTools)
|
||||
|
||||
ctx := &StageContext{}
|
||||
ctx.ToolResults = []ToolResult{{Plugin: "other", Name: "other_tool"}}
|
||||
|
||||
err := registered(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
if callCount != 0 {
|
||||
t.Error("handler should not be called for other plugin's tool result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStageOwnToolsNilRegStage(t *testing.T) {
|
||||
s := &PluginSDK{name: "test"}
|
||||
s.RegisterStage(StageBeforeToolcall, func(ctx *StageContext) error { return nil }, StageScopeOwnTools)
|
||||
}
|
||||
|
||||
func TestToolDefNoMemory(t *testing.T) {
|
||||
def := ToolDef{
|
||||
Name: "test_tool",
|
||||
NoMemory: true,
|
||||
}
|
||||
if !def.NoMemory {
|
||||
t.Error("NoMemory should be true")
|
||||
}
|
||||
def2 := ToolDef{Name: "normal_tool"}
|
||||
if def2.NoMemory {
|
||||
t.Error("default NoMemory should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterTextCleaner(t *testing.T) {
|
||||
s := &PluginSDK{name: "test"}
|
||||
|
||||
c1 := func(text string) string { return text + "_c1" }
|
||||
c2 := func(text string) string { return text + "_c2" }
|
||||
|
||||
s.RegisterTextCleaner(c1)
|
||||
s.RegisterTextCleaner(c2)
|
||||
|
||||
cleaners := s.TextCleaners()
|
||||
if len(cleaners) != 2 {
|
||||
t.Fatalf("expected 2 cleaners, got %d", len(cleaners))
|
||||
}
|
||||
|
||||
got := cleaners[0]("hello")
|
||||
if got != "hello_c1" {
|
||||
t.Errorf("expected hello_c1, got %s", got)
|
||||
}
|
||||
|
||||
got = cleaners[1]("hello")
|
||||
if got != "hello_c2" {
|
||||
t.Errorf("expected hello_c2, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextCleanersEmpty(t *testing.T) {
|
||||
s := New("test", nil, nil, nil, nil, nil)
|
||||
cleaners := s.TextCleaners()
|
||||
if cleaners == nil {
|
||||
t.Error("TextCleaners should return empty slice, not nil")
|
||||
}
|
||||
if len(cleaners) != 0 {
|
||||
t.Errorf("expected 0 cleaners, got %d", len(cleaners))
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
package sdk
|
||||
|
||||
type SettingsAPI interface {
|
||||
// Get reads the plugin's own config value (config_<name> table).
|
||||
Get(key string) (interface{}, error)
|
||||
|
||||
// Set writes a config value to the plugin's own config table.
|
||||
Set(key string, value interface{}) error
|
||||
|
||||
// List returns all keys matching the given prefix.
|
||||
List(prefix string) ([]string, error)
|
||||
|
||||
// GetCore reads the core config table.
|
||||
GetCore(key string) (interface{}, error)
|
||||
|
||||
// SetCore writes to the core config table.
|
||||
SetCore(key string, value interface{}) error
|
||||
|
||||
// ListCore lists core config keys matching the prefix.
|
||||
ListCore(prefix string) ([]string, error)
|
||||
|
||||
// GetPlugin reads another plugin's config table.
|
||||
GetPlugin(plugin, key string) (interface{}, error)
|
||||
|
||||
// SetPlugin writes to another plugin's config table.
|
||||
SetPlugin(plugin, key string, value interface{}) error
|
||||
|
||||
// ListPlugin lists another plugin's config keys matching the prefix.
|
||||
ListPlugin(plugin, prefix string) ([]string, error)
|
||||
|
||||
// RegisterDef registers a config definition for UI display.
|
||||
RegisterDef(def ConfigDef)
|
||||
|
||||
// Defs returns config definitions matching the prefix.
|
||||
Defs(prefix string) []*ConfigDef
|
||||
|
||||
// Dump returns all config values.
|
||||
Dump() map[string]interface{}
|
||||
|
||||
// Plugins returns a list of all plugin config namespaces.
|
||||
Plugins() []string
|
||||
}
|
||||
|
||||
// ConfigDef describes a configuration field for the WebUI.
|
||||
type ConfigDef struct {
|
||||
Key string `json:"key"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Step float64 `json:"step,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
}
|
||||
@ -220,6 +220,7 @@ func main() {
|
||||
input, _ := evt.Payload["input"].(string)
|
||||
response, _ := evt.Payload["response"].(string)
|
||||
toolsUsed, _ := evt.Payload["tools_used"].([]string)
|
||||
toolResults, _ := evt.Payload["tool_results"].([]interface{})
|
||||
agentID, _ := evt.Payload["agent_id"].(string)
|
||||
|
||||
if input != "" && textMem != nil {
|
||||
@ -242,6 +243,15 @@ func main() {
|
||||
if response != "" && memDB != nil {
|
||||
distiller.Append("agent", "assistant", response)
|
||||
}
|
||||
|
||||
// 工具输出接入蒸馏管线
|
||||
for _, tr := range toolResults {
|
||||
if trMap, ok := tr.(map[string]interface{}); ok {
|
||||
if text, ok := trMap["output"].(string); ok && text != "" && memDB != nil {
|
||||
distiller.Append("agent", "tool", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -423,8 +433,7 @@ func main() {
|
||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||
log.Printf("[homed] warning: load plugins: %v", err)
|
||||
}
|
||||
memory.SetTextCleaner(pluginReg.CleanText)
|
||||
log.Printf("[homed] stage host ready with %d registered tools, text cleaner set", stageHost.ToolCount())
|
||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
||||
|
||||
// 日志管理:层级压缩 + 保留策略
|
||||
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
package main
|
||||
|
||||
type termios struct {
|
||||
Iflag uint32
|
||||
Oflag uint32
|
||||
Cflag uint32
|
||||
Lflag uint32
|
||||
Cc [20]byte
|
||||
Ispeed uint32
|
||||
Ospeed uint32
|
||||
}
|
||||
|
||||
const (
|
||||
TCGETS = 0x5401
|
||||
TCSETS = 0x5402
|
||||
ICANON = 0x2
|
||||
ECHO = 0x8
|
||||
ISIG = 0x1
|
||||
VMIN = 6
|
||||
VTIME = 5
|
||||
)
|
||||
@ -1,9 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func setRawMode(fd int) (func(), error) {
|
||||
return func() {}, fmt.Errorf("raw terminal mode not supported on this platform")
|
||||
}
|
||||
@ -8,7 +8,7 @@ HomeAgent's cognitive architecture consists of three subsystems: the event loop
|
||||
|
||||
**The event loop (eventLoop)** is a three-way select: `a.io.InputChan()` receives external user input and dispatches to `processTextInput` / `processMediaInput`; `a.selfInputCh` receives internal system tasks (memory merges, distillation callbacks) routed through `processConsolidation` under the `_consolidation_` output channel; `a.ctx.Done()` accepts shutdown signals. A concurrently running `interceptLoop` goroutine independently reads `a.io.InputInterruptChan()` — on receiving a high-priority interrupt, it cancels the in-flight LLM HTTP request (`a.cancelLLM()`), then writes the event to `a.interceptCh`. This channel is drained non-blockingly by `drainInterrupts()` before each LLM call in `process()`, injecting interrupts as `[打断消息]` formatted entries into message history. The three interrupt delivery paths carry distinct semantics: `cancelLLM` terminates the current HTTP request, `interceptCh` injects text before the next LLM turn, and `InjectInput` triggers a new processing cycle when the event loop is idle.
|
||||
|
||||
**The stage pipeline (StageHost)** manages two registration categories: tool definitions (ToolDef) and stage handlers (StageHandler). `RegisterTool` rejects duplicate names, infers the owning plugin name from the tool name prefix, and maintains a `toolPlugins` mapping. `RegisterStage` appends handlers to the corresponding stage list. On stage execution (`RunStage`), **all registered handlers execute in parallel via goroutines**, sharing a single `*StageContext` protected by `sync.RWMutex`. Individual handler panics are recovered independently without affecting other handlers. Short-circuit semantics are implemented by checking `ctx.Response != nil` — any stage handler can set this value to terminate the pipeline early. `ExecuteTool` includes built-in panic recovery with stack-trace recording. `UnregisterPluginTools` removes a plugin's tool set during hot-reload.
|
||||
**The stage pipeline (StageHost)** manages two registration categories: tool definitions (ToolDef) and stage handlers (StageHandler). ToolDef includes two optional memory control fields: `NoMemory bool` — when true, the tool's output is excluded from vectorization/jieba/distillation (original text preserved); and `Cleaner func(string) string` — a filter applied before the output enters the computation layer (e.g., extracting a `content` field from JSON). Neither modifies the original output; both only affect the computation layer input. `RegisterTool` rejects duplicate names, infers the owning plugin name from the tool name prefix, and maintains a `toolPlugins` mapping. `RegisterStage` appends handlers to the corresponding stage list. On stage execution (`RunStage`), **all registered handlers execute in parallel via goroutines**, sharing a single `*StageContext` protected by `sync.RWMutex`. Individual handler panics are recovered independently without affecting other handlers. Short-circuit semantics are implemented by checking `ctx.Response != nil` — any stage handler can set this value to terminate the pipeline early. `ExecuteTool` includes built-in panic recovery with stack-trace recording. `UnregisterPluginTools` removes a plugin's tool set during hot-reload.
|
||||
|
||||
**The context window (RelevanceContext)** maintains a chronologically ordered event list. `Append` applies `CleanTemplateText` to strip QQ templates and timestamp noise before computing the embedding vector using a three-branch strategy (agent events use Response, user events use Input, cold_storage uses Input+Response). `Prune` triggers when the event count exceeds `topK`: it **unconditionally protects the last 10 events from eviction** (recency bias), scores remaining candidates against the current input via CosineSimilarity, keeps `topK - 10` highest-scoring entries (floor at 0), then re-sorts chronologically. Pruned events from sources other than `agentcli` and `terminal` are archived to the Document layer via `docStore.ContextToDoc`, retaining original timestamps. Persistence uses 5-second debounced writes to a JSON file.
|
||||
|
||||
|
||||
@ -250,6 +250,10 @@ shared by both Windows DLL and Linux/macOS .so builds. No manual bridge code nee
|
||||
s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
Name: "weather_query",
|
||||
Description: "Query weather for a specified city",
|
||||
NoMemory: false, // false=output participates in memory, true=skip
|
||||
// Cleaner: func(output string) string { // Optional: clean output before vector/jieba/distill
|
||||
// return extractJSON(output, "content")
|
||||
// },
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -270,6 +274,26 @@ s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
})
|
||||
```
|
||||
|
||||
##### NoMemory and Cleaner
|
||||
|
||||
`NoMemory` and `Cleaner` are optional fields on `ToolDef` that control how tool output participates in the **memory computation layer** (vectorization, jieba tokenization, distillation):
|
||||
|
||||
- **`NoMemory`** (default `false`): When `true`, the tool's output is excluded from all memory computation (vector, tokenization, distillation), but the original text is preserved in Context and Document. LLM attention is unaffected. Use cases: `cmd_run` (unpredictable noise in command output), pure operation tools like file upload/delete.
|
||||
|
||||
- **`Cleaner`** (optional): A function `func(output string) string`. When set, the tool output is filtered through this function before participating in vectorization/jieba/distillation. Typical use: stripping SQL prefixes, extracting a `content` field from JSON. The original output is never modified — Cleaner only affects the computation layer input.
|
||||
|
||||
Decision matrix:
|
||||
|
||||
```
|
||||
Tool output → valuable for LLM attention?
|
||||
├── No → NoMemory=true (output preserved, skipped in computation)
|
||||
└── Yes → Contains cleanable noise?
|
||||
├── Yes → Cleaner filters before computation
|
||||
└── No → Normal memory, no extra handling
|
||||
```
|
||||
|
||||
> **Note**: `Cleaner` is a Go `func` type (`json:"-"`), cannot cross C ABI boundaries. Not available for Lua plugins or remote plugins.
|
||||
|
||||
#### Stage Hooks — Intervene in message processing flow
|
||||
|
||||
7 stages:
|
||||
|
||||
@ -8,7 +8,7 @@ HomeAgent 的认知架构由三个核心子系统构成:事件循环(eventLo
|
||||
|
||||
**事件循环(eventLoop)** 是一个三路 select 循环:`a.io.InputChan()` 接收外部用户输入并分发至 `processTextInput` / `processMediaInput`;`a.selfInputCh` 接收内部系统任务(如记忆合并、蒸馏回调),以 `_consolidation_` 输出通道标识区分,走 `processConsolidation` 路径;`a.ctx.Done()` 接受关闭信号。与之并行运行的 `interceptLoop` 协程独立监听 `a.io.InputInterruptChan()`,收到高优先级中断时先取消当前 LLM HTTP 请求(`a.cancelLLM()`),再将事件写入 `a.interceptCh`——该通道在 `process()` 每次 LLM 调用前由 `drainInterrupts()` 非阻塞排空,以 `[打断消息]` 格式注入消息历史。三条中断投递路径各具语义:`cancelLLM` 终结当前 HTTP 请求,`interceptCh` 在下一轮 LLM 调用前注入文本,`InjectInput` 在 eventLoop 空闲时触发新一轮处理。
|
||||
|
||||
**阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。
|
||||
**阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。
|
||||
|
||||
**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `CleanTemplateText` 剥离 QQ 模板与时间戳噪声,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。
|
||||
|
||||
|
||||
@ -248,6 +248,10 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
Name: "weather_query",
|
||||
Description: "查询指定城市的天气",
|
||||
NoMemory: false, // false=输出参与记忆计算,true=跳过计算
|
||||
// Cleaner: func(output string) string { // 可选:输出参与向量化/jieba/蒸馏前的清洗
|
||||
// return extractJSON(output, "content")
|
||||
// },
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
@ -268,6 +272,26 @@ s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
})
|
||||
```
|
||||
|
||||
##### NoMemory 与 Cleaner 说明
|
||||
|
||||
`NoMemory` 和 `Cleaner` 是 `ToolDef` 上的两个可选字段,控制工具输出在**记忆计算层**(向量化、jieba 分词、蒸馏)中的行为:
|
||||
|
||||
- **`NoMemory`**(默认 `false`):设为 `true` 时,工具输出不参与任何记忆计算(向量、分词、蒸馏),但原文保留在 Context 和 Document 中,LLM 注意力不受影响。适用场景:`cmd_run`(命令输出含不可控噪音)、文件上传/删除等纯操作工具。
|
||||
|
||||
- **`Cleaner`**(可选):函数签名 `func(output string) string`。注册后,工具输出在参与向量化/jieba/蒸馏前先经过此函数过滤。典型用途:SQL 查询去前缀、JSON 包裹提取 `content` 字段。原文始终不变,Cleaner 只影响计算层输入。
|
||||
|
||||
决策矩阵:
|
||||
|
||||
```
|
||||
工具输出 → 对 LLM 注意力有信号价值?
|
||||
├── 否 → NoMemory=true(输出保留原文,跳过计算层)
|
||||
└── 是 → 有可控噪音?
|
||||
├── 是 → Cleaner 过滤后参与计算
|
||||
└── 否 → 正常记忆,无需额外处理
|
||||
```
|
||||
|
||||
> **注意**:`Cleaner` 是 Go `func` 类型(`json:"-"`),不能跨 C ABI 边界序列化。Lua 插件和远程插件无法使用。
|
||||
|
||||
#### 阶段钩子 — 干预消息处理流
|
||||
|
||||
7 个阶段:
|
||||
|
||||
2
go.mod
2
go.mod
@ -12,5 +12,5 @@ require github.com/yanyiwu/gojieba v1.4.7
|
||||
|
||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.7.1
|
||||
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local
|
||||
replace gitcode.com/JianFeeeee/homeagent-sdk => ../homeagentsdk
|
||||
|
||||
|
||||
@ -16,13 +16,19 @@ import (
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
type ToolResultItem struct {
|
||||
Name string `json:"name"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
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"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
Vector vector.Vector `json:"-"`
|
||||
}
|
||||
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
@ -64,25 +70,49 @@ func (c *RelevanceContext) load() {
|
||||
return
|
||||
}
|
||||
for _, evt := range events {
|
||||
evt.Input = memory.CleanText(evt.Input)
|
||||
evt.Vector = c.computeVector(evt)
|
||||
}
|
||||
c.events = events
|
||||
}
|
||||
|
||||
func textForVector(evt *ContextEvent) string {
|
||||
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string {
|
||||
var text string
|
||||
switch {
|
||||
case evt.Source == "agent" && evt.Response != "":
|
||||
return memory.CleanText(evt.Response)
|
||||
text = evt.Response
|
||||
case evt.Source == "cold_storage":
|
||||
return memory.CleanText(evt.Input + " " + evt.Response)
|
||||
text = evt.Input + " " + evt.Response
|
||||
default:
|
||||
return memory.CleanText(evt.Input)
|
||||
text = evt.Input
|
||||
}
|
||||
|
||||
// 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤
|
||||
if toolDefLookup != nil {
|
||||
noMemory := make(map[string]bool)
|
||||
for _, tr := range evt.ToolResults {
|
||||
def := toolDefLookup(tr.Name)
|
||||
if def != nil && def.NoMemory {
|
||||
noMemory[tr.Name] = true
|
||||
}
|
||||
}
|
||||
for _, tr := range evt.ToolResults {
|
||||
if noMemory[tr.Name] {
|
||||
continue
|
||||
}
|
||||
cleaned := tr.Output
|
||||
def := toolDefLookup(tr.Name)
|
||||
if def != nil && def.Cleaner != nil {
|
||||
cleaned = def.Cleaner(cleaned)
|
||||
}
|
||||
text += " " + cleaned
|
||||
}
|
||||
}
|
||||
|
||||
return memory.CleanText(text)
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
|
||||
return c.embedder.Vectorize(textForVector(evt))
|
||||
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) Save() error {
|
||||
@ -103,7 +133,6 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
evt.Input = memory.CleanText(evt.Input)
|
||||
evt.Vector = c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
@ -199,25 +228,19 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
||||
|
||||
archived := 0
|
||||
if docStore != nil && len(archive) > 0 {
|
||||
var filtered []scored
|
||||
for _, s := range archive {
|
||||
if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
entries := make([]document.ContextEntry, len(filtered))
|
||||
for i, s := range filtered {
|
||||
entries := make([]document.ContextEntry, len(archive))
|
||||
for i, s := range archive {
|
||||
entries[i] = document.ContextEntry{
|
||||
Timestamp: s.event.Timestamp,
|
||||
Source: s.event.Source,
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
Timestamp: s.event.Timestamp,
|
||||
Source: s.event.Source,
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
}
|
||||
}
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
|
||||
if err == nil && doc != nil {
|
||||
archived = len(filtered)
|
||||
archived = len(entries)
|
||||
}
|
||||
}
|
||||
|
||||
@ -266,14 +289,15 @@ func (c *RelevanceContext) Len() int {
|
||||
return len(c.events)
|
||||
}
|
||||
|
||||
func hasNoMemoryTool(toolsUsed []string, lookup func(string) *sdk.ToolDef) bool {
|
||||
if lookup == nil {
|
||||
return false
|
||||
func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
for _, name := range toolsUsed {
|
||||
if def := lookup(name); def != nil && def.NoMemory {
|
||||
return true
|
||||
}
|
||||
result := make([]document.ToolResultItem, len(items))
|
||||
for i, item := range items {
|
||||
result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output}
|
||||
}
|
||||
return false
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func newTestCtx() *RelevanceContext {
|
||||
@ -238,40 +237,6 @@ func containsStr(s, substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestHasNoMemoryTool(t *testing.T) {
|
||||
host := NewStageHost()
|
||||
host.RegisterTool("no_mem_tool", sdk.ToolDef{Name: "no_mem_tool", NoMemory: true}, nil)
|
||||
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool"}, nil)
|
||||
lookup := host.ToolDef
|
||||
|
||||
gotNil := hasNoMemoryTool([]string{"no_mem_tool"}, nil)
|
||||
if gotNil {
|
||||
t.Error("hasNoMemoryTool with nil lookup should return false")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
toolsUsed []string
|
||||
want bool
|
||||
}{
|
||||
{"empty tools", nil, false},
|
||||
{"no matching tool", []string{"unknown"}, false},
|
||||
{"tool without NoMemory", []string{"mem_tool"}, false},
|
||||
{"tool with NoMemory", []string{"no_mem_tool"}, true},
|
||||
{"mixed tools, first is no_memory", []string{"no_mem_tool", "mem_tool"}, true},
|
||||
{"mixed tools, last is no_memory", []string{"mem_tool", "no_mem_tool"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasNoMemoryTool(tt.toolsUsed, lookup)
|
||||
if got != tt.want {
|
||||
t.Errorf("hasNoMemoryTool(%v) = %v, want %v", tt.toolsUsed, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
|
||||
@ -377,14 +377,15 @@ func docToTriples(doc *document.Doc) []memory.Triple {
|
||||
return triples
|
||||
}
|
||||
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolsUsed []string) {
|
||||
func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) {
|
||||
a.io.EmitOutput("memory", "memory_candidate", map[string]interface{}{
|
||||
"source": source,
|
||||
"input": input,
|
||||
"response": response,
|
||||
"tools_used": toolsUsed,
|
||||
"agent_id": string(a.id),
|
||||
"timestamp": time.Now().Unix(),
|
||||
"source": source,
|
||||
"input": input,
|
||||
"response": response,
|
||||
"tool_results": toolResults,
|
||||
"tools_used": toolsUsed,
|
||||
"agent_id": string(a.id),
|
||||
"timestamp": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -406,17 +407,18 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
Source: "system",
|
||||
Input: input,
|
||||
})
|
||||
response, toolsUsed, err := a.process(input, stageCtx)
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
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,
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
})
|
||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
||||
Input: fallback,
|
||||
})
|
||||
|
||||
response, toolsUsed, err := a.process(fallback, stageCtx)
|
||||
response, toolsUsed, toolResults, err := a.process(fallback, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process media error: %v", err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
@ -196,17 +196,18 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: fallback,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: fallback,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
})
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
|
||||
a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
|
||||
}
|
||||
}
|
||||
|
||||
@ -312,7 +313,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
Input: input,
|
||||
})
|
||||
|
||||
response, toolsUsed, err := a.process(input, stageCtx)
|
||||
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
|
||||
if err != nil {
|
||||
log.Printf("[agent] process error: %v", err)
|
||||
resp := fmt.Sprintf("处理错误: %v", err)
|
||||
@ -325,17 +326,18 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
|
||||
|
||||
a.context.Append(ContextEvent{
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
Timestamp: time.Now(),
|
||||
Source: "agent",
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults,
|
||||
})
|
||||
|
||||
a.emitResponse(evt, response)
|
||||
|
||||
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
|
||||
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, input, response, toolResults, toolsUsed)
|
||||
}
|
||||
}
|
||||
|
||||
@ -386,15 +388,6 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
||||
}
|
||||
|
||||
func (a *Agent) hasNoMemoryTool(toolsUsed []string) bool {
|
||||
for _, name := range toolsUsed {
|
||||
if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) drainInterrupts() []string {
|
||||
var out []string
|
||||
for {
|
||||
|
||||
@ -15,12 +15,12 @@ import (
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
|
||||
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.provider == nil {
|
||||
return "", nil, fmt.Errorf("agent: no LLM provider configured")
|
||||
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
|
||||
}
|
||||
|
||||
memContext := a.buildMemoryContext(input)
|
||||
@ -40,7 +40,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
a.docStoreSize())
|
||||
|
||||
if a.runStage(sdk.StagePreAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, nil
|
||||
return *stageCtx.Response, toolsUsed, toolResults, nil
|
||||
}
|
||||
if len(stageCtx.ContextMsgs) > 0 {
|
||||
for _, m := range stageCtx.ContextMsgs {
|
||||
@ -132,11 +132,11 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
if llmErr != nil {
|
||||
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
|
||||
if a.currentOutputChannel == "_consolidation_" {
|
||||
return "", toolsUsed, fmt.Errorf("interrupted by user input")
|
||||
return "", toolsUsed, toolResults, fmt.Errorf("interrupted by user input")
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
|
||||
return "", toolsUsed, toolResults, fmt.Errorf("all %d providers failed, last error: %w",
|
||||
len(providers), llmErr)
|
||||
}
|
||||
|
||||
@ -154,7 +154,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
}
|
||||
if a.runStage(sdk.StagePostAction, stageCtx) {
|
||||
return *stageCtx.Response, toolsUsed, nil
|
||||
return *stageCtx.Response, toolsUsed, toolResults, nil
|
||||
}
|
||||
resp.Content = stageCtx.LLMText
|
||||
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
|
||||
@ -176,7 +176,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
a.publishEvent(events.EventAgentLLMChain, chainPayload)
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
return resp.Content, toolsUsed, nil
|
||||
return resp.Content, toolsUsed, toolResults, nil
|
||||
}
|
||||
|
||||
contentOnce := true
|
||||
@ -226,6 +226,7 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
|
||||
}
|
||||
|
||||
result := a.executeToolCall(tc)
|
||||
toolResults = append(toolResults, ToolResultItem{Name: tc.Name, Output: result})
|
||||
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
|
||||
|
||||
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}
|
||||
|
||||
@ -159,6 +159,29 @@ func (h *StageHost) RunStage(stage sdk.Stage, ctx *sdk.StageContext) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *StageHost) ToolDefCleaner(name string) func(string) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, def := range h.toolDefs {
|
||||
if def.Name == name {
|
||||
return def.Cleaner
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *StageHost) NoMemoryToolNames() map[string]bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
set := make(map[string]bool, len(h.toolDefs))
|
||||
for _, def := range h.toolDefs {
|
||||
if def.NoMemory {
|
||||
set[def.Name] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (h *StageHost) ToolCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
@ -7,24 +7,24 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func init() {
|
||||
globalTextCleaner = func(text string) string {
|
||||
reQQGroupSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊,content 设为 JSON 字符串:\{[^}]*\}`)
|
||||
reQQPrivateSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方,content 设为 JSON 字符串:\{[^}]*\}`)
|
||||
reQQOldReply := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取后必须使用[^。]+。`)
|
||||
reQQOldForbid := regexp.MustCompile(`你只能通过qq_get_message先看消息,然后直接用%!s\(MISSING\)send_private_msg回复,中间的思考过程禁止调用任何其他工具\s*→\s*`)
|
||||
reQQGeneral := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文[。,][^。]*?(?:回复|发送消息)`)
|
||||
reTimestamp := regexp.MustCompile(`\[\d{2}:\d{2}\]\s*`)
|
||||
reMultiSpace := regexp.MustCompile(`\s+`)
|
||||
text = reQQGroupSuffix.ReplaceAllString(text, "")
|
||||
text = reQQPrivateSuffix.ReplaceAllString(text, "")
|
||||
text = reQQOldReply.ReplaceAllString(text, "")
|
||||
text = reQQOldForbid.ReplaceAllString(text, "")
|
||||
text = reQQGeneral.ReplaceAllString(text, "")
|
||||
text = reTimestamp.ReplaceAllString(text, "")
|
||||
text = reMultiSpace.ReplaceAllString(text, " ")
|
||||
return text
|
||||
}
|
||||
// cleanQQTemplate 模拟之前由 globalTextCleaner 执行的模板噪音清理,
|
||||
// 用于 stress test 中生成 cleanedText。
|
||||
func cleanQQTemplate(text string) string {
|
||||
reQQGroupSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊,content 设为 JSON 字符串:\{[^}]*\}`)
|
||||
reQQPrivateSuffix := regexp.MustCompile(`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方,content 设为 JSON 字符串:\{[^}]*\}`)
|
||||
reQQOldReply := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文。获取后必须使用[^。]+。`)
|
||||
reQQOldForbid := regexp.MustCompile(`你只能通过qq_get_message先看消息,然后直接用%!s\(MISSING\)send_private_msg回复对方,中间的思考过程禁止调用任何其他工具\s*→\s*`)
|
||||
reQQGeneral := regexp.MustCompile(`通过id\d+使用qq_get_message工具获取消息正文[。,][^。]*?(?:回复|发送消息)`)
|
||||
reTimestamp := regexp.MustCompile(`\[\d{2}:\d{2}\]\s*`)
|
||||
reMultiSpace := regexp.MustCompile(`\s+`)
|
||||
text = reQQGroupSuffix.ReplaceAllString(text, "")
|
||||
text = reQQPrivateSuffix.ReplaceAllString(text, "")
|
||||
text = reQQOldReply.ReplaceAllString(text, "")
|
||||
text = reQQOldForbid.ReplaceAllString(text, "")
|
||||
text = reQQGeneral.ReplaceAllString(text, "")
|
||||
text = reTimestamp.ReplaceAllString(text, "")
|
||||
text = reMultiSpace.ReplaceAllString(text, " ")
|
||||
return text
|
||||
}
|
||||
|
||||
type cleanTestEvent struct {
|
||||
@ -305,13 +305,14 @@ func genStressEvents(n int) []cleanTestEvent {
|
||||
}
|
||||
|
||||
func cleanEventText(source, input, response string) string {
|
||||
// 先做基础 CleanText(去空格/逗号),再做模板噪音清理
|
||||
switch {
|
||||
case source == "agent" && response != "":
|
||||
return CleanText(response)
|
||||
return cleanQQTemplate(CleanText(response))
|
||||
case source == "cold_storage":
|
||||
return CleanText(input + " " + response)
|
||||
return cleanQQTemplate(CleanText(input + " " + response))
|
||||
default:
|
||||
return CleanText(input)
|
||||
return cleanQQTemplate(CleanText(input))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,17 +6,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||
)
|
||||
|
||||
var globalTextCleaner func(string) string
|
||||
|
||||
func SetTextCleaner(fn func(string) string) {
|
||||
globalTextCleaner = fn
|
||||
}
|
||||
|
||||
func CleanText(text string) string {
|
||||
if globalTextCleaner != nil {
|
||||
text = globalTextCleaner(text)
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
if text == "" {
|
||||
|
||||
@ -5,10 +5,6 @@ import (
|
||||
)
|
||||
|
||||
func TestCleanTextTrim(t *testing.T) {
|
||||
prev := globalTextCleaner
|
||||
globalTextCleaner = nil
|
||||
defer func() { globalTextCleaner = prev }()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
@ -29,55 +25,3 @@ func TestCleanTextTrim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTextWithRegisteredCleaner(t *testing.T) {
|
||||
prev := globalTextCleaner
|
||||
globalTextCleaner = func(text string) string {
|
||||
return "prefix_" + text
|
||||
}
|
||||
defer func() { globalTextCleaner = prev }()
|
||||
|
||||
got := CleanText(" hello ")
|
||||
if got != "prefix_ hello" {
|
||||
t.Errorf("CleanText with cleaner = %q, want %q", got, "prefix_ hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTextCleanerChain(t *testing.T) {
|
||||
prev := globalTextCleaner
|
||||
globalTextCleaner = func(text string) string {
|
||||
text = text + "_step1"
|
||||
text = text + "_step2"
|
||||
return text
|
||||
}
|
||||
defer func() { globalTextCleaner = prev }()
|
||||
|
||||
got := CleanText("test")
|
||||
if got != "test_step1_step2" {
|
||||
t.Errorf("CleanText chain = %q, want %q", got, "test_step1_step2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTextCleanerReplace(t *testing.T) {
|
||||
prev := globalTextCleaner
|
||||
globalTextCleaner = func(text string) string { return "old_" + text }
|
||||
|
||||
SetTextCleaner(func(text string) string { return "new_" + text })
|
||||
defer func() { globalTextCleaner = prev }()
|
||||
|
||||
got := CleanText("x")
|
||||
if got != "new_x" {
|
||||
t.Errorf("after SetTextCleaner = %q, want %q", got, "new_x")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanTextEmptyAfterCleaner(t *testing.T) {
|
||||
prev := globalTextCleaner
|
||||
globalTextCleaner = func(text string) string { return "" }
|
||||
defer func() { globalTextCleaner = prev }()
|
||||
|
||||
got := CleanText("something")
|
||||
if got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,25 +124,34 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
}
|
||||
|
||||
// ContextToDoc — 将一段上下文对话历史提炼为文档(带内容去重)
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer) (*Doc, error) {
|
||||
// cleanFn 可选,用于在计算层(摘要/标签/实体提取)前过滤文本,不影响原文存储。
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn ...func(string) string) (*Doc, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cleanText := func(text string) string { return text }
|
||||
if len(cleanFn) > 0 && cleanFn[0] != nil {
|
||||
cleanText = cleanFn[0]
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, e := range entries {
|
||||
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
|
||||
if e.Response != "" {
|
||||
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
contentHash := simpleHash(content)
|
||||
|
||||
summary := summarizeEntries(entries)
|
||||
tags := extractTags(entries)
|
||||
entities := extractEntities(entries)
|
||||
summary := summarizeEntries(entries, cleanText)
|
||||
tags := extractTags(entries, cleanText)
|
||||
entities := extractEntities(entries, cleanText)
|
||||
|
||||
s.mu.Lock()
|
||||
|
||||
@ -417,23 +426,38 @@ func (s *Store) flush() {
|
||||
s.dirty = false
|
||||
}
|
||||
|
||||
type ContextEntry struct {
|
||||
Timestamp time.Time
|
||||
Source string
|
||||
Content string
|
||||
Response string
|
||||
type ToolResultItem struct {
|
||||
Name string
|
||||
Output string
|
||||
}
|
||||
|
||||
func summarizeEntries(entries []ContextEntry) string {
|
||||
type ContextEntry struct {
|
||||
Timestamp time.Time
|
||||
Source string
|
||||
Content string
|
||||
Response string
|
||||
ToolResults []ToolResultItem
|
||||
}
|
||||
|
||||
func summarizeEntries(entries []ContextEntry, cleanText ...func(string) string) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
clean := func(text string) string { return text }
|
||||
if len(cleanText) > 0 && cleanText[0] != nil {
|
||||
clean = cleanText[0]
|
||||
}
|
||||
sources := make(map[string]int)
|
||||
var topics []string
|
||||
for _, e := range entries {
|
||||
sources[e.Source]++
|
||||
words := memory.ExtractKeywords(e.Content)
|
||||
words := memory.ExtractKeywords(clean(e.Content))
|
||||
topics = append(topics, words...)
|
||||
for _, tr := range e.ToolResults {
|
||||
cleaned := clean(tr.Output)
|
||||
toolWords := memory.ExtractKeywords(cleaned)
|
||||
topics = append(topics, toolWords...)
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
|
||||
@ -461,12 +485,21 @@ func summarizeEntries(entries []ContextEntry) string {
|
||||
return summary
|
||||
}
|
||||
|
||||
func extractTags(entries []ContextEntry) []string {
|
||||
func extractTags(entries []ContextEntry, cleanText ...func(string) string) []string {
|
||||
clean := func(text string) string { return text }
|
||||
if len(cleanText) > 0 && cleanText[0] != nil {
|
||||
clean = cleanText[0]
|
||||
}
|
||||
tagSet := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
for _, kw := range memory.ExtractKeywords(e.Content) {
|
||||
for _, kw := range memory.ExtractKeywords(clean(e.Content)) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
for _, kw := range memory.ExtractKeywords(clean(tr.Output)) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var tags []string
|
||||
for t := range tagSet {
|
||||
@ -478,17 +511,29 @@ func extractTags(entries []ContextEntry) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
func extractEntities(entries []ContextEntry) []string {
|
||||
func extractEntities(entries []ContextEntry, cleanText ...func(string) string) []string {
|
||||
// 简易实体提取:提取引号内的内容、粗体/标记词
|
||||
clean := func(text string) string { return text }
|
||||
if len(cleanText) > 0 && cleanText[0] != nil {
|
||||
clean = cleanText[0]
|
||||
}
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
for _, kw := range memory.ExtractKeywords(e.Content) {
|
||||
for _, kw := range memory.ExtractKeywords(clean(e.Content)) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
}
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
for _, kw := range memory.ExtractKeywords(clean(tr.Output)) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(entities) > 20 {
|
||||
entities = entities[:20]
|
||||
|
||||
@ -53,7 +53,7 @@ func TestCleanText(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, c := range cases {
|
||||
got := CleanText(c.input)
|
||||
got := cleanQQTemplate(CleanText(c.input))
|
||||
if c.expected != "" && got != c.expected {
|
||||
t.Errorf("case %d:\n input: %q\n expected: %q\n got: %q", i, trimLen(c.input, 60), c.expected, got)
|
||||
}
|
||||
|
||||
@ -84,7 +84,6 @@ type Registry struct {
|
||||
toolCleaner PluginToolCleaner
|
||||
|
||||
knownDisabled map[string]bool
|
||||
textCleaners []func(string) string
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
@ -110,17 +109,6 @@ func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage =
|
||||
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
|
||||
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
|
||||
|
||||
// CleanText applies all registered text cleaners in order.
|
||||
func (r *Registry) CleanText(text string) string {
|
||||
r.mu.RLock()
|
||||
cleaners := r.textCleaners
|
||||
r.mu.RUnlock()
|
||||
for _, fn := range cleaners {
|
||||
text = fn(text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -270,7 +258,6 @@ func (r *Registry) Load(dir string) error {
|
||||
r.plugins[name] = p
|
||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||
r.instances = append(r.instances, p)
|
||||
r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)
|
||||
r.mu.Unlock()
|
||||
log.Printf("[plugin] loaded: %s", name)
|
||||
}
|
||||
@ -353,7 +340,6 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
||||
r.plugins[name] = plg
|
||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||
r.instances = append(r.instances, plg)
|
||||
r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)
|
||||
r.mu.Unlock()
|
||||
log.Printf("[plugin] loaded: %s", name)
|
||||
return true
|
||||
@ -370,7 +356,6 @@ func (r *Registry) StopAll() {
|
||||
r.plugins = make(map[string]sdk.Plugin)
|
||||
r.instances = nil
|
||||
r.pluginAutoRestart = make(map[string]bool)
|
||||
r.textCleaners = nil
|
||||
}
|
||||
|
||||
func (r *Registry) Reload(dir string) (string, error) {
|
||||
|
||||
@ -214,8 +214,13 @@ func TestCmdRunNonZeroExit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTruncateOutput(t *testing.T) {
|
||||
p, _, err := setupPlugin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
short := "hello"
|
||||
if s := truncateOutput(short); s != short {
|
||||
if s := p.truncateOutput(short); s != short {
|
||||
t.Fatalf("expected %q, got %q", short, s)
|
||||
}
|
||||
|
||||
@ -223,7 +228,7 @@ func TestTruncateOutput(t *testing.T) {
|
||||
for i := range long {
|
||||
long[i] = 'x'
|
||||
}
|
||||
s := truncateOutput(string(long))
|
||||
s := p.truncateOutput(string(long))
|
||||
if len(s) >= 40000 {
|
||||
t.Fatal("expected truncation")
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@ -61,6 +62,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||
Name: tp + "read",
|
||||
Description: fmt.Sprintf("读取文件内容。支持 offset/limit 分段读取大文件。沙箱路径: %s", p.filesDir),
|
||||
NoMemory: false,
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Content string }
|
||||
if err := json.Unmarshal([]byte(output), &r); err != nil {
|
||||
return output
|
||||
}
|
||||
return r.Content
|
||||
},
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
|
||||
735
plan.md
735
plan.md
@ -1,561 +1,274 @@
|
||||
# 记忆系统重构计划 — NoMemory 与 TextCleaner 设计修正
|
||||
|
||||
基于 `review.md` 审查结论,当前实现存在两个设计偏差:
|
||||
> **更新日期:** 2026-07-25
|
||||
> **实施状态:** 全部完成 ✅
|
||||
|
||||
| 机制 | 当前(错误) | 应然(目标) |
|
||||
|---|---|---|
|
||||
| `RegisterTextCleaner` | 插件级全局 cleaner,`CleanText` 入口直接改原文 | **删除**,拆为 `ToolDef.Cleaner`(工具级,仅计算层生效) |
|
||||
| `ToolDef.Cleaner` | 不存在 | 工具级,仅向量化/jieba/蒸馏时调用,不改原文 |
|
||||
| `NoMemory=true` | `hasNoMemoryTool` 二值判断 → 整轮不写记忆 | 工具输出不参与向量/jieba/蒸馏,但原文保留在 Context/Document |
|
||||
基于 `review.md` 审查结论,核心层两个设计偏差已全部修复:
|
||||
- `RegisterTextCleaner` → 拆为 `ToolDef.Cleaner`(工具级,仅计算层生效)✅
|
||||
- `hasNoMemoryTool` → 工具输出不参与向量/jieba/蒸馏,原文保留 ✅
|
||||
|
||||
---
|
||||
|
||||
## 阶段零:理解当前数据流(现状确认)
|
||||
## 第九阶段:SDK 示例插件更新(全部完成 ✅)
|
||||
|
||||
```
|
||||
用户输入 → processTextInput()
|
||||
→ context.Append(Input) // evt.Input = CleanText(evt.Input) ← 原文被改
|
||||
→ a.process() → LLM call loop
|
||||
→ tool execution → result → msgs ← 工具输出在此
|
||||
→ context.Append(Response, ToolsUsed) // evt.Response 是 LLM 回复,不是工具输出
|
||||
→ emitMemoryCandidate(input, response, toolsUsed)
|
||||
→ main.go goroutine
|
||||
→ textMem.Append(Event{Input, Response}) // JSONL 原文
|
||||
→ distiller.Append("assistant", response) // 仅 LLM 回复
|
||||
→ extractKeyTriples → graph memory
|
||||
> **当前状态:** 所有示例插件已按以下分类添加 `NoMemory`/`Cleaner` 字段。其中多数插件由前期迭代完成,`editdoc`/`rss`/`qq` 三个插件在本次审查后补全。
|
||||
|
||||
蒸馏心跳:
|
||||
context.Prune() → ContextToDoc(entries) → Doc.Content ← hasNoMemoryTool 跳过整条
|
||||
reorgGraph() → docToTriples(Doc) → graph memory
|
||||
### 9.1 背景
|
||||
|
||||
SDK 公有仓 `homeagentsdk/example/` 中的示例插件全部使用基础 `ToolDef`(仅 `Name`/`Description`/`Parameters`),未展示 `NoMemory`/`Cleaner` 用法,新插件开发者无从知晓这些字段。
|
||||
|
||||
### 9.2 QQ 插件分析
|
||||
|
||||
**文件:** `homeagentsdk/example/qq/plugin.go`
|
||||
|
||||
QQ 插件通过 `regTool` 包装方法注册(line 437),已扩展为直接透传 `sdk.ToolDef`:
|
||||
|
||||
```go
|
||||
func (p *Plugin) regTool(s *sdk.PluginSDK, def sdk.ToolDef, handler sdk.ToolHandler) {
|
||||
s.RegisterTool(def.Name, def, handler)
|
||||
}
|
||||
```
|
||||
|
||||
关键发现:**工具输出从未直接进入 pipeline**(pipeline 只存 user/assistant 的原始文本)。`hasNoMemoryTool` 跳过了整个 LLM 回复,这是过度保守的。
|
||||
备选方案(保持便捷性但增加可选参数):
|
||||
```go
|
||||
func (p *Plugin) regTool(s *sdk.PluginSDK, name, desc string, params map[string]interface{}, handler sdk.ToolHandler, opts ...ToolOpt) {
|
||||
def := sdk.ToolDef{Name: name, Description: desc, Parameters: params}
|
||||
for _, o := range opts { o(&def) }
|
||||
s.RegisterTool(name, def, handler)
|
||||
}
|
||||
type ToolOpt func(*sdk.ToolDef)
|
||||
func WithNoMemory() ToolOpt { return func(d *sdk.ToolDef) { d.NoMemory = true } }
|
||||
func WithCleaner(fn func(string) string) ToolOpt { return func(d *sdk.ToolDef) { d.Cleaner = fn } }
|
||||
```
|
||||
|
||||
### 9.3 示例插件完整清单
|
||||
|
||||
| 示例插件 | 工具数 | 实际状态 |
|
||||
|---------|--------|---------|
|
||||
| `files/plugin.go` | 4 (read/write/edit/ls) | `files_read` `NoMemory=false` + `Cleaner` ✅ |
|
||||
| `memo/plugin.go` | 3 | 无需改动 ✅ |
|
||||
| `weather/plugin.go` | 3 | 无需改动 ✅ |
|
||||
| `browser/plugin.go` | 11 | `search/fetch/render` `Cleaner` ✅ |
|
||||
| `qq/plugin.go` | 18 | `regTool` 已扩展 `def sdk.ToolDef`;12 查询类 `NoMemory=false`(6 个加 `Cleaner`),6 操作类 `NoMemory=true` ✅(本次补全) |
|
||||
| `a2a/plugin.go` | 4 | `a2a_query` `Cleaner` ✅ |
|
||||
| `ai_image/plugin.go` | 1 | 无需 Cleaner ✅ |
|
||||
| `bili/plugin.go` | 1 | `bili_video` `Cleaner` ✅ |
|
||||
| `calendar/plugin.go` | 6 | 无需改动 ✅ |
|
||||
| `editdoc/plugin.go` | 1 | `edit_document` `NoMemory=true` ✅(本次补全) |
|
||||
| `music/plugin.go` | 2 | `music_search` `Cleaner` ✅ |
|
||||
| `ocr/plugin.go` | 1 | `ocr_image` `Cleaner` ✅ |
|
||||
| `rss/plugin.go` | 4 | `subscribe/unsubscribe/check_now` `NoMemory=true` ✅(本次补全) |
|
||||
| `sanitizer/plugin.go` | 0 (stage only) | 无需改动 ✅ |
|
||||
|
||||
### 9.4 各示例插件具体改动(均已实施 ✅)
|
||||
|
||||
**`files/plugin.go`** — `files_read` `NoMemory=false` + Cleaner(提取 JSON `.content` 字段),与核心仓内置插件对齐。✅
|
||||
|
||||
**`browser/plugin.go`** — `search/fetch/render` 注册 Cleaner 提取正文。✅
|
||||
|
||||
**`qq/plugin.go`** — 各工具分类(全部已实施):
|
||||
- 查询类(NoMemory=false,6 个加 Cleaner 提取 `.content`): `get_message`, `get_history`, `read_document`, `video_download`, `get_group_files`, `get_download_tasks`, `get_groups`, `get_friends`, `get_recent_contacts`, `resolve_name`, `resolve_nickname`, `get_group_member_info`
|
||||
- 操作类(NoMemory=true): `send_file`, `download_file`, `upload_group_file`, `group_manage`, `friend_action`, `send_like`
|
||||
|
||||
**`a2a/plugin.go`** — `a2a_query` Cleaner 提取 response 文本。✅
|
||||
|
||||
**`bili/plugin.go`** — `bili_video` Cleaner 提取视频信息文本。✅
|
||||
|
||||
**`editdoc/plugin.go`** — `edit_document` NoMemory=true。✅(本次补全)
|
||||
|
||||
**`music/plugin.go`** — `music_search` Cleaner 提取纯文本。✅
|
||||
|
||||
**`ocr/plugin.go`** — `ocr_image` Cleaner 确保纯文本进入计算层。✅
|
||||
|
||||
**`rss/plugin.go`** — `subscribe/unsubscribe/check_now` NoMemory=true。✅(本次补全)
|
||||
|
||||
**无需改动:** `memo`, `weather`, `calendar`, `sanitizer`, `ai_image`(输出简短或结构化,无噪音)。
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段:SDK 定义修改(外部包 `homeagent-sdk`)
|
||||
## 第十阶段:plugindev 工具链更新
|
||||
|
||||
**文件:** `_sdk_local/sdk/plugin.go`
|
||||
> **当前状态:** 生成模板已更新 ✅,mock 调试框架尚未实施。
|
||||
|
||||
### 1.1 ToolDef 增加 Cleaner 字段
|
||||
### 10.1 生成模板更新 ✅
|
||||
|
||||
**文件:** `homeagentsdk/tools/plugindev/templates.go`
|
||||
|
||||
`tmplPluginGo` 模板(line 48-57)生成的注册代码已展示 `NoMemory`/`Cleaner` 用法:
|
||||
|
||||
```go
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
Cleaner func(string) string `json:"-"` // ← 新增:计算层过滤函数,不改原文
|
||||
}
|
||||
// 修改前:
|
||||
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||
Name: tp + "hello", Description: "A hello world tool",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, p.handleHello)
|
||||
|
||||
// 修改后:
|
||||
s.RegisterTool(tp+"hello", sdk.ToolDef{
|
||||
Name: tp + "hello",
|
||||
Description: "A hello world tool",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
NoMemory: false, // 工具输出对 LLM 注意力有信号价值时为 false,纯操作工具为 true
|
||||
// Cleaner: func(output string) string {
|
||||
// // 工具输出参与向量化/jieba/蒸馏前,在此过滤噪音
|
||||
// return output
|
||||
// },
|
||||
}, p.handleHello)
|
||||
```
|
||||
|
||||
- `Cleaner` 是函数类型,不序列化(`json:"-"`)
|
||||
- 仅在向量化/jieba/蒸馏等计算环节使用
|
||||
- 默认 nil = 不过滤
|
||||
`tmplMainLua` Lua 模板同理,在注册工具时添加 `no_memory` 注释示范。
|
||||
|
||||
### 1.2 删除 PluginSDK 中的 TextCleaner
|
||||
### 10.2 C ABI 桥接无需改动
|
||||
|
||||
```go
|
||||
// PluginSDK 中删除:
|
||||
// textCleaners []func(text string) string // ← 删除
|
||||
// RegisterTextCleaner() // ← 删除
|
||||
// TextCleaners() // ← 删除
|
||||
- `Cleaner` 是 `func` 类型 + `json:"-"`,天然无法序列化过 C 边界(正确行为)
|
||||
- `NoMemory` 是 `bool` + `json:"no_memory,omitempty"`,JSON 序列化后自动包含
|
||||
|
||||
### 10.3 新增 mock 调试框架
|
||||
|
||||
**背景:** 当前 `plugindev debug` 对 Go 插件仅打印 "use standard Go tooling"(`cmd_debug.go:123-132`),无任何 mock 能力。Lua 插件虽有 REPL,但无法模拟完整的 agent 消息管道。
|
||||
|
||||
**需求:** 在 `plugindev` 中新增一个 mock 调试框架,让插件开发者可以在本地模拟消息处理流程,无需连接真实 agent 内核。
|
||||
|
||||
**设计要点:**
|
||||
|
||||
```
|
||||
plugindev debug --mock <dir>
|
||||
```
|
||||
|
||||
### 1.3 相应修改单元测试
|
||||
Mock 框架应提供:
|
||||
|
||||
**文件:** `_sdk_local/sdk/plugin_test.go`
|
||||
1. **Mock StageContext 构建器** — 通过 CLI 或 YAML/JSON 配置文件构建模拟的 `StageContext`,包含:
|
||||
- `RawMessage` / `UserID` / `GroupID`
|
||||
- `ToolCalls` / `ToolResults`
|
||||
- `LLMText` / `FinalText`
|
||||
- `NoMemory` / `Memory` / `Extra`
|
||||
|
||||
- 删除 `TestRegisterTextCleaner`、`TestTextCleanersEmpty`
|
||||
- `TestToolDefNoMemory` 保留
|
||||
- 新增 `TestToolDefCleaner` 验证 Cleaner 字段
|
||||
2. **Mock API 实现** — 为 `MemoryAPI`、`TextMemoryAPI`、`DocMemoryAPI`、`KnowledgeAPI`、`LLMAPI`、`SettingsAPI`、`SocialAPI` 提供内存 mock 实现:
|
||||
```go
|
||||
// 内置 mock 实现,开发者可直接使用
|
||||
mockSDK := sdk.New("test-plugin", mockSettings, mockRegTool, mockRegStage, mockRegAPI, mockRegOutput)
|
||||
mockSDK.SetMemoryAPI(NewMockMemory())
|
||||
mockSDK.SetLLMAPI(NewMockLLM())
|
||||
```
|
||||
|
||||
### 1.4 go.mod 确认 replace 指令
|
||||
3. **Stage 触发模拟** — 支持手动触发各个 Stage:
|
||||
```go
|
||||
// 模拟 before_toolcall 阶段
|
||||
ctx := sdk.StageContext{
|
||||
ToolCalls: []sdk.ToolCall{{Name: "files_read", Arguments: {"path": "/test.txt"}}},
|
||||
}
|
||||
host.RunStage(sdk.StageBeforeToolcall, &ctx)
|
||||
```
|
||||
|
||||
`go.mod` 已有 `replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local`,无需改动。
|
||||
4. **工具直接调用** — 按名称调用已注册的工具并验证返回值:
|
||||
```go
|
||||
result, err := host.ExecuteTool("files_read", map[string]interface{}{"path": "/test.txt"})
|
||||
```
|
||||
|
||||
5. **配置文件驱动** — 支持 YAML/JSON 测试场景文件:
|
||||
```yaml
|
||||
# test_scenario.yaml
|
||||
stages:
|
||||
- stage: before_toolcall
|
||||
context:
|
||||
tool_calls:
|
||||
- name: files_read
|
||||
arguments:
|
||||
path: "/test.txt"
|
||||
expectations:
|
||||
- check: context.modified
|
||||
path: "tool_calls[0].arguments.path"
|
||||
equals: "/test.txt"
|
||||
```
|
||||
|
||||
6. **集成 `go test`** — 提供 `mocktest` 包,插件开发者可在 `_test.go` 中直接使用:
|
||||
|
||||
```go
|
||||
// homeagentsdk/example/files/plugin_test.go
|
||||
func TestFilesReadTool(t *testing.T) {
|
||||
m := mocktest.New(t)
|
||||
p := &Plugin{name: "files", filesDir: t.TempDir()}
|
||||
os.WriteFile(filepath.Join(p.filesDir, "test.txt"), []byte("hello"), 0644)
|
||||
|
||||
m.RegisterPlugin(p)
|
||||
m.ToolShouldReturn(t, "files_read", map[string]interface{}{"path": "test.txt"},
|
||||
map[string]interface{}{"content": "hello"})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:内部适配层更新
|
||||
## 第十一阶段:文档更新(全部完成 ✅)
|
||||
|
||||
### 2.1 Registry:删除 textCleaners 聚合
|
||||
### 11.1 核心仓文档
|
||||
|
||||
**文件:** `internal/plugin/registry.go`
|
||||
**文件:**
|
||||
- `docs/zh/PLUGIN_DEV.md:248` — 中文插件开发指南
|
||||
- `docs/en/PLUGIN_DEV.md:250` — 英文插件开发指南
|
||||
- `docs/zh/ARCHITECTURE.md:11` — 中文架构文档
|
||||
- `docs/en/ARCHITECTURE.md:11` — 英文架构文档
|
||||
|
||||
删除项:
|
||||
- 字段 `textCleaners []func(string) string`
|
||||
- 字段 `CleanText(text string) string` 方法
|
||||
- `buildSDK` 不再收集 cleaner(cleaner 附着在 ToolDef 上,由 StageHost 管理)
|
||||
- `loadOne` 和 `Load` 中的 `r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)` 移除
|
||||
- `StopAll` 中的重置移除
|
||||
|
||||
改动点:
|
||||
| 行号 | 当前 | 改为 |
|
||||
|---|---|---|
|
||||
| 87 | `textCleaners []func(string) string` | 删除 |
|
||||
| 113-122 | `CleanText()` 方法 | 删除 |
|
||||
| 273 | `r.textCleaners = append(...)` | 删除 |
|
||||
| 356 | `r.textCleaners = append(...)` | 删除 |
|
||||
| 373 | `r.textCleaners = nil` | 删除 |
|
||||
|
||||
### 2.2 memory/clean_text.go:删除全局 cleaner
|
||||
|
||||
**文件:** `internal/memory/clean_text.go`
|
||||
**PLUGIN_DEV.md 改动:** 在注册工具示例中展示 `NoMemory`/`Cleaner` 用法:
|
||||
|
||||
```go
|
||||
// 删除:
|
||||
var globalTextCleaner func(string) string // ← 删除
|
||||
func SetTextCleaner(fn func(string) string) { // ← 删除
|
||||
globalTextCleaner = fn
|
||||
}
|
||||
// 修改前:
|
||||
s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
Name: "weather_query",
|
||||
Description: "Get current weather...",
|
||||
Parameters: map[string]interface{}{...},
|
||||
}, handler)
|
||||
|
||||
func CleanText(text string) string {
|
||||
// if globalTextCleaner != nil { // ← 删除
|
||||
// text = globalTextCleaner(text) // ← 删除
|
||||
// } // ← 删除
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
text = strings.TrimPrefix(text, ",")
|
||||
text = strings.TrimPrefix(text, ",")
|
||||
text = strings.TrimSpace(text)
|
||||
return text
|
||||
}
|
||||
// 修改后:
|
||||
s.RegisterTool("weather_query", sdk.ToolDef{
|
||||
Name: "weather_query",
|
||||
Description: "Get current weather...",
|
||||
Parameters: map[string]interface{}{...},
|
||||
NoMemory: false, // ← 文档新增
|
||||
// Cleaner: func(output string) string { // ← 文档新增(注释示范)
|
||||
// return extractJSON(output, "content")
|
||||
// },
|
||||
}, handler)
|
||||
```
|
||||
|
||||
`VectorizeClean` 保持不变(使用精简后的 `CleanText`)。
|
||||
并在文档中新增独立章节说明 NoMemory 和 Cleaner 的设计意图与使用场景。
|
||||
|
||||
### 2.3 StageHost:暴露 ToolDef Cleaner 查询
|
||||
**ARCHITECTURE.md 改动:** 在阶段管道说明中补充 ToolDef 的 NoMemory/Cleaner 字段描述。
|
||||
|
||||
**文件:** `internal/agent/core/stages.go`
|
||||
### 11.2 SDK 仓文档
|
||||
|
||||
`ToolDef(name)` 方法已有,返回 `*sdk.ToolDef`。由于 `ToolDef` 现在有 `Cleaner` 字段,调用方可直接通过 `stageHost.ToolDef(name).Cleaner` 获取。
|
||||
**文件:** `homeagentsdk/README.md` / `README_EN.md`
|
||||
|
||||
新增便捷方法:
|
||||
|
||||
```go
|
||||
func (h *StageHost) ToolDefCleaner(name string) func(string) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, def := range h.toolDefs {
|
||||
if def.Name == name {
|
||||
return def.Cleaner
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 新增:返回所有 NoMemory 工具名集合,供计算环节跳过
|
||||
func (h *StageHost) NoMemoryToolNames() map[string]bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
set := make(map[string]bool, len(h.toolDefs))
|
||||
for _, def := range h.toolDefs {
|
||||
if def.NoMemory {
|
||||
set[def.Name] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 StageHost:Import 更新
|
||||
|
||||
需 import `sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"`(已有)。
|
||||
在 SDK README 的 ToolDef 说明中列出新增字段。
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:核心逻辑修正
|
||||
## SDK 公有仓实施状态清单
|
||||
|
||||
### 3.1 context.Append:不再修改原文
|
||||
| 文件 | 操作 | 阶段 | 优先级 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| `homeagentsdk/sdk/plugin_test.go` | `TestToolDefCleaner`/`TestToolDefNoMemory`/`TestToolDefRegisterPreservesNoMemory` | 一 | P0 | ✅ |
|
||||
| `homeagentsdk/example/qq/plugin.go` | `regTool` 签名扩展 `def sdk.ToolDef` + 各工具 NoMemory/Cleaner | 九 | P1 | ✅(本次补全) |
|
||||
| `homeagentsdk/example/files/plugin.go` | `files_read` `NoMemory=false` + `Cleaner` | 九 | P1 | ✅ |
|
||||
| `homeagentsdk/example/browser/plugin.go` | `search/fetch/render` `Cleaner` | 九 | P1 | ✅ |
|
||||
| `homeagentsdk/example/a2a/plugin.go` | `a2a_query` `Cleaner` | 九 | P1 | ✅ |
|
||||
| `homeagentsdk/example/bili/plugin.go` | `bili_video` `Cleaner` | 九 | P1 | ✅ |
|
||||
| `homeagentsdk/example/editdoc/plugin.go` | `edit_document` `NoMemory=true` | 九 | P1 | ✅(本次补全) |
|
||||
| `homeagentsdk/example/music/plugin.go` | `music_search` `Cleaner` | 九 | P1 | ✅ |
|
||||
| `homeagentsdk/example/ocr/plugin.go` | `ocr_image` `Cleaner` | 九 | P1 | ✅ |
|
||||
| `homeagentsdk/example/rss/plugin.go` | `subscribe/unsubscribe/check_now` `NoMemory=true` | 九 | P1 | ✅(本次补全) |
|
||||
| `homeagentsdk/tools/plugindev/templates.go` | `tmplPluginGo` 展示 `NoMemory` + `Cleaner` | 十 | P2 | ✅(本次补全) |
|
||||
| `homeagentsdk/tools/plugindev/...` | mock 调试框架 | 十 | P2 | ❌ |
|
||||
| `docs/zh/PLUGIN_DEV.md` | 注册工具示例展示 NoMemory/Cleaner + 独立说明章节 | 十一 | P3 | ✅(本次补全) |
|
||||
| `docs/en/PLUGIN_DEV.md` | 同上(英文版) | 十一 | P3 | ✅(本次补全) |
|
||||
| `docs/zh/ARCHITECTURE.md` | 补充 ToolDef 新字段描述 | 十一 | P3 | ✅(本次补全) |
|
||||
| `docs/en/ARCHITECTURE.md` | 同上(英文版) | 十一 | P3 | ✅(本次补全) |
|
||||
| `homeagentsdk/README.md` / `README_EN.md` | 在 ToolDef 说明中列出新增字段 | 十一 | P3 | ✅(本次补全) |
|
||||
|
||||
**文件:** `internal/agent/core/context.go`
|
||||
|
||||
```go
|
||||
func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// evt.Input = memory.CleanText(evt.Input) // ← 删除:不修改原文
|
||||
evt.Vector = c.computeVector(&evt) // 向量化仍使用 textForVector
|
||||
c.events = append(c.events, &evt)
|
||||
c.save()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 textForVector:计算层使用 Cleaner
|
||||
|
||||
```go
|
||||
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef) string {
|
||||
text := ""
|
||||
switch {
|
||||
case evt.Source == "agent" && evt.Response != "":
|
||||
text = evt.Response
|
||||
case evt.Source == "cold_storage":
|
||||
text = evt.Input + " " + evt.Response
|
||||
default:
|
||||
text = evt.Input
|
||||
}
|
||||
|
||||
// 基础清洗(不修改原文)
|
||||
text = memory.CleanText(text)
|
||||
|
||||
// 若 evt 关联了 NoMemory 工具,不再跳过整个事件
|
||||
// 但若工具注册了 Cleaner,在计算层过滤
|
||||
// textForVector 是对整个事件的向量化,不拆到工具级别
|
||||
return text
|
||||
}
|
||||
```
|
||||
|
||||
关键变更:
|
||||
- `textForVector` 的入参增加 `toolDefLookup`(通过 `RelevanceContext.toolDefLookup` 已有)
|
||||
- 移除 `textForVector` 对 `memory.CleanText` 的依赖(因为 `CleanText` 不再做插件级过滤)
|
||||
- `computeVector` 传入 `toolDefLookup`
|
||||
|
||||
### 3.3 context.Prune:不再跳过 NoMemory 事件
|
||||
|
||||
```go
|
||||
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
|
||||
// ... 前面的排序逻辑不变 ...
|
||||
|
||||
archived := 0
|
||||
if docStore != nil && len(archive) > 0 {
|
||||
// 删除 hasNoMemoryTool 跳过整条的逻辑
|
||||
// for _, s := range archive {
|
||||
// if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
// 改为:所有事件都归档,工具级过滤在 ContextToDoc 内部处理
|
||||
entries := make([]document.ContextEntry, len(archive))
|
||||
for i, s := range archive {
|
||||
entries[i] = document.ContextEntry{
|
||||
Timestamp: s.event.Timestamp,
|
||||
Source: s.event.Source,
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
}
|
||||
}
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder)
|
||||
if err == nil && doc != nil {
|
||||
archived = len(entries)
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
删除 `hasNoMemoryTool` 辅助函数(`context.go:269-278`),迁移到 StageHost 的 `NoMemoryToolNames()`。
|
||||
|
||||
### 3.4 eventloop:移除 NoMemory 跳过记忆候选
|
||||
|
||||
**文件:** `internal/agent/core/eventloop.go`
|
||||
|
||||
```go
|
||||
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
||||
// ... 前面的逻辑不变 ...
|
||||
|
||||
// 删除 NoMemory 跳过:
|
||||
// if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
|
||||
// a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
||||
// }
|
||||
// 改为:始终 emit,工具过滤在消费端处理
|
||||
// 但保留 stageCtx.NoMemory(IO 注入的 no_memory flag)
|
||||
if !stageCtx.NoMemory {
|
||||
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同理修改 `processMediaInput` 中的对应检查。
|
||||
|
||||
删除 `hasNoMemoryTool` 方法(`eventloop.go:389-396`)。
|
||||
|
||||
### 3.5 main.go:删除 SetTextCleaner
|
||||
|
||||
**文件:** `cmd/homed/main.go`
|
||||
|
||||
```go
|
||||
// 删除:
|
||||
// memory.SetTextCleaner(pluginReg.CleanText)
|
||||
```
|
||||
|
||||
### 3.6 context.go:textForVector 签名更新
|
||||
|
||||
`computeVector` 需传入 `toolDefLookup`:
|
||||
|
||||
```go
|
||||
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
|
||||
return c.embedder.Vectorize(textForVector(evt, c.toolDefLookup))
|
||||
}
|
||||
```
|
||||
|
||||
`c.toolDefLookup` 已有(通过 `SetToolDefLookup` 注入)。
|
||||
|
||||
---
|
||||
|
||||
## 第四阶段:文档记忆与蒸馏修正
|
||||
|
||||
### 4.1 document.ContextToDoc:接收 cleaned entries
|
||||
|
||||
**文件:** `internal/memory/document/document.go`
|
||||
|
||||
```go
|
||||
// ContextToDoc 入参增加 cleanFn,在 summarizeEntries/extractTags/extractEntities 前过滤
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, vec vector.Vectorizer, cleanFn func(string) string) (*Doc, error) {
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var parts []string
|
||||
for _, e := range entries {
|
||||
// Content 用 cleanFn 过滤后拼接,原文保留
|
||||
cleaned := e.Content
|
||||
if cleanFn != nil {
|
||||
cleaned = cleanFn(e.Content)
|
||||
}
|
||||
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, cleaned)
|
||||
if e.Response != "" {
|
||||
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
// ... 后续不变
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 context.Prune:传入 cleaner
|
||||
|
||||
```go
|
||||
// 在传 entries 给 ContextToDoc 前,先构建工具→cleaner 映射
|
||||
toolCleaners := make(map[string]func(string)string)
|
||||
for _, s := range archive {
|
||||
for _, name := range s.event.ToolsUsed {
|
||||
if c.toolDefLookup != nil {
|
||||
if def := c.toolDefLookup(name); def != nil && def.Cleaner != nil {
|
||||
toolCleaners[name] = def.Cleaner
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 构建清理函数:对所有工具输出依次应用对应 Cleaner
|
||||
entryCleanFn := func(text string) string {
|
||||
// 此处 text 是 Content(用户输入),不含工具输出,所以不需要 Cleaner
|
||||
// Cleaner 在 distill.go 的 docToTriples 中使用
|
||||
return text
|
||||
}
|
||||
```
|
||||
|
||||
实际上,`ContextToDoc` 中 `Content` 是用户输入,不包含工具输出。工具输出过滤主要发生在 `docToTriples`。
|
||||
|
||||
### 4.3 docToTriples:应用 Cleaner
|
||||
|
||||
**文件:** `internal/agent/core/distill.go`
|
||||
|
||||
```go
|
||||
func docToTriples(doc *document.Doc, toolCleaners map[string]func(string) string) []memory.Triple {
|
||||
var triples []memory.Triple
|
||||
if doc == nil {
|
||||
return triples
|
||||
}
|
||||
|
||||
triples = append(triples, memory.Triple{...})
|
||||
|
||||
lines := strings.Split(doc.Content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// 应用 Cleaner(如果匹配工具输出行)
|
||||
// 实际 doc.Content 是 `[时间] source: content → response` 格式,
|
||||
// 其中 content 是用户输入,不直接包含工具输出
|
||||
// Cleaner 在此暂不应用,保留后续扩展
|
||||
terms := memory.CutExact(line)
|
||||
// ...
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
注意:`Doc.Content` 中的内容是用户在 Prune 时输入的文本和 LLM 回复,不包含原始工具输出。工具输出在 `msgs` 中(LLM 对话历史),但不在 `Doc.Content` 中。因此 `docToTriples` 不需要直接应用 Cleaner。
|
||||
|
||||
---
|
||||
|
||||
## 第五阶段:NoMemory 语义修正
|
||||
|
||||
### 5.1 NoMemory 的新语义
|
||||
|
||||
| 场景 | 旧行为 | 新行为 |
|
||||
|---|---|---|
|
||||
| `emitMemoryCandidate` | `hasNoMemoryTool` → 跳过 | 始终写入 text memory + pipeline |
|
||||
| `context.Prune` → `ContextToDoc` | `hasNoMemoryTool` → 跳过 | 全部归档,统一进入文档记忆 |
|
||||
| `textForVector` | `CleanText` 改原文后向量化 | 基础 Trim + 向量化(原文不变) |
|
||||
| `docToTriples` | 无 Cleaner 直接 jieba | 无 Cleaner 直接 jieba(同理) |
|
||||
| `StageContext.NoMemory` | `InjectTextNoMemory` → 整轮跳过 | 保留(IO 层控制的整轮跳过) |
|
||||
|
||||
NoMemory 的声明式语义变为:
|
||||
- `NoMemory=true` 是一个**工具元数据标记**,当前不在内核层面做特殊跳过
|
||||
- 为后续精确过滤(如管道层跳过 NoMemory 工具的输出)预留标记
|
||||
- 未来管道增强时可读取此标记,跳过对应工具输出片段
|
||||
|
||||
### 5.2 内置插件标记更新
|
||||
|
||||
**文件:** `internal/plugins/cmd/plugin.go`
|
||||
|
||||
```go
|
||||
s.RegisterTool("cmd_run", sdk.ToolDef{
|
||||
Name: "cmd_run",
|
||||
Description: "执行 Shell 命令...",
|
||||
NoMemory: true, // 保留:标记工具输出对 LLM 注意力无信号价值
|
||||
// Cleaner: nil, // 不注册 Cleaner:命令输出噪音不可控
|
||||
}, p.handleCmdRun)
|
||||
```
|
||||
|
||||
**文件:** `internal/plugins/agentcli/plugin.go`
|
||||
|
||||
```go
|
||||
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||
Name: "terminal_create",
|
||||
NoMemory: true, // 保留:终端交互噪音
|
||||
}, p.handleCreate)
|
||||
// 同理其它 5 个工具
|
||||
```
|
||||
|
||||
**文件:** `internal/plugins/files/plugin.go`(示例插件 `_sdk_local/example/files/plugin.go`)
|
||||
|
||||
```go
|
||||
s.RegisterTool(tp+"read", sdk.ToolDef{
|
||||
Name: tp + "read",
|
||||
NoMemory: false, // 明确 false:文件内容对 LLM 注意力有信号价值
|
||||
Cleaner: func(output string) string {
|
||||
var r struct{ Content string }
|
||||
if err := json.Unmarshal([]byte(output), &r); err != nil {
|
||||
return output
|
||||
}
|
||||
return r.Content // 去 JSON 包裹,供未来向量化使用
|
||||
},
|
||||
}, p.handleRead)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第六阶段:代码清理
|
||||
|
||||
### 6.1 删除无用代码
|
||||
|
||||
| 文件 | 删除内容 |
|
||||
|---|---|
|
||||
| `internal/plugin/registry.go` | `textCleaners` 字段、`CleanText()` 方法、相关的 append 逻辑 |
|
||||
| `internal/memory/clean_text.go` | `globalTextCleaner`、`SetTextCleaner()` |
|
||||
| `internal/agent/core/context.go` | `hasNoMemoryTool()` 辅助函数 |
|
||||
| `internal/agent/core/eventloop.go` | `hasNoMemoryTool()` 方法 |
|
||||
| `cmd/homed/main.go` | `memory.SetTextCleaner(pluginReg.CleanText)` |
|
||||
|
||||
### 6.2 _sdk_local 清理
|
||||
|
||||
| 文件 | 操作 |
|
||||
|---|---|
|
||||
| `_sdk_local/sdk/plugin.go` | `ToolDef` 增 `Cleaner`;删除 `RegisterTextCleaner`/`TextCleaners`/`textCleaners` |
|
||||
| `_sdk_local/sdk/plugin_test.go` | 替换 TextCleaner 测试为 Cleaner 测试 |
|
||||
|
||||
---
|
||||
|
||||
## 涉及文件清单
|
||||
|
||||
| 文件 | 操作 | 阶段 |
|
||||
|---|---|---|
|
||||
| `_sdk_local/sdk/plugin.go` | `ToolDef` 增 `Cleaner`;删 `RegisterTextCleaner`/`TextCleaners` | 一 |
|
||||
| `_sdk_local/sdk/plugin_test.go` | 更新测试 | 一 |
|
||||
| `internal/plugin/registry.go` | 删 textCleaners + CleanText | 二 |
|
||||
| `internal/memory/clean_text.go` | 删 globalTextCleaner + SetTextCleaner | 二 |
|
||||
| `internal/agent/core/stages.go` | 增 `ToolDefCleaner` + `NoMemoryToolNames` | 二 |
|
||||
| `internal/agent/core/context.go` | `Append` 不改原文;`Prune` 不跳 NoMemory;`textForVector` 入参 toolDefLookup | 三 |
|
||||
| `internal/agent/core/eventloop.go` | 删 `hasNoMemoryTool` 调用 + 方法 | 三 |
|
||||
| `internal/agent/core/distill.go` | `emitMemoryCandidate` 不加过滤(已在 eventloop 处理) | 三 |
|
||||
| `internal/memory/document/document.go` | `ContextToDoc` 可选 cleanFn 参数 | 四 |
|
||||
| `internal/plugins/cmd/plugin.go` | 确认 NoMemory=true | 五 |
|
||||
| `internal/plugins/agentcli/plugin.go` | 确认 NoMemory=true | 五 |
|
||||
| `_sdk_local/example/files/plugin.go` | 示例 Cleaner 注册 | 五 |
|
||||
| `cmd/homed/main.go` | 删 `memory.SetTextCleaner(pluginReg.CleanText)` | 六 |
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
### 实施顺序
|
||||
|
||||
```
|
||||
阶段一 (SDK 定义) → 阶段二 (内部适配删除) → 阶段三 (核心逻辑)
|
||||
↓
|
||||
阶段六 (代码清理) ← 阶段五 (NoMemory 语义) ← 阶段四 (文档记忆)
|
||||
P0 (单元测试) ── 已完成 ✅
|
||||
P1 (示例插件) ── 全部完成 ✅
|
||||
P2 (生成模板) ── tmplPluginGo 已完成 ✅,mock 框架待实施 ❌
|
||||
P3 (文档) ── 全部完成 ✅
|
||||
```
|
||||
|
||||
### Step-by-step 实施步骤
|
||||
|
||||
1. **`_sdk_local/sdk/plugin.go`** — `ToolDef` 加 `Cleaner` 字段;删除 `RegisterTextCleaner`/`TextCleaners`/`textCleaners` 字段及方法
|
||||
2. **`_sdk_local/sdk/plugin_test.go`** — 更新测试用例
|
||||
3. **`internal/plugin/registry.go`** — 删除 `textCleaners`、`CleanText()`、收集逻辑
|
||||
4. **`internal/memory/clean_text.go`** — 删除 `globalTextCleaner`、`SetTextCleaner`
|
||||
5. **`internal/agent/core/stages.go`** — 增 `ToolDefCleaner`、`NoMemoryToolNames`
|
||||
6. **`internal/agent/core/context.go`** —
|
||||
- `Append`: 删除 `evt.Input = memory.CleanText(evt.Input)`
|
||||
- `textForVector`: 入参加 `toolDefLookup`
|
||||
- `computeVector`: 传入 `c.toolDefLookup`
|
||||
- `Prune`: 删除 `hasNoMemoryTool` 跳过逻辑
|
||||
- 删除 `hasNoMemoryTool` 辅助函数
|
||||
- `load()`: 不再对已加载事件调用 `CleanText`
|
||||
7. **`internal/agent/core/eventloop.go`** —
|
||||
- `processTextInput`: `emitMemoryCandidate` 前删 `!a.hasNoMemoryTool(toolsUsed)` 条件
|
||||
- `processMediaInput`: 同上
|
||||
- 删除 `hasNoMemoryTool` 方法
|
||||
8. **`internal/memory/document/document.go`** — `ContextToDoc` 入参加 `cleanFn`
|
||||
9. **`cmd/homed/main.go`** — 删除 `memory.SetTextCleaner(pluginReg.CleanText)`
|
||||
10. **内置插件** — 确认 NoMemory 标记,示例插件加 Cleaner
|
||||
11. **编译测试** — `go build ./...` 确认无编译错误
|
||||
|
||||
---
|
||||
|
||||
## 验证方法
|
||||
|
||||
### 编译检查
|
||||
```bash
|
||||
go build ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
### 单元测试
|
||||
```bash
|
||||
# SDK 测试
|
||||
cd _sdk_local && go test ./sdk/...
|
||||
|
||||
# 内核测试
|
||||
cd /home/program/TrueAgent && go test ./internal/agent/core/...
|
||||
go test ./internal/memory/...
|
||||
go test ./internal/plugin/...
|
||||
```
|
||||
|
||||
### 行为验证
|
||||
|
||||
**场景 1:NoMemory 工具调用后记忆仍然产生**
|
||||
1. 用户输入:"查一下 /etc/passwd"
|
||||
2. Agent 调用 `cmd_run`(NoMemory=true)
|
||||
3. LLM 回复:"第一行是 root,uid=0,超级管理员哦~"
|
||||
4. ✅ `textMem.Append(Event{Response: "第一行是 root..."})` — 写入
|
||||
5. ✅ `distiller.Append("assistant", "第一行是 root...")` — 写入
|
||||
6. ✅ `context.Append(ContextEvent{Response: "第一行是 root..."})` — 可见
|
||||
|
||||
**场景 2:Cleaner 仅影响计算层**
|
||||
1. Agent 调用 `files_read` 返回 `{"content": "敏感数据"}`
|
||||
2. ✅ 原文 `msgs` 中保留 `{"content": "敏感数据"}`
|
||||
3. ✅ LLM 回复中可见原始内容
|
||||
4. `textForVector` 使用 Cleaner 提取 `content` 字段(注:实际 textForVector 对 Response 操作,Response 已是 LLM 的自然语言,不是 JSON 包裹。Cleaner 在工具输出进入 msgs 时注释即可。)
|
||||
|
||||
实际上,Cleaner 的调用时机需要斟酌。工具输出通过 `executeToolCallInner` 返回字符串,进入 `msgs` 中的 `role: "tool"` 消息。`msgs` 用于 LLM 上下文,不需要 Cleaner。Cleaner 是在"工具输出单独进入记忆计算"时才需要。
|
||||
|
||||
当前架构中,工具输出不单独进入记忆计算,而是通过 LLM 的回复间接影响记忆。所以 Cleaner 的实际用途是**预留扩展**:当未来有直接对工具输出进行向量化/摘要的环节时,使用 Cleaner 过滤。
|
||||
|
||||
### 回归测试
|
||||
- 确认旧有 `InjectTextNoMemory`(IO 层整轮跳过,通过 `stageCtx.NoMemory` 控制)不受影响
|
||||
- 确认 context prune 不再因 NoMemory 工具跳过低相关性事件归档
|
||||
- 确认 text memory 日志完整记录所有对话轮次
|
||||
155
review.md
155
review.md
@ -1,5 +1,11 @@
|
||||
# 记忆系统审查:NoMemory 与 TextCleaner 设计偏差
|
||||
|
||||
> **审查日期:** 2026-07-25
|
||||
> **审查范围:** 核心仓 `homeagent/`(`internal/agent/core/`、`internal/memory/`、`internal/plugins/`、`internal/plugin/`、`cmd/homed/`)及 SDK 仓 `homeagentsdk/`(`sdk/`、`example/`、`tools/`)
|
||||
> **当前状态:** 核心层全部修复完成 ✅,SDK 示例插件及模板 **全部更新完成** ✅
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则
|
||||
|
||||
**Context 和 Document 层始终保留原始文本。** Cleaner 和 NoMemory 不修改原文,只控制文本在**计算层**(向量化、jieba 分词、蒸馏)中的参与方式。原文完整性是 LLM 注意力分配的基础——清洗掉工具特征输出会干扰 LLM 对上下文的理解。
|
||||
@ -166,11 +172,11 @@ NoMemory 在三层计算中的语义:
|
||||
|
||||
## 四、设计对照表
|
||||
|
||||
| 机制 | 当前实现 | 应然设计 |
|
||||
| 机制 | 旧实现(已废弃) | 当前实现(已修复) |
|
||||
|---|---|---|
|
||||
| `RegisterTextCleaner` | 插件级,`memory.CleanText` 入口直接改原文 | **删除**,拆为 `ToolDef.Cleaner` |
|
||||
| `ToolDef.Cleaner` | 不存在 | 工具级,仅计算层生效,不改原文 |
|
||||
| `NoMemory=true` | `hasNoMemoryTool` 二值 → 整轮跳过 | 工具输出不参与向量/jieba/蒸馏,原文保留 |
|
||||
| `RegisterTextCleaner` | 插件级,`memory.CleanText` 入口直接改原文 | **已删除**,拆为 `ToolDef.Cleaner` ✅ |
|
||||
| `ToolDef.Cleaner` | 不存在 | 工具级字段,`textForVector`/`summarizeEntries`/`extractTags`/`extractEntities` 中调用 ✅ |
|
||||
| `NoMemory=true` | `hasNoMemoryTool` 二值 → 整轮跳过 | `textForVector` 跳过对应 `ToolResults` 条目,原文保留 ✅ |
|
||||
|
||||
### 决策矩阵
|
||||
|
||||
@ -195,25 +201,126 @@ NoMemory 在三层计算中的语义:
|
||||
|
||||
---
|
||||
|
||||
## 五、影响范围
|
||||
## 五、影响范围(已实施)
|
||||
|
||||
| 层次 | 文件 | 改动 |
|
||||
### 5.1 核心仓(已全部修复 ✅)
|
||||
|
||||
| 层次 | 文件 | 改动 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **SDK 适配层** | `internal/sdk/plugin.go` | 类型别名 `ToolDef = pubsdk.ToolDef` 透传 `NoMemory`/`Cleaner` | ✅ |
|
||||
| **Registry** | `internal/plugin/registry.go` | 移除 `textCleaners` 收集;移除 `CleanText` 方法;`buildSDK` 不再收集 cleaner | ✅ |
|
||||
| **全局 CleanText** | `internal/memory/clean_text.go` | 移除 `globalTextCleaner`/`SetTextCleaner`;仅保留 TrimSpace 等基础清洗 | ✅ |
|
||||
| **main** | `cmd/homed/main.go` | 移除 `memory.SetTextCleaner(pluginReg.CleanText)` | ✅ |
|
||||
| **StageHost** | `internal/agent/core/stages.go` | 新增 `ToolDefCleaner()`/`NoMemoryToolNames()` | ✅ |
|
||||
| **ContextEvent** | `internal/agent/core/context.go:24-32` | 新增 `ToolResults []ToolResultItem` 字段 | ✅ |
|
||||
| **process 返回值** | `internal/agent/core/process.go:18` | 新增 `toolResults []ToolResultItem` 返回值;line 229 收集 | ✅ |
|
||||
| **textForVector** | `internal/agent/core/context.go:78-111` | 遍历 `evt.ToolResults`:NoMemory 跳过,其余经 Cleaner 过滤后拼入 | ✅ |
|
||||
| **Append** | `internal/agent/core/context.go:132-140` | 不再调用 `CleanText` 修改原文 | ✅ |
|
||||
| **Prune** | `internal/agent/core/context.go:173-250` | 不再 `hasNoMemoryTool` 跳过;`ToolResults` 传入 `ContextEntry` | ✅ |
|
||||
| **eventloop** | `internal/agent/core/eventloop.go` | `context.Append`/`emitMemoryCandidate` 传入 `toolResults`;删除 `hasNoMemoryTool` | ✅ |
|
||||
| **emitMemoryCandidate** | `internal/agent/core/distill.go:380-390` | 签名扩展传 `toolResults`;payload 含 `tool_results` | ✅ |
|
||||
| **ContextToDoc** | `internal/memory/document/document.go:128-210` | 可选 `cleanFn` 参数;`summarizeEntries`/`extractTags`/`extractEntities` 消费 ToolResults | ✅ |
|
||||
| **ContextEntry** | `internal/memory/document/document.go:434-440` | 新增 `ToolResults []ToolResultItem` | ✅ |
|
||||
| **内置插件 cmd** | `internal/plugins/cmd/plugin.go:110-114` | `cmd_run`: `NoMemory=true` | ✅ |
|
||||
| **内置插件 agentcli** | `internal/plugins/agentcli/plugin.go` | 6 个工具: `NoMemory=true` | ✅ |
|
||||
| **内置插件 files** | `internal/plugins/files/plugin.go:62-72` | `files_read`: `NoMemory=false` + `Cleaner` 去 JSON 包裹 | ✅ |
|
||||
|
||||
### 5.2 SDK 公有仓(全部更新完成 ✅)
|
||||
|
||||
| 层次 | 文件 | 改动 | 状态 |
|
||||
|---|---|---|---|
|
||||
| **ToolDef 定义** | `homeagentsdk/sdk/plugin.go:90-97` | `NoMemory bool` + `Cleaner func(string) string` | ✅ |
|
||||
| **版本号** | `homeagentsdk/meta/meta.go:8` | `v0.7.1` → `v0.8.0` | ✅ |
|
||||
| **单元测试** | `homeagentsdk/sdk/plugin_test.go` | 已含 `TestToolDefCleaner`/`TestToolDefNoMemory`/`TestToolDefRegisterPreservesNoMemory` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/qq/plugin.go` | `regTool` 签名已扩展为 `def sdk.ToolDef`;12 查询工具 `NoMemory=false`(含 Cleaner 6 个),6 操作工具 `NoMemory=true` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/files/plugin.go` | `files_read` `NoMemory=false` + `Cleaner` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/browser/plugin.go` | `search/fetch/render` 加 `Cleaner` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/a2a/plugin.go` | `a2a_query` 加 `Cleaner` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/bili/plugin.go` | `bili_video` 加 `Cleaner` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/editdoc/plugin.go` | `edit_document` `NoMemory=true` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/music/plugin.go` | `music_search` 加 `Cleaner` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/ocr/plugin.go` | `ocr_image` 加 `Cleaner` | ✅ |
|
||||
| **示例插件** | `homeagentsdk/example/rss/plugin.go` | `subscribe/unsubscribe/check_now` `NoMemory=true` | ✅ |
|
||||
| **生成模板** | `homeagentsdk/tools/plugindev/templates.go` | `tmplPluginGo` 展示 `NoMemory` + `Cleaner`(注释) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 六、重构后二次审查:工具输出未接入记忆管道(已修复 ✅)
|
||||
|
||||
> **原始发现(历史记录):** 前一 agent 只改了"删除坏逻辑"(删 TextCleaner、加字段),没改"接入好逻辑"。
|
||||
> **当前状态:** 以下 7 项缺陷已在后续迭代中全部修复。详情参见 `plan.md §7`。
|
||||
|
||||
### 6.1 审查背景(历史)
|
||||
|
||||
前一 agent 按 `plan.md` 实施了重构。审查发现:**删旧代码的工作完成,但"接新数据流"的工作未做**。Cleaner 和 NoMemory 的消费端全是空壳。
|
||||
|
||||
### 6.2 修复后数据流(当前现状 ✅)
|
||||
|
||||
```
|
||||
process.go:228 result = a.executeToolCall(tc)
|
||||
│
|
||||
├──→ msgs (line 248) ← LLM 对话上下文
|
||||
│
|
||||
├──→ toolResults = append(...) ← ✅ 已收集到返回值
|
||||
│
|
||||
└──→ return (response, toolsUsed, toolResults)
|
||||
|
||||
eventloop.go:328-335
|
||||
a.context.Append(ContextEvent{
|
||||
Input: input,
|
||||
Response: response,
|
||||
ToolsUsed: toolsUsed,
|
||||
ToolResults: toolResults, ← ✅ 已传入
|
||||
})
|
||||
→ computeVector → textForVector
|
||||
→ 遍历 ToolResults, NoMemory 跳过, Cleaner 过滤 ✅
|
||||
|
||||
emitMemoryCandidate(source, input, response, toolResults, toolsUsed) ✅
|
||||
|
||||
Prune → ContextToDoc:
|
||||
ContextEntry.ToolResults → summarizeEntries/extractTags/extractEntities ✅
|
||||
```
|
||||
|
||||
### 6.3 修复清单(7 项断点全部修复 ✅)
|
||||
|
||||
| # | 位置 | 原缺陷 | 修复状态 |
|
||||
|---|---|---|---|
|
||||
| **1** | `context.go:19-26` `ContextEvent` | 缺 `ToolResults` 字段 | ✅ `ToolResults []ToolResultItem` 已新增 |
|
||||
| **2** | `process.go:18` 返回值签名 | 没返回工具输出 | ✅ 签名增加 `toolResults []ToolResultItem` |
|
||||
| **3** | `process.go:228` 工具执行后 | 未收集到返回值 | ✅ `toolResults = append(toolResults, ...)` |
|
||||
| **4** | `eventloop.go:327-333` `context.Append` | 工具输出未进存储层 | ✅ 传入 `ToolResults` |
|
||||
| **5** | `context.go:72-82` `textForVector` | `_ = toolDefLookup` 空壳 | ✅ 遍历 ToolResults,应用 Cleaner/NoMemory |
|
||||
| **6** | `distill.go:380-389` `emitMemoryCandidate` | 没传工具输出 | ✅ 签名扩展为 `(..., toolResults, toolsUsed)` |
|
||||
| **7** | `context.go:200-210` `Prune→ContextToDoc` | 归档时工具输出丢失 | ✅ `ContextEntry.ToolResults` + `convertToolResults()` |
|
||||
|
||||
### 6.4 修复后数据流示例
|
||||
|
||||
```
|
||||
用户: "服务器上 Python 文件有哪些?"
|
||||
→ process()
|
||||
→ executeToolCall("files_read")
|
||||
→ result = "main.py, utils.py, deploy.py"
|
||||
→ toolResults = [{Name:"files_read", Output:"main.py, utils.py, deploy.py"}]
|
||||
→ LLM 回复 "有好几个呢~"
|
||||
→ return (response, toolsUsed, toolResults)
|
||||
|
||||
→ context.Append({..., ToolResults: [{Name:"files_read", Output:"..."}]})
|
||||
→ computeVector → textForVector
|
||||
→ "有好几个呢~ deploy.py, main.py, utils.py" (Cleaner 去 JSON 包裹)
|
||||
→ Vectorize → 向量包含工具输出内容 ✅
|
||||
|
||||
→ emitMemoryCandidate(input, response, toolResults, toolsUsed)
|
||||
→ textMem: 记录了工具输出 ✅
|
||||
|
||||
用户: "deploy.py 在哪个目录?"
|
||||
→ context.Prune → 语义检索匹配到 deploy.py ✅
|
||||
```
|
||||
|
||||
### 6.5 修复验证
|
||||
|
||||
| 场景 | 行为 | 状态 |
|
||||
|---|---|---|
|
||||
| **SDK** | `_sdk_local/sdk/plugin.go` | `ToolDef` 新增 `Cleaner func(string) string`;移除 `RegisterTextCleaner` / `TextCleaners` / `textCleaners` |
|
||||
| **SDK** | `_sdk_local/sdk/plugin_test.go` | 移除 TextCleaner 测试,新增 NoMemory/Cleaner 组合测试 |
|
||||
| **Registry** | `internal/plugin/registry.go` | 移除 `textCleaners` 收集逻辑;移除 `CleanText` 方法;构建 `StageHost` 时传入工具的 Cleaner 映射 |
|
||||
| **全局 CleanText** | `internal/memory/clean_text.go` | 移除 `globalTextCleaner` / `SetTextCleaner`;`CleanText` 只保留 TrimSpace 等基础清洗 |
|
||||
| **main** | `cmd/homed/main.go` | 移除 `memory.SetTextCleaner(pluginReg.CleanText)` |
|
||||
| **内核入口** | `internal/agent/core/process.go:228` | 工具返回结果后,结果原文进 `msgs`,同时 `Cleaner(text)` 结果进后续记忆管道 |
|
||||
| **工具调度** | `internal/agent/core/toolcall.go` | `executeToolCallInner` 返回值额外返回 cleaned 版本(或通过 `StageHost.ToolDef(name).Cleaner` 延迟计算) |
|
||||
| **Context 向量** | `internal/agent/core/context.go:73-86` | `textForVector` 从 `stageHost` 获取 Cleaner,对文本做计算层过滤后再 `Vectorize` |
|
||||
| **Context Prune** | `internal/agent/core/context.go:144-215` | 不再 `hasNoMemoryTool` 跳过整条;改为只传 Cleaner 过滤后的文本给 `docStore.ContextToDoc` |
|
||||
| **Context Append** | `internal/agent/core/context.go:102-111` | `computeVector` 之前对 `Input`/`Response` 走 Cleaner 过滤,原文不修改 |
|
||||
| **记忆候选** | `internal/agent/core/eventloop.go:337` | 不跳过 `emitMemoryCandidate`;`emitMemoryCandidate` 同时传出原始和 cleaned 版本 |
|
||||
| **Document** | `internal/memory/document/document.go:132-200` | `ContextToDoc` 接收 cleaned 文本用于 `summarizeEntries`/`extractTags`/`extractEntities`/向量计算,`Doc.Content` 原文不变 |
|
||||
| **Document→Graph** | `internal/agent/core/distill.go:332-378` | `docToTriples` 对每行走 Cleaner 后再 `CutExact`;跳过 NoMemory 工具输出行 |
|
||||
| **Pipeline** | `internal/memory/pipeline/pipeline.go:235` | `distillBatch` 跳过 NoMemory 工具输出片段 |
|
||||
| **StageHost** | `internal/agent/core/stages.go` | 新增 `ToolDefCleaner(name string) func(string) string` 查询 |
|
||||
| **内置插件** | `internal/plugins/cmd/plugin.go` | `cmd_run`: `NoMemory=true`,不注册 Cleaner(噪音不可控) |
|
||||
| **内置插件** | `internal/plugins/agentcli/plugin.go` | 6 个工具各注册专用 Cleaner + `NoMemory=true`(输出仍含不可控噪音,但 Cleaner 提取有价值信号) |
|
||||
| **内置插件** | `internal/plugins/files/plugin.go` | `files_read/edit` 注册 Cleaner 截断长文本、去 JSON 包裹,正常记忆(NoMemory=false 或移除) |
|
||||
| NoMemory 工具 (`cmd_run`) | 工具输出进 `ContextEvent.ToolResults`,但 `textForVector` 跳过;LLM 回复正常向量化 | ✅ |
|
||||
| Cleaner 工具 (`files_read`) | `ToolResults[0].Output` 保留原文 JSON,`textForVector` 中 Cleaner 提取 `content` 字段后参与向量化 | ✅ |
|
||||
| Prune 归档 | 工具输出通过 `ContextEntry.ToolResults` 传入 `ContextToDoc`,`summarizeEntries`/`extractTags`/`extractEntities` 消费 | ✅ |
|
||||
| 文档蒸馏 | `Doc.Content` 包含 `[工具] name: output` 行,`docToTriples` 直接 `CutExact`(Cleaner/NoMemory 在此暂未应用,因 doc.Content 不含原始工具输出结构) | ⚠️ 按设计保留 |
|
||||
|
||||
Reference in New Issue
Block a user