mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
refactor: pluginize text cleaning and tool NoMemory control
- SDK: ToolDef.NoMemory field, PluginSDK.RegisterTextCleaner/TextCleaners - Registry: aggregate text cleaners from plugins, expose CleanText() - Memory: replace hardcoded QQ regex CleanTemplateText with dynamic CleanText/SetTextCleaner - StageHost: add ToolDef(name) lookup - eventloop: check ToolDef.NoMemory before emitMemoryCandidate - context/Prune: replace hardcoded agentcli/terminal source filter with ToolsUsed NoMemory check - agentcli/cmd: mark tools with NoMemory: true - main.go: wire memory.SetTextCleaner(pluginReg.CleanText)
This commit is contained in:
205
_sdk_local/README.md
Normal file
205
_sdk_local/README.md
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
# 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` 放入插件目录后重启平台。
|
||||||
206
_sdk_local/README_EN.md
Normal file
206
_sdk_local/README_EN.md
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# 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.
|
||||||
11
_sdk_local/example/files/plg.json
Normal file
11
_sdk_local/example/files/plg.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "files",
|
||||||
|
"name_zh": "文件系统",
|
||||||
|
"name_en": "File System",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "文件系统操作工具集(读取/写入/编辑/列表),提供沙箱化文件访问,支持配置工作目录。",
|
||||||
|
"author": "HomeAgent",
|
||||||
|
"entry": "plugin.so",
|
||||||
|
"tags": ["files", "filesystem"],
|
||||||
|
"targets": "linux/amd64"
|
||||||
|
}
|
||||||
483
_sdk_local/example/files/plugin.go
Normal file
483
_sdk_local/example/files/plugin.go
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
3
_sdk_local/go.mod
Normal file
3
_sdk_local/go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module gitcode.com/JianFeeeee/homeagent-sdk
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
23
_sdk_local/meta/meta.go
Normal file
23
_sdk_local/meta/meta.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// 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 + ")"
|
||||||
|
}
|
||||||
14
_sdk_local/sdk/knowledge.go
Normal file
14
_sdk_local/sdk/knowledge.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
8
_sdk_local/sdk/llm.go
Normal file
8
_sdk_local/sdk/llm.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package sdk
|
||||||
|
|
||||||
|
// LLMAPI provides access to the LLM provider manager.
|
||||||
|
type LLMAPI interface {
|
||||||
|
ListSources() []string
|
||||||
|
SetSource(name string) error
|
||||||
|
CurrentSource() string
|
||||||
|
}
|
||||||
87
_sdk_local/sdk/memory.go
Normal file
87
_sdk_local/sdk/memory.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
356
_sdk_local/sdk/plugin.go
Normal file
356
_sdk_local/sdk/plugin.go
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
229
_sdk_local/sdk/plugin_test.go
Normal file
229
_sdk_local/sdk/plugin_test.go
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
58
_sdk_local/sdk/settings.go
Normal file
58
_sdk_local/sdk/settings.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
@ -19,6 +19,7 @@ import (
|
|||||||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||||||
|
logpkg "gitcode.com/JianFeeeee/HomeAgent/internal/log"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
|
||||||
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
||||||
@ -327,6 +328,7 @@ func main() {
|
|||||||
|
|
||||||
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
// Wire registration callbacks: plugins' RegisterTool/RegisterStage → StageHost
|
||||||
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
pluginReg.SetToolRegistrar(func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||||||
|
log.Printf("[homed] SetToolRegistrar registering tool: %s (plugin=%s)", name, def.Plugin)
|
||||||
return stageHost.RegisterTool(name, def, handler)
|
return stageHost.RegisterTool(name, def, handler)
|
||||||
})
|
})
|
||||||
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
pluginReg.SetStageRegistrar(func(stage sdk.Stage, handler sdk.StageHandler) {
|
||||||
@ -335,6 +337,7 @@ func main() {
|
|||||||
pluginReg.SetAPIRegistrar(func(name string) error {
|
pluginReg.SetAPIRegistrar(func(name string) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
pluginReg.SetToolCleaner(stageHost)
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
// Agent Core (需在插件加载前创建,因为插件 Configure 需要 StatusProvider)
|
||||||
@ -420,7 +423,13 @@ func main() {
|
|||||||
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
if err := pluginReg.Load(cfg.Plugin.Dir); err != nil {
|
||||||
log.Printf("[homed] warning: load plugins: %v", err)
|
log.Printf("[homed] warning: load plugins: %v", err)
|
||||||
}
|
}
|
||||||
log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount())
|
memory.SetTextCleaner(pluginReg.CleanText)
|
||||||
|
log.Printf("[homed] stage host ready with %d registered tools, text cleaner set", stageHost.ToolCount())
|
||||||
|
|
||||||
|
// 日志管理:层级压缩 + 保留策略
|
||||||
|
logManager := logpkg.NewManager(logDir, cfgReg)
|
||||||
|
go logManager.Start(ctx)
|
||||||
|
defer logManager.Stop()
|
||||||
|
|
||||||
agent.Start()
|
agent.Start()
|
||||||
defer agent.Stop()
|
defer agent.Stop()
|
||||||
|
|||||||
2
go.mod
2
go.mod
@ -12,3 +12,5 @@ require github.com/yanyiwu/gojieba v1.4.7
|
|||||||
|
|
||||||
require gitcode.com/JianFeeeee/homeagent-sdk v0.7.1
|
require gitcode.com/JianFeeeee/homeagent-sdk v0.7.1
|
||||||
|
|
||||||
|
replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local
|
||||||
|
|
||||||
|
|||||||
@ -165,6 +165,11 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
cfg.DocStore.ReindexWithVectorizer(embedder)
|
cfg.DocStore.ReindexWithVectorizer(embedder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rc := NewRelevanceContext(cfg.ContextSavePath, embedder)
|
||||||
|
if cfg.StageHost != nil {
|
||||||
|
rc.SetToolDefLookup(cfg.StageHost.ToolDef)
|
||||||
|
}
|
||||||
|
|
||||||
return &Agent{
|
return &Agent{
|
||||||
id: cfg.ID,
|
id: cfg.ID,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
@ -175,7 +180,7 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
indexer: cfg.Indexer,
|
indexer: cfg.Indexer,
|
||||||
skills: cfg.Skills,
|
skills: cfg.Skills,
|
||||||
tracker: cfg.Tracker,
|
tracker: cfg.Tracker,
|
||||||
context: NewRelevanceContext(cfg.ContextSavePath, embedder),
|
context: rc,
|
||||||
systemPrompt: cfg.SystemPrompt,
|
systemPrompt: cfg.SystemPrompt,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextEvent struct {
|
type ContextEvent struct {
|
||||||
@ -27,12 +28,13 @@ type ContextEvent struct {
|
|||||||
const contextFlushInterval = 5 * time.Second
|
const contextFlushInterval = 5 * time.Second
|
||||||
|
|
||||||
type RelevanceContext struct {
|
type RelevanceContext struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
events []*ContextEvent
|
events []*ContextEvent
|
||||||
embedder *memory.StaticEmbedder
|
embedder *memory.StaticEmbedder
|
||||||
savePath string
|
savePath string
|
||||||
saveTimer *time.Timer
|
saveTimer *time.Timer
|
||||||
dirty bool
|
dirty bool
|
||||||
|
toolDefLookup func(name string) *sdk.ToolDef
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext {
|
||||||
@ -46,6 +48,12 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
|
|||||||
return rc
|
return rc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.toolDefLookup = fn
|
||||||
|
}
|
||||||
|
|
||||||
func (c *RelevanceContext) load() {
|
func (c *RelevanceContext) load() {
|
||||||
data, err := os.ReadFile(c.savePath)
|
data, err := os.ReadFile(c.savePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -56,7 +64,7 @@ func (c *RelevanceContext) load() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, evt := range events {
|
for _, evt := range events {
|
||||||
evt.Input = memory.CleanTemplateText(evt.Input)
|
evt.Input = memory.CleanText(evt.Input)
|
||||||
evt.Vector = c.computeVector(evt)
|
evt.Vector = c.computeVector(evt)
|
||||||
}
|
}
|
||||||
c.events = events
|
c.events = events
|
||||||
@ -65,11 +73,11 @@ func (c *RelevanceContext) load() {
|
|||||||
func textForVector(evt *ContextEvent) string {
|
func textForVector(evt *ContextEvent) string {
|
||||||
switch {
|
switch {
|
||||||
case evt.Source == "agent" && evt.Response != "":
|
case evt.Source == "agent" && evt.Response != "":
|
||||||
return memory.CleanTemplateText(evt.Response)
|
return memory.CleanText(evt.Response)
|
||||||
case evt.Source == "cold_storage":
|
case evt.Source == "cold_storage":
|
||||||
return memory.CleanTemplateText(evt.Input + " " + evt.Response)
|
return memory.CleanText(evt.Input + " " + evt.Response)
|
||||||
default:
|
default:
|
||||||
return memory.CleanTemplateText(evt.Input)
|
return memory.CleanText(evt.Input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +103,7 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
evt.Input = memory.CleanTemplateText(evt.Input)
|
evt.Input = memory.CleanText(evt.Input)
|
||||||
evt.Vector = c.computeVector(&evt)
|
evt.Vector = c.computeVector(&evt)
|
||||||
c.events = append(c.events, &evt)
|
c.events = append(c.events, &evt)
|
||||||
|
|
||||||
@ -193,7 +201,7 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
|
|||||||
if docStore != nil && len(archive) > 0 {
|
if docStore != nil && len(archive) > 0 {
|
||||||
var filtered []scored
|
var filtered []scored
|
||||||
for _, s := range archive {
|
for _, s := range archive {
|
||||||
if s.event.Source == "agentcli" || s.event.Source == "terminal" {
|
if hasNoMemoryTool(s.event.ToolsUsed, c.toolDefLookup) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filtered = append(filtered, s)
|
filtered = append(filtered, s)
|
||||||
@ -257,3 +265,15 @@ func (c *RelevanceContext) Len() int {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return len(c.events)
|
return len(c.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasNoMemoryTool(toolsUsed []string, lookup func(string) *sdk.ToolDef) bool {
|
||||||
|
if lookup == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, name := range toolsUsed {
|
||||||
|
if def := lookup(name); def != nil && def.NoMemory {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||||
|
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestCtx() *RelevanceContext {
|
func newTestCtx() *RelevanceContext {
|
||||||
@ -237,6 +238,40 @@ func containsStr(s, substr string) bool {
|
|||||||
return false
|
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 {
|
func splitLines(s string) []string {
|
||||||
var lines []string
|
var lines []string
|
||||||
start := 0
|
start := 0
|
||||||
|
|||||||
@ -205,7 +205,7 @@ func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
|
|||||||
|
|
||||||
a.emitResponse(evt, response)
|
a.emitResponse(evt, response)
|
||||||
|
|
||||||
if !stageCtx.NoMemory {
|
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
|
||||||
a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
|
a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -334,7 +334,7 @@ func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
|
|||||||
|
|
||||||
a.emitResponse(evt, response)
|
a.emitResponse(evt, response)
|
||||||
|
|
||||||
if !stageCtx.NoMemory {
|
if !stageCtx.NoMemory && !a.hasNoMemoryTool(toolsUsed) {
|
||||||
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -386,6 +386,15 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
|||||||
a.runStage(sdk.StageAfterOutput, stageCtx)
|
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 {
|
func (a *Agent) drainInterrupts() []string {
|
||||||
var out []string
|
var out []string
|
||||||
for {
|
for {
|
||||||
|
|||||||
@ -54,6 +54,17 @@ func (h *StageHost) GetToolDefs() []sdk.ToolDef {
|
|||||||
return defs
|
return defs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *StageHost) ToolDef(name string) *sdk.ToolDef {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, def := range h.toolDefs {
|
||||||
|
if def.Name == name {
|
||||||
|
return &def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|||||||
@ -197,3 +197,54 @@ func TestStageHostMultipleTools(t *testing.T) {
|
|||||||
t.Errorf("expected from_p2, got %v", r2)
|
t.Errorf("expected from_p2, got %v", r2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStageHostToolDefLookup(t *testing.T) {
|
||||||
|
host := NewStageHost()
|
||||||
|
host.RegisterTool("tool_a", sdk.ToolDef{Name: "tool_a", NoMemory: true}, nil)
|
||||||
|
host.RegisterTool("tool_b", sdk.ToolDef{Name: "tool_b"}, nil)
|
||||||
|
|
||||||
|
def := host.ToolDef("tool_a")
|
||||||
|
if def == nil {
|
||||||
|
t.Fatal("expected tool_a to be found")
|
||||||
|
}
|
||||||
|
if !def.NoMemory {
|
||||||
|
t.Error("tool_a should have NoMemory=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
def = host.ToolDef("tool_b")
|
||||||
|
if def == nil {
|
||||||
|
t.Fatal("expected tool_b to be found")
|
||||||
|
}
|
||||||
|
if def.NoMemory {
|
||||||
|
t.Error("tool_b should have NoMemory=false")
|
||||||
|
}
|
||||||
|
|
||||||
|
def = host.ToolDef("nonexistent")
|
||||||
|
if def != nil {
|
||||||
|
t.Errorf("expected nil for nonexistent tool, got %v", def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStageHostToolDefNoMemoryStored(t *testing.T) {
|
||||||
|
host := NewStageHost()
|
||||||
|
host.RegisterTool("mem_tool", sdk.ToolDef{Name: "mem_tool", NoMemory: true}, nil)
|
||||||
|
host.RegisterTool("normal_tool", sdk.ToolDef{Name: "normal_tool", NoMemory: false}, nil)
|
||||||
|
|
||||||
|
defs := host.GetToolDefs()
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, d := range defs {
|
||||||
|
found[d.Name] = d.NoMemory
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := found["mem_tool"]; !ok {
|
||||||
|
t.Error("mem_tool not found in defs")
|
||||||
|
} else if !v {
|
||||||
|
t.Error("mem_tool.NoMemory should be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := found["normal_tool"]; !ok {
|
||||||
|
t.Error("normal_tool not found in defs")
|
||||||
|
} else if v {
|
||||||
|
t.Error("normal_tool.NoMemory should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -180,7 +180,7 @@ func TestBilingualVectorizeClean(t *testing.T) {
|
|||||||
cleanVec := e.VectorizeClean(inp)
|
cleanVec := e.VectorizeClean(inp)
|
||||||
sim := cosineSim(rawVec, cleanVec)
|
sim := cosineSim(rawVec, cleanVec)
|
||||||
rawTokens := len(e.tokenize(inp))
|
rawTokens := len(e.tokenize(inp))
|
||||||
cleanTokens := len(e.tokenize(CleanTemplateText(inp)))
|
cleanTokens := len(e.tokenize(CleanText(inp)))
|
||||||
t.Logf("[%d] sim(raw,clean)=%.4f tokens: raw=%d clean=%d", i, sim, rawTokens, cleanTokens)
|
t.Logf("[%d] sim(raw,clean)=%.4f tokens: raw=%d clean=%d", i, sim, rawTokens, cleanTokens)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -236,8 +236,8 @@ func genBilingualEvents() []bilingualEvent {
|
|||||||
func textForBilingual(ev bilingualEvent, modelPaths []string) string {
|
func textForBilingual(ev bilingualEvent, modelPaths []string) string {
|
||||||
switch {
|
switch {
|
||||||
case ev.source == "agent" && ev.text != "":
|
case ev.source == "agent" && ev.text != "":
|
||||||
return CleanTemplateText(ev.text)
|
return CleanText(ev.text)
|
||||||
default:
|
default:
|
||||||
return CleanTemplateText(ev.text)
|
return CleanText(ev.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,31 @@ package memory
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type cleanTestEvent struct {
|
type cleanTestEvent struct {
|
||||||
idx int
|
idx int
|
||||||
source string
|
source string
|
||||||
@ -173,7 +194,7 @@ func TestCleanVectorConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tmpl := range templates {
|
for _, tmpl := range templates {
|
||||||
cleaned := CleanTemplateText(tmpl)
|
cleaned := CleanText(tmpl)
|
||||||
t.Logf("template {%q} → {%q} (%d chars)", trimLen(tmpl, 60), cleaned, len(cleaned))
|
t.Logf("template {%q} → {%q} (%d chars)", trimLen(tmpl, 60), cleaned, len(cleaned))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,11 +307,11 @@ func genStressEvents(n int) []cleanTestEvent {
|
|||||||
func cleanEventText(source, input, response string) string {
|
func cleanEventText(source, input, response string) string {
|
||||||
switch {
|
switch {
|
||||||
case source == "agent" && response != "":
|
case source == "agent" && response != "":
|
||||||
return CleanTemplateText(response)
|
return CleanText(response)
|
||||||
case source == "cold_storage":
|
case source == "cold_storage":
|
||||||
return CleanTemplateText(input + " " + response)
|
return CleanText(input + " " + response)
|
||||||
default:
|
default:
|
||||||
return CleanTemplateText(input)
|
return CleanText(input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +1,22 @@
|
|||||||
package memory
|
package memory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var globalTextCleaner func(string) string
|
||||||
reQQGroupSuffix = regexp.MustCompile(
|
|
||||||
`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复该群聊,content 设为 JSON 字符串:\{[^}]*\}`,
|
func SetTextCleaner(fn func(string) string) {
|
||||||
)
|
globalTextCleaner = fn
|
||||||
reQQPrivateSuffix = regexp.MustCompile(
|
}
|
||||||
`,通过id\d+使用qq_get_message工具获取消息正文。获取内容后使用 output_send\(channel="qq"\) 回复对方,content 设为 JSON 字符串:\{[^}]*\}`,
|
|
||||||
)
|
func CleanText(text string) string {
|
||||||
reQQOldReply = regexp.MustCompile(
|
if globalTextCleaner != nil {
|
||||||
`通过id\d+使用qq_get_message工具获取消息正文。获取后必须使用[^。]+。`,
|
text = globalTextCleaner(text)
|
||||||
)
|
}
|
||||||
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*`,
|
|
||||||
)
|
|
||||||
reAgentPrefix = regexp.MustCompile(
|
|
||||||
`冷知识|注意|提示|核心要求|规则`,
|
|
||||||
)
|
|
||||||
reMultiSpace = regexp.MustCompile(`\s+`)
|
|
||||||
)
|
|
||||||
|
|
||||||
func CleanTemplateText(text string) string {
|
|
||||||
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, " ")
|
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
|
|
||||||
if text == "" {
|
if text == "" {
|
||||||
@ -53,5 +30,5 @@ func CleanTemplateText(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *StaticEmbedder) VectorizeClean(text string) vector.Vector {
|
func (e *StaticEmbedder) VectorizeClean(text string) vector.Vector {
|
||||||
return e.Vectorize(CleanTemplateText(text))
|
return e.Vectorize(CleanText(text))
|
||||||
}
|
}
|
||||||
|
|||||||
83
internal/memory/clean_text_test.go
Normal file
83
internal/memory/clean_text_test.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCleanTextTrim(t *testing.T) {
|
||||||
|
prev := globalTextCleaner
|
||||||
|
globalTextCleaner = nil
|
||||||
|
defer func() { globalTextCleaner = prev }()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{" hello ", "hello"},
|
||||||
|
{",hello", "hello"},
|
||||||
|
{",,hello", "hello"},
|
||||||
|
{" ,,hello ", "hello"},
|
||||||
|
{"", ""},
|
||||||
|
{" ", ""},
|
||||||
|
{",", ""},
|
||||||
|
{",x", "x"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := CleanText(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("CleanText(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -93,7 +93,7 @@ var stopWords = map[string]bool{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExtractKeywords(text string) []string {
|
func ExtractKeywords(text string) []string {
|
||||||
text = CleanTemplateText(text)
|
text = CleanText(text)
|
||||||
x := GetJieba()
|
x := GetJieba()
|
||||||
if x == nil {
|
if x == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -120,7 +120,7 @@ func ExtractKeywords(text string) []string {
|
|||||||
|
|
||||||
// CutExact 精确模式分词:返回去停用词后的所有有义项(不限数量),用于 doc→graph 蒸馏
|
// CutExact 精确模式分词:返回去停用词后的所有有义项(不限数量),用于 doc→graph 蒸馏
|
||||||
func CutExact(text string) []string {
|
func CutExact(text string) []string {
|
||||||
text = CleanTemplateText(text)
|
text = CleanText(text)
|
||||||
x := GetJieba()
|
x := GetJieba()
|
||||||
if x == nil {
|
if x == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -101,7 +101,7 @@ func TestCutExactRemoveTimestamp(t *testing.T) {
|
|||||||
got := CutExact("[15:04] 今天天气不错")
|
got := CutExact("[15:04] 今天天气不错")
|
||||||
for _, g := range got {
|
for _, g := range got {
|
||||||
if g == "15" || g == "04" || g == "15:04" {
|
if g == "15" || g == "04" || g == "15:04" {
|
||||||
t.Errorf("timestamp should be removed by CleanTemplateText, got %q in %v", g, got)
|
t.Errorf("timestamp should be removed by CleanText, got %q in %v", g, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,7 +90,7 @@ func (idx *Indexer) BuildContext(userInput string) *InjectedContext {
|
|||||||
return &InjectedContext{Summary: ""}
|
return &InjectedContext{Summary: ""}
|
||||||
}
|
}
|
||||||
|
|
||||||
input := CleanTemplateText(userInput)
|
input := CleanText(userInput)
|
||||||
|
|
||||||
// 1. 向量搜索:从实体名向量索引中找到相关实体
|
// 1. 向量搜索:从实体名向量索引中找到相关实体
|
||||||
vectorEntities := idx.vectorSearchEntities(input)
|
vectorEntities := idx.vectorSearchEntities(input)
|
||||||
|
|||||||
@ -16,7 +16,7 @@ type realEvent struct {
|
|||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanTemplateText(t *testing.T) {
|
func TestCleanText(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
input string
|
input string
|
||||||
expected string
|
expected string
|
||||||
@ -53,7 +53,7 @@ func TestCleanTemplateText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, c := range cases {
|
for i, c := range cases {
|
||||||
got := CleanTemplateText(c.input)
|
got := CleanText(c.input)
|
||||||
if c.expected != "" && got != c.expected {
|
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)
|
t.Errorf("case %d:\n input: %q\n expected: %q\n got: %q", i, trimLen(c.input, 60), c.expected, got)
|
||||||
}
|
}
|
||||||
@ -91,11 +91,11 @@ func TestRealContextPerSourceVector(t *testing.T) {
|
|||||||
clean := func(ev realEvent) string {
|
clean := func(ev realEvent) string {
|
||||||
switch {
|
switch {
|
||||||
case ev.Source == "agent" && ev.Response != "":
|
case ev.Source == "agent" && ev.Response != "":
|
||||||
return CleanTemplateText(ev.Response)
|
return CleanText(ev.Response)
|
||||||
case ev.Source == "cold_storage":
|
case ev.Source == "cold_storage":
|
||||||
return CleanTemplateText(ev.Input + " " + ev.Response)
|
return CleanText(ev.Input + " " + ev.Response)
|
||||||
default:
|
default:
|
||||||
return CleanTemplateText(ev.Input)
|
return CleanText(ev.Input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -275,11 +275,11 @@ func TestRealContextEmbedderStats(t *testing.T) {
|
|||||||
var text string
|
var text string
|
||||||
switch {
|
switch {
|
||||||
case ev.Source == "agent" && ev.Response != "":
|
case ev.Source == "agent" && ev.Response != "":
|
||||||
text = CleanTemplateText(ev.Response)
|
text = CleanText(ev.Response)
|
||||||
case ev.Source == "cold_storage":
|
case ev.Source == "cold_storage":
|
||||||
text = CleanTemplateText(ev.Input + " " + ev.Response)
|
text = CleanText(ev.Input + " " + ev.Response)
|
||||||
default:
|
default:
|
||||||
text = CleanTemplateText(ev.Input)
|
text = CleanText(ev.Input)
|
||||||
}
|
}
|
||||||
vec := e.Vectorize(text)
|
vec := e.Vectorize(text)
|
||||||
origLen := len(ev.Input + ev.Response)
|
origLen := len(ev.Input + ev.Response)
|
||||||
|
|||||||
@ -54,6 +54,11 @@ func RegisterFactory(name string, factory NativeFactory) {
|
|||||||
globalFactories.Store(name, factory)
|
globalFactories.Store(name, factory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PluginToolCleaner 定义插件工具注销接口,由 StageHost 实现。
|
||||||
|
type PluginToolCleaner interface {
|
||||||
|
UnregisterPluginTools(pluginName string)
|
||||||
|
}
|
||||||
|
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
plugins map[string]sdk.Plugin
|
plugins map[string]sdk.Plugin
|
||||||
@ -75,6 +80,11 @@ type Registry struct {
|
|||||||
regTool sdk.ToolRegistrar
|
regTool sdk.ToolRegistrar
|
||||||
regStage sdk.StageRegistrar
|
regStage sdk.StageRegistrar
|
||||||
regAPI sdk.APIRegistrar
|
regAPI sdk.APIRegistrar
|
||||||
|
|
||||||
|
toolCleaner PluginToolCleaner
|
||||||
|
|
||||||
|
knownDisabled map[string]bool
|
||||||
|
textCleaners []func(string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRegistry() *Registry {
|
func NewRegistry() *Registry {
|
||||||
@ -82,6 +92,7 @@ func NewRegistry() *Registry {
|
|||||||
plugins: make(map[string]sdk.Plugin),
|
plugins: make(map[string]sdk.Plugin),
|
||||||
factories: make(map[string]NativeFactory),
|
factories: make(map[string]NativeFactory),
|
||||||
pluginAutoRestart: make(map[string]bool),
|
pluginAutoRestart: make(map[string]bool),
|
||||||
|
knownDisabled: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,6 +108,18 @@ func (r *Registry) SetPluginDir(dir string) { r.plgDir = di
|
|||||||
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
|
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
|
||||||
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
|
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
|
||||||
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
|
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) {
|
func (r *Registry) RegisterNative(name string, factory NativeFactory) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@ -217,6 +240,13 @@ func (r *Registry) Load(dir string) error {
|
|||||||
if loaded[name] {
|
if loaded[name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if r.isDisabled(name) {
|
||||||
|
log.Printf("[plugin] %s is disabled, skipping", name)
|
||||||
|
r.mu.Lock()
|
||||||
|
r.knownDisabled[name] = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
plgDir := filepath.Join(dir, name)
|
plgDir := filepath.Join(dir, name)
|
||||||
os.MkdirAll(plgDir, 0755)
|
os.MkdirAll(plgDir, 0755)
|
||||||
|
|
||||||
@ -240,6 +270,7 @@ func (r *Registry) Load(dir string) error {
|
|||||||
r.plugins[name] = p
|
r.plugins[name] = p
|
||||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||||
r.instances = append(r.instances, p)
|
r.instances = append(r.instances, p)
|
||||||
|
r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
log.Printf("[plugin] loaded: %s", name)
|
log.Printf("[plugin] loaded: %s", name)
|
||||||
}
|
}
|
||||||
@ -247,7 +278,26 @@ func (r *Registry) Load(dir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Registry) isDisabled(name string) bool {
|
||||||
|
if r.cfgReg == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, err := r.cfgReg.PluginConfig(name).Get("disabled")
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return fmt.Sprint(v) == "true"
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) loadOne(plgDir, name string) bool {
|
func (r *Registry) loadOne(plgDir, name string) bool {
|
||||||
|
if r.isDisabled(name) {
|
||||||
|
log.Printf("[plugin] %s is disabled, skipping", name)
|
||||||
|
r.mu.Lock()
|
||||||
|
r.knownDisabled[name] = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 1) 查找工厂(init 自注册或 RegisterNative)
|
// 1) 查找工厂(init 自注册或 RegisterNative)
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
factory, hasFactory := r.factories[name]
|
factory, hasFactory := r.factories[name]
|
||||||
@ -303,6 +353,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
|||||||
r.plugins[name] = plg
|
r.plugins[name] = plg
|
||||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||||
r.instances = append(r.instances, plg)
|
r.instances = append(r.instances, plg)
|
||||||
|
r.textCleaners = append(r.textCleaners, plgSDK.TextCleaners()...)
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
log.Printf("[plugin] loaded: %s", name)
|
log.Printf("[plugin] loaded: %s", name)
|
||||||
return true
|
return true
|
||||||
@ -319,6 +370,7 @@ func (r *Registry) StopAll() {
|
|||||||
r.plugins = make(map[string]sdk.Plugin)
|
r.plugins = make(map[string]sdk.Plugin)
|
||||||
r.instances = nil
|
r.instances = nil
|
||||||
r.pluginAutoRestart = make(map[string]bool)
|
r.pluginAutoRestart = make(map[string]bool)
|
||||||
|
r.textCleaners = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Registry) Reload(dir string) (string, error) {
|
func (r *Registry) Reload(dir string) (string, error) {
|
||||||
@ -382,6 +434,94 @@ func (r *Registry) AutoRestartEnabled(name string) bool {
|
|||||||
return enabled
|
return enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Registry) IsDisabled(name string) bool {
|
||||||
|
return r.isDisabled(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Enable(name string) error {
|
||||||
|
if r.cfgReg != nil {
|
||||||
|
r.cfgReg.PluginConfig(name).Set("disabled", "false")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
delete(r.knownDisabled, name)
|
||||||
|
r.mu.Unlock()
|
||||||
|
plgDir := filepath.Join(r.plgDir, name)
|
||||||
|
if r.loadOne(plgDir, name) {
|
||||||
|
log.Printf("[plugin] enabled: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("enable plugin %s failed", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Disable(name string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
p, ok := r.plugins[name]
|
||||||
|
if ok {
|
||||||
|
if err := p.Stop(); err != nil {
|
||||||
|
log.Printf("[plugin] stop %s for disable: %v", name, err)
|
||||||
|
}
|
||||||
|
delete(r.plugins, name)
|
||||||
|
for i, inst := range r.instances {
|
||||||
|
if inst.Name() == name {
|
||||||
|
r.instances = append(r.instances[:i], r.instances[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.knownDisabled[name] = true
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
if r.toolCleaner != nil {
|
||||||
|
r.toolCleaner.UnregisterPluginTools(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.cfgReg != nil {
|
||||||
|
r.cfgReg.PluginConfig(name).Set("disabled", "true")
|
||||||
|
}
|
||||||
|
log.Printf("[plugin] disabled: %s", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListKnown 返回所有已知插件(已加载 + 已禁用 + 已安装但未加载)。
|
||||||
|
func (r *Registry) ListKnown() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
known := make(map[string]bool)
|
||||||
|
for name := range r.plugins {
|
||||||
|
known[name] = true
|
||||||
|
}
|
||||||
|
for name := range r.knownDisabled {
|
||||||
|
known[name] = true
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if r.plgDir != "" {
|
||||||
|
entries, _ := os.ReadDir(r.plgDir)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
known[e.Name()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.RLock()
|
||||||
|
for name := range r.factories {
|
||||||
|
known[name] = true
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
globalFactories.Range(func(key, val interface{}) bool {
|
||||||
|
known[key.(string)] = true
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
list := make([]string, 0, len(known))
|
||||||
|
for name := range known {
|
||||||
|
list = append(list, name)
|
||||||
|
}
|
||||||
|
sort.Strings(list)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Registry) PluginMetas() map[string]PluginMeta {
|
func (r *Registry) PluginMetas() map[string]PluginMeta {
|
||||||
metas := make(map[string]PluginMeta)
|
metas := make(map[string]PluginMeta)
|
||||||
globalPluginMeta.Range(func(key, val interface{}) bool {
|
globalPluginMeta.Range(func(key, val interface{}) bool {
|
||||||
|
|||||||
@ -197,6 +197,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("terminal_create", sdk.ToolDef{
|
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||||
Name: "terminal_create",
|
Name: "terminal_create",
|
||||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -225,6 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("terminal_write", sdk.ToolDef{
|
s.RegisterTool("terminal_write", sdk.ToolDef{
|
||||||
Name: "terminal_write",
|
Name: "terminal_write",
|
||||||
Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入)。特殊键包括:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。",
|
Description: "向指定终端发送输入。支持普通文本和特殊键(通过 key 参数传入)。特殊键包括:enter, tab, escape, ctrl_a~ctrl_z, alt_a~alt_z, f1~f12, up, down, left, right, home, end, backspace, delete, page_up, page_down。普通文本传入 input 参数即可。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -250,6 +252,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("terminal_read", sdk.ToolDef{
|
s.RegisterTool("terminal_read", sdk.ToolDef{
|
||||||
Name: "terminal_read",
|
Name: "terminal_read",
|
||||||
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
|
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -271,6 +274,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("terminal_resize", sdk.ToolDef{
|
s.RegisterTool("terminal_resize", sdk.ToolDef{
|
||||||
Name: "terminal_resize",
|
Name: "terminal_resize",
|
||||||
Description: "调整指定终端的尺寸(行数和列数)。",
|
Description: "调整指定终端的尺寸(行数和列数)。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -296,6 +300,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("terminal_close", sdk.ToolDef{
|
s.RegisterTool("terminal_close", sdk.ToolDef{
|
||||||
Name: "terminal_close",
|
Name: "terminal_close",
|
||||||
Description: "关闭指定终端会话。释放资源。",
|
Description: "关闭指定终端会话。释放资源。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@ -313,6 +318,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("terminal_list", sdk.ToolDef{
|
s.RegisterTool("terminal_list", sdk.ToolDef{
|
||||||
Name: "terminal_list",
|
Name: "terminal_list",
|
||||||
Description: "列出所有活跃的终端会话及其状态。",
|
Description: "列出所有活跃的终端会话及其状态。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{},
|
"properties": map[string]interface{}{},
|
||||||
|
|||||||
@ -110,6 +110,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
s.RegisterTool("cmd_run", sdk.ToolDef{
|
s.RegisterTool("cmd_run", sdk.ToolDef{
|
||||||
Name: "cmd_run",
|
Name: "cmd_run",
|
||||||
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
|
Description: "执行一条系统命令并返回输出。适用于查询系统信息、运行脚本、操作文件等单次命令场景。命令在临时 shell 中执行,不支持交互。如需交互式终端(如 vim、ssh、top),请使用 terminal_create 相关工具。",
|
||||||
|
NoMemory: true,
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
|
|||||||
246
plan.md
Normal file
246
plan.md
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
# 插件化改造 — 实施计划
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
当前 core 层两处硬编码耦合:
|
||||||
|
|
||||||
|
1. **文本清洗**:`internal/memory/clean_text.go` 硬编码 6 个 QQ 正则,context/indexer/cut/VertorizeClean 各环节直接调用 `CleanTemplateText`
|
||||||
|
|
||||||
|
2. **工具输出不进记忆**:`internal/agent/core/context.go:196` 硬编码 source 名过滤
|
||||||
|
```go
|
||||||
|
if s.event.Source == "agentcli" || s.event.Source == "terminal" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
意图是 `cmd_run`、`terminal_*` 等工具的产出不归档到 document memory,但手段是查 source 而不是查工具定义,且只覆盖了 Prune 路径(document),没有覆盖 `emitMemoryCandidate` 路径(text + graph memory)。
|
||||||
|
|
||||||
|
同时 `eventloop.go:337` 的 `!stageCtx.NoMemory` 检查是命令式的(`InjectTextNoMemory` 设 payload flag),不是声明式的工具级控制。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
| 问题 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 文本清洗 | 插件通过 `s.RegisterTextCleaner()` 注册清洗函数,core 遍历执行 |
|
||||||
|
| 工具记忆控制 | `ToolDef` 增加 `NoMemory bool` 字段,插件声明式标记工具;core 在 emitMemoryCandidate 和 Prune 两处自动跳过 |
|
||||||
|
|
||||||
|
## SDK 修改(外部包 `homeagent-sdk`)
|
||||||
|
|
||||||
|
**位置:** `sdk/plugin.go`
|
||||||
|
|
||||||
|
### 1.1 ToolDef 增加 NoMemory
|
||||||
|
|
||||||
|
```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"` // ← 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 PluginSDK 增加 TextCleaner
|
||||||
|
|
||||||
|
```go
|
||||||
|
// PluginSDK 新增字段
|
||||||
|
textCleaners []func(text string) string
|
||||||
|
|
||||||
|
func (s *PluginSDK) RegisterTextCleaner(cleaner func(text string) string) {
|
||||||
|
s.textCleaners = append(s.textCleaners, cleaner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 框架用 — 提取所有 cleaner
|
||||||
|
func (s *PluginSDK) TextCleaners() []func(text string) string {
|
||||||
|
return s.textCleaners
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SDK 管理策略
|
||||||
|
|
||||||
|
复制 SDK 到 `_sdk_local/`,`go.mod` 加 `replace gitcode.com/JianFeeeee/homeagent-sdk => ./_sdk_local`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 内部变更
|
||||||
|
|
||||||
|
### 2.1 Registry:聚合 text cleaners
|
||||||
|
|
||||||
|
**文件:** `internal/plugin/registry.go`
|
||||||
|
|
||||||
|
- 新增字段 `textCleaners []func(string) string`
|
||||||
|
- `buildSDK()` 中 `plg.Start(plgSDK)` 成功后提取 `plgSDK.TextCleaners()` 到 `textCleaners`
|
||||||
|
- 新增 `CleanText(text string) string` 依次执行所有 cleaner,无 cleaner 时返回原文本
|
||||||
|
- `StopAll()` / `Reload()` 时重置
|
||||||
|
|
||||||
|
### 2.2 Memory:动态 cleaner 入口
|
||||||
|
|
||||||
|
**文件:** `internal/memory/clean_text.go`
|
||||||
|
|
||||||
|
替换硬编码 QQ 正则为动态 cleaner:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var globalTextCleaner func(string) string
|
||||||
|
|
||||||
|
func SetTextCleaner(fn func(string) string) { globalTextCleaner = fn }
|
||||||
|
func CleanText(text string) string {
|
||||||
|
if globalTextCleaner != nil {
|
||||||
|
text = globalTextCleaner(text)
|
||||||
|
}
|
||||||
|
// 保留通用 trim
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
text = strings.TrimPrefix(text, ",")
|
||||||
|
text = strings.TrimPrefix(text, ",")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
删除 `reQQ*` 正则变量。
|
||||||
|
|
||||||
|
### 2.3 StageHost:按名查 ToolDef
|
||||||
|
|
||||||
|
**文件:** `internal/agent/core/stages.go`
|
||||||
|
|
||||||
|
新增方法:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *StageHost) ToolDef(name string) *sdk.ToolDef {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, def := range h.toolDefs {
|
||||||
|
if def.Name == name {
|
||||||
|
return &def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Agent:两处 NoMemory 检查
|
||||||
|
|
||||||
|
#### 2.4a emitMemoryCandidate 路径
|
||||||
|
|
||||||
|
**文件:** `internal/agent/core/eventloop.go`
|
||||||
|
|
||||||
|
`processTextInput()` 和 `processMediaInput()` 中的 `emitMemoryCandidate` 调用前,除了检查 `stageCtx.NoMemory`,还检查本次调用的工具是否有 `NoMemory`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hasNoMemoryTool := false
|
||||||
|
for _, name := range toolsUsed {
|
||||||
|
if def := a.stageHost.ToolDef(name); def != nil && def.NoMemory {
|
||||||
|
hasNoMemoryTool = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !stageCtx.NoMemory && !hasNoMemoryTool {
|
||||||
|
a.emitMemoryCandidate(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
这样不论通过 `InjectText` 还是 `InjectTextNoMemory`,只要 LLM 调用了 `NoMemory: true` 的工具,整轮对话就不进 text memory 和 graph memory。
|
||||||
|
|
||||||
|
#### 2.4b Prune 归档路径
|
||||||
|
|
||||||
|
**文件:** `internal/agent/core/context.go`
|
||||||
|
|
||||||
|
删除 hardcoded source 过滤,改为检查事件中 `ToolsUsed` 是否有 `NoMemory` 工具:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for _, s := range archive {
|
||||||
|
if hasNoMemoryTool(s.event.ToolsUsed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`hasNoMemoryTool` 通过 `StageHost.ToolDef` 查每个工具名是否有 `NoMemory: true`。
|
||||||
|
|
||||||
|
### 2.5 caller CleanTemplateText → CleanText
|
||||||
|
|
||||||
|
| 文件 | 替换 |
|
||||||
|
|------|------|
|
||||||
|
| `internal/agent/core/context.go` | `memory.CleanTemplateText` → `memory.CleanText` |
|
||||||
|
| `internal/memory/indexer.go` | 同上 |
|
||||||
|
| `internal/memory/cut.go` | 同上 |
|
||||||
|
| `internal/memory/clean_text.go` | 删除函数 `CleanTemplateText`,新增 `CleanText` + `SetTextCleaner` |
|
||||||
|
|
||||||
|
### 2.6 main.go 串联
|
||||||
|
|
||||||
|
**文件:** `cmd/homed/main.go`
|
||||||
|
|
||||||
|
`pluginReg.Load()` 之后:
|
||||||
|
|
||||||
|
```go
|
||||||
|
memory.SetTextCleaner(pluginReg.CleanText)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 agentcli / cmd 插件:标记 NoMemory
|
||||||
|
|
||||||
|
**文件:** `internal/plugins/agentcli/plugin.go` 和 `internal/plugins/cmd/plugin.go`
|
||||||
|
|
||||||
|
在 `RegisterTool` 调用的 `ToolDef` 中加 `NoMemory: true`:
|
||||||
|
|
||||||
|
- `terminal_create`, `terminal_write`, `terminal_read`, `terminal_resize`, `terminal_close`, `terminal_list`
|
||||||
|
- `cmd_run`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 外部插件变更
|
||||||
|
|
||||||
|
### QQ 插件
|
||||||
|
|
||||||
|
在 `Start()` 中:
|
||||||
|
|
||||||
|
```go
|
||||||
|
s.RegisterTextCleaner(func(text string) string {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
s.RegisterTool("qq_get_message", sdk.ToolDef{
|
||||||
|
Description: "获取QQ消息正文",
|
||||||
|
Parameters: map[string]interface{}{...},
|
||||||
|
NoMemory: true,
|
||||||
|
}, handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
| 文件 | 操作 |
|
||||||
|
|------|------|
|
||||||
|
| `sdk/plugin.go`(外部 SDK) | 修改:`ToolDef` 增 `NoMemory`、`PluginSDK` 增 `RegisterTextCleaner`/`TextCleaners` |
|
||||||
|
| `go.mod` | 修改:增 `replace` 指令 |
|
||||||
|
| `internal/plugin/registry.go` | 修改:增 cleaners 聚合 + `CleanText` |
|
||||||
|
| `internal/memory/clean_text.go` | 修改:硬编码 → 动态 cleaner,删除 `CleanTemplateText` |
|
||||||
|
| `internal/agent/core/stages.go` | 修改:增 `ToolDef(name)` 查找 |
|
||||||
|
| `internal/agent/core/eventloop.go` | 修改:`emitMemoryCandidate` 前查 `ToolDef.NoMemory` |
|
||||||
|
| `internal/agent/core/context.go` | 修改:source 过滤 → `ToolsUsed` NoMemory 检查 + `CleanTemplateText` → `CleanText` |
|
||||||
|
| `internal/agent/core/process.go` | 修改(可能需要):传递 toolsUsed 到 Prune 或加辅助方法 |
|
||||||
|
| `internal/memory/indexer.go` | 修改:`CleanTemplateText` → `CleanText` |
|
||||||
|
| `internal/memory/cut.go` | 修改:同上 |
|
||||||
|
| `internal/plugins/agentcli/plugin.go` | 修改:工具标记 `NoMemory: true` |
|
||||||
|
| `internal/plugins/cmd/plugin.go` | 修改:`cmd_run` 标记 `NoMemory: true` |
|
||||||
|
| `cmd/homed/main.go` | 修改:`memory.SetTextCleaner(pluginReg.CleanText)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
1. SDK 本地副本 + 加 `NoMemory`/`RegisterTextCleaner`
|
||||||
|
2. Registry 聚合 cleaners + `CleanText`
|
||||||
|
3. Memory 动态 cleaner + 改名调用者
|
||||||
|
4. StageHost `ToolDef(name)`
|
||||||
|
5. eventloop + context Prune + process 加入 NoMemory 检查
|
||||||
|
6. 标记 agentcli/cmd 工具的 `NoMemory: true`
|
||||||
|
7. main.go 串联
|
||||||
|
8. 删除旧代码(QQ 正则、source 硬编码)
|
||||||
|
9. 构建 + 测试
|
||||||
Reference in New Issue
Block a user