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