diff --git a/README.md b/README.md
index d43e51c..a63882c 100644
--- a/README.md
+++ b/README.md
@@ -163,7 +163,7 @@ internal/
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
├── knowledge/ 知识库(文件系统 + TF-IDF)
├── plugin/ 插件注册表 + .so 动态加载器
-├── plugins/ 内置 10 个插件(webui/cli/timer/cmd/mcp/openclaw/agentcli/healthcheck/pluginmgr/files)
+├── plugins/ 内置 11 个插件(webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
├── config/ SQLite 配置中心
├── events/ 事件总线
@@ -173,7 +173,7 @@ internal/
## 项目状态
-**v0.7.1** — 核心可用,插件系统和 SDK 已就绪。内置 10 个插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链。输出通道系统、受限外部插件 API、EventAgentLLMChain 事件已上线。
+**v0.7.1** — 核心可用,插件系统和 SDK 已就绪。内置 11 个插件,外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链。输出通道系统、受限外部插件 API、EventAgentLLMChain 事件已上线。
## 文档
diff --git a/README_EN.md b/README_EN.md
index 1c263b2..1e0255b 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -163,7 +163,7 @@ internal/
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
├── knowledge/ Knowledge base (filesystem + TF-IDF)
├── plugin/ Plugin registry + .so/.dll dynamic loader
-├── plugins/ 10 built-in plugins (webui/cli/timer/cmd/mcp/openclaw/agentcli/healthcheck/pluginmgr/files)
+├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
├── config/ SQLite config center
├── events/ Event bus
@@ -173,7 +173,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
## Project Status
-**v0.7.1** — Core is functional, plugin system and SDK are ready. 10 built-in plugins. External plugin development via [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo using `plugindev` toolchain. Output channel system, restricted external plugin API, and EventAgentLLMChain event are live.
+**v0.7.1** — Core is functional, plugin system and SDK are ready. 11 built-in plugins. External plugin development via [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo using `plugindev` toolchain. Output channel system, restricted external plugin API, and EventAgentLLMChain event are live.
## Documentation
diff --git a/docs/en/ARCHITECTURE.md b/docs/en/ARCHITECTURE.md
index 7a64338..9fe73b8 100644
--- a/docs/en/ARCHITECTURE.md
+++ b/docs/en/ARCHITECTURE.md
@@ -256,7 +256,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
| Built-in | `init()` → `RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
| External `.so` | C ABI dynamic loading | `-buildmode=c-shared` + bridge | qq/files/web/memo etc. |
| Lua script plugin | Parse `main.lua` to register tools | No compilation, hot-reload | luaplugintest/testlua etc. |
-| SKILL plugin | Parse `SKILL.md` | Markdown definition | OpenClaw compatible |
+| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
Built-in plugin registration: `internal/plugins/all.go` blank imports → each plugin `init()` → `Registry.Load()` scans directory to match factory.
External plugin loading: `internal/plugin/dynamic.go` → copy to SHA256 temp path (bypass `plugin.Open` path cache) → `Open` + `Lookup("NewPlugin")`.
@@ -403,10 +403,12 @@ internal/
│ ├── timer/ — Timer
│ ├── cmd/ — Command execution
│ ├── mcp/ — MCP protocol
-│ ├── openclaw/ — OpenClaw compatible
+│ ├── files/ — File operations
+│ ├── clawhubadapter/ — ClawHub adapter (OC plugin/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY terminal
│ ├── healthcheck/ — Health check
-│ └── pluginmgr/ — Plugin manager
+│ ├── pluginmgr/ — Plugin manager
+│ └── cfgmgr/ — Config manager
├── sdk/ — PluginSDK definitions
│ ├── plugin.go — Plugin interface + PluginSDK
│ ├── memory.go — MemoryAPI
diff --git a/docs/en/OVERVIEW.md b/docs/en/OVERVIEW.md
index 48cda93..87a1279 100644
--- a/docs/en/OVERVIEW.md
+++ b/docs/en/OVERVIEW.md
@@ -69,12 +69,19 @@ Code is in the project root, implemented in Go.
- OpenAI API-compatible `/v1/chat/completions` endpoint
- SSE event stream `/api/v1/chat/events`
+**ClawHub Adapter** (`internal/plugins/clawhubadapter/`):
+- Unified loader for OC plugins (Node.js), Python sidecar, JS sidecar, and SKILL plugins
+- RegistryDispatcher pattern: routes registration notifications to Tool/Provider/Channel/Stage registries
+- ClawHub marketplace search and install: `clawhubadapter_search` / `clawhubadapter_npm_install`
+- 9 provider types mapped to LLM-accessible tools (image generation, web search, speech, etc.)
+- OC channels auto-registered as IO devices with text/file/image/audio capability flags
+
:
## Project Status
Core functionality is operational. Plugin system and SDK are ready for independent external plugin development.
-- Built-in plugins: webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / openclaw / files
+- Built-in plugins: webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / clawhubadapter / files / cfgmgr
- External plugin examples ([homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo `example/`, both Go and Lua types): qq / files / web / memo / bili / editdoc / a2a / ocr / sanitizer / luaplugintest / testlua
- Distribution: `.hmap` plugin package format, installable via WebUI
diff --git a/docs/zh/ARCHITECTURE.md b/docs/zh/ARCHITECTURE.md
index d8cfaf6..f4c3285 100644
--- a/docs/zh/ARCHITECTURE.md
+++ b/docs/zh/ARCHITECTURE.md
@@ -256,7 +256,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部 `.so` | C ABI 动态加载 | `-buildmode=c-shared` + bridge | qq/files/web/memo 等 |
| Lua 脚本插件 | 解析 `main.lua` 注册工具 | 无需编译,热加载 | luaplugintest/testlua 等 |
-| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | OpenClaw 兼容 |
+| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()` → `Registry.Load()` 扫描目录匹配工厂。
外部插件加载:`internal/plugin/dynamic.go` → 复制到 SHA256 临时路径(绕过 `plugin.Open` 路径缓存)→ `Open` + `Lookup("NewPlugin")`。
@@ -403,10 +403,12 @@ internal/
│ ├── timer/ — 定时器
│ ├── cmd/ — 命令执行
│ ├── mcp/ — MCP 协议
-│ ├── openclaw/ — OpenClaw 兼容
+│ ├── files/ — 文件操作
+│ ├── clawhubadapter/ — ClawHub 适配器(OC 插件/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY 终端
│ ├── healthcheck/ — 健康检查
-│ └── pluginmgr/ — 插件管理器
+│ ├── pluginmgr/ — 插件管理器
+│ └── cfgmgr/ — 配置管理
├── sdk/ — PluginSDK 定义
│ ├── plugin.go — Plugin 接口 + PluginSDK
│ ├── memory.go — MemoryAPI
diff --git a/docs/zh/OVERVIEW.md b/docs/zh/OVERVIEW.md
index deef6c8..2cac65a 100644
--- a/docs/zh/OVERVIEW.md
+++ b/docs/zh/OVERVIEW.md
@@ -69,12 +69,19 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
- 兼容 OpenAI API 格式的 `/v1/chat/completions` 端点
- SSE 事件流 `/api/v1/chat/events`
+**ClawHub 适配器** (`internal/plugins/clawhubadapter/`):
+- 统一加载 OC 插件(Node.js)、Python sidecar、JS sidecar、SKILL 四种插件类型
+- RegistryDispatcher 模式:Tool/Provider/Channel/Stage 注册通知分发
+- ClawHub 市场搜索与安装:`clawhubadapter_search` / `clawhubadapter_npm_install`
+- 9 种 Provider 类型映射为 LLM 可用工具(图片生成、搜索、语音等)
+- OC 通道自动注册为 IO 设备,支持文本/文件/图片/音频能力标志
+
:
## 项目状态
核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。
-- 内置插件:webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / openclaw / files
+- 内置插件:webui / cli / timer / cmd / mcp / agentcli / healthcheck / pluginmgr / clawhubadapter / files / cfgmgr
- 外部插件示例([homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库 `example/`,含 Go 和 Lua 两种类型):qq / files / web / memo / bili / editdoc / a2a / ocr / sanitizer / luaplugintest / testlua
- 打包分发:`.hmap` 插件包格式,通过 WebUI 安装
diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go
index 5012f77..515d1e2 100644
--- a/internal/agent/core/agent.go
+++ b/internal/agent/core/agent.go
@@ -2,16 +2,10 @@ package core
import (
"context"
- "encoding/json"
- "errors"
- "fmt"
"log"
- "runtime/debug"
- "sort"
"strings"
"sync"
"time"
- "unicode/utf8"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
@@ -23,7 +17,6 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
- sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@@ -236,3042 +229,3 @@ func (a *Agent) injectSelf(task string) {
log.Printf("[agent] self input channel full, dropping task: %s", truncateStr(task, 80))
}
}
-
-func (a *Agent) eventLoop() {
- defer func() {
- if r := recover(); r != nil {
- log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
- time.Sleep(time.Second)
- go a.eventLoop()
- }
- }()
- for {
- select {
- case evt := <-a.io.InputChan():
- a.handleInput(evt)
- case task := <-a.selfInputCh:
- a.handleSelfInput(task)
- case <-a.ctx.Done():
- return
- }
- }
-}
-
-// interceptLoop 独立 goroutine 监控中断通道。
-// 两种路径投递:
-// a) 通过 cancelLLM + interceptCh 直接打断进行中的 LLM 请求
-// b) 通过 a.io.InjectInput() → InputChan → eventLoop(代理空闲时触发新处理循环)
-func (a *Agent) interceptLoop() {
- defer func() {
- if r := recover(); r != nil {
- log.Printf("[agent] interceptLoop panic recovered: %v\n%s", r, debug.Stack())
- time.Sleep(time.Second)
- go a.interceptLoop()
- }
- }()
- for {
- select {
- case evt := <-a.io.InputInterruptChan():
- text, _ := evt.Payload["content"].(string)
- if text == "" {
- continue
- }
- log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80))
-
- clone := &agentIO.InputEvent{
- RequestID: evt.RequestID,
- Source: evt.Source,
- Type: evt.Type,
- Payload: map[string]interface{}{},
- OutputChannel: evt.OutputChannel,
- }
- for k, v := range evt.Payload {
- clone.Payload[k] = v
- }
- clone.Payload["interrupt"] = true
- clone.Payload["interrupt_source"] = evt.Source
- clone.Payload["interrupt_channel"] = evt.OutputChannel
-
- // 若当前有进行中的 LLM 请求,则打断并走 interceptCh;否则直接回注入普通输入队列。
- a.llmMu.Lock()
- hasActiveLLM := a.cancelLLM != nil
- if hasActiveLLM {
- a.cancelLLM()
- log.Printf("[agent] LLM request cancelled by interrupt")
- }
- a.llmMu.Unlock()
-
- if hasActiveLLM {
- // 后台整理任务被打断:中断消息重新注入为独立输入(consolidation 的 process 不会路由回复)
- if a.currentOutputChannel == "_consolidation_" {
- log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
- a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
- "content": text,
- "interrupt": true,
- "interrupt_source": evt.Source,
- "interrupt_channel": evt.OutputChannel,
- })
- } else {
- select {
- case a.interceptCh <- clone:
- default:
- log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
- a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
- "content": text,
- "interrupt": true,
- "interrupt_source": evt.Source,
- "interrupt_channel": evt.OutputChannel,
- })
- }
- }
- } else {
- a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
- "content": text,
- "interrupt": true,
- "interrupt_source": evt.Source,
- "interrupt_channel": evt.OutputChannel,
- })
- }
-
- case <-a.ctx.Done():
- return
- }
- }
-}
-
-// handleSelfInput 处理自循环输入(内部任务,不经过 IO 层)
-func (a *Agent) handleSelfInput(task string) {
- a.processTextInput(&agentIO.InputEvent{
- Source: "system",
- Type: "text",
- Payload: map[string]interface{}{"content": task},
- OutputChannel: "_consolidation_",
- }, task)
-}
-
-func (a *Agent) handleInput(evt *agentIO.InputEvent) {
- switch evt.Type {
- case "text":
- input, _ := evt.Payload["content"].(string)
- if input == "" {
- return
- }
- a.processTextInput(evt, input)
-
- case "image", "audio":
- a.processMediaInput(evt)
-
- case "event":
- log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
-
- case "command":
- cmd, _ := evt.Payload["command"].(string)
- log.Printf("[agent] command from %s: %s", evt.Source, cmd)
-
- default:
- log.Printf("[agent] unknown event type from %s: %s", evt.Source, evt.Type)
- }
-}
-
-// processMediaInput 处理图片/音频等非文本输入。
-// 将媒体数据附着到对话中,LLM 可通过 describe_image / transcribe_audio 等工具自主处理。
-func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
- start := time.Now()
- a.pendingMedia = evt.Payload
- defer func() { a.pendingMedia = nil }()
-
- a.currentOutputChannel = evt.OutputChannel
- if a.currentOutputChannel == "" {
- a.currentOutputChannel = evt.Source
- }
-
- blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
-
- // stage 上下文携带 blocks,process() 会将其附着到 user message 上
- stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
- stageCtx.Extra = map[string]interface{}{
- "media_blocks": blocks,
- "media_type": evt.Type,
- "input_source": evt.Source,
- "output_channel": evt.OutputChannel,
- }
- a.injectSourceContext(stageCtx, evt)
-
- // === Stage: on_input — 插件可拦截/改写/短路(在 Append 之前) ===
- if a.runStage(sdk.StageOnInput, stageCtx) {
- a.emitResponse(evt, *stageCtx.Response)
- return
- }
-
- a.publishEvent(events.EventRawInput, map[string]interface{}{
- "content": evt.Payload,
- "source": evt.Source,
- })
-
- // 先遗忘再输入
- archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
- if archived > 0 {
- log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
- }
-
- a.context.Append(ContextEvent{
- Timestamp: start,
- Source: evt.Source,
- Input: fallback,
- })
-
- response, toolsUsed, err := a.process(fallback, stageCtx)
- if err != nil {
- log.Printf("[agent] process media error: %v", err)
- resp := fmt.Sprintf("处理错误: %v", err)
- a.emitResponse(evt, resp)
- a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: fallback, Response: resp})
- return
- }
-
- elapsed := time.Since(start)
- 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,
- })
-
- a.emitResponse(evt, response)
-
- if !stageCtx.NoMemory {
- a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
- }
-}
-
-// mediaToBlocks 将媒体 payload 转为多模态 ContentBlock 数组和纯文本 fallback。
-// source 是输入通道名,用于生成可读的描述文本(如"从 cli 收到了一张图片")。
-func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
- data, _ := payload["data"].(string)
- mime, _ := payload["mime"].(string)
- url, _ := payload["url"].(string)
- alt, _ := payload["alt"].(string)
- if alt == "" {
- if source == "" {
- source = "unknown"
- }
- alt = fmt.Sprintf("[从 %s 收到了 %s]", source, mediaType)
- }
-
- var blocks []agentAPI.ContentBlock
-
- // 文本描述块
- desc := ""
- switch mediaType {
- case "image":
- desc = a.inputCfg.Image.DescribePrompt
- if desc == "" {
- desc = fmt.Sprintf("从 %s 收到了一张图片,请使用 describe_image 工具查看详情。", source)
- }
- case "audio":
- desc = a.inputCfg.Audio.DescribePrompt
- if desc == "" {
- desc = fmt.Sprintf("从 %s 收到了一段音频,请使用 transcribe_audio 工具查看内容。", source)
- }
- }
- blocks = append(blocks, agentAPI.ContentBlock{Type: "text", Text: desc})
-
- if data != "" || url != "" {
- imgURL := url
- if data != "" {
- if mime == "" {
- mime = "image/png"
- }
- imgURL = "data:" + mime + ";base64," + data
- }
- if mediaType == "image" {
- blocks = append(blocks, agentAPI.ContentBlock{
- Type: "image_url",
- ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
- })
- } else if mediaType == "audio" {
- blocks = append(blocks, agentAPI.ContentBlock{
- Type: "audio_url",
- AudioURL: &agentAPI.AudioURL{URL: imgURL},
- })
- }
- }
-
- return blocks, alt
-}
-
-func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
- start := time.Now()
-
- // 设置该请求的输出通道(默认 = 输入事件配套的通道)
- a.currentOutputChannel = evt.OutputChannel
- if a.currentOutputChannel == "" {
- a.currentOutputChannel = evt.Source
- }
-
- // 记忆整理任务:不路由到外部输出通道
- if evt.OutputChannel == "_consolidation_" {
- a.processConsolidation(evt, input)
- return
- }
-
- noMemory := false
- if v, ok := evt.Payload["no_memory"].(bool); ok {
- noMemory = v
- }
-
- // === Stage: on_input — 消息到达,插件可拦截 ===
- stageCtx := a.stageCtxFromInput(input, evt.Source, "")
- stageCtx.Extra["input_source"] = evt.Source
- stageCtx.Extra["output_channel"] = evt.OutputChannel
- if noMemory {
- stageCtx.NoMemory = true
- }
- a.injectSourceContext(stageCtx, evt)
-
- if a.runStage(sdk.StageOnInput, stageCtx) {
- a.emitResponse(evt, *stageCtx.Response)
- return
- }
-
- input = stageCtx.RawMessage
-
- a.publishEvent(events.EventRawInput, map[string]interface{}{
- "content": input,
- "source": evt.Source,
- })
-
- // 先"遗忘"再输入:用当前输入决定淘汰哪些不相关旧事件(LSTM forget gate 模式)
- archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
- if archived > 0 {
- log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
- }
-
- a.context.Append(ContextEvent{
- Timestamp: start,
- Source: evt.Source,
- Input: input,
- })
-
- response, toolsUsed, err := a.process(input, stageCtx)
- if err != nil {
- log.Printf("[agent] process error: %v", err)
- resp := fmt.Sprintf("处理错误: %v", err)
- a.emitResponse(evt, resp)
- a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
- return
- }
-
- elapsed := time.Since(start)
- 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,
- })
-
- a.emitResponse(evt, response)
-
- if !stageCtx.NoMemory {
- a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
- }
-}
-
-func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
- // === Stage: before_output — 最终文本就绪,插件可改写 ===
- stageCtx := &sdk.StageContext{
- FinalText: response,
- Phase: sdk.StageBeforeOutput,
- }
- a.runStage(sdk.StageBeforeOutput, stageCtx)
- response = stageCtx.FinalText
-
- // 读取当前输出通道(来源:输入事件自带的 OutputChannel)
- ch := a.currentOutputChannel
- if ch == "" {
- ch = evt.OutputChannel
- }
- if ch == "" {
- ch = evt.Source
- }
-
- payload := map[string]interface{}{
- "content": response,
- "request_id": evt.RequestID,
- }
- if stageCtx.ReasoningContent != "" {
- payload["reasoning_content"] = stageCtx.ReasoningContent
- }
- if stageCtx.TokenUsage != nil {
- payload["usage"] = stageCtx.TokenUsage
- }
-
- if evt.ResponseCh != nil {
- evt.ResponseCh <- &agentIO.OutputEvent{
- RequestID: evt.RequestID,
- Target: evt.Source,
- Type: "text",
- Payload: payload,
- Done: true,
- OutputChannel: ch,
- }
- }
-
- // === Stage: after_output — 输出完成,插件只读 ===
- a.publishEvent(events.EventAgentOutput, map[string]interface{}{
- "content": response,
- "channel": ch,
- "source": evt.Source,
- })
- stageCtx.Phase = sdk.StageAfterOutput
- a.runStage(sdk.StageAfterOutput, stageCtx)
-}
-
-// process — 内部处理,带工具循环和阶段管道
-func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
- a.mu.Lock()
- defer a.mu.Unlock()
-
- if a.provider == nil {
- return "", nil, fmt.Errorf("agent: no LLM provider configured")
- }
-
- memContext := a.buildMemoryContext(input)
- sysPrompt := a.buildSystemPrompt(memContext, input)
- tools := a.buildToolDefs()
-
- msgs := a.buildMessages(sysPrompt, input)
- // 如果 stageCtx 携带多模态 blocks,附着到 user message 上
- if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
- if len(msgs) > 0 {
- msgs[len(msgs)-1].Blocks = blocks
- }
- }
-
- log.Printf("[agent] tool call loop start, %d tools, %d context events, personality=%t, docs=%d",
- len(tools), a.context.Len(),
- a.personality != nil && a.personality.Content != "",
- a.docStoreSize())
-
- if a.runStage(sdk.StagePreAction, stageCtx) {
- return *stageCtx.Response, toolsUsed, nil
- }
- if len(stageCtx.ContextMsgs) > 0 {
- for _, m := range stageCtx.ContextMsgs {
- role, _ := m["role"].(string)
- content, _ := m["content"].(string)
- if role != "" {
- msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
- }
- }
- }
-
- for turn := 0; ; turn++ {
- // === 高优先级打断:每次 LLM 调用前检查拦截通道 ===
- for _, interrupt := range a.drainInterrupts() {
- msgs = append(msgs, agentAPI.Message{
- Role: "system",
- Content: interrupt,
- })
- }
-
-
- eb := map[string]interface{}{}
- if !a.thinkingEnabled {
- eb["thinking"] = map[string]interface{}{"type": "disabled"}
- }
- req := &agentAPI.CompletionRequest{
- Messages: msgs,
- MaxTokens: 4096,
- Tools: tools,
- ToolChoice: "auto",
- ExtraBody: eb,
- }
-
- // 可取消的 LLM 调用:interceptLoop 通过 cancelLLM 打断进行中的请求
- // 多 LLM 源顺位降级:当当前 provider 失败时,按注册顺序依次尝试
- // 带断路器:401/403 自动标记不可用,连续失败指数退避
- var providers []agentAPI.Provider
- if a.providerManager != nil {
- allProviders := a.providerManager.OrderedProviders()
- providers = make([]agentAPI.Provider, 0, len(allProviders))
- for _, p := range allProviders {
- if a.providerManager.IsAvailable(p.Name()) {
- providers = append(providers, p)
- }
- }
- }
- if len(providers) == 0 {
- providers = []agentAPI.Provider{a.provider}
- }
- var resp *agentAPI.CompletionResponse
- var llmErr error
-
- for pi, fbProvider := range providers {
- if pi > 0 {
- log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
- fbProvider.Name(), pi, len(providers)-1)
- }
-
- fCtx, fCancel := context.WithCancel(a.ctx)
- a.llmMu.Lock()
- a.cancelLLM = fCancel
- a.llmMu.Unlock()
-
- resp, llmErr = fbProvider.Chat(fCtx, req)
-
- a.llmMu.Lock()
- a.cancelLLM = nil
- a.llmMu.Unlock()
- fCancel()
-
- if llmErr == nil {
- a.providerManager.ResetAvailability(fbProvider.Name())
- if fbProvider != a.provider {
- a.provider = fbProvider
- log.Printf("[agent] switched active provider to %q after fallback",
- fbProvider.Name())
- }
- break
- }
-
- if errors.Is(llmErr, context.Canceled) {
- // 打断不是 provider 故障,不标记不可用,直接让外层循环重试
- break
- }
- var pe *agentAPI.ProviderError
- if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
- a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
- log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
- } else {
- a.providerManager.MarkUnavailable(fbProvider.Name())
- }
- log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
- }
-
- 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")
- }
- // 用户对话被打断:继续下一轮 drain 打断消息,注入到当前对话上下文
- continue
- }
- return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
- len(providers), llmErr)
- }
-
- // === Stage: post_action — LLM 返回,插件可审查/修改 ===
- stageCtx.LLMText = resp.Content
- stageCtx.ReasoningContent = resp.ReasoningContent
- stageCtx.TokenUsage = map[string]int{
- "prompt_tokens": resp.TokenUsage.Prompt,
- "completion_tokens": resp.TokenUsage.Completion,
- "total_tokens": resp.TokenUsage.Total,
- }
- stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
- for i := range stageCtx.ToolCalls {
- if stageCtx.ToolCalls[i].Plugin == "" {
- stageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(stageCtx.ToolCalls[i].Name)
- }
- }
- if a.runStage(sdk.StagePostAction, stageCtx) {
- return *stageCtx.Response, toolsUsed, nil
- }
- resp.Content = stageCtx.LLMText
- resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
-
- // === 发布完整 LLM 响应(含 tool_calls)供插件消费(如 webui 展示) ===
- chainPayload := map[string]interface{}{
- "content": resp.Content,
- "reasoning": resp.ReasoningContent,
- "tool_calls": resp.ToolCalls,
- "phase": "intermediate",
- "turn": turn,
- }
- if resp.TokenUsage.Total > 0 {
- chainPayload["usage"] = map[string]int{
- "prompt": resp.TokenUsage.Prompt,
- "completion": resp.TokenUsage.Completion,
- "total": resp.TokenUsage.Total,
- }
- }
- a.publishEvent(events.EventAgentLLMChain, chainPayload)
-
- if len(resp.ToolCalls) == 0 {
- return resp.Content, toolsUsed, nil
- }
-
- for _, tc := range resp.ToolCalls {
- // === 工具执行前检查打断通道 ===
- if len(a.interceptCh) > 0 {
- for _, interrupt := range a.drainInterrupts() {
- msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
- }
- a.publishEvent(events.EventToolCall, map[string]interface{}{
- "tool": tc.Name,
- "plugin": a.resolveToolPlugin(tc.Name),
- "args": tc.Arguments,
- "status": "interrupted",
- "reason": "user interrupt before execution",
- })
- break
- }
-
- toolsUsed = append(toolsUsed, tc.Name)
- pluginName := a.resolveToolPlugin(tc.Name)
- log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
-
- sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
- stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
- stageCtx.ToolResults = nil
- if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
- result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
- msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
- msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
- a.publishEvent(events.EventToolCall, map[string]interface{}{
- "tool": tc.Name,
- "plugin": pluginName,
- "args": tc.Arguments,
- "result": result,
- "status": "denied",
- })
- continue
- }
- tc.Arguments = stageCtx.ToolCalls[0].Arguments
-
- if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
- result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
- log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
- msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
- msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
- continue
- }
-
- result := a.executeToolCall(tc)
- log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
-
- // === Stage: after_toolcall — 插件可改结果 ===
- stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}
- a.runStage(sdk.StageAfterToolcall, stageCtx)
- if len(stageCtx.ToolResults) > 0 {
- if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
- result = r
- }
- }
-
- // 记录到工具调用环缓冲区(保护最近 40 条)
- argsJSON, _ := json.Marshal(tc.Arguments)
- a.recordToolCall(tc.Name, string(argsJSON), result)
-
- msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
- msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
-
- a.publishEvent(events.EventToolCall, map[string]interface{}{
- "tool": tc.Name,
- "plugin": pluginName,
- "args": tc.Arguments,
- "result": result,
- "status": "ok",
- })
-
- // === 工具执行后检查打断通道 ===
- if len(a.interceptCh) > 0 {
- for _, interrupt := range a.drainInterrupts() {
- msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
- }
- break
- }
- }
-}
-}
-
-func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
- if tcs == nil {
- return nil
- }
- result := make([]sdk.ToolCall, len(tcs))
- for i, tc := range tcs {
- result[i] = sdk.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
- }
- return result
-}
-
-func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
- if tcs == nil {
- return nil
- }
- result := make([]agentAPI.ToolCall, len(tcs))
- for i, tc := range tcs {
- result[i] = agentAPI.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
- }
- return result
-}
-
-func (a *Agent) docStoreSize() int {
- if a.docStore == nil {
- return 0
- }
- s := a.docStore.Stats()
- if n, ok := s["doc_count"]; ok {
- if ni, ok := n.(int); ok {
- return ni
- }
- }
- return 0
-}
-
-// recordToolCall 在工具执行后记录到环缓冲区,保留最近 40 条,
-// 确保 LLM 在后续回合中能感知到已执行过的工具及其结果。
-func (a *Agent) recordToolCall(name, args, result string) {
- a.toolCallRingMu.Lock()
- defer a.toolCallRingMu.Unlock()
-
- // 参数截断
- if len(args) > 200 {
- args = args[:200] + "..."
- }
-
- var resultStub string
- var fullResult string
- if len(a.toolCallRing) < 5 {
- // 最近 5 条保留完整结果
- fullResult = result
- }
- if len(result) > 80 {
- resultStub = result[:80] + "..."
- } else {
- resultStub = result
- }
-
- rec := ToolCallRecord{
- Timestamp: time.Now(),
- Name: name,
- Args: args,
- ResultStub: resultStub,
- FullResult: fullResult,
- }
-
- if len(a.toolCallRing) >= a.toolCallRingMax {
- a.toolCallRing = a.toolCallRing[1:]
- }
- a.toolCallRing = append(a.toolCallRing, rec)
-}
-
-// formatToolCallRing 输出工具调用环缓冲区为可读文本,注入到 system prompt。
-func (a *Agent) formatToolCallRing() string {
- a.toolCallRingMu.Lock()
- defer a.toolCallRingMu.Unlock()
-
- if len(a.toolCallRing) == 0 {
- return ""
- }
-
- var sb strings.Builder
- sb.WriteString("【已执行工具记录(最近40条)】\n")
- start := 0
- if len(a.toolCallRing) > 40 {
- start = len(a.toolCallRing) - 40
- }
- for i, rec := range a.toolCallRing[start:] {
- if len(rec.FullResult) > 0 {
- sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s)=%s\n", i+1,
- rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args,
- truncateStr(rec.FullResult, 120)))
- } else {
- sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s) → (已缓存,具体结果通过文本记忆层获取)\n", i+1,
- rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args))
- }
- }
- return sb.String()
-}
-
-// formatMergedTimeline 合并上下文事件和工具调用记录为一条按时间排序的对话时序,
-// 替代原先分块注入(【近期事件】+【已执行工具记录】)的方式。
-func (a *Agent) formatMergedTimeline() string {
- a.context.mu.Lock()
- events := make([]*ContextEvent, len(a.context.events))
- copy(events, a.context.events)
- a.context.mu.Unlock()
-
- a.toolCallRingMu.Lock()
- ring := make([]ToolCallRecord, len(a.toolCallRing))
- copy(ring, a.toolCallRing)
- a.toolCallRingMu.Unlock()
-
- if len(events) == 0 && len(ring) == 0 {
- return ""
- }
-
- type timelineEntry struct {
- ts time.Time
- label string
- text string
- }
- entries := make([]timelineEntry, 0, len(events)+len(ring))
-
- for _, e := range events {
- text := fmt.Sprintf("[对话] %s: %s", e.Source, e.Input)
- if len(e.ToolsUsed) > 0 {
- text += fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", "))
- }
- if e.Response != "" {
- text += fmt.Sprintf(" → %s", truncateStr(e.Response, 120))
- }
- entries = append(entries, timelineEntry{ts: e.Timestamp, label: "对话", text: text})
- }
-
- for _, r := range ring {
- text := fmt.Sprintf("[工具] %s(%s)", r.Name, r.Args)
- if r.FullResult != "" {
- text += fmt.Sprintf(" = %s", truncateStr(r.FullResult, 120))
- } else {
- text += " → (结果已缓存,可通过文本记忆层获取)"
- }
- entries = append(entries, timelineEntry{ts: r.Timestamp, label: "工具", text: text})
- }
-
- sort.Slice(entries, func(i, j int) bool {
- return entries[i].ts.Before(entries[j].ts)
- })
-
- var sb strings.Builder
- sb.WriteString("【对话时序】\n")
- for _, e := range entries {
- sb.WriteString(fmt.Sprintf("[%s] %s\n", e.ts.Format("15:04:05"), e.text))
- }
- return sb.String()
-}
-
-func (a *Agent) buildMessages(sysPrompt, input string) []agentAPI.Message {
- msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}}
-
- // 合并上下文事件 + 工具调用记录为一条完整时序
- if ctxStr := a.formatMergedTimeline(); ctxStr != "" {
- msgs = append(msgs, agentAPI.Message{Role: "system", Content: ctxStr})
- }
-
- msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
- return msgs
-}
-
-func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
- defer func() {
- if r := recover(); r != nil {
- stack := debug.Stack()
- log.Printf("[agent] tool %s panic: %v\n%s", tc.Name, r, stack)
-
- if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
- if a.pluginHealth.recordCrash(pluginName) {
- log.Printf("[agent] plugin %s exceeded crash threshold, scheduling reload", pluginName)
- }
- }
-
- ret = fmt.Sprintf("工具 %s 执行崩溃: %v", tc.Name, r)
- }
- }()
-
- done := make(chan string, 1)
- go func() {
- done <- a.executeToolCallInner(tc)
- }()
-
- select {
- case result := <-done:
- return result
- case <-time.After(60 * time.Second):
- log.Printf("[agent] tool %s timed out after 60s", tc.Name)
- return fmt.Sprintf("工具 %s 执行超时(60秒),已取消", tc.Name)
- }
-}
-
-func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
- switch {
- case strings.HasPrefix(tc.Name, "memory_"):
- return a.executeMemoryTool(tc)
- case strings.HasPrefix(tc.Name, "social_"):
- return a.executeSocialTool(tc)
- case strings.HasPrefix(tc.Name, "knowledge_"):
- return a.executeKnowledgeTool(tc)
- case strings.HasPrefix(tc.Name, "doc_"):
- return a.executeDocTool(tc)
- case strings.HasPrefix(tc.Name, "output_send__") && strings.HasSuffix(tc.Name, "_help"):
- return a.executeOutputSendHelp(tc)
- case strings.HasPrefix(tc.Name, "output_send__"):
- return a.executeOutputSendTool(tc)
- case tc.Name == "output_list_channels":
- return a.executeOutputListChannels()
- case tc.Name == "plgreload":
- return a.executePluginReload()
- case tc.Name == "spawn_child":
- return a.executeSpawnChild(tc)
- case tc.Name == "child_result":
- return a.executeChildResultTool(tc)
- case strings.HasPrefix(tc.Name, "llm_"):
- return a.executeLLMTool(tc)
- case tc.Name == "describe_image":
- return a.executeDescribeImage(tc)
- case tc.Name == "transcribe_audio":
- return a.executeTranscribeAudio(tc)
- case tc.Name == "ocr_image":
- return a.executeOCRImage(tc)
- }
-
- // 插件工具(通过 SDK RegisterTool 注册)
- if a.stageHost != nil {
- if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
- return fmt.Sprintf("%v", result)
- } else if !strings.Contains(err.Error(), "not found in any plugin") {
- return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
- }
- }
-
- if a.tracker != nil {
- a.tracker.PreAction(tc.Name)
- }
- result, err := a.io.ExecuteTool(tc.Name, tc.Arguments)
- if a.tracker != nil {
- if cs := a.tracker.PostAction(tc.Name); cs != nil && len(cs.Files) > 0 {
- log.Printf("[agent] tool %s changed %d files (changeset: %s)", tc.Name, len(cs.Files), cs.ID)
- }
- }
- if err != nil {
- return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
- }
- return fmt.Sprintf("%v", result)
-}
-
-func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
- if a.memory == nil {
- // 即使图记忆不可用,文档记忆仍可查询
- if tc.Name == "memory_document_query" {
- return a.executeDocTool(tc)
- }
- return "图记忆系统不可用"
- }
- switch tc.Name {
- case "memory_recall":
- query, _ := tc.Arguments["query_intent"].(string)
- depth, _ := tc.Arguments["depth"].(float64)
- if depth <= 0 {
- depth = 2
- }
- if query == "" {
- return "请输入查询关键词"
- }
- result, err := a.memory.Recall(strings.Split(query, ","), nil, int(depth), "")
- if err != nil {
- return fmt.Sprintf("记忆检索失败: %v", err)
- }
- if len(result.Entities) == 0 && len(result.Relations) == 0 {
- return "未找到相关记忆"
- }
- // 标记已显式召回的实体,后续自动注入时跳过,避免重复
- if a.indexer != nil {
- names := make([]string, len(result.Entities))
- for i, e := range result.Entities {
- names[i] = e.Name
- }
- a.indexer.MarkRecalled(names...)
- }
- var parts []string
- parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities)))
- for _, e := range result.Entities {
- parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type))
- }
- parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations)))
- for i, r := range result.Relations {
- if i >= 10 {
- parts = append(parts, "...更多关系被截断")
- break
- }
- parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
- }
- return strings.Join(parts, "\n")
-
- case "memory_block_merge":
- entityA, _ := tc.Arguments["entity_a"].(string)
- entityB, _ := tc.Arguments["entity_b"].(string)
- rounds, _ := tc.Arguments["rounds"].(float64)
- if entityA == "" || entityB == "" || rounds <= 0 {
- return "entity_a、entity_b 和 rounds 不能为空"
- }
- if entityA > entityB {
- entityA, entityB = entityB, entityA
- }
- key := entityA + "||" + entityB
- a.noMergeMu.Lock()
- a.noMergeMarkers[key] = int(rounds)
- a.noMergeMu.Unlock()
- return fmt.Sprintf("已标记「%s」与「%s」在 %d 轮内不合并", entityA, entityB, int(rounds))
-
- case "memory_commit":
- triplesData, ok := tc.Arguments["triples"].([]interface{})
- if !ok {
- return "参数格式错误,需要 triples 数组"
- }
- var triples []memory.Triple
- for _, td := range triplesData {
- if m, ok := td.(map[string]interface{}); ok {
- t := memory.Triple{
- Subject: getString(m, "subject"),
- Relation: getString(m, "relation"),
- Object: getString(m, "object"),
- }
- if t.Subject != "" && t.Relation != "" && t.Object != "" {
- triples = append(triples, t)
- }
- }
- }
- if len(triples) == 0 {
- return "没有有效的三元组"
- }
- ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
- if err != nil {
- return fmt.Sprintf("记忆写入失败: %v", err)
- }
- return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
-
- case "memory_introspect":
- stats, err := a.memory.Introspect()
- if err != nil {
- return fmt.Sprintf("查询失败: %v", err)
- }
- return fmt.Sprintf("记忆统计: %v", stats)
-
- case "memory_document_query":
- return a.executeDocTool(tc)
-
- case "memory_merge":
- source, _ := tc.Arguments["source"].(string)
- target, _ := tc.Arguments["target"].(string)
- if source == "" || target == "" {
- return "source 和 target 不能为空"
- }
- count, err := a.memory.MergeEntities(source, target)
- if err != nil {
- return fmt.Sprintf("合并失败: %v", err)
- }
- return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
-
- case "memory_delete_entity":
- name, _ := tc.Arguments["name"].(string)
- if name == "" {
- return "name 不能为空"
- }
- if err := a.memory.DeleteEntity(name); err != nil {
- return fmt.Sprintf("删除失败: %v", err)
- }
- return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
-
- case "memory_purge":
- criteria := make(map[string]string)
- if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
- criteria["subject_contains"] = v
- }
- if v, ok := tc.Arguments["relation_type"].(string); ok && v != "" {
- criteria["relation_type"] = v
- }
- if v, ok := tc.Arguments["target_contains"].(string); ok && v != "" {
- criteria["target_contains"] = v
- }
- mode, _ := tc.Arguments["mode"].(string)
- if mode == "" {
- mode = "soft"
- }
- n, err := a.memory.Purge(criteria, mode)
- if err != nil {
- return fmt.Sprintf("删除图记忆失败: %v", err)
- }
-
- // 也清理文本记忆中匹配源的数据
- textRemoved := 0
- if a.textMem != nil {
- if subj, ok := criteria["subject_contains"]; ok && subj != "" {
- textRemoved, _ = a.textMem.PurgeByFilter(func(evt text.Event) bool {
- return strings.Contains(evt.Source, subj) || strings.Contains(evt.Input, subj) || strings.Contains(evt.Response, subj)
- })
- }
- }
- parts := []string{fmt.Sprintf("已%s删除 %d 条图记忆关系", mode, n)}
- if textRemoved > 0 {
- parts = append(parts, fmt.Sprintf("清理 %d 条文本记忆日志", textRemoved))
- }
- return strings.Join(parts, ",")
-
- case "memory_edit":
- oldSubject, _ := tc.Arguments["old_subject"].(string)
- oldRelation, _ := tc.Arguments["old_relation"].(string)
- oldObject, _ := tc.Arguments["old_object"].(string)
- if oldSubject == "" || oldRelation == "" || oldObject == "" {
- return "old_subject、old_relation、old_object 不能为空"
- }
- newSubject, _ := tc.Arguments["new_subject"].(string)
- newRelation, _ := tc.Arguments["new_relation"].(string)
- newObject, _ := tc.Arguments["new_object"].(string)
- if newSubject == "" && newRelation == "" && newObject == "" {
- return "至少提供一个新值(new_subject / new_relation / new_object)"
- }
- if newSubject == "" {
- newSubject = oldSubject
- }
- if newRelation == "" {
- newRelation = oldRelation
- }
- if newObject == "" {
- newObject = oldObject
- }
- // 先删旧的,再写新的(图记忆)
- n, err := a.memory.Purge(map[string]string{
- "subject_contains": oldSubject,
- "relation_type": oldRelation,
- "target_contains": oldObject,
- }, "hard")
- if err != nil {
- return fmt.Sprintf("编辑图记忆失败(删除旧记录): %v", err)
- }
- triples := []memory.Triple{{
- Subject: newSubject,
- Relation: newRelation,
- Object: newObject,
- }}
- ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
- if err != nil {
- return fmt.Sprintf("编辑图记忆失败(写入新记录): %v", err)
- }
-
- // 也编辑文本记忆中匹配的内容
- textReplaced := 0
- if a.textMem != nil && oldSubject != "" {
- textReplaced, _ = a.textMem.ReplaceByFilter(
- func(evt text.Event) bool {
- return strings.Contains(evt.Input, oldSubject) || strings.Contains(evt.Response, oldSubject)
- },
- func(evt text.Event) text.Event {
- evt.Input = strings.ReplaceAll(evt.Input, oldSubject, newSubject)
- evt.Response = strings.ReplaceAll(evt.Response, oldSubject, newSubject)
- return evt
- },
- )
- }
- result := fmt.Sprintf("已编辑记忆:删除 %d 条旧关系,写入 %d 个实体 + %d 条新关系", n, ec, rc)
- if textReplaced > 0 {
- result += fmt.Sprintf(",更新 %d 条文本记忆日志", textReplaced)
- }
- return result
-
- default:
- return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
- }
-}
-
-func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
- if a.social == nil {
- return "人物关系网不可用(social store 未初始化)"
- }
- switch tc.Name {
- case "person_query":
- name, _ := tc.Arguments["name"].(string)
- if name == "" {
- return "请输入人物名称"
- }
- profile, err := a.social.GetPerson(name)
- if err != nil {
- return fmt.Sprintf("查询人物失败: %v", err)
- }
- var parts []string
- parts = append(parts, fmt.Sprintf("▎%s 的档案", name))
- if len(profile.Traits) > 0 {
- parts = append(parts, "【特质】")
- for k, v := range profile.Traits {
- parts = append(parts, fmt.Sprintf(" %s: %s", k, v))
- }
- }
- if len(profile.Relations) > 0 {
- parts = append(parts, "【社交关系】")
- for _, r := range profile.Relations {
- parts = append(parts, fmt.Sprintf(" %s —(%s)—→ %s", name, r.Relation, r.Person))
- }
- }
- if len(profile.Traits) == 0 && len(profile.Relations) == 0 {
- parts = append(parts, " (尚无记录)")
- }
- return strings.Join(parts, "\n")
-
- case "person_set_trait":
- name, _ := tc.Arguments["name"].(string)
- trait, _ := tc.Arguments["trait"].(string)
- value, _ := tc.Arguments["value"].(string)
- if name == "" || trait == "" || value == "" {
- return "name、trait、value 都不能为空"
- }
- if err := a.social.SetTrait(name, trait, value); err != nil {
- return fmt.Sprintf("设置特质失败: %v", err)
- }
- return fmt.Sprintf("已记录:%s 的 %s = %s", name, trait, value)
-
- case "person_relate":
- personA, _ := tc.Arguments["person_a"].(string)
- relation, _ := tc.Arguments["relation"].(string)
- personB, _ := tc.Arguments["person_b"].(string)
- if personA == "" || relation == "" || personB == "" {
- return "person_a、relation、person_b 都不能为空"
- }
- if err := a.social.AddRelation(personA, relation, personB); err != nil {
- return fmt.Sprintf("建立关系失败: %v", err)
- }
- return fmt.Sprintf("已记录:%s —(%s)—→ %s", personA, relation, personB)
-
- case "person_network":
- name, _ := tc.Arguments["name"].(string)
- depth := int(getFloat(tc.Arguments, "depth"))
- if depth <= 0 {
- depth = 2
- }
- if name == "" {
- return "请输入人物名称"
- }
- profiles, err := a.social.GetNetwork(name, depth)
- if err != nil {
- return fmt.Sprintf("查询社交网络失败: %v", err)
- }
- if len(profiles) == 0 {
- return fmt.Sprintf("未找到 %s 的社交网络", name)
- }
- var parts []string
- parts = append(parts, fmt.Sprintf("▎%s 的社交网络(%d 度)", name, depth))
- for _, p := range profiles {
- if p.Name == name {
- continue
- }
- parts = append(parts, fmt.Sprintf(" · %s", p.Name))
- for k, v := range p.Traits {
- parts = append(parts, fmt.Sprintf(" %s: %s", k, v))
- }
- for _, r := range p.Relations {
- if r.Person != name {
- parts = append(parts, fmt.Sprintf(" —(%s)—→ %s", r.Relation, r.Person))
- }
- }
- }
- return strings.Join(parts, "\n")
-
- default:
- return fmt.Sprintf("未知的人物工具: %s", tc.Name)
- }
-}
-
-func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
- if a.knowledge == nil {
- return "知识库不可用"
- }
- switch tc.Name {
- case "knowledge_search":
- query, _ := tc.Arguments["query"].(string)
- topK := int(getFloat(tc.Arguments, "top_k"))
- if topK <= 0 {
- topK = 5
- }
- if query == "" {
- return "请输入查询关键词"
- }
- results := a.knowledge.Search(query, topK)
- if len(results) == 0 {
- return "未找到相关知识"
- }
- var parts []string
- for i, k := range results {
- if i >= topK {
- break
- }
- label := k.Name
- if k.Category != "" {
- label = k.Category + "/" + k.Name
- }
- parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200)))
- }
- return strings.Join(parts, "\n---\n")
-
- case "knowledge_create":
- name, _ := tc.Arguments["name"].(string)
- content, _ := tc.Arguments["content"].(string)
- if name == "" || content == "" {
- return "name 和 content 不能为空"
- }
- if err := a.knowledge.Add(name, content); err != nil {
- return fmt.Sprintf("知识创建失败: %v", err)
- }
- return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content))
-
- case "knowledge_list":
- tree := a.knowledge.BuildTree()
- return formatTree(tree, 0)
-
- case "knowledge_delete":
- name, _ := tc.Arguments["name"].(string)
- if name == "" {
- return "name 不能为空"
- }
- if err := a.knowledge.Remove(name); err != nil {
- return fmt.Sprintf("知识删除失败: %v", err)
- }
- return fmt.Sprintf("知识「%s」已删除", name)
-
- default:
- return fmt.Sprintf("未知的知识工具: %s", tc.Name)
- }
-}
-
-func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
- if a.docStore == nil {
- return "文档记忆不可用"
- }
- switch tc.Name {
- case "doc_query":
- query, _ := tc.Arguments["query"].(string)
- topK := int(getFloat(tc.Arguments, "top_k"))
- if topK <= 0 {
- topK = 3
- }
- if query == "" {
- return "请输入查询内容"
- }
- docs := a.docStore.Consume(query, topK)
- if len(docs) == 0 {
- return "未找到相关文档记忆"
- }
- var parts []string
- var refs []string
- for i, d := range docs {
- parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
- if len(d.Tags) > 0 {
- parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
- }
- // 每个文档按原始时间写入 context 事件,确保时序正确
- content := d.Content
- if len(content) > 2000 {
- content = content[:2000] + "..."
- }
- a.context.Append(ContextEvent{
- Timestamp: d.CreatedAt,
- Source: "cold_storage",
- Input: fmt.Sprintf("加载文档记忆: %s", query),
- Response: content,
- })
- refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
- }
- return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
- len(docs), strings.Join(refs, ", "))
-
- case "doc_commit":
- content, _ := tc.Arguments["content"].(string)
- summary, _ := tc.Arguments["summary"].(string)
- if content == "" {
- return "content 不能为空"
- }
- if summary == "" {
- summary = truncateStr(content, 100)
- }
-
- tagsRaw, _ := tc.Arguments["tags"].([]interface{})
- var tags []string
- for _, t := range tagsRaw {
- if s, ok := t.(string); ok {
- tags = append(tags, s)
- }
- }
-
- doc := &document.Doc{
- Summary: summary,
- Content: content,
- Tags: tags,
- Source: "manual",
- }
- if err := a.docStore.Insert(doc); err != nil {
- return fmt.Sprintf("文档写入失败: %v", err)
- }
- return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
-
- default:
- return fmt.Sprintf("未知的文档工具: %s", tc.Name)
- }
-}
-
-func (a *Agent) buildMemoryContext(input string) string {
- if a.indexer == nil {
- return ""
- }
- injected := a.indexer.BuildContext(input)
- return a.indexer.FormatContext(injected)
-}
-
-func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
- prompt := a.systemPrompt
- if prompt == "" {
- prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
- }
-
- // 人格设定 — 固定,不变
- if a.personality != nil {
- if pp := a.personality.InjectPrompt(); pp != "" {
- prompt += "\n\n" + pp
- }
- }
-
- // 图记忆上下文(索引摘要)
- if memContext != "" {
- prompt += "\n\n" + memContext
- }
-
- // 记忆清理指令:当用户要求整理或清理记忆时,必须实际调用 memory_ 工具执行操作,
- // 不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情,
- // 然后依次调用 memory_merge/memory_purge/memory_edit/memory_block_merge 执行清理。
- prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
-
- // 文档记忆 — 查询相关文档摘要注入
- if a.docStore != nil {
- docs := a.docStore.Query(userInput, 3)
- if len(docs) > 0 {
- var parts []string
- parts = append(parts, "【相关记忆文档】")
- for i, d := range docs {
- parts = append(parts, fmt.Sprintf(" [%d] %s", i+1, d.Summary))
- }
- prompt += "\n\n" + strings.Join(parts, "\n")
- }
- }
-
- // 输出指令:使用 output_send__{channel} 作为回复手段
- prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
- prompt += "- payload 参数是消息载荷(文本直接填文字),type 指定载荷类型(text/voice/image/file),meta 是 JSON 发送元数据(群号/用户号等)。\n"
- prompt += "- 用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举。\n"
- prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
- prompt += "- 直接返回纯文本不会到达任何用户端。"
-
- if a.skills != nil {
- if sp := a.skills.GetInjectedPrompt(); sp != "" {
- prompt += "\n\n" + sp
- }
- }
-
- if a.indexer != nil {
- prompt += "\n\n" + a.indexer.BuildToolPrompt()
- }
-
- // 动态工具目录
- prompt += a.buildToolCatalog()
-
- return prompt
-}
-
-// cleanParams removes empty required arrays from tool parameters that strict APIs reject.
-func cleanParams(params map[string]interface{}) map[string]interface{} {
- if params == nil {
- return nil
- }
- cleaned := make(map[string]interface{}, len(params))
- for k, v := range params {
- cleaned[k] = v
- }
- if req, ok := cleaned["required"]; ok {
- switch v := req.(type) {
- case []interface{}:
- if len(v) == 0 {
- delete(cleaned, "required")
- }
- case []string:
- if len(v) == 0 {
- delete(cleaned, "required")
- }
- }
- }
- return cleaned
-}
-
-func (a *Agent) buildToolCatalog() string {
- defs := a.buildToolDefs()
- if len(defs) == 0 {
- return ""
- }
- var sb strings.Builder
- sb.WriteString("\n\n【可用工具列表】")
- seen := make(map[string]bool)
- for _, d := range defs {
- t, ok := d.(map[string]interface{})
- if !ok {
- continue
- }
- fn, ok := t["function"].(map[string]interface{})
- if !ok {
- continue
- }
- name, _ := fn["name"].(string)
- if name == "" || seen[name] {
- continue
- }
- seen[name] = true
- desc, _ := fn["description"].(string)
- sb.WriteString(fmt.Sprintf("\n- %s", name))
- if desc != "" {
- // only first 80 chars of description
- if len(desc) > 80 {
- desc = desc[:80] + "..."
- }
- sb.WriteString(": " + desc)
- }
- }
- return sb.String()
-}
-
-func (a *Agent) buildToolDefs() []interface{} {
- var tools []interface{}
-
- if a.io != nil {
- for _, td := range a.io.GetAllTools() {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": td.Name,
- "description": td.Description,
- "parameters": cleanParams(td.Parameters),
- },
- })
- }
- }
-
- // 插件注册的工具(通过 SDK RegisterTool)
- if a.stageHost != nil {
- for _, td := range a.stageHost.GetToolDefs() {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": td.Name,
- "description": td.Description,
- "parameters": cleanParams(td.Parameters),
- },
- })
- }
- }
-
- if a.indexer != nil {
- for _, td := range a.indexer.GetToolDefinitions() {
- tools = append(tools, td)
- }
- }
-
- // 实体合并工具(心跳检测到冲突时 LLM 使用)
- if a.memory != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "memory_merge",
- "description": "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
- "target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
- },
- "required": []string{"source", "target"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "memory_delete_entity",
- "description": "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
- },
- "required": []string{"name"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "memory_block_merge",
- "description": "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
- "entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
- "rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
- },
- "required": []string{"entity_a", "entity_b", "rounds"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "memory_purge",
- "description": "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
- "relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
- "target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
- "mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
- },
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "memory_edit",
- "description": "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
- "old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
- "old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
- "new_subject": map[string]interface{}{"type": "string", "description": "新主体名(不填则不变)"},
- "new_relation": map[string]interface{}{"type": "string", "description": "新关系类型(不填则不变)"},
- "new_object": map[string]interface{}{"type": "string", "description": "新客体名(不填则不变)"},
- },
- "required": []string{"old_subject", "old_relation", "old_object"},
- },
- },
- })
- }
-
- // 知识库工具
- if a.knowledge != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "knowledge_search",
- "description": "搜索知识库。输入查询关键词,返回相关知识内容。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "query": map[string]interface{}{"type": "string", "description": "查询关键词"},
- "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5},
- },
- "required": []string{"query"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "knowledge_list",
- "description": "列出知识库中所有知识分类。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{},
- },
- },
- })
- }
-
- // 知识创建工具
- if a.knowledge != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "knowledge_create",
- "description": "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
- "content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
- },
- "required": []string{"name", "content"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "knowledge_delete",
- "description": "删除知识库中的指定知识条目。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{"type": "string", "description": "要删除的知识名称"},
- },
- "required": []string{"name"},
- },
- },
- })
- }
-
- // 文档记忆工具
- if a.docStore != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "doc_query",
- "description": "查询文档记忆。输入查询内容,返回相关文档摘要。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "query": map[string]interface{}{"type": "string", "description": "查询内容"},
- "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
- },
- "required": []string{"query"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "doc_commit",
- "description": "提交一条文档记忆。将重要信息显式写入文档记忆层。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "content": map[string]interface{}{"type": "string", "description": "文档内容"},
- "summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
- "tags": map[string]interface{}{
- "type": "array",
- "description": "标签列表",
- "items": map[string]interface{}{"type": "string"},
- },
- },
- "required": []string{"content"},
- },
- },
- })
- }
-
- // 人物特质与关系网工具
- if a.social != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "person_query",
- "description": "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{"type": "string", "description": "人物名称"},
- },
- "required": []string{"name"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "person_set_trait",
- "description": "记录/更新一个人的特质(性格、喜好、习惯等)。例如:person_set_trait(name=\"张三\", trait=\"喜欢\", value=\"红色\")。如果该特质已存在则覆盖。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{"type": "string", "description": "人物名称"},
- "trait": map[string]interface{}{"type": "string", "description": "特质名称,如:喜欢、性格、职业、年龄"},
- "value": map[string]interface{}{"type": "string", "description": "特质值,如:红色、开朗、工程师、25岁"},
- },
- "required": []string{"name", "trait", "value"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "person_relate",
- "description": "记录两个人之间的社交关系。例如:person_relate(person_a=\"张三\", relation=\"朋友\", person_b=\"李四\")。关系是双向的。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "person_a": map[string]interface{}{"type": "string", "description": "人物A"},
- "relation": map[string]interface{}{"type": "string", "description": "关系类型,如:朋友、家人、同事、邻居、同学"},
- "person_b": map[string]interface{}{"type": "string", "description": "人物B"},
- },
- "required": []string{"person_a", "relation", "person_b"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "person_network",
- "description": "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{"type": "string", "description": "人物名称"},
- "depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2},
- },
- "required": []string{"name"},
- },
- },
- })
- }
-
- // 插件重载工具
- if a.pluginReg != nil && a.pluginDir != "" {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "plgreload",
- "description": "重载 plugins/ 目录的所有插件。扫描目录变更,原子化替换 IO 设备。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{},
- },
- },
- })
- }
-
- // 子任务工具
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "spawn_child",
- "description": "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "task": map[string]interface{}{
- "type": "string",
- "description": "要子 Agent 完成的任务描述。请描述清晰、完整,包含所有必要背景。",
- },
- },
- "required": []string{"task"},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "child_result",
- "description": "查询异步子 Agent 的执行结果。当收到'子任务已完成'的通知后,调用此工具获取输出。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "task_id": map[string]interface{}{
- "type": "string",
- "description": "spawn_child 返回的任务 ID,如 child_1",
- },
- },
- "required": []string{"task_id"},
- },
- },
- })
-
- // LLM 源管理工具
- if a.providerManager != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "llm_list_sources",
- "description": "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{},
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "llm_set_source",
- "description": "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "name": map[string]interface{}{
- "type": "string",
- "description": "LLM 源名称(如 deepseek、openai、ollama)",
- },
- },
- "required": []string{"name"},
- },
- },
- })
- }
-
- // 输出通道工具 — 每注册通道生成两个工具:
- // output_send__{name} (type=function) — 向该通道发送内容
- // output_send__{name}_help (type=function) — 查看该通道的 JSON 格式说明
- channels := a.io.ListChannels()
- for _, ch := range channels {
- if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
- continue
- }
- capStr := a.io.GetChannelCapabilities(ch.Name).String()
- desc := ch.Description
- if desc == "" {
- desc = ch.Name + " 输出通道"
- }
-
- // 输出门工具
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "output_send__" + ch.Name,
- "description": desc + "。能力: " + capStr + "。payload 为消息载荷,meta 为 JSON 发送元数据,type 为载荷类型。用 _help 查看 meta 格式和 type 枚举。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "payload": map[string]interface{}{
- "type": "string",
- "description": "消息载荷。type=text 时填文字,type=file/image 时填 URL 或路径",
- },
- "meta": map[string]interface{}{
- "type": "string",
- "description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
- },
- "type": map[string]interface{}{
- "type": "string",
- "description": "载荷类型,用 channel._help 查看支持的枚举值",
- },
- },
- "required": []string{"payload", "type"},
- },
- },
- })
-
- // 帮助工具
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "output_send__" + ch.Name + "_help",
- "description": "查看 " + ch.Name + " 输出通道的 meta 格式说明和 type 枚举",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{},
- },
- },
- })
- }
-
- // output_list_channels — 列出所有可用输出通道
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "output_list_channels",
- "description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{},
- },
- },
- })
-
- // 媒体处理工具:仅当本轮有未处理的媒体数据时注册
- if a.pendingMedia != nil {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "describe_image",
- "description": "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "provider": map[string]interface{}{
- "type": "string",
- "description": "可选:用于图片描述的 LLM 源名称,不填则使用默认模型",
- },
- "detail": map[string]interface{}{
- "type": "string",
- "description": "描述详细程度: high / low / auto",
- "default": "high",
- },
- },
- },
- },
- })
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "transcribe_audio",
- "description": "转写当前用户上传的音频内容为文字。使用配置的多模态模型或默认 LLM 进行语音识别。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "provider": map[string]interface{}{
- "type": "string",
- "description": "可选:用于音频转写的 LLM 源名称,不填则使用默认模型",
- },
- },
- },
- },
- })
- if a.inputCfg.Image.OCREnabled {
- tools = append(tools, map[string]interface{}{
- "type": "function",
- "function": map[string]interface{}{
- "name": "ocr_image",
- "description": "对当前用户上传的图片执行 OCR 文字识别,提取图片中的文字内容。适用于截图、文档照片、菜单等场景。",
- "parameters": map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "language": map[string]interface{}{
- "type": "string",
- "description": "OCR 语言(如 chi_sim+eng),默认自动",
- },
- },
- },
- },
- })
- }
- }
-
- return tools
-}
-
-// ConsolidationTask 心跳检测到的记忆整理任务,通过 IO 发送给 Agent 让 LLM 决策
-type ConsolidationTask struct {
- Type string `json:"type"` // "entity_merge", "relation_conflict", "doc_archival"
- Reason string `json:"reason"` // 人类可读的描述
- Data interface{} `json:"data"` // 任务相关数据
-}
-
-// enqueueConsolidationTask 将记忆整理任务通过自循环通道注入 Agent(不经过 IO 层)
-func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
- msg := fmt.Sprintf(
- "【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
- task.Type, task.Reason,
- )
- a.injectSelf(msg)
- log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
-}
-
-// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
-func (a *Agent) distillLoop() {
- defer func() {
- if r := recover(); r != nil {
- log.Printf("[agent] distillLoop panic recovered: %v\n%s", r, debug.Stack())
- time.Sleep(time.Second)
- go a.distillLoop()
- }
- }()
- if a.docStore == nil && a.memory == nil {
- return
- }
- ticker := time.NewTicker(a.distillInterval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ticker.C:
- log.Printf("[agent] heartbeat distill tick")
- a.distillContext()
- a.syncGraphToDocs()
- a.reorgGraph()
- a.autoReloadPlugins()
- case <-a.ctx.Done():
- return
- }
- }
-}
-
-func (a *Agent) distillContext() {
- if a.docStore == nil {
- return
- }
- // 心跳时执行安全裁剪:上下文超过 maxContextSize*2 时强制归档
- n := a.context.Len()
- if n > a.maxContextSize*2 {
- archived := a.context.Prune("", a.maxContextSize, a.docStore)
- if archived > 0 {
- log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
- }
- }
-}
-
-// syncGraphToDocs — 将图记忆的实体和关系注入文档记忆层
-func (a *Agent) syncGraphToDocs() {
- if a.memory == nil || a.docStore == nil {
- return
- }
-
- // 拉取图记忆统计
- stats, err := a.memory.Introspect()
- if err != nil {
- return
- }
-
- entityCount, _ := stats["entity_count"].(int)
- if entityCount == 0 {
- return
- }
-
- // 查询热点实体,生成文档
- result, err := a.memory.Recall(nil, nil, 1, "")
- if err != nil || result == nil {
- return
- }
-
- if len(result.Entities) == 0 && len(result.Relations) == 0 {
- return
- }
-
- // 构建摘要文档
- var summaryParts []string
- summaryParts = append(summaryParts, fmt.Sprintf("图记忆快照: %d 个热点实体", len(result.Entities)))
- for _, e := range result.Entities {
- summaryParts = append(summaryParts, fmt.Sprintf("- %s (%s, %d次)", e.Name, e.Type, e.MentionCount))
- }
- if len(result.Relations) > 0 {
- summaryParts = append(summaryParts, "关联关系:")
- for i, r := range result.Relations {
- if i >= 10 {
- break
- }
- summaryParts = append(summaryParts, fmt.Sprintf(" %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
- }
- }
-
- summary := fmt.Sprintf("图记忆索引 (%d 实体, %d 关系)", len(result.Entities), len(result.Relations))
- content := strings.Join(summaryParts, "\n")
-
- // 去重:如果最近一篇 graph 文档内容相同,跳过
- recent := a.docStore.RecentDocs(1)
- if len(recent) > 0 && recent[0].Source == "graph" && recent[0].Content == content {
- return
- }
-
- doc := &document.Doc{
- Summary: summary,
- Content: content,
- Tags: []string{"graph_memory", "auto_sync"},
- Entities: extractEntityNames(result.Entities),
- Source: "graph",
- }
- if err := a.docStore.Insert(doc); err != nil {
- log.Printf("[agent] graph→doc sync error: %v", err)
- } else {
- log.Printf("[agent] graph→doc synced: %s", doc.Summary)
- }
-}
-
-func extractEntityNames(entities []memory.Entity) []string {
- names := make([]string, len(entities))
- for i, e := range entities {
- names[i] = e.Name
- }
- return names
-}
-
-// reorgGraph — 图数据库重整:向量索引更新 + 同义实体合并+消歧
-func (a *Agent) reorgGraph() {
- if a.memory == nil {
- return
- }
-
- log.Printf("[agent] graph reorg start")
-
- // 1. 同步实体名到向量索引(Indexer 的向量搜索)
- if a.indexer != nil {
- if err := a.indexer.Sync(); err != nil {
- log.Printf("[agent] indexer sync error: %v", err)
- }
- }
-
- // 2. 更新文档记忆的向量索引
- if a.docStore != nil {
- a.docStore.Reindex()
- }
-
- // 3. 冷文档→图记忆归化
- if a.docStore != nil {
- coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
- for _, doc := range coldDocs {
- triples := docToTriples(doc)
- if len(triples) > 0 {
- ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0)
- if err != nil {
- log.Printf("[agent] doc→graph archival error: %v", err)
- continue
- }
- log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
- a.docStore.Remove(doc.ID)
- }
- }
- }
-
- // 4. 实体同义冲突检测 → 交由 LLM 决策
- result, err := a.memory.Recall(nil, nil, 1, "")
- if err != nil || result == nil || len(result.Entities) < 2 {
- return
- }
-
- // 最多处理 5 个候选,避免阻塞用户消息太久
- maxCandidates := 5
- candidates := 0
- for i := 0; i < len(result.Entities) && candidates < maxCandidates; i++ {
- for j := i + 1; j < len(result.Entities) && candidates < maxCandidates; j++ {
- ea, eb := result.Entities[i].Name, result.Entities[j].Name
- if ea > eb {
- ea, eb = eb, ea
- }
- key := ea + "||" + eb
- a.noMergeMu.Lock()
- rounds, ok := a.noMergeMarkers[key]
- if ok {
- rounds--
- if rounds <= 0 {
- delete(a.noMergeMarkers, key)
- } else {
- a.noMergeMarkers[key] = rounds
- }
- }
- a.noMergeMu.Unlock()
- if ok {
- continue
- }
- sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
- if sim > 0.75 {
- candidates++
- a.enqueueConsolidationTask(ConsolidationTask{
- Type: "entity_merge",
- Reason: fmt.Sprintf(
- "实体「%s」(类型:%s, 提及%d次) 与「%s」(类型:%s, 提及%d次) 相似度 %.0f%%,可能指代同一事物,请判断是否需要合并",
- result.Entities[i].Name, result.Entities[i].Type, result.Entities[i].MentionCount,
- result.Entities[j].Name, result.Entities[j].Type, result.Entities[j].MentionCount,
- sim*100,
- ),
- Data: map[string]interface{}{
- "entity_a": result.Entities[i].Name,
- "entity_a_type": result.Entities[i].Type,
- "entity_a_mentions": result.Entities[i].MentionCount,
- "entity_b": result.Entities[j].Name,
- "entity_b_type": result.Entities[j].Type,
- "entity_b_mentions": result.Entities[j].MentionCount,
- "similarity": sim,
- },
- })
- }
- }
- }
-
- if candidates > 0 {
- log.Printf("[agent] graph reorg: %d merge candidates sent for LLM decision", candidates)
- } else {
- log.Printf("[agent] graph reorg: no similar entities found")
- }
-
- // 5. 图连接质量评估:由 LLM 判断低质量关系并丢弃
- a.evaluateGraphQuality()
-}
-
-func (a *Agent) evaluateGraphQuality() {
- if a.memory == nil {
- return
- }
-
- // 召回近期低 confidence 关系(使用默认 recall 获取最新实体和关系)
- result, err := a.memory.Recall(nil, nil, 1, "")
- if err != nil || result == nil || len(result.Relations) == 0 {
- return
- }
-
- // 选出低质量候选:generic 关系(如 distiller 自动生成的泛化关系)
- var lowQuality []string
- for _, r := range result.Relations {
- // 自动蒸馏生成的 (用户, 提及, ...), (AI, 回应, ...) 和 jieba 共现 (..., 关联, ...) 通常是噪音
- if (r.SourceName == "用户" || r.SourceName == "AI") &&
- (r.RelationType == "提及" || r.RelationType == "回应") {
- lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」", r.SourceName, r.RelationType, r.TargetName))
- continue
- }
- if r.RelationType == "关联" {
- lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(jieba 共现)", r.SourceName, r.RelationType, r.TargetName))
- continue
- }
- // 极低 mention 的实体+generic 关系
- if r.Confidence < 0.3 && r.RelationType != "" {
- lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(confidence=%.1f)", r.SourceName, r.RelationType, r.TargetName, r.Confidence))
- }
- }
-
- if len(lowQuality) == 0 {
- return
- }
-
- // 分批发送给 LLM 决策,每批最多 10 条
- batchSize := 10
- for i := 0; i < len(lowQuality); i += batchSize {
- end := i + batchSize
- if end > len(lowQuality) {
- end = len(lowQuality)
- }
- batch := lowQuality[i:end]
-
- a.enqueueConsolidationTask(ConsolidationTask{
- Type: "graph_quality",
- Reason: fmt.Sprintf(
- "图数据库中发现 %d 条低质量关系,请逐条判断是否应该删除(保留 = keep,删除 = discard):\n%s",
- len(batch),
- strings.Join(batch, "\n"),
- ),
- Data: map[string]interface{}{
- "candidates": batch,
- "action": "evaluate_quality",
- },
- })
- }
-
- log.Printf("[agent] graph quality: %d low-quality connection batches sent for LLM evaluation", (len(lowQuality)+batchSize-1)/batchSize)
-}
-
-// entitySimilarity 计算两个实体名的相似度(字符 bigram Jaccard)
-func entitySimilarity(a, b string) float64 {
- if a == "" || b == "" {
- return 0
- }
- if a == b {
- return 1.0
- }
- runesA, runesB := []rune(a), []rune(b)
- if len(runesA) < 2 || len(runesB) < 2 {
- if len(runesA) == len(runesB) && len(runesA) == 1 {
- if runesA[0] == runesB[0] {
- return 1.0
- }
- }
- return 0
- }
-
- setA := make(map[string]bool)
- for i := 0; i < len(runesA)-1; i++ {
- setA[string(runesA[i:i+2])] = true
- }
-
- setB := make(map[string]bool)
- for i := 0; i < len(runesB)-1; i++ {
- setB[string(runesB[i:i+2])] = true
- }
-
- intersect := 0
- for bg := range setA {
- if setB[bg] {
- intersect++
- }
- }
-
- union := len(setA) + len(setB) - intersect
- if union <= 0 {
- return 0
- }
-
- return float64(intersect) / float64(union)
-}
-
-// docToTriples 将文档转为图记忆三元组
-func docToTriples(doc *document.Doc) []memory.Triple {
- var triples []memory.Triple
- if doc == nil {
- return triples
- }
-
- triples = append(triples, memory.Triple{
- Subject: "文档",
- SubjectType: "Concept",
- Relation: "主题",
- Object: doc.Summary,
- ObjectType: "Topic",
- Confidence: 1.0,
- })
-
- // 用 jieba 精确模式逐句分词 → 相邻词共现三元组
- lines := strings.Split(doc.Content, "\n")
- for _, line := range lines {
- line = strings.TrimSpace(line)
- if line == "" {
- continue
- }
- terms := memory.CutExact(line)
- for i := 0; i < len(terms)-1; i++ {
- triples = append(triples, memory.Triple{
- Subject: terms[i],
- SubjectType: "Concept",
- Relation: "关联",
- Object: terms[i+1],
- ObjectType: "Concept",
- Confidence: 0.8,
- })
- }
- }
-
- if doc.Source != "" {
- triples = append(triples, memory.Triple{
- Subject: "文档",
- SubjectType: "Concept",
- Relation: "来源",
- Object: doc.Source,
- ObjectType: "Source",
- Confidence: 1.0,
- })
- }
-
- return triples
-}
-
-func (a *Agent) emitMemoryCandidate(source, input, response string, 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(),
- })
-}
-
-// executeOutputChannelTool — AI 切换当前请求的输出通道
-// 在 process() 内调用,mutex 保护,只有一个请求在执行
-// processConsolidation 处理后台记忆整理任务(不发外部输出)
-func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
- start := time.Now()
- a.currentOutputChannel = "_consolidation_"
-
- stageCtx := a.stageCtxFromInput(input, evt.Source, "")
- stageCtx.Extra["output_channel"] = evt.OutputChannel
- a.injectSourceContext(stageCtx, evt)
-
- // 遗忘不相关的旧事件
- archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
- if archived > 0 {
- log.Printf("[agent] consolidation: pruned %d low-relevance events", archived)
- }
-
- a.context.Append(ContextEvent{
- Timestamp: start,
- Source: "system",
- Input: input,
- })
- response, toolsUsed, 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,
- })
- // 整理任务不发射记忆候选,防止任务文本被蒸馏进图库造成污染
- log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
-}
-
-// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
-func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
- channel := strings.TrimPrefix(tc.Name, "output_send__")
- payload, _ := tc.Arguments["payload"].(string)
- rawType, _ := tc.Arguments["type"].(string)
- if channel == "" || payload == "" || rawType == "" {
- return "工具名称格式: output_send__{channel},payload 和 type 不能为空"
- }
- meta, _ := tc.Arguments["meta"].(string)
-
- // 通道能力检查
- caps := a.io.GetChannelCapabilities(channel)
- if caps == 0 {
- return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
- }
- switch rawType {
- case "text":
- if !caps.Supports(agentIO.CapText) {
- return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s)", channel, caps.String())
- }
- case "voice", "audio":
- if !caps.Supports(agentIO.CapAudio) {
- return fmt.Sprintf("通道 [%s] 不支持语音输出(能力: %s)", channel, caps.String())
- }
- case "image":
- if !caps.Supports(agentIO.CapImage) {
- return fmt.Sprintf("通道 [%s] 不支持图片输出(能力: %s)", channel, caps.String())
- }
- case "file":
- if !caps.Supports(agentIO.CapFile) {
- return fmt.Sprintf("通道 [%s] 不支持文件输出(能力: %s)", channel, caps.String())
- }
- }
-
- // 组装参数传给设备处理器
- args := map[string]interface{}{
- "payload": payload,
- "type": rawType,
- }
- if meta != "" {
- args["meta"] = meta
- }
-
- // === Stage: before_output — 输出前插件可审查/改写/拦截 ===
- stageCtx := &sdk.StageContext{
- FinalText: payload,
- Phase: sdk.StageBeforeOutput,
- }
- a.runStage(sdk.StageBeforeOutput, stageCtx)
- if stageCtx.Response != nil {
- return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
- }
- if stageCtx.FinalText == "" {
- return "输出被插件清空"
- }
- args["payload"] = stageCtx.FinalText
-
- // 通过设备处理器投递
- if dev := a.io.GetDevice(channel); dev != nil {
- result, err := dev.Execute("output", args)
- if err != nil {
- return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
- }
- return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
- }
-
- // 降级
- a.io.EmitTextTo("agent_io", channel, payload)
- return fmt.Sprintf("已通过 [%s] 通道发送", channel)
-}
-
-// executeOutputSendHelp — 返回指定通道的 meta 格式和 type 枚举
-func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
- suffix := strings.TrimPrefix(tc.Name, "output_send__")
- channel := strings.TrimSuffix(suffix, "_help")
- if channel == "" {
- return "工具名称格式: output_send__{channel}_help"
- }
-
- dev := a.io.GetDevice(channel)
- if dev == nil {
- return fmt.Sprintf("通道 [%s] 不存在", channel)
- }
-
- caps := a.io.GetChannelCapabilities(channel)
- capStr := "无"
- if caps != 0 {
- capStr = caps.String()
- }
-
- desc := dev.Description()
- if desc == "" {
- desc = channel + " 输出通道"
- }
-
- return fmt.Sprintf(`通道 [%s]
-描述: %s
-能力: %s
-
-【参数说明】
-payload — 消息载荷(必填)。type=text 时直接填文字,type=file/image 时填 URL 或路径
-meta — JSON 对象,发送所需的元数据(可选,取决于通道是否需要路由信息)
-type — 载荷类型(必填),枚举值见下方
-
-【type 枚举】
-- text — 文本消息
-- voice — 语音消息
-- image — 图片
-- file — 文件
-
-【meta JSON 格式】
-由通道描述定义,通常包含:
-- "group_id" 群号(群聊时必填)
-- "user_id" 目标用户 QQ 号(私聊时必填)
-- "reply_to" 回复某条消息 ID(可选)
-
-示例: output_send__%s(payload="你好", meta="{\"group_id\": 123456789}", type="text")`, channel, desc, capStr, channel)
-}
-
-// executeOutputListChannels — 列出所有可用通道及其能力
-func (a *Agent) executeOutputListChannels() string {
- channels := a.io.ListChannels()
- if len(channels) == 0 {
- return "没有可用通道"
- }
- var parts []string
- parts = append(parts, "可用通道:")
- for _, ch := range channels {
- if ch.OutputCaps == 0 {
- continue // 纯输入通道不列出
- }
- parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description))
- for _, t := range ch.Tools {
- parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
- }
- }
- return strings.Join(parts, "\n")
-}
-
-func getString(m map[string]interface{}, key string) string {
- if v, ok := m[key]; ok {
- if s, ok := v.(string); ok {
- return s
- }
- }
- return ""
-}
-
-// executePluginReload — 重载所有插件(原子替换 IO 设备)
-func (a *Agent) executePluginReload() string {
- if a.pluginReg == nil {
- return "插件系统未启用"
- }
- msg, err := a.pluginReg.Reload(a.pluginDir)
- if err != nil {
- return fmt.Sprintf("插件重载失败: %v", err)
- }
- return msg
-}
-
-func (a *Agent) autoReloadPlugins() {
- if a.pluginReg == nil {
- return
- }
- for _, name := range a.pluginHealth.pendingReloads() {
- if !a.pluginReg.AutoRestartEnabled(name) {
- log.Printf("[agent] skip auto-reload plugin %s: auto-restart disabled by plugin", name)
- continue
- }
- log.Printf("[agent] auto-reloading unhealthy plugin: %s", name)
- if a.stageHost != nil {
- a.stageHost.UnregisterPluginTools(name)
- }
- if err := a.pluginReg.ReloadOne(name); err != nil {
- log.Printf("[agent] auto-reload plugin %s failed: %v", name, err)
- } else {
- a.pluginHealth.markReloaded(name)
- log.Printf("[agent] plugin %s reloaded successfully", name)
- }
- }
-}
-
-// executeSpawnChild 创建子 Agent 异步执行独立任务
-// 不阻塞主 Agent,子任务完成后通过 selfInputCh 通知主 Agent 查看结果
-func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
- task, _ := tc.Arguments["task"].(string)
- if task == "" {
- return "请提供 task 参数"
- }
-
- // 生成唯一任务 ID
- a.childMu.Lock()
- a.childNextID++
- taskID := fmt.Sprintf("child_%d", a.childNextID)
- a.childMu.Unlock()
-
- // 异步启动子 Agent
- go a.runChildTask(taskID, task)
-
- return fmt.Sprintf("子任务已启动(ID: %s),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID)
-}
-
-// runChildTask 后台运行子 Agent 任务,完成后将结果存储并通过 selfInputCh 通知主 Agent
-func (a *Agent) runChildTask(taskID, task string) {
- if a.provider == nil {
- log.Printf("[child] %s failed: no LLM provider configured", taskID)
- return
- }
- log.Printf("[child] %s started: %s", taskID, truncateStr(task, 80))
-
- sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。
-请完成以下任务。完成即可,无需保留记忆或查询历史。
-任务: %s`, task)
-
- msgs := []agentAPI.Message{
- {Role: "system", Content: sysPrompt},
- {Role: "user", Content: task},
- }
-
- // 子 Agent 可调用核心以外的全部工具(记忆/知识/文档/社交),但不能调用输出工具
- allTools := a.buildToolDefs()
- childTools := make([]interface{}, 0, len(allTools))
- for _, t := range allTools {
- toolMap, ok := t.(map[string]interface{})
- if !ok {
- continue
- }
- fn, ok := toolMap["function"].(map[string]interface{})
- if !ok {
- continue
- }
- name, _ := fn["name"].(string)
- if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" {
- continue
- }
- childTools = append(childTools, t)
- }
-
- var finalResult string
- for turn := 0; turn < 5; turn++ {
- eb := map[string]interface{}{}
- if !a.thinkingEnabled {
- eb["thinking"] = map[string]interface{}{"type": "disabled"}
- }
- req := &agentAPI.CompletionRequest{
- Messages: msgs,
- MaxTokens: 4096,
- Tools: childTools,
- ToolChoice: "auto",
- ExtraBody: eb,
- }
-
- resp, err := a.provider.Chat(a.ctx, req)
- if err != nil {
- finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err)
- break
- }
-
- if len(resp.ToolCalls) == 0 {
- finalResult = resp.Content
- break
- }
-
- for _, ct := range resp.ToolCalls {
- var result string
- switch {
- case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels":
- result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
- case ct.Name == "spawn_child" || ct.Name == "plgreload":
- result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
- default:
- result = a.executeToolCall(ct)
- }
- msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
- msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
- }
- }
-
- if finalResult == "" {
- finalResult = "子 Agent 执行超时(超过 5 轮)"
- }
-
- // 存储结果
- a.childMu.Lock()
- a.childResults[taskID] = finalResult
- a.childMu.Unlock()
-
- log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
-
- // 通过自循环通道通知主 Agent
- notification := fmt.Sprintf("子任务 %s 已完成,请调用 child_result 工具查看输出", taskID)
- select {
- case a.selfInputCh <- notification:
- default:
- log.Printf("[child] self input channel full, dropping notification for %s", taskID)
- }
-}
-
-// executeChildResultTool 查询子 Agent 执行结果
-func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
- taskID, _ := tc.Arguments["task_id"].(string)
- if taskID == "" {
- return "请提供 task_id 参数"
- }
-
- a.childMu.Lock()
- result, ok := a.childResults[taskID]
- if !ok {
- a.childMu.Unlock()
-
- // 可能还在执行中
- a.childMu.Lock()
- _, exists := a.childResults[taskID]
- a.childMu.Unlock()
- if !exists {
- return fmt.Sprintf("子任务 %s 不存在或已过期", taskID)
- }
- }
- delete(a.childResults, taskID)
- a.childMu.Unlock()
-
- return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
-}
-
-func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
- if a.providerManager == nil {
- return "LLM 源管理器不可用"
- }
- switch tc.Name {
- case "llm_list_sources":
- sources := a.providerManager.List()
- if len(sources) == 0 {
- return "没有可用的 LLM 源"
- }
- parts := []string{"可用 LLM 源:"}
- for _, name := range sources {
- mark := " "
- if p := a.providerManager.Get(""); p != nil && p.Name() == name {
- mark = "→"
- }
- parts = append(parts, fmt.Sprintf(" %s %s", mark, name))
- }
- return strings.Join(parts, "\n")
-
- case "llm_set_source":
- name, _ := tc.Arguments["name"].(string)
- if name == "" {
- return "请提供源名称"
- }
- if err := a.providerManager.SetDefault(name); err != nil {
- return fmt.Sprintf("切换失败: %v", err)
- }
- a.provider = a.providerManager.Get(name)
- return fmt.Sprintf("已切换到 LLM 源: %s", name)
-
- default:
- return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
- }
-}
-
-// mediaDataURL 从 pendingMedia 构建 data URL,返回最终 URL。
-func (a *Agent) mediaDataURL(defaultMime string) string {
- if a.pendingMedia == nil {
- return ""
- }
- data, _ := a.pendingMedia["data"].(string)
- mime, _ := a.pendingMedia["mime"].(string)
- url, _ := a.pendingMedia["url"].(string)
- if data != "" {
- if mime == "" {
- mime = defaultMime
- }
- return "data:" + mime + ";base64," + data
- }
- return url
-}
-
-// mediaRequest 构造多模态请求并调用 LLM,统一处理 pendingMedia 检查和 data URL 转换。
-func (a *Agent) mediaRequest(p agentAPI.Provider, mime, emptyPendingMsg, emptyDataMsg, prompt, resultPrefix string, maxTokens int, blockType string, detail string) string {
- if a.pendingMedia == nil {
- return emptyPendingMsg
- }
- url := a.mediaDataURL(mime)
- if url == "" {
- return emptyDataMsg
- }
- msg := agentAPI.Message{
- Role: "user",
- Blocks: []agentAPI.ContentBlock{
- {Type: "text", Text: prompt},
- },
- }
- if blockType == "image_url" {
- msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
- Type: "image_url",
- ImageURL: &agentAPI.ImageURL{URL: url, Detail: detail},
- })
- } else {
- msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
- Type: "audio_url",
- AudioURL: &agentAPI.AudioURL{URL: url},
- })
- }
- return a.mediaChat(p, msg, resultPrefix, maxTokens)
-}
-
-// mediaChat 调用指定 provider 的多模态 Chat,统一处理超时和错误。
-func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string {
- ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
- defer cancel()
- resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
- Messages: []agentAPI.Message{msg},
- MaxTokens: maxTokens,
- })
- if err != nil {
- return fmt.Sprintf("%s失败: %v", resultPrefix, err)
- }
- return fmt.Sprintf("[%s] %s", resultPrefix, resp.Content)
-}
-
-// executeDescribeImage 调用多模态模型描述当前图片。
-func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
- providerName, _ := tc.Arguments["provider"].(string)
- p := a.providerManager.Get(providerName)
- if p == nil {
- p = a.provider
- }
- detail, _ := tc.Arguments["detail"].(string)
- if detail == "" {
- detail = "high"
- }
- return a.mediaRequest(p, "image/png", "没有待处理的图片数据", "图片数据为空",
- a.inputCfg.Image.DescribePrompt, "图片描述", 2048, "image_url", detail)
-}
-
-// executeTranscribeAudio 调用多模态模型转写/描述当前音频。
-func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
- providerName, _ := tc.Arguments["provider"].(string)
- p := a.providerManager.Get(providerName)
- if p == nil {
- p = a.provider
- }
- return a.mediaRequest(p, "audio/wav", "没有待处理的音频数据", "音频数据为空",
- a.inputCfg.Audio.DescribePrompt, "音频转写", 2048, "audio_url", "")
-}
-
-// executeOCRImage 对图片执行 OCR 文字识别(通过多模态模型实现)。
-func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
- return a.mediaRequest(a.provider, "image/png", "没有待处理的图片数据", "图片数据为空",
- a.inputCfg.Image.OCRPrompt, "OCR 结果", 4096, "image_url", "high")
-}
-
-// runStage — 运行阶段管道,若插件 Response 被设置则返回 true(短路)
-func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
- if a.stageHost == nil {
- return false
- }
- ctx.Phase = stage
- func() {
- defer func() {
- if r := recover(); r != nil {
- log.Printf("[agent] stage %q plugin panic: %v\n%s", stage, r, debug.Stack())
- }
- }()
- a.stageHost.RunStage(stage, ctx)
- }()
- return ctx.Response != nil
-}
-
-// publishEvent — 发布系统事件
-func (a *Agent) publishEvent(evtType events.EventType, payload map[string]interface{}) {
- if a.eventBus == nil {
- return
- }
- a.eventBus.Publish(&events.Event{
- Type: evtType,
- Source: string(a.id),
- Payload: payload,
- Timestamp: time.Now().Unix(),
- })
-}
-
-// stageCtxFromInput — 根据输入构建阶段上下文
-func (a *Agent) stageCtxFromInput(input, userID, groupID string) *sdk.StageContext {
- return &sdk.StageContext{
- RawMessage: input,
- UserID: userID,
- GroupID: groupID,
- Phase: sdk.StageOnInput,
- Extra: make(map[string]interface{}),
- }
-}
-
-func getFloat(m map[string]interface{}, key string) float64 {
- if v, ok := m[key]; ok {
- switch n := v.(type) {
- case float64:
- return n
- case int:
- return float64(n)
- }
- }
- return 0
-}
-
-func truncateStr(s string, max int) string {
- if utf8.RuneCountInString(s) <= max {
- return s
- }
- var truncated int
- for i := range s {
- if truncated >= max {
- return s[:i] + "..."
- }
- truncated++
- }
- return s
-}
-
-func formatTree(node *knowledge.TreeIndex, depth int) string {
- var sb strings.Builder
- indent := strings.Repeat(" ", depth)
- // 先渲染子目录
- for _, child := range node.Children {
- sb.WriteString(fmt.Sprintf("%s%s/\n", indent, child.Name))
- sb.WriteString(formatTree(child, depth+1))
- }
- // 再渲染当前节点条目(根节点也能显示)
- for _, item := range node.Items {
- preview := item.Preview
- if len([]rune(preview)) > 60 {
- preview = string([]rune(preview)[:60]) + "..."
- }
- tags := ""
- if len(item.Tags) > 0 {
- tags = " [" + strings.Join(item.Tags, ", ") + "]"
- }
- sb.WriteString(fmt.Sprintf("%s· %s%s\n", indent, item.Name, tags))
- sb.WriteString(fmt.Sprintf("%s %s\n", indent, preview))
- }
- if sb.Len() == 0 {
- sb.WriteString("(空)")
- }
- return sb.String()
-}
-
-func (a *Agent) resolveToolPlugin(name string) string {
- if a.stageHost != nil {
- if plugin := a.stageHost.ToolPlugin(name); plugin != "" {
- return plugin
- }
- }
- if idx := strings.IndexByte(name, '_'); idx > 0 {
- return name[:idx]
- }
- return "core"
-}
-
-func (a *Agent) injectSourceContext(stageCtx *sdk.StageContext, evt *agentIO.InputEvent) {
- if stageCtx == nil || evt == nil {
- return
- }
- source := evt.Source
- if source == "" {
- source = "unknown"
- }
- channel := evt.OutputChannel
- if channel == "" {
- channel = source
- }
- content := fmt.Sprintf("当前输入来源: %s;默认输出通道: %s。", source, channel)
- if flag, _ := evt.Payload["interrupt"].(bool); flag {
- content = fmt.Sprintf("这是一条打断输入。来源: %s;默认输出通道: %s。", source, channel)
- }
- stageCtx.ContextMsgs = append(stageCtx.ContextMsgs, map[string]interface{}{
- "role": "system",
- "content": content,
- })
-}
-
-// drainInterrupts 非阻塞读取 interceptCh 中全部待处理打断消息,逐条保留来源信息。
-func (a *Agent) drainInterrupts() []string {
- var out []string
- for {
- select {
- case evt := <-a.interceptCh:
- if evt == nil {
- continue
- }
- text, _ := evt.Payload["content"].(string)
- if text == "" {
- continue
- }
- source := evt.Source
- if source == "" {
- source = "unknown"
- }
- channel := evt.OutputChannel
- if channel == "" {
- channel = source
- }
- out = append(out, fmt.Sprintf("[打断消息][来源:%s][输出通道:%s] %s", source, channel, text))
- default:
- return out
- }
- }
-}
diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go
new file mode 100644
index 0000000..012ef4e
--- /dev/null
+++ b/internal/agent/core/distill.go
@@ -0,0 +1,422 @@
+package core
+
+import (
+ "fmt"
+ "log"
+ "runtime/debug"
+ "strings"
+ "time"
+
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
+)
+
+type ConsolidationTask struct {
+ Type string `json:"type"`
+ Reason string `json:"reason"`
+ Data interface{} `json:"data"`
+}
+
+func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
+ msg := fmt.Sprintf(
+ "【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
+ task.Type, task.Reason,
+ )
+ a.injectSelf(msg)
+ log.Printf("[agent] enqueued consolidation task: %s", task.Reason)
+}
+
+func (a *Agent) distillLoop() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] distillLoop panic recovered: %v\n%s", r, debug.Stack())
+ time.Sleep(time.Second)
+ go a.distillLoop()
+ }
+ }()
+ if a.docStore == nil && a.memory == nil {
+ return
+ }
+ ticker := time.NewTicker(a.distillInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ticker.C:
+ log.Printf("[agent] heartbeat distill tick")
+ a.distillContext()
+ a.syncGraphToDocs()
+ a.reorgGraph()
+ a.autoReloadPlugins()
+ case <-a.ctx.Done():
+ return
+ }
+ }
+}
+
+func (a *Agent) distillContext() {
+ if a.docStore == nil {
+ return
+ }
+ n := a.context.Len()
+ if n > a.maxContextSize*2 {
+ archived := a.context.Prune("", a.maxContextSize, a.docStore)
+ if archived > 0 {
+ log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
+ }
+ }
+}
+
+func (a *Agent) syncGraphToDocs() {
+ if a.memory == nil || a.docStore == nil {
+ return
+ }
+
+ stats, err := a.memory.Introspect()
+ if err != nil {
+ return
+ }
+
+ entityCount, _ := stats["entity_count"].(int)
+ if entityCount == 0 {
+ return
+ }
+
+ result, err := a.memory.Recall(nil, nil, 1, "")
+ if err != nil || result == nil {
+ return
+ }
+
+ if len(result.Entities) == 0 && len(result.Relations) == 0 {
+ return
+ }
+
+ var summaryParts []string
+ summaryParts = append(summaryParts, fmt.Sprintf("图记忆快照: %d 个热点实体", len(result.Entities)))
+ for _, e := range result.Entities {
+ summaryParts = append(summaryParts, fmt.Sprintf("- %s (%s, %d次)", e.Name, e.Type, e.MentionCount))
+ }
+ if len(result.Relations) > 0 {
+ summaryParts = append(summaryParts, "关联关系:")
+ for i, r := range result.Relations {
+ if i >= 10 {
+ break
+ }
+ summaryParts = append(summaryParts, fmt.Sprintf(" %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
+ }
+ }
+
+ summary := fmt.Sprintf("图记忆索引 (%d 实体, %d 关系)", len(result.Entities), len(result.Relations))
+ content := strings.Join(summaryParts, "\n")
+
+ recent := a.docStore.RecentDocs(1)
+ if len(recent) > 0 && recent[0].Source == "graph" && recent[0].Content == content {
+ return
+ }
+
+ doc := &document.Doc{
+ Summary: summary,
+ Content: content,
+ Tags: []string{"graph_memory", "auto_sync"},
+ Entities: extractEntityNames(result.Entities),
+ Source: "graph",
+ }
+ if err := a.docStore.Insert(doc); err != nil {
+ log.Printf("[agent] graph→doc sync error: %v", err)
+ } else {
+ log.Printf("[agent] graph→doc synced: %s", doc.Summary)
+ }
+}
+
+func extractEntityNames(entities []memory.Entity) []string {
+ names := make([]string, len(entities))
+ for i, e := range entities {
+ names[i] = e.Name
+ }
+ return names
+}
+
+func (a *Agent) reorgGraph() {
+ if a.memory == nil {
+ return
+ }
+
+ log.Printf("[agent] graph reorg start")
+
+ if a.indexer != nil {
+ if err := a.indexer.Sync(); err != nil {
+ log.Printf("[agent] indexer sync error: %v", err)
+ }
+ }
+
+ if a.docStore != nil {
+ a.docStore.Reindex()
+ }
+
+ if a.docStore != nil {
+ coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
+ for _, doc := range coldDocs {
+ triples := docToTriples(doc)
+ if len(triples) > 0 {
+ ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0)
+ if err != nil {
+ log.Printf("[agent] doc→graph archival error: %v", err)
+ continue
+ }
+ log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
+ a.docStore.Remove(doc.ID)
+ }
+ }
+ }
+
+ result, err := a.memory.Recall(nil, nil, 1, "")
+ if err != nil || result == nil || len(result.Entities) < 2 {
+ return
+ }
+
+ maxCandidates := 5
+ candidates := 0
+ for i := 0; i < len(result.Entities) && candidates < maxCandidates; i++ {
+ for j := i + 1; j < len(result.Entities) && candidates < maxCandidates; j++ {
+ ea, eb := result.Entities[i].Name, result.Entities[j].Name
+ if ea > eb {
+ ea, eb = eb, ea
+ }
+ key := ea + "||" + eb
+ a.noMergeMu.Lock()
+ rounds, ok := a.noMergeMarkers[key]
+ if ok {
+ rounds--
+ if rounds <= 0 {
+ delete(a.noMergeMarkers, key)
+ } else {
+ a.noMergeMarkers[key] = rounds
+ }
+ }
+ a.noMergeMu.Unlock()
+ if ok {
+ continue
+ }
+ sim := entitySimilarity(result.Entities[i].Name, result.Entities[j].Name)
+ if sim > 0.75 {
+ candidates++
+ a.enqueueConsolidationTask(ConsolidationTask{
+ Type: "entity_merge",
+ Reason: fmt.Sprintf(
+ "实体「%s」(类型:%s, 提及%d次) 与「%s」(类型:%s, 提及%d次) 相似度 %.0f%%,可能指代同一事物,请判断是否需要合并",
+ result.Entities[i].Name, result.Entities[i].Type, result.Entities[i].MentionCount,
+ result.Entities[j].Name, result.Entities[j].Type, result.Entities[j].MentionCount,
+ sim*100,
+ ),
+ Data: map[string]interface{}{
+ "entity_a": result.Entities[i].Name,
+ "entity_a_type": result.Entities[i].Type,
+ "entity_a_mentions": result.Entities[i].MentionCount,
+ "entity_b": result.Entities[j].Name,
+ "entity_b_type": result.Entities[j].Type,
+ "entity_b_mentions": result.Entities[j].MentionCount,
+ "similarity": sim,
+ },
+ })
+ }
+ }
+ }
+
+ if candidates > 0 {
+ log.Printf("[agent] graph reorg: %d merge candidates sent for LLM decision", candidates)
+ } else {
+ log.Printf("[agent] graph reorg: no similar entities found")
+ }
+
+ a.evaluateGraphQuality()
+}
+
+func (a *Agent) evaluateGraphQuality() {
+ if a.memory == nil {
+ return
+ }
+
+ result, err := a.memory.Recall(nil, nil, 1, "")
+ if err != nil || result == nil || len(result.Relations) == 0 {
+ return
+ }
+
+ var lowQuality []string
+ for _, r := range result.Relations {
+ if (r.SourceName == "用户" || r.SourceName == "AI") &&
+ (r.RelationType == "提及" || r.RelationType == "回应") {
+ lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」", r.SourceName, r.RelationType, r.TargetName))
+ continue
+ }
+ if r.RelationType == "关联" {
+ lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(jieba 共现)", r.SourceName, r.RelationType, r.TargetName))
+ continue
+ }
+ if r.Confidence < 0.3 && r.RelationType != "" {
+ lowQuality = append(lowQuality, fmt.Sprintf("「%s」-「%s」→「%s」(confidence=%.1f)", r.SourceName, r.RelationType, r.TargetName, r.Confidence))
+ }
+ }
+
+ if len(lowQuality) == 0 {
+ return
+ }
+
+ batchSize := 10
+ for i := 0; i < len(lowQuality); i += batchSize {
+ end := i + batchSize
+ if end > len(lowQuality) {
+ end = len(lowQuality)
+ }
+ batch := lowQuality[i:end]
+
+ a.enqueueConsolidationTask(ConsolidationTask{
+ Type: "graph_quality",
+ Reason: fmt.Sprintf(
+ "图数据库中发现 %d 条低质量关系,请逐条判断是否应该删除(保留 = keep,删除 = discard):\n%s",
+ len(batch),
+ strings.Join(batch, "\n"),
+ ),
+ Data: map[string]interface{}{
+ "candidates": batch,
+ "action": "evaluate_quality",
+ },
+ })
+ }
+
+ log.Printf("[agent] graph quality: %d low-quality connection batches sent for LLM evaluation", (len(lowQuality)+batchSize-1)/batchSize)
+}
+
+func entitySimilarity(a, b string) float64 {
+ if a == "" || b == "" {
+ return 0
+ }
+ if a == b {
+ return 1.0
+ }
+ runesA, runesB := []rune(a), []rune(b)
+ if len(runesA) < 2 || len(runesB) < 2 {
+ if len(runesA) == len(runesB) && len(runesA) == 1 {
+ if runesA[0] == runesB[0] {
+ return 1.0
+ }
+ }
+ return 0
+ }
+
+ setA := make(map[string]bool)
+ for i := 0; i < len(runesA)-1; i++ {
+ setA[string(runesA[i:i+2])] = true
+ }
+
+ setB := make(map[string]bool)
+ for i := 0; i < len(runesB)-1; i++ {
+ setB[string(runesB[i:i+2])] = true
+ }
+
+ intersect := 0
+ for bg := range setA {
+ if setB[bg] {
+ intersect++
+ }
+ }
+
+ union := len(setA) + len(setB) - intersect
+ if union <= 0 {
+ return 0
+ }
+
+ return float64(intersect) / float64(union)
+}
+
+func docToTriples(doc *document.Doc) []memory.Triple {
+ var triples []memory.Triple
+ if doc == nil {
+ return triples
+ }
+
+ triples = append(triples, memory.Triple{
+ Subject: "文档",
+ SubjectType: "Concept",
+ Relation: "主题",
+ Object: doc.Summary,
+ ObjectType: "Topic",
+ Confidence: 1.0,
+ })
+
+ lines := strings.Split(doc.Content, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ terms := memory.CutExact(line)
+ for i := 0; i < len(terms)-1; i++ {
+ triples = append(triples, memory.Triple{
+ Subject: terms[i],
+ SubjectType: "Concept",
+ Relation: "关联",
+ Object: terms[i+1],
+ ObjectType: "Concept",
+ Confidence: 0.8,
+ })
+ }
+ }
+
+ if doc.Source != "" {
+ triples = append(triples, memory.Triple{
+ Subject: "文档",
+ SubjectType: "Concept",
+ Relation: "来源",
+ Object: doc.Source,
+ ObjectType: "Source",
+ Confidence: 1.0,
+ })
+ }
+
+ return triples
+}
+
+func (a *Agent) emitMemoryCandidate(source, input, response string, 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(),
+ })
+}
+
+func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
+ start := time.Now()
+ a.currentOutputChannel = "_consolidation_"
+
+ stageCtx := a.stageCtxFromInput(input, evt.Source, "")
+ stageCtx.Extra["output_channel"] = evt.OutputChannel
+ a.injectSourceContext(stageCtx, evt)
+
+ archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
+ if archived > 0 {
+ log.Printf("[agent] consolidation: pruned %d low-relevance events", archived)
+ }
+
+ a.context.Append(ContextEvent{
+ Timestamp: start,
+ Source: "system",
+ Input: input,
+ })
+ response, toolsUsed, 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,
+ })
+ 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
new file mode 100644
index 0000000..fec9831
--- /dev/null
+++ b/internal/agent/core/eventloop.go
@@ -0,0 +1,414 @@
+package core
+
+import (
+ "fmt"
+ "log"
+ "runtime/debug"
+ "time"
+
+ agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/events"
+ sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
+)
+
+func (a *Agent) eventLoop() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
+ time.Sleep(time.Second)
+ go a.eventLoop()
+ }
+ }()
+ for {
+ select {
+ case evt := <-a.io.InputChan():
+ a.handleInput(evt)
+ case task := <-a.selfInputCh:
+ a.handleSelfInput(task)
+ case <-a.ctx.Done():
+ return
+ }
+ }
+}
+
+func (a *Agent) interceptLoop() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] interceptLoop panic recovered: %v\n%s", r, debug.Stack())
+ time.Sleep(time.Second)
+ go a.interceptLoop()
+ }
+ }()
+ for {
+ select {
+ case evt := <-a.io.InputInterruptChan():
+ text, _ := evt.Payload["content"].(string)
+ if text == "" {
+ continue
+ }
+ log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80))
+
+ clone := &agentIO.InputEvent{
+ RequestID: evt.RequestID,
+ Source: evt.Source,
+ Type: evt.Type,
+ Payload: map[string]interface{}{},
+ OutputChannel: evt.OutputChannel,
+ }
+ for k, v := range evt.Payload {
+ clone.Payload[k] = v
+ }
+ clone.Payload["interrupt"] = true
+ clone.Payload["interrupt_source"] = evt.Source
+ clone.Payload["interrupt_channel"] = evt.OutputChannel
+
+ a.llmMu.Lock()
+ hasActiveLLM := a.cancelLLM != nil
+ if hasActiveLLM {
+ a.cancelLLM()
+ log.Printf("[agent] LLM request cancelled by interrupt")
+ }
+ a.llmMu.Unlock()
+
+ if hasActiveLLM {
+ if a.currentOutputChannel == "_consolidation_" {
+ log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
+ a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
+ "content": text,
+ "interrupt": true,
+ "interrupt_source": evt.Source,
+ "interrupt_channel": evt.OutputChannel,
+ })
+ } else {
+ select {
+ case a.interceptCh <- clone:
+ default:
+ log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
+ a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
+ "content": text,
+ "interrupt": true,
+ "interrupt_source": evt.Source,
+ "interrupt_channel": evt.OutputChannel,
+ })
+ }
+ }
+ } else {
+ a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
+ "content": text,
+ "interrupt": true,
+ "interrupt_source": evt.Source,
+ "interrupt_channel": evt.OutputChannel,
+ })
+ }
+
+ case <-a.ctx.Done():
+ return
+ }
+ }
+}
+
+func (a *Agent) handleSelfInput(task string) {
+ a.processTextInput(&agentIO.InputEvent{
+ Source: "system",
+ Type: "text",
+ Payload: map[string]interface{}{"content": task},
+ OutputChannel: "_consolidation_",
+ }, task)
+}
+
+func (a *Agent) handleInput(evt *agentIO.InputEvent) {
+ switch evt.Type {
+ case "text":
+ input, _ := evt.Payload["content"].(string)
+ if input == "" {
+ return
+ }
+ a.processTextInput(evt, input)
+
+ case "image", "audio":
+ a.processMediaInput(evt)
+
+ case "event":
+ log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
+
+ case "command":
+ cmd, _ := evt.Payload["command"].(string)
+ log.Printf("[agent] command from %s: %s", evt.Source, cmd)
+
+ default:
+ log.Printf("[agent] unknown event type from %s: %s", evt.Source, evt.Type)
+ }
+}
+
+func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
+ start := time.Now()
+ a.pendingMedia = evt.Payload
+ defer func() { a.pendingMedia = nil }()
+
+ a.currentOutputChannel = evt.OutputChannel
+ if a.currentOutputChannel == "" {
+ a.currentOutputChannel = evt.Source
+ }
+
+ blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
+
+ stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
+ stageCtx.Extra = map[string]interface{}{
+ "media_blocks": blocks,
+ "media_type": evt.Type,
+ "input_source": evt.Source,
+ "output_channel": evt.OutputChannel,
+ }
+ a.injectSourceContext(stageCtx, evt)
+
+ if a.runStage(sdk.StageOnInput, stageCtx) {
+ a.emitResponse(evt, *stageCtx.Response)
+ return
+ }
+
+ a.publishEvent(events.EventRawInput, map[string]interface{}{
+ "content": evt.Payload,
+ "source": evt.Source,
+ })
+
+ archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
+ if archived > 0 {
+ log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
+ }
+
+ a.context.Append(ContextEvent{
+ Timestamp: start,
+ Source: evt.Source,
+ Input: fallback,
+ })
+
+ response, toolsUsed, err := a.process(fallback, stageCtx)
+ if err != nil {
+ log.Printf("[agent] process media error: %v", err)
+ resp := fmt.Sprintf("处理错误: %v", err)
+ a.emitResponse(evt, resp)
+ a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: fallback, Response: resp})
+ return
+ }
+
+ elapsed := time.Since(start)
+ 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,
+ })
+
+ a.emitResponse(evt, response)
+
+ if !stageCtx.NoMemory {
+ a.emitMemoryCandidate(evt.Source, fallback, response, toolsUsed)
+ }
+}
+
+func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
+ data, _ := payload["data"].(string)
+ mime, _ := payload["mime"].(string)
+ url, _ := payload["url"].(string)
+ alt, _ := payload["alt"].(string)
+ if alt == "" {
+ if source == "" {
+ source = "unknown"
+ }
+ alt = fmt.Sprintf("[从 %s 收到了 %s]", source, mediaType)
+ }
+
+ var blocks []agentAPI.ContentBlock
+
+ desc := ""
+ switch mediaType {
+ case "image":
+ desc = a.inputCfg.Image.DescribePrompt
+ if desc == "" {
+ desc = fmt.Sprintf("从 %s 收到了一张图片,请使用 describe_image 工具查看详情。", source)
+ }
+ case "audio":
+ desc = a.inputCfg.Audio.DescribePrompt
+ if desc == "" {
+ desc = fmt.Sprintf("从 %s 收到了一段音频,请使用 transcribe_audio 工具查看内容。", source)
+ }
+ }
+ blocks = append(blocks, agentAPI.ContentBlock{Type: "text", Text: desc})
+
+ if data != "" || url != "" {
+ imgURL := url
+ if data != "" {
+ if mime == "" {
+ mime = "image/png"
+ }
+ imgURL = "data:" + mime + ";base64," + data
+ }
+ if mediaType == "image" {
+ blocks = append(blocks, agentAPI.ContentBlock{
+ Type: "image_url",
+ ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
+ })
+ } else if mediaType == "audio" {
+ blocks = append(blocks, agentAPI.ContentBlock{
+ Type: "audio_url",
+ AudioURL: &agentAPI.AudioURL{URL: imgURL},
+ })
+ }
+ }
+
+ return blocks, alt
+}
+
+func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
+ start := time.Now()
+
+ a.currentOutputChannel = evt.OutputChannel
+ if a.currentOutputChannel == "" {
+ a.currentOutputChannel = evt.Source
+ }
+
+ if evt.OutputChannel == "_consolidation_" {
+ a.processConsolidation(evt, input)
+ return
+ }
+
+ noMemory := false
+ if v, ok := evt.Payload["no_memory"].(bool); ok {
+ noMemory = v
+ }
+
+ stageCtx := a.stageCtxFromInput(input, evt.Source, "")
+ stageCtx.Extra["input_source"] = evt.Source
+ stageCtx.Extra["output_channel"] = evt.OutputChannel
+ if noMemory {
+ stageCtx.NoMemory = true
+ }
+ a.injectSourceContext(stageCtx, evt)
+
+ if a.runStage(sdk.StageOnInput, stageCtx) {
+ a.emitResponse(evt, *stageCtx.Response)
+ return
+ }
+
+ input = stageCtx.RawMessage
+
+ a.publishEvent(events.EventRawInput, map[string]interface{}{
+ "content": input,
+ "source": evt.Source,
+ })
+
+ archived := a.context.Prune(input, a.maxContextSize-1, a.docStore)
+ if archived > 0 {
+ log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
+ }
+
+ a.context.Append(ContextEvent{
+ Timestamp: start,
+ Source: evt.Source,
+ Input: input,
+ })
+
+ response, toolsUsed, err := a.process(input, stageCtx)
+ if err != nil {
+ log.Printf("[agent] process error: %v", err)
+ resp := fmt.Sprintf("处理错误: %v", err)
+ a.emitResponse(evt, resp)
+ a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
+ return
+ }
+
+ elapsed := time.Since(start)
+ 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,
+ })
+
+ a.emitResponse(evt, response)
+
+ if !stageCtx.NoMemory {
+ a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
+ }
+}
+
+func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
+ stageCtx := &sdk.StageContext{
+ FinalText: response,
+ Phase: sdk.StageBeforeOutput,
+ }
+ a.runStage(sdk.StageBeforeOutput, stageCtx)
+ response = stageCtx.FinalText
+
+ ch := a.currentOutputChannel
+ if ch == "" {
+ ch = evt.OutputChannel
+ }
+ if ch == "" {
+ ch = evt.Source
+ }
+
+ payload := map[string]interface{}{
+ "content": response,
+ "request_id": evt.RequestID,
+ }
+ if stageCtx.ReasoningContent != "" {
+ payload["reasoning_content"] = stageCtx.ReasoningContent
+ }
+ if stageCtx.TokenUsage != nil {
+ payload["usage"] = stageCtx.TokenUsage
+ }
+
+ if evt.ResponseCh != nil {
+ evt.ResponseCh <- &agentIO.OutputEvent{
+ RequestID: evt.RequestID,
+ Target: evt.Source,
+ Type: "text",
+ Payload: payload,
+ Done: true,
+ OutputChannel: ch,
+ }
+ }
+
+ a.publishEvent(events.EventAgentOutput, map[string]interface{}{
+ "content": response,
+ "channel": ch,
+ "source": evt.Source,
+ })
+ stageCtx.Phase = sdk.StageAfterOutput
+ a.runStage(sdk.StageAfterOutput, stageCtx)
+}
+
+func (a *Agent) drainInterrupts() []string {
+ var out []string
+ for {
+ select {
+ case evt := <-a.interceptCh:
+ if evt == nil {
+ continue
+ }
+ text, _ := evt.Payload["content"].(string)
+ if text == "" {
+ continue
+ }
+ source := evt.Source
+ if source == "" {
+ source = "unknown"
+ }
+ channel := evt.OutputChannel
+ if channel == "" {
+ channel = source
+ }
+ out = append(out, fmt.Sprintf("[打断消息][来源:%s][输出通道:%s] %s", source, channel, text))
+ default:
+ return out
+ }
+ }
+}
diff --git a/internal/agent/core/media.go b/internal/agent/core/media.go
new file mode 100644
index 0000000..d67b5bf
--- /dev/null
+++ b/internal/agent/core/media.go
@@ -0,0 +1,95 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
+)
+
+func (a *Agent) mediaDataURL(defaultMime string) string {
+ if a.pendingMedia == nil {
+ return ""
+ }
+ data, _ := a.pendingMedia["data"].(string)
+ mime, _ := a.pendingMedia["mime"].(string)
+ url, _ := a.pendingMedia["url"].(string)
+ if data != "" {
+ if mime == "" {
+ mime = defaultMime
+ }
+ return "data:" + mime + ";base64," + data
+ }
+ return url
+}
+
+func (a *Agent) mediaRequest(p agentAPI.Provider, mime, emptyPendingMsg, emptyDataMsg, prompt, resultPrefix string, maxTokens int, blockType string, detail string) string {
+ if a.pendingMedia == nil {
+ return emptyPendingMsg
+ }
+ url := a.mediaDataURL(mime)
+ if url == "" {
+ return emptyDataMsg
+ }
+ msg := agentAPI.Message{
+ Role: "user",
+ Blocks: []agentAPI.ContentBlock{
+ {Type: "text", Text: prompt},
+ },
+ }
+ if blockType == "image_url" {
+ msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
+ Type: "image_url",
+ ImageURL: &agentAPI.ImageURL{URL: url, Detail: detail},
+ })
+ } else {
+ msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
+ Type: "audio_url",
+ AudioURL: &agentAPI.AudioURL{URL: url},
+ })
+ }
+ return a.mediaChat(p, msg, resultPrefix, maxTokens)
+}
+
+func (a *Agent) mediaChat(p agentAPI.Provider, msg agentAPI.Message, resultPrefix string, maxTokens int) string {
+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
+ defer cancel()
+ resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
+ Messages: []agentAPI.Message{msg},
+ MaxTokens: maxTokens,
+ })
+ if err != nil {
+ return fmt.Sprintf("%s失败: %v", resultPrefix, err)
+ }
+ return fmt.Sprintf("[%s] %s", resultPrefix, resp.Content)
+}
+
+func (a *Agent) executeDescribeImage(tc agentAPI.ToolCall) string {
+ providerName, _ := tc.Arguments["provider"].(string)
+ p := a.providerManager.Get(providerName)
+ if p == nil {
+ p = a.provider
+ }
+ detail, _ := tc.Arguments["detail"].(string)
+ if detail == "" {
+ detail = "high"
+ }
+ return a.mediaRequest(p, "image/png", "没有待处理的图片数据", "图片数据为空",
+ a.inputCfg.Image.DescribePrompt, "图片描述", 2048, "image_url", detail)
+}
+
+func (a *Agent) executeTranscribeAudio(tc agentAPI.ToolCall) string {
+ providerName, _ := tc.Arguments["provider"].(string)
+ p := a.providerManager.Get(providerName)
+ if p == nil {
+ p = a.provider
+ }
+ return a.mediaRequest(p, "audio/wav", "没有待处理的音频数据", "音频数据为空",
+ a.inputCfg.Audio.DescribePrompt, "音频转写", 2048, "audio_url", "")
+}
+
+func (a *Agent) executeOCRImage(tc agentAPI.ToolCall) string {
+ return a.mediaRequest(a.provider, "image/png", "没有待处理的图片数据", "图片数据为空",
+ a.inputCfg.Image.OCRPrompt, "OCR 结果", 4096, "image_url", "high")
+}
diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go
new file mode 100644
index 0000000..b203c17
--- /dev/null
+++ b/internal/agent/core/output.go
@@ -0,0 +1,124 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+ agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
+ sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
+)
+
+func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
+ channel := strings.TrimPrefix(tc.Name, "output_send__")
+ if channel == "" {
+ return "工具名称格式: output_send__{channel}"
+ }
+ content, _ := tc.Arguments["content"].(string)
+ if content == "" {
+ return "content 不能为空"
+ }
+
+ caps := a.io.GetChannelCapabilities(channel)
+ if caps == 0 {
+ return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
+ }
+
+ if !caps.Supports(agentIO.CapText) {
+ return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s)", channel, caps.String())
+ }
+
+ stageCtx := &sdk.StageContext{
+ FinalText: content,
+ Phase: sdk.StageBeforeOutput,
+ }
+ a.runStage(sdk.StageBeforeOutput, stageCtx)
+ if stageCtx.Response != nil {
+ return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
+ }
+ if stageCtx.FinalText == "" {
+ return "输出被插件清空"
+ }
+
+ args := map[string]interface{}{
+ "payload": stageCtx.FinalText,
+ "type": "text",
+ }
+
+ if dev := a.io.GetDevice(channel); dev != nil {
+ result, err := dev.Execute("output", args)
+ if err != nil {
+ return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
+ }
+ return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
+ }
+
+ a.io.EmitTextTo("agent_io", channel, stageCtx.FinalText)
+ return fmt.Sprintf("已通过 [%s] 通道发送", channel)
+}
+
+func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {
+ suffix := strings.TrimPrefix(tc.Name, "output_send__")
+ channel := strings.TrimSuffix(suffix, "_help")
+ if channel == "" {
+ return "工具名称格式: output_send__{channel}_help"
+ }
+
+ dev := a.io.GetDevice(channel)
+ if dev == nil {
+ return fmt.Sprintf("通道 [%s] 不存在", channel)
+ }
+
+ caps := a.io.GetChannelCapabilities(channel)
+ capStr := "无"
+ if caps != 0 {
+ capStr = caps.String()
+ }
+
+ desc := dev.Description()
+ if desc == "" {
+ desc = channel + " 输出通道"
+ }
+
+ return fmt.Sprintf(`通道 [%s]
+描述: %s
+能力: %s
+
+【参数说明】
+payload — 消息载荷(必填)。type=text 时直接填文字,type=file/image 时填 URL 或路径
+meta — JSON 对象,发送所需的元数据(可选,取决于通道是否需要路由信息)
+type — 载荷类型(必填),枚举值见下方
+
+【type 枚举】
+- text — 文本消息
+- voice — 语音消息
+- image — 图片
+- file — 文件
+
+【meta JSON 格式】
+由通道描述定义,通常包含:
+- "group_id" 群号(群聊时必填)
+- "user_id" 目标用户 QQ 号(私聊时必填)
+- "reply_to" 回复某条消息 ID(可选)
+
+示例: output_send__%s(payload="你好", meta="{\"group_id\": 123456789}", type="text")`, channel, desc, capStr, channel)
+}
+
+func (a *Agent) executeOutputListChannels() string {
+ channels := a.io.ListChannels()
+ if len(channels) == 0 {
+ return "没有可用通道"
+ }
+ var parts []string
+ parts = append(parts, "可用通道:")
+ for _, ch := range channels {
+ if ch.OutputCaps == 0 {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description))
+ for _, t := range ch.Tools {
+ parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
+ }
+ }
+ return strings.Join(parts, "\n")
+}
diff --git a/internal/agent/core/plugins.go b/internal/agent/core/plugins.go
new file mode 100644
index 0000000..ca030e5
--- /dev/null
+++ b/internal/agent/core/plugins.go
@@ -0,0 +1,52 @@
+package core
+
+import (
+ "fmt"
+ "log"
+ "strings"
+)
+
+func (a *Agent) executePluginReload() string {
+ if a.pluginReg == nil {
+ return "插件系统未启用"
+ }
+ msg, err := a.pluginReg.Reload(a.pluginDir)
+ if err != nil {
+ return fmt.Sprintf("插件重载失败: %v", err)
+ }
+ return msg
+}
+
+func (a *Agent) autoReloadPlugins() {
+ if a.pluginReg == nil {
+ return
+ }
+ for _, name := range a.pluginHealth.pendingReloads() {
+ if !a.pluginReg.AutoRestartEnabled(name) {
+ log.Printf("[agent] skip auto-reload plugin %s: auto-restart disabled by plugin", name)
+ continue
+ }
+ log.Printf("[agent] auto-reloading unhealthy plugin: %s", name)
+ if a.stageHost != nil {
+ a.stageHost.UnregisterPluginTools(name)
+ }
+ if err := a.pluginReg.ReloadOne(name); err != nil {
+ log.Printf("[agent] auto-reload plugin %s failed: %v", name, err)
+ } else {
+ a.pluginHealth.markReloaded(name)
+ log.Printf("[agent] plugin %s reloaded successfully", name)
+ }
+ }
+}
+
+func (a *Agent) resolveToolPlugin(name string) string {
+ if a.stageHost != nil {
+ if plugin := a.stageHost.ToolPlugin(name); plugin != "" {
+ return plugin
+ }
+ }
+ if idx := strings.IndexByte(name, '_'); idx > 0 {
+ return name[:idx]
+ }
+ return "core"
+}
diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go
new file mode 100644
index 0000000..6a7ca32
--- /dev/null
+++ b/internal/agent/core/process.go
@@ -0,0 +1,421 @@
+package core
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+ "time"
+
+ agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/events"
+ sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
+)
+
+func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, err error) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.provider == nil {
+ return "", nil, fmt.Errorf("agent: no LLM provider configured")
+ }
+
+ memContext := a.buildMemoryContext(input)
+ sysPrompt := a.buildSystemPrompt(memContext, input)
+ tools := a.buildToolDefs()
+
+ msgs := a.buildMessages(sysPrompt, input)
+ if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
+ if len(msgs) > 0 {
+ msgs[len(msgs)-1].Blocks = blocks
+ }
+ }
+
+ log.Printf("[agent] tool call loop start, %d tools, %d context events, personality=%t, docs=%d",
+ len(tools), a.context.Len(),
+ a.personality != nil && a.personality.Content != "",
+ a.docStoreSize())
+
+ if a.runStage(sdk.StagePreAction, stageCtx) {
+ return *stageCtx.Response, toolsUsed, nil
+ }
+ if len(stageCtx.ContextMsgs) > 0 {
+ for _, m := range stageCtx.ContextMsgs {
+ role, _ := m["role"].(string)
+ content, _ := m["content"].(string)
+ if role != "" {
+ msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
+ }
+ }
+ }
+
+ for turn := 0; ; turn++ {
+ for _, interrupt := range a.drainInterrupts() {
+ msgs = append(msgs, agentAPI.Message{
+ Role: "system",
+ Content: interrupt,
+ })
+ }
+
+ eb := map[string]interface{}{}
+ if !a.thinkingEnabled {
+ eb["thinking"] = map[string]interface{}{"type": "disabled"}
+ }
+ req := &agentAPI.CompletionRequest{
+ Messages: msgs,
+ MaxTokens: 4096,
+ Tools: tools,
+ ToolChoice: "auto",
+ ExtraBody: eb,
+ }
+
+ var providers []agentAPI.Provider
+ if a.providerManager != nil {
+ allProviders := a.providerManager.OrderedProviders()
+ providers = make([]agentAPI.Provider, 0, len(allProviders))
+ for _, p := range allProviders {
+ if a.providerManager.IsAvailable(p.Name()) {
+ providers = append(providers, p)
+ }
+ }
+ }
+ if len(providers) == 0 {
+ providers = []agentAPI.Provider{a.provider}
+ }
+ var resp *agentAPI.CompletionResponse
+ var llmErr error
+
+ for pi, fbProvider := range providers {
+ if pi > 0 {
+ log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
+ fbProvider.Name(), pi, len(providers)-1)
+ }
+
+ fCtx, fCancel := context.WithCancel(a.ctx)
+ a.llmMu.Lock()
+ a.cancelLLM = fCancel
+ a.llmMu.Unlock()
+
+ resp, llmErr = fbProvider.Chat(fCtx, req)
+
+ a.llmMu.Lock()
+ a.cancelLLM = nil
+ a.llmMu.Unlock()
+ fCancel()
+
+ if llmErr == nil {
+ a.providerManager.ResetAvailability(fbProvider.Name())
+ if fbProvider != a.provider {
+ a.provider = fbProvider
+ log.Printf("[agent] switched active provider to %q after fallback",
+ fbProvider.Name())
+ }
+ break
+ }
+
+ if errors.Is(llmErr, context.Canceled) {
+ break
+ }
+ var pe *agentAPI.ProviderError
+ if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
+ a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
+ log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
+ } else {
+ a.providerManager.MarkUnavailable(fbProvider.Name())
+ }
+ log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
+ }
+
+ 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")
+ }
+ continue
+ }
+ return "", toolsUsed, fmt.Errorf("all %d providers failed, last error: %w",
+ len(providers), llmErr)
+ }
+
+ stageCtx.LLMText = resp.Content
+ stageCtx.ReasoningContent = resp.ReasoningContent
+ stageCtx.TokenUsage = map[string]int{
+ "prompt_tokens": resp.TokenUsage.Prompt,
+ "completion_tokens": resp.TokenUsage.Completion,
+ "total_tokens": resp.TokenUsage.Total,
+ }
+ stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
+ for i := range stageCtx.ToolCalls {
+ if stageCtx.ToolCalls[i].Plugin == "" {
+ stageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(stageCtx.ToolCalls[i].Name)
+ }
+ }
+ if a.runStage(sdk.StagePostAction, stageCtx) {
+ return *stageCtx.Response, toolsUsed, nil
+ }
+ resp.Content = stageCtx.LLMText
+ resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
+
+ chainPayload := map[string]interface{}{
+ "content": resp.Content,
+ "reasoning": resp.ReasoningContent,
+ "tool_calls": resp.ToolCalls,
+ "phase": "intermediate",
+ "turn": turn,
+ }
+ if resp.TokenUsage.Total > 0 {
+ chainPayload["usage"] = map[string]int{
+ "prompt": resp.TokenUsage.Prompt,
+ "completion": resp.TokenUsage.Completion,
+ "total": resp.TokenUsage.Total,
+ }
+ }
+ a.publishEvent(events.EventAgentLLMChain, chainPayload)
+
+ if len(resp.ToolCalls) == 0 {
+ return resp.Content, toolsUsed, nil
+ }
+
+ for _, tc := range resp.ToolCalls {
+ if len(a.interceptCh) > 0 {
+ for _, interrupt := range a.drainInterrupts() {
+ msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
+ }
+ a.publishEvent(events.EventToolCall, map[string]interface{}{
+ "tool": tc.Name,
+ "plugin": a.resolveToolPlugin(tc.Name),
+ "args": tc.Arguments,
+ "status": "interrupted",
+ "reason": "user interrupt before execution",
+ })
+ break
+ }
+
+ toolsUsed = append(toolsUsed, tc.Name)
+ pluginName := a.resolveToolPlugin(tc.Name)
+ log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
+
+ sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
+ stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
+ stageCtx.ToolResults = nil
+ if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
+ result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
+ msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
+ msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
+ a.publishEvent(events.EventToolCall, map[string]interface{}{
+ "tool": tc.Name,
+ "plugin": pluginName,
+ "args": tc.Arguments,
+ "result": result,
+ "status": "denied",
+ })
+ continue
+ }
+ tc.Arguments = stageCtx.ToolCalls[0].Arguments
+
+ if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
+ result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
+ log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
+ msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
+ msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
+ continue
+ }
+
+ result := a.executeToolCall(tc)
+ 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}}
+ a.runStage(sdk.StageAfterToolcall, stageCtx)
+ if len(stageCtx.ToolResults) > 0 {
+ if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
+ result = r
+ }
+ }
+
+ argsJSON, _ := json.Marshal(tc.Arguments)
+ a.recordToolCall(tc.Name, string(argsJSON), result)
+
+ msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{tc}})
+ msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
+
+ a.publishEvent(events.EventToolCall, map[string]interface{}{
+ "tool": tc.Name,
+ "plugin": pluginName,
+ "args": tc.Arguments,
+ "result": result,
+ "status": "ok",
+ })
+
+ if len(a.interceptCh) > 0 {
+ for _, interrupt := range a.drainInterrupts() {
+ msgs = append(msgs, agentAPI.Message{Role: "system", Content: interrupt})
+ }
+ break
+ }
+ }
+ }
+}
+
+func convertToolCalls(tcs []agentAPI.ToolCall) []sdk.ToolCall {
+ if tcs == nil {
+ return nil
+ }
+ result := make([]sdk.ToolCall, len(tcs))
+ for i, tc := range tcs {
+ result[i] = sdk.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
+ }
+ return result
+}
+
+func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
+ if tcs == nil {
+ return nil
+ }
+ result := make([]agentAPI.ToolCall, len(tcs))
+ for i, tc := range tcs {
+ result[i] = agentAPI.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: tc.Arguments}
+ }
+ return result
+}
+
+func (a *Agent) docStoreSize() int {
+ if a.docStore == nil {
+ return 0
+ }
+ s := a.docStore.Stats()
+ if n, ok := s["doc_count"]; ok {
+ if ni, ok := n.(int); ok {
+ return ni
+ }
+ }
+ return 0
+}
+
+func (a *Agent) recordToolCall(name, args, result string) {
+ a.toolCallRingMu.Lock()
+ defer a.toolCallRingMu.Unlock()
+
+ if len(args) > 200 {
+ args = args[:200] + "..."
+ }
+
+ var resultStub string
+ var fullResult string
+ if len(a.toolCallRing) < 5 {
+ fullResult = result
+ }
+ if len(result) > 80 {
+ resultStub = result[:80] + "..."
+ } else {
+ resultStub = result
+ }
+
+ rec := ToolCallRecord{
+ Timestamp: time.Now(),
+ Name: name,
+ Args: args,
+ ResultStub: resultStub,
+ FullResult: fullResult,
+ }
+
+ if len(a.toolCallRing) >= a.toolCallRingMax {
+ a.toolCallRing = a.toolCallRing[1:]
+ }
+ a.toolCallRing = append(a.toolCallRing, rec)
+}
+
+func (a *Agent) formatToolCallRing() string {
+ a.toolCallRingMu.Lock()
+ defer a.toolCallRingMu.Unlock()
+
+ if len(a.toolCallRing) == 0 {
+ return ""
+ }
+
+ var sb strings.Builder
+ sb.WriteString("【已执行工具记录(最近40条)】\n")
+ start := 0
+ if len(a.toolCallRing) > 40 {
+ start = len(a.toolCallRing) - 40
+ }
+ for i, rec := range a.toolCallRing[start:] {
+ if len(rec.FullResult) > 0 {
+ sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s)=%s\n", i+1,
+ rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args,
+ truncateStr(rec.FullResult, 120)))
+ } else {
+ sb.WriteString(fmt.Sprintf(" [%d] %s: %s(%s) → (已缓存,具体结果通过文本记忆层获取)\n", i+1,
+ rec.Timestamp.Format("15:04:05"), rec.Name, rec.Args))
+ }
+ }
+ return sb.String()
+}
+
+func (a *Agent) formatMergedTimeline() string {
+ a.context.mu.Lock()
+ events := make([]*ContextEvent, len(a.context.events))
+ copy(events, a.context.events)
+ a.context.mu.Unlock()
+
+ a.toolCallRingMu.Lock()
+ ring := make([]ToolCallRecord, len(a.toolCallRing))
+ copy(ring, a.toolCallRing)
+ a.toolCallRingMu.Unlock()
+
+ if len(events) == 0 && len(ring) == 0 {
+ return ""
+ }
+
+ type timelineEntry struct {
+ ts time.Time
+ label string
+ text string
+ }
+ entries := make([]timelineEntry, 0, len(events)+len(ring))
+
+ for _, e := range events {
+ text := fmt.Sprintf("[对话] %s: %s", e.Source, e.Input)
+ if len(e.ToolsUsed) > 0 {
+ text += fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", "))
+ }
+ if e.Response != "" {
+ text += fmt.Sprintf(" → %s", truncateStr(e.Response, 120))
+ }
+ entries = append(entries, timelineEntry{ts: e.Timestamp, label: "对话", text: text})
+ }
+
+ for _, r := range ring {
+ text := fmt.Sprintf("[工具] %s(%s)", r.Name, r.Args)
+ if r.FullResult != "" {
+ text += fmt.Sprintf(" = %s", truncateStr(r.FullResult, 120))
+ } else {
+ text += " → (结果已缓存,可通过文本记忆层获取)"
+ }
+ entries = append(entries, timelineEntry{ts: r.Timestamp, label: "工具", text: text})
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].ts.Before(entries[j].ts)
+ })
+
+ var sb strings.Builder
+ sb.WriteString("【对话时序】\n")
+ for _, e := range entries {
+ sb.WriteString(fmt.Sprintf("[%s] %s\n", e.ts.Format("15:04:05"), e.text))
+ }
+ return sb.String()
+}
+
+func (a *Agent) buildMessages(sysPrompt, input string) []agentAPI.Message {
+ msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}}
+
+ if ctxStr := a.formatMergedTimeline(); ctxStr != "" {
+ msgs = append(msgs, agentAPI.Message{Role: "system", Content: ctxStr})
+ }
+
+ msgs = append(msgs, agentAPI.Message{Role: "user", Content: input})
+ return msgs
+}
diff --git a/internal/agent/core/spawn.go b/internal/agent/core/spawn.go
new file mode 100644
index 0000000..788293a
--- /dev/null
+++ b/internal/agent/core/spawn.go
@@ -0,0 +1,179 @@
+package core
+
+import (
+ "fmt"
+ "log"
+ "strings"
+
+ agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
+)
+
+func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
+ task, _ := tc.Arguments["task"].(string)
+ if task == "" {
+ return "请提供 task 参数"
+ }
+
+ a.childMu.Lock()
+ a.childNextID++
+ taskID := fmt.Sprintf("child_%d", a.childNextID)
+ a.childMu.Unlock()
+
+ go a.runChildTask(taskID, task)
+
+ return fmt.Sprintf("子任务已启动(ID: %s),完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID)
+}
+
+func (a *Agent) runChildTask(taskID, task string) {
+ if a.provider == nil {
+ log.Printf("[child] %s failed: no LLM provider configured", taskID)
+ return
+ }
+ log.Printf("[child] %s started: %s", taskID, truncateStr(task, 80))
+
+ sysPrompt := fmt.Sprintf(`你是 HomeAgent 的子任务助手。
+请完成以下任务。完成即可,无需保留记忆或查询历史。
+任务: %s`, task)
+
+ msgs := []agentAPI.Message{
+ {Role: "system", Content: sysPrompt},
+ {Role: "user", Content: task},
+ }
+
+ allTools := a.buildToolDefs()
+ childTools := make([]interface{}, 0, len(allTools))
+ for _, t := range allTools {
+ toolMap, ok := t.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ fn, ok := toolMap["function"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ name, _ := fn["name"].(string)
+ if strings.HasPrefix(name, "output_send__") || name == "output_list_channels" || name == "spawn_child" || name == "plgreload" {
+ continue
+ }
+ childTools = append(childTools, t)
+ }
+
+ var finalResult string
+ for turn := 0; turn < 5; turn++ {
+ eb := map[string]interface{}{}
+ if !a.thinkingEnabled {
+ eb["thinking"] = map[string]interface{}{"type": "disabled"}
+ }
+ req := &agentAPI.CompletionRequest{
+ Messages: msgs,
+ MaxTokens: 4096,
+ Tools: childTools,
+ ToolChoice: "auto",
+ ExtraBody: eb,
+ }
+
+ resp, err := a.provider.Chat(a.ctx, req)
+ if err != nil {
+ finalResult = fmt.Sprintf("子 Agent 执行失败: %v", err)
+ break
+ }
+
+ if len(resp.ToolCalls) == 0 {
+ finalResult = resp.Content
+ break
+ }
+
+ for _, ct := range resp.ToolCalls {
+ var result string
+ switch {
+ case strings.HasPrefix(ct.Name, "output_send__") || ct.Name == "output_list_channels":
+ result = fmt.Sprintf("子 Agent 不允许调用输出工具: %s", ct.Name)
+ case ct.Name == "spawn_child" || ct.Name == "plgreload":
+ result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
+ default:
+ result = a.executeToolCall(ct)
+ }
+ msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
+ msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
+ }
+ }
+
+ if finalResult == "" {
+ finalResult = "子 Agent 执行超时(超过 5 轮)"
+ }
+
+ a.childMu.Lock()
+ a.childResults[taskID] = finalResult
+ a.childMu.Unlock()
+
+ log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
+
+ notification := fmt.Sprintf("子任务 %s 已完成,请调用 child_result 工具查看输出", taskID)
+ select {
+ case a.selfInputCh <- notification:
+ default:
+ log.Printf("[child] self input channel full, dropping notification for %s", taskID)
+ }
+}
+
+func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
+ taskID, _ := tc.Arguments["task_id"].(string)
+ if taskID == "" {
+ return "请提供 task_id 参数"
+ }
+
+ a.childMu.Lock()
+ result, ok := a.childResults[taskID]
+ if !ok {
+ a.childMu.Unlock()
+
+ a.childMu.Lock()
+ _, exists := a.childResults[taskID]
+ a.childMu.Unlock()
+ if !exists {
+ return fmt.Sprintf("子任务 %s 不存在或已过期", taskID)
+ }
+ }
+ delete(a.childResults, taskID)
+ a.childMu.Unlock()
+
+ return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
+}
+
+func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
+ if a.providerManager == nil {
+ return "LLM 源管理器不可用"
+ }
+ switch tc.Name {
+ case "llm_list_sources":
+ sources := a.providerManager.List()
+ if len(sources) == 0 {
+ return "没有可用的 LLM 源"
+ }
+ parts := []string{"可用 LLM 源:"}
+ for _, name := range sources {
+ mark := " "
+ if p := a.providerManager.Get(""); p != nil && p.Name() == name {
+ mark = "→"
+ }
+ parts = append(parts, fmt.Sprintf(" %s %s", mark, name))
+ }
+ return strings.Join(parts, "\n")
+
+ case "llm_set_source":
+ name, _ := tc.Arguments["name"].(string)
+ if name == "" {
+ return "请提供源名称"
+ }
+ if err := a.providerManager.SetDefault(name); err != nil {
+ return fmt.Sprintf("切换失败: %v", err)
+ }
+ a.provider = a.providerManager.Get(name)
+ return fmt.Sprintf("已切换到 LLM 源: %s", name)
+
+ default:
+ return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
+ }
+}
+
+
diff --git a/internal/agent/core/stage.go b/internal/agent/core/stage.go
new file mode 100644
index 0000000..a7394f0
--- /dev/null
+++ b/internal/agent/core/stage.go
@@ -0,0 +1,72 @@
+package core
+
+import (
+ "fmt"
+ "log"
+ "runtime/debug"
+ "time"
+
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/events"
+ sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
+)
+
+func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
+ if a.stageHost == nil {
+ return false
+ }
+ ctx.Phase = stage
+ func() {
+ defer func() {
+ if r := recover(); r != nil {
+ log.Printf("[agent] stage %q plugin panic: %v\n%s", stage, r, debug.Stack())
+ }
+ }()
+ a.stageHost.RunStage(stage, ctx)
+ }()
+ return ctx.Response != nil
+}
+
+func (a *Agent) publishEvent(evtType events.EventType, payload map[string]interface{}) {
+ if a.eventBus == nil {
+ return
+ }
+ a.eventBus.Publish(&events.Event{
+ Type: evtType,
+ Source: string(a.id),
+ Payload: payload,
+ Timestamp: time.Now().Unix(),
+ })
+}
+
+func (a *Agent) stageCtxFromInput(input, userID, groupID string) *sdk.StageContext {
+ return &sdk.StageContext{
+ RawMessage: input,
+ UserID: userID,
+ GroupID: groupID,
+ Phase: sdk.StageOnInput,
+ Extra: make(map[string]interface{}),
+ }
+}
+
+func (a *Agent) injectSourceContext(stageCtx *sdk.StageContext, evt *agentIO.InputEvent) {
+ if stageCtx == nil || evt == nil {
+ return
+ }
+ source := evt.Source
+ if source == "" {
+ source = "unknown"
+ }
+ channel := evt.OutputChannel
+ if channel == "" {
+ channel = source
+ }
+ content := fmt.Sprintf("当前输入来源: %s;默认输出通道: %s。", source, channel)
+ if flag, _ := evt.Payload["interrupt"].(bool); flag {
+ content = fmt.Sprintf("这是一条打断输入。来源: %s;默认输出通道: %s。", source, channel)
+ }
+ stageCtx.ContextMsgs = append(stageCtx.ContextMsgs, map[string]interface{}{
+ "role": "system",
+ "content": content,
+ })
+}
diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go
new file mode 100644
index 0000000..e1f3525
--- /dev/null
+++ b/internal/agent/core/toolcall.go
@@ -0,0 +1,549 @@
+package core
+
+import (
+ "fmt"
+ "log"
+ "runtime/debug"
+ "strings"
+ "time"
+
+ agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
+ "gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
+)
+
+func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
+ defer func() {
+ if r := recover(); r != nil {
+ stack := debug.Stack()
+ log.Printf("[agent] tool %s panic: %v\n%s", tc.Name, r, stack)
+
+ if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
+ if a.pluginHealth.recordCrash(pluginName) {
+ log.Printf("[agent] plugin %s exceeded crash threshold, scheduling reload", pluginName)
+ }
+ }
+
+ ret = fmt.Sprintf("工具 %s 执行崩溃: %v", tc.Name, r)
+ }
+ }()
+
+ done := make(chan string, 1)
+ go func() {
+ done <- a.executeToolCallInner(tc)
+ }()
+
+ select {
+ case result := <-done:
+ return result
+ case <-time.After(60 * time.Second):
+ log.Printf("[agent] tool %s timed out after 60s", tc.Name)
+ return fmt.Sprintf("工具 %s 执行超时(60秒),已取消", tc.Name)
+ }
+}
+
+func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
+ switch {
+ case strings.HasPrefix(tc.Name, "memory_"):
+ return a.executeMemoryTool(tc)
+ case strings.HasPrefix(tc.Name, "social_"):
+ return a.executeSocialTool(tc)
+ case strings.HasPrefix(tc.Name, "knowledge_"):
+ return a.executeKnowledgeTool(tc)
+ case strings.HasPrefix(tc.Name, "doc_"):
+ return a.executeDocTool(tc)
+ case strings.HasPrefix(tc.Name, "output_send__") && strings.HasSuffix(tc.Name, "_help"):
+ return a.executeOutputSendHelp(tc)
+ case strings.HasPrefix(tc.Name, "output_send__"):
+ return a.executeOutputSendTool(tc)
+ case tc.Name == "output_list_channels":
+ return a.executeOutputListChannels()
+ case tc.Name == "plgreload":
+ return a.executePluginReload()
+ case tc.Name == "spawn_child":
+ return a.executeSpawnChild(tc)
+ case tc.Name == "child_result":
+ return a.executeChildResultTool(tc)
+ case strings.HasPrefix(tc.Name, "llm_"):
+ return a.executeLLMTool(tc)
+ case tc.Name == "describe_image":
+ return a.executeDescribeImage(tc)
+ case tc.Name == "transcribe_audio":
+ return a.executeTranscribeAudio(tc)
+ case tc.Name == "ocr_image":
+ return a.executeOCRImage(tc)
+ }
+
+ if a.stageHost != nil {
+ if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
+ return fmt.Sprintf("%v", result)
+ } else if !strings.Contains(err.Error(), "not found in any plugin") {
+ return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
+ }
+ }
+
+ if a.tracker != nil {
+ a.tracker.PreAction(tc.Name)
+ }
+ result, err := a.io.ExecuteTool(tc.Name, tc.Arguments)
+ if a.tracker != nil {
+ if cs := a.tracker.PostAction(tc.Name); cs != nil && len(cs.Files) > 0 {
+ log.Printf("[agent] tool %s changed %d files (changeset: %s)", tc.Name, len(cs.Files), cs.ID)
+ }
+ }
+ if err != nil {
+ return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
+ }
+ return fmt.Sprintf("%v", result)
+}
+
+func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
+ if a.memory == nil {
+ if tc.Name == "memory_document_query" {
+ return a.executeDocTool(tc)
+ }
+ return "图记忆系统不可用"
+ }
+ switch tc.Name {
+ case "memory_recall":
+ query, _ := tc.Arguments["query_intent"].(string)
+ depth, _ := tc.Arguments["depth"].(float64)
+ if depth <= 0 {
+ depth = 2
+ }
+ if query == "" {
+ return "请输入查询关键词"
+ }
+ result, err := a.memory.Recall(strings.Split(query, ","), nil, int(depth), "")
+ if err != nil {
+ return fmt.Sprintf("记忆检索失败: %v", err)
+ }
+ if len(result.Entities) == 0 && len(result.Relations) == 0 {
+ return "未找到相关记忆"
+ }
+ if a.indexer != nil {
+ names := make([]string, len(result.Entities))
+ for i, e := range result.Entities {
+ names[i] = e.Name
+ }
+ a.indexer.MarkRecalled(names...)
+ }
+ var parts []string
+ parts = append(parts, fmt.Sprintf("找到 %d 个相关实体:", len(result.Entities)))
+ for _, e := range result.Entities {
+ parts = append(parts, fmt.Sprintf("- %s (提及%d次, 类型:%s)", e.Name, e.MentionCount, e.Type))
+ }
+ parts = append(parts, fmt.Sprintf("找到 %d 条关系:", len(result.Relations)))
+ for i, r := range result.Relations {
+ if i >= 10 {
+ parts = append(parts, "...更多关系被截断")
+ break
+ }
+ parts = append(parts, fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName))
+ }
+ return strings.Join(parts, "\n")
+
+ case "memory_block_merge":
+ entityA, _ := tc.Arguments["entity_a"].(string)
+ entityB, _ := tc.Arguments["entity_b"].(string)
+ rounds, _ := tc.Arguments["rounds"].(float64)
+ if entityA == "" || entityB == "" || rounds <= 0 {
+ return "entity_a、entity_b 和 rounds 不能为空"
+ }
+ if entityA > entityB {
+ entityA, entityB = entityB, entityA
+ }
+ key := entityA + "||" + entityB
+ a.noMergeMu.Lock()
+ a.noMergeMarkers[key] = int(rounds)
+ a.noMergeMu.Unlock()
+ return fmt.Sprintf("已标记「%s」与「%s」在 %d 轮内不合并", entityA, entityB, int(rounds))
+
+ case "memory_commit":
+ triplesData, ok := tc.Arguments["triples"].([]interface{})
+ if !ok {
+ return "参数格式错误,需要 triples 数组"
+ }
+ var triples []memory.Triple
+ for _, td := range triplesData {
+ if m, ok := td.(map[string]interface{}); ok {
+ t := memory.Triple{
+ Subject: getString(m, "subject"),
+ Relation: getString(m, "relation"),
+ Object: getString(m, "object"),
+ }
+ if t.Subject != "" && t.Relation != "" && t.Object != "" {
+ triples = append(triples, t)
+ }
+ }
+ }
+ if len(triples) == 0 {
+ return "没有有效的三元组"
+ }
+ ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
+ if err != nil {
+ return fmt.Sprintf("记忆写入失败: %v", err)
+ }
+ return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
+
+ case "memory_introspect":
+ stats, err := a.memory.Introspect()
+ if err != nil {
+ return fmt.Sprintf("查询失败: %v", err)
+ }
+ return fmt.Sprintf("记忆统计: %v", stats)
+
+ case "memory_document_query":
+ return a.executeDocTool(tc)
+
+ case "memory_merge":
+ source, _ := tc.Arguments["source"].(string)
+ target, _ := tc.Arguments["target"].(string)
+ if source == "" || target == "" {
+ return "source 和 target 不能为空"
+ }
+ count, err := a.memory.MergeEntities(source, target)
+ if err != nil {
+ return fmt.Sprintf("合并失败: %v", err)
+ }
+ return fmt.Sprintf("已将「%s」合并到「%s」,source 已彻底删除,%d 条关系已重定向", source, target, count)
+
+ case "memory_delete_entity":
+ name, _ := tc.Arguments["name"].(string)
+ if name == "" {
+ return "name 不能为空"
+ }
+ if err := a.memory.DeleteEntity(name); err != nil {
+ return fmt.Sprintf("删除失败: %v", err)
+ }
+ return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
+
+ case "memory_purge":
+ criteria := make(map[string]string)
+ if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
+ criteria["subject_contains"] = v
+ }
+ if v, ok := tc.Arguments["relation_type"].(string); ok && v != "" {
+ criteria["relation_type"] = v
+ }
+ if v, ok := tc.Arguments["target_contains"].(string); ok && v != "" {
+ criteria["target_contains"] = v
+ }
+ mode, _ := tc.Arguments["mode"].(string)
+ if mode == "" {
+ mode = "soft"
+ }
+ n, err := a.memory.Purge(criteria, mode)
+ if err != nil {
+ return fmt.Sprintf("删除图记忆失败: %v", err)
+ }
+
+ textRemoved := 0
+ if a.textMem != nil {
+ if subj, ok := criteria["subject_contains"]; ok && subj != "" {
+ textRemoved, _ = a.textMem.PurgeByFilter(func(evt text.Event) bool {
+ return strings.Contains(evt.Source, subj) || strings.Contains(evt.Input, subj) || strings.Contains(evt.Response, subj)
+ })
+ }
+ }
+ parts := []string{fmt.Sprintf("已%s删除 %d 条图记忆关系", mode, n)}
+ if textRemoved > 0 {
+ parts = append(parts, fmt.Sprintf("清理 %d 条文本记忆日志", textRemoved))
+ }
+ return strings.Join(parts, ",")
+
+ case "memory_edit":
+ oldSubject, _ := tc.Arguments["old_subject"].(string)
+ oldRelation, _ := tc.Arguments["old_relation"].(string)
+ oldObject, _ := tc.Arguments["old_object"].(string)
+ if oldSubject == "" || oldRelation == "" || oldObject == "" {
+ return "old_subject、old_relation、old_object 不能为空"
+ }
+ newSubject, _ := tc.Arguments["new_subject"].(string)
+ newRelation, _ := tc.Arguments["new_relation"].(string)
+ newObject, _ := tc.Arguments["new_object"].(string)
+ if newSubject == "" && newRelation == "" && newObject == "" {
+ return "至少提供一个新值(new_subject / new_relation / new_object)"
+ }
+ if newSubject == "" {
+ newSubject = oldSubject
+ }
+ if newRelation == "" {
+ newRelation = oldRelation
+ }
+ if newObject == "" {
+ newObject = oldObject
+ }
+ n, err := a.memory.Purge(map[string]string{
+ "subject_contains": oldSubject,
+ "relation_type": oldRelation,
+ "target_contains": oldObject,
+ }, "hard")
+ if err != nil {
+ return fmt.Sprintf("编辑图记忆失败(删除旧记录): %v", err)
+ }
+ triples := []memory.Triple{{
+ Subject: newSubject,
+ Relation: newRelation,
+ Object: newObject,
+ }}
+ ec, rc, err := a.memory.Commit(triples, string(a.id), 0)
+ if err != nil {
+ return fmt.Sprintf("编辑图记忆失败(写入新记录): %v", err)
+ }
+
+ textReplaced := 0
+ if a.textMem != nil && oldSubject != "" {
+ textReplaced, _ = a.textMem.ReplaceByFilter(
+ func(evt text.Event) bool {
+ return strings.Contains(evt.Input, oldSubject) || strings.Contains(evt.Response, oldSubject)
+ },
+ func(evt text.Event) text.Event {
+ evt.Input = strings.ReplaceAll(evt.Input, oldSubject, newSubject)
+ evt.Response = strings.ReplaceAll(evt.Response, oldSubject, newSubject)
+ return evt
+ },
+ )
+ }
+ result := fmt.Sprintf("已编辑记忆:删除 %d 条旧关系,写入 %d 个实体 + %d 条新关系", n, ec, rc)
+ if textReplaced > 0 {
+ result += fmt.Sprintf(",更新 %d 条文本记忆日志", textReplaced)
+ }
+ return result
+
+ default:
+ return fmt.Sprintf("未知的记忆工具: %s", tc.Name)
+ }
+}
+
+func (a *Agent) executeSocialTool(tc agentAPI.ToolCall) string {
+ if a.social == nil {
+ return "人物关系网不可用(social store 未初始化)"
+ }
+ switch tc.Name {
+ case "person_query":
+ name, _ := tc.Arguments["name"].(string)
+ if name == "" {
+ return "请输入人物名称"
+ }
+ profile, err := a.social.GetPerson(name)
+ if err != nil {
+ return fmt.Sprintf("查询人物失败: %v", err)
+ }
+ var parts []string
+ parts = append(parts, fmt.Sprintf("▎%s 的档案", name))
+ if len(profile.Traits) > 0 {
+ parts = append(parts, "【特质】")
+ for k, v := range profile.Traits {
+ parts = append(parts, fmt.Sprintf(" %s: %s", k, v))
+ }
+ }
+ if len(profile.Relations) > 0 {
+ parts = append(parts, "【社交关系】")
+ for _, r := range profile.Relations {
+ parts = append(parts, fmt.Sprintf(" %s —(%s)—→ %s", name, r.Relation, r.Person))
+ }
+ }
+ if len(profile.Traits) == 0 && len(profile.Relations) == 0 {
+ parts = append(parts, " (尚无记录)")
+ }
+ return strings.Join(parts, "\n")
+
+ case "person_set_trait":
+ name, _ := tc.Arguments["name"].(string)
+ trait, _ := tc.Arguments["trait"].(string)
+ value, _ := tc.Arguments["value"].(string)
+ if name == "" || trait == "" || value == "" {
+ return "name、trait、value 都不能为空"
+ }
+ if err := a.social.SetTrait(name, trait, value); err != nil {
+ return fmt.Sprintf("设置特质失败: %v", err)
+ }
+ return fmt.Sprintf("已记录:%s 的 %s = %s", name, trait, value)
+
+ case "person_relate":
+ personA, _ := tc.Arguments["person_a"].(string)
+ relation, _ := tc.Arguments["relation"].(string)
+ personB, _ := tc.Arguments["person_b"].(string)
+ if personA == "" || relation == "" || personB == "" {
+ return "person_a、relation、person_b 都不能为空"
+ }
+ if err := a.social.AddRelation(personA, relation, personB); err != nil {
+ return fmt.Sprintf("建立关系失败: %v", err)
+ }
+ return fmt.Sprintf("已记录:%s —(%s)—→ %s", personA, relation, personB)
+
+ case "person_network":
+ name, _ := tc.Arguments["name"].(string)
+ depth := int(getFloat(tc.Arguments, "depth"))
+ if depth <= 0 {
+ depth = 2
+ }
+ if name == "" {
+ return "请输入人物名称"
+ }
+ profiles, err := a.social.GetNetwork(name, depth)
+ if err != nil {
+ return fmt.Sprintf("查询社交网络失败: %v", err)
+ }
+ if len(profiles) == 0 {
+ return fmt.Sprintf("未找到 %s 的社交网络", name)
+ }
+ var parts []string
+ parts = append(parts, fmt.Sprintf("▎%s 的社交网络(%d 度)", name, depth))
+ for _, p := range profiles {
+ if p.Name == name {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf(" · %s", p.Name))
+ for k, v := range p.Traits {
+ parts = append(parts, fmt.Sprintf(" %s: %s", k, v))
+ }
+ for _, r := range p.Relations {
+ if r.Person != name {
+ parts = append(parts, fmt.Sprintf(" —(%s)—→ %s", r.Relation, r.Person))
+ }
+ }
+ }
+ return strings.Join(parts, "\n")
+
+ default:
+ return fmt.Sprintf("未知的人物工具: %s", tc.Name)
+ }
+}
+
+func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
+ if a.knowledge == nil {
+ return "知识库不可用"
+ }
+ switch tc.Name {
+ case "knowledge_search":
+ query, _ := tc.Arguments["query"].(string)
+ topK := int(getFloat(tc.Arguments, "top_k"))
+ if topK <= 0 {
+ topK = 5
+ }
+ if query == "" {
+ return "请输入查询关键词"
+ }
+ results := a.knowledge.Search(query, topK)
+ if len(results) == 0 {
+ return "未找到相关知识"
+ }
+ var parts []string
+ for i, k := range results {
+ if i >= topK {
+ break
+ }
+ label := k.Name
+ if k.Category != "" {
+ label = k.Category + "/" + k.Name
+ }
+ parts = append(parts, fmt.Sprintf("[%s]\n%s", label, truncateStr(k.Content, 200)))
+ }
+ return strings.Join(parts, "\n---\n")
+
+ case "knowledge_create":
+ name, _ := tc.Arguments["name"].(string)
+ content, _ := tc.Arguments["content"].(string)
+ if name == "" || content == "" {
+ return "name 和 content 不能为空"
+ }
+ if err := a.knowledge.Add(name, content); err != nil {
+ return fmt.Sprintf("知识创建失败: %v", err)
+ }
+ return fmt.Sprintf("知识「%s」已创建并向量化索引(%d 字符)", name, len(content))
+
+ case "knowledge_list":
+ tree := a.knowledge.BuildTree()
+ return formatTree(tree, 0)
+
+ case "knowledge_delete":
+ name, _ := tc.Arguments["name"].(string)
+ if name == "" {
+ return "name 不能为空"
+ }
+ if err := a.knowledge.Remove(name); err != nil {
+ return fmt.Sprintf("知识删除失败: %v", err)
+ }
+ return fmt.Sprintf("知识「%s」已删除", name)
+
+ default:
+ return fmt.Sprintf("未知的知识工具: %s", tc.Name)
+ }
+}
+
+func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
+ if a.docStore == nil {
+ return "文档记忆不可用"
+ }
+ switch tc.Name {
+ case "doc_query":
+ query, _ := tc.Arguments["query"].(string)
+ topK := int(getFloat(tc.Arguments, "top_k"))
+ if topK <= 0 {
+ topK = 3
+ }
+ if query == "" {
+ return "请输入查询内容"
+ }
+ docs := a.docStore.Consume(query, topK)
+ if len(docs) == 0 {
+ return "未找到相关文档记忆"
+ }
+ var parts []string
+ var refs []string
+ for i, d := range docs {
+ parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
+ if len(d.Tags) > 0 {
+ parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
+ }
+ content := d.Content
+ if len(content) > 2000 {
+ content = content[:2000] + "..."
+ }
+ a.context.Append(ContextEvent{
+ Timestamp: d.CreatedAt,
+ Source: "cold_storage",
+ Input: fmt.Sprintf("加载文档记忆: %s", query),
+ Response: content,
+ })
+ refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
+ }
+ return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
+ len(docs), strings.Join(refs, ", "))
+
+ case "doc_commit":
+ content, _ := tc.Arguments["content"].(string)
+ summary, _ := tc.Arguments["summary"].(string)
+ if content == "" {
+ return "content 不能为空"
+ }
+ if summary == "" {
+ summary = truncateStr(content, 100)
+ }
+
+ tagsRaw, _ := tc.Arguments["tags"].([]interface{})
+ var tags []string
+ for _, t := range tagsRaw {
+ if s, ok := t.(string); ok {
+ tags = append(tags, s)
+ }
+ }
+
+ doc := &document.Doc{
+ Summary: summary,
+ Content: content,
+ Tags: tags,
+ Source: "manual",
+ }
+ if err := a.docStore.Insert(doc); err != nil {
+ return fmt.Sprintf("文档写入失败: %v", err)
+ }
+ return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
+
+ default:
+ return fmt.Sprintf("未知的文档工具: %s", tc.Name)
+ }
+}
diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go
new file mode 100644
index 0000000..764f6e5
--- /dev/null
+++ b/internal/agent/core/tooldefs.go
@@ -0,0 +1,608 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+
+ agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
+)
+
+func (a *Agent) buildMemoryContext(input string) string {
+ if a.indexer == nil {
+ return ""
+ }
+ injected := a.indexer.BuildContext(input)
+ return a.indexer.FormatContext(injected)
+}
+
+func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
+ prompt := a.systemPrompt
+ if prompt == "" {
+ prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
+ }
+
+ if a.personality != nil {
+ if pp := a.personality.InjectPrompt(); pp != "" {
+ prompt += "\n\n" + pp
+ }
+ }
+
+ if memContext != "" {
+ prompt += "\n\n" + memContext
+ }
+
+ prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
+
+ if a.docStore != nil {
+ docs := a.docStore.Query(userInput, 3)
+ if len(docs) > 0 {
+ var parts []string
+ parts = append(parts, "【相关记忆文档】")
+ for i, d := range docs {
+ parts = append(parts, fmt.Sprintf(" [%d] %s", i+1, d.Summary))
+ }
+ prompt += "\n\n" + strings.Join(parts, "\n")
+ }
+ }
+
+ prompt += "\n\n【输出规则】你有多组输出门工具(type=output),每个对应一个输出通道。回复用户时必须调用对应的 output_send__{通道名} 工具。\n"
+ prompt += "- payload 参数是消息载荷(文本直接填文字),type 指定载荷类型(text/voice/image/file),meta 是 JSON 发送元数据(群号/用户号等)。\n"
+ prompt += "- 用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举。\n"
+ prompt += "- 同一轮对话中可多次调用输出门工具。长消息应当分多次发出,而不是一口气发完。\n"
+ prompt += "- 直接返回纯文本不会到达任何用户端。"
+
+ if a.skills != nil {
+ if sp := a.skills.GetInjectedPrompt(); sp != "" {
+ prompt += "\n\n" + sp
+ }
+ }
+
+ if a.indexer != nil {
+ prompt += "\n\n" + a.indexer.BuildToolPrompt()
+ }
+
+ prompt += a.buildToolCatalog()
+
+ return prompt
+}
+
+func cleanParams(params map[string]interface{}) map[string]interface{} {
+ if params == nil {
+ return nil
+ }
+ cleaned := make(map[string]interface{}, len(params))
+ for k, v := range params {
+ cleaned[k] = v
+ }
+ if req, ok := cleaned["required"]; ok {
+ switch v := req.(type) {
+ case []interface{}:
+ if len(v) == 0 {
+ delete(cleaned, "required")
+ }
+ case []string:
+ if len(v) == 0 {
+ delete(cleaned, "required")
+ }
+ }
+ }
+ return cleaned
+}
+
+func (a *Agent) buildToolCatalog() string {
+ defs := a.buildToolDefs()
+ if len(defs) == 0 {
+ return ""
+ }
+ var sb strings.Builder
+ sb.WriteString("\n\n【可用工具列表】")
+ seen := make(map[string]bool)
+ for _, d := range defs {
+ t, ok := d.(map[string]interface{})
+ if !ok {
+ continue
+ }
+ fn, ok := t["function"].(map[string]interface{})
+ if !ok {
+ continue
+ }
+ name, _ := fn["name"].(string)
+ if name == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+ desc, _ := fn["description"].(string)
+ sb.WriteString(fmt.Sprintf("\n- %s", name))
+ if desc != "" {
+ if len(desc) > 80 {
+ desc = desc[:80] + "..."
+ }
+ sb.WriteString(": " + desc)
+ }
+ }
+ return sb.String()
+}
+
+func (a *Agent) buildToolDefs() []interface{} {
+ var tools []interface{}
+
+ if a.io != nil {
+ for _, td := range a.io.GetAllTools() {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": td.Name,
+ "description": td.Description,
+ "parameters": cleanParams(td.Parameters),
+ },
+ })
+ }
+ }
+
+ if a.stageHost != nil {
+ for _, td := range a.stageHost.GetToolDefs() {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": td.Name,
+ "description": td.Description,
+ "parameters": cleanParams(td.Parameters),
+ },
+ })
+ }
+ }
+
+ if a.indexer != nil {
+ for _, td := range a.indexer.GetToolDefinitions() {
+ tools = append(tools, td)
+ }
+ }
+
+ if a.memory != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "memory_merge",
+ "description": "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
+ "target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
+ },
+ "required": []string{"source", "target"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "memory_delete_entity",
+ "description": "【记忆清理】彻底删除指定实体及其所有关联关系。用于清理无用的噪音实体,如 mentionCount=0 的孤立实体、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
+ },
+ "required": []string{"name"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "memory_block_merge",
+ "description": "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
+ "entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
+ "rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
+ },
+ "required": []string{"entity_a", "entity_b", "rounds"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "memory_purge",
+ "description": "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
+ "relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
+ "target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
+ "mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
+ },
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "memory_edit",
+ "description": "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
+ "old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
+ "old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
+ "new_subject": map[string]interface{}{"type": "string", "description": "新主体名(不填则不变)"},
+ "new_relation": map[string]interface{}{"type": "string", "description": "新关系类型(不填则不变)"},
+ "new_object": map[string]interface{}{"type": "string", "description": "新客体名(不填则不变)"},
+ },
+ "required": []string{"old_subject", "old_relation", "old_object"},
+ },
+ },
+ })
+ }
+
+ if a.knowledge != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "knowledge_search",
+ "description": "搜索知识库。输入查询关键词,返回相关知识内容。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "query": map[string]interface{}{"type": "string", "description": "查询关键词"},
+ "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 5},
+ },
+ "required": []string{"query"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "knowledge_list",
+ "description": "列出知识库中所有知识分类。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ },
+ },
+ })
+ }
+
+ if a.knowledge != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "knowledge_create",
+ "description": "创建新知识。将知识写入知识库(knowledge/目录),自动向量化索引。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{"type": "string", "description": "知识名称(用作目录名)"},
+ "content": map[string]interface{}{"type": "string", "description": "知识内容,支持 Markdown"},
+ },
+ "required": []string{"name", "content"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "knowledge_delete",
+ "description": "删除知识库中的指定知识条目。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{"type": "string", "description": "要删除的知识名称"},
+ },
+ "required": []string{"name"},
+ },
+ },
+ })
+ }
+
+ if a.docStore != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "doc_query",
+ "description": "查询文档记忆。输入查询内容,返回相关文档摘要。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "query": map[string]interface{}{"type": "string", "description": "查询内容"},
+ "top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
+ },
+ "required": []string{"query"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "doc_commit",
+ "description": "提交一条文档记忆。将重要信息显式写入文档记忆层。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "content": map[string]interface{}{"type": "string", "description": "文档内容"},
+ "summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
+ "tags": map[string]interface{}{
+ "type": "array",
+ "description": "标签列表",
+ "items": map[string]interface{}{"type": "string"},
+ },
+ },
+ "required": []string{"content"},
+ },
+ },
+ })
+ }
+
+ if a.social != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "person_query",
+ "description": "查询指定人物的完整档案(特质+社交关系)。用于了解一个人的性格、喜好、背景和社交圈。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{"type": "string", "description": "人物名称"},
+ },
+ "required": []string{"name"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "person_set_trait",
+ "description": "记录/更新一个人的特质(性格、喜好、习惯等)。例如:person_set_trait(name=\"张三\", trait=\"喜欢\", value=\"红色\")。如果该特质已存在则覆盖。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{"type": "string", "description": "人物名称"},
+ "trait": map[string]interface{}{"type": "string", "description": "特质名称,如:喜欢、性格、职业、年龄"},
+ "value": map[string]interface{}{"type": "string", "description": "特质值,如:红色、开朗、工程师、25岁"},
+ },
+ "required": []string{"name", "trait", "value"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "person_relate",
+ "description": "记录两个人之间的社交关系。例如:person_relate(person_a=\"张三\", relation=\"朋友\", person_b=\"李四\")。关系是双向的。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "person_a": map[string]interface{}{"type": "string", "description": "人物A"},
+ "relation": map[string]interface{}{"type": "string", "description": "关系类型,如:朋友、家人、同事、邻居、同学"},
+ "person_b": map[string]interface{}{"type": "string", "description": "人物B"},
+ },
+ "required": []string{"person_a", "relation", "person_b"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "person_network",
+ "description": "查询某人的社交网络(多度关系)。显示该人物周围的相关人物及其关系和特质。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{"type": "string", "description": "人物名称"},
+ "depth": map[string]interface{}{"type": "integer", "description": "关系深度(默认2)", "default": 2},
+ },
+ "required": []string{"name"},
+ },
+ },
+ })
+ }
+
+ if a.pluginReg != nil && a.pluginDir != "" {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "plgreload",
+ "description": "重载 plugins/ 目录的所有插件。扫描目录变更,原子化替换 IO 设备。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ },
+ },
+ })
+ }
+
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "spawn_child",
+ "description": "启动一个异步子 Agent 执行独立任务。子 Agent 后台运行,不阻塞当前对话。完成后系统会自动通知你,届时请调用 child_result 工具查看输出。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "task": map[string]interface{}{
+ "type": "string",
+ "description": "要子 Agent 完成的任务描述。请描述清晰、完整,包含所有必要背景。",
+ },
+ },
+ "required": []string{"task"},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "child_result",
+ "description": "查询异步子 Agent 的执行结果。当收到'子任务已完成'的通知后,调用此工具获取输出。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "task_id": map[string]interface{}{
+ "type": "string",
+ "description": "spawn_child 返回的任务 ID,如 child_1",
+ },
+ },
+ "required": []string{"task_id"},
+ },
+ },
+ })
+
+ if a.providerManager != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "llm_list_sources",
+ "description": "列出所有可用的 LLM 源(如 deepseek、openai、ollama),每个源有对应的 Lua 适配器和配置。如需切换 LLM 源,请使用 llm_set_source。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "llm_set_source",
+ "description": "切换当前 LLM 源到指定名称。变更立即生效,后续对话将使用新的 LLM 源。源名称可通过 llm_list_sources 查看。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{
+ "type": "string",
+ "description": "LLM 源名称(如 deepseek、openai、ollama)",
+ },
+ },
+ "required": []string{"name"},
+ },
+ },
+ })
+ }
+
+ channels := a.io.ListChannels()
+ for _, ch := range channels {
+ if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
+ continue
+ }
+ capStr := a.io.GetChannelCapabilities(ch.Name).String()
+ desc := ch.Description
+ if desc == "" {
+ desc = ch.Name + " 输出通道"
+ }
+
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "output_send__" + ch.Name,
+ "description": desc + "。能力: " + capStr + "。payload 为消息载荷,meta 为 JSON 发送元数据,type 为载荷类型。用 _help 查看 meta 格式和 type 枚举。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "payload": map[string]interface{}{
+ "type": "string",
+ "description": "消息载荷。type=text 时填文字,type=file/image 时填 URL 或路径",
+ },
+ "meta": map[string]interface{}{
+ "type": "string",
+ "description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
+ },
+ "type": map[string]interface{}{
+ "type": "string",
+ "description": "载荷类型,用 channel._help 查看支持的枚举值",
+ },
+ },
+ "required": []string{"payload", "type"},
+ },
+ },
+ })
+
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "output_send__" + ch.Name + "_help",
+ "description": "查看 " + ch.Name + " 输出通道的 meta 格式说明和 type 枚举",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ },
+ },
+ })
+ }
+
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "output_list_channels",
+ "description": "列出所有可用输出通道及其能力(如 text/file/image/audio)和对应的输出门工具名称。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{},
+ },
+ },
+ })
+
+ if a.pendingMedia != nil {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "describe_image",
+ "description": "描述当前用户上传的图片内容。使用配置的多模态模型或默认 LLM 进行识别。调用此工具后你将获得图片的详细文字描述。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "provider": map[string]interface{}{
+ "type": "string",
+ "description": "可选:用于图片描述的 LLM 源名称,不填则使用默认模型",
+ },
+ "detail": map[string]interface{}{
+ "type": "string",
+ "description": "描述详细程度: high / low / auto",
+ "default": "high",
+ },
+ },
+ },
+ },
+ })
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "transcribe_audio",
+ "description": "转写当前用户上传的音频内容为文字。使用配置的多模态模型或默认 LLM 进行语音识别。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "provider": map[string]interface{}{
+ "type": "string",
+ "description": "可选:用于音频转写的 LLM 源名称,不填则使用默认模型",
+ },
+ },
+ },
+ },
+ })
+ if a.inputCfg.Image.OCREnabled {
+ tools = append(tools, map[string]interface{}{
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "ocr_image",
+ "description": "对当前用户上传的图片执行 OCR 文字识别,提取图片中的文字内容。适用于截图、文档照片、菜单等场景。",
+ "parameters": map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "language": map[string]interface{}{
+ "type": "string",
+ "description": "OCR 语言(如 chi_sim+eng),默认自动",
+ },
+ },
+ },
+ },
+ })
+ }
+ }
+
+ return tools
+}
diff --git a/internal/agent/core/utils.go b/internal/agent/core/utils.go
new file mode 100644
index 0000000..4531b45
--- /dev/null
+++ b/internal/agent/core/utils.go
@@ -0,0 +1,69 @@
+package core
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
+)
+
+func getString(m map[string]interface{}, key string) string {
+ if v, ok := m[key]; ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+func getFloat(m map[string]interface{}, key string) float64 {
+ if v, ok := m[key]; ok {
+ switch n := v.(type) {
+ case float64:
+ return n
+ case int:
+ return float64(n)
+ }
+ }
+ return 0
+}
+
+func truncateStr(s string, max int) string {
+ if utf8.RuneCountInString(s) <= max {
+ return s
+ }
+ var truncated int
+ for i := range s {
+ if truncated >= max {
+ return s[:i] + "..."
+ }
+ truncated++
+ }
+ return s
+}
+
+func formatTree(node *knowledge.TreeIndex, depth int) string {
+ var sb strings.Builder
+ indent := strings.Repeat(" ", depth)
+ for _, child := range node.Children {
+ sb.WriteString(fmt.Sprintf("%s%s/\n", indent, child.Name))
+ sb.WriteString(formatTree(child, depth+1))
+ }
+ for _, item := range node.Items {
+ preview := item.Preview
+ if len([]rune(preview)) > 60 {
+ preview = string([]rune(preview)[:60]) + "..."
+ }
+ tags := ""
+ if len(item.Tags) > 0 {
+ tags = " [" + strings.Join(item.Tags, ", ") + "]"
+ }
+ sb.WriteString(fmt.Sprintf("%s· %s%s\n", indent, item.Name, tags))
+ sb.WriteString(fmt.Sprintf("%s %s\n", indent, preview))
+ }
+ if sb.Len() == 0 {
+ sb.WriteString("(空)")
+ }
+ return sb.String()
+}
diff --git a/internal/plugins/mcp/client.go b/internal/plugins/mcp/client.go
index 9cf6b02..7205e5f 100644
--- a/internal/plugins/mcp/client.go
+++ b/internal/plugins/mcp/client.go
@@ -134,3 +134,11 @@ func (s *Server) CallTool(name string, args map[string]interface{}) (string, err
func (s *Server) Close() error {
return s.transport.Close()
}
+
+func (s *Server) SetTransport(t Transport) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.transport.Close()
+ s.transport = t
+ s.nextID = 0
+}
diff --git a/internal/plugins/mcp/plugin.go b/internal/plugins/mcp/plugin.go
index 4b62ee2..348cf42 100644
--- a/internal/plugins/mcp/plugin.go
+++ b/internal/plugins/mcp/plugin.go
@@ -30,6 +30,8 @@ func init() {
type Plugin struct {
name string
servers []*Server
+ configs []serverConfig
+ sdk *sdk.PluginSDK
mu sync.Mutex
wg sync.WaitGroup
}
@@ -42,41 +44,39 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
+ p.sdk = s
cfgs, err := p.loadConfig(s)
if err != nil {
return fmt.Errorf("load mcp config: %w", err)
}
+ p.configs = cfgs
if len(cfgs) == 0 {
log.Printf("[mcp] no servers configured, idle")
return nil
}
for _, cfg := range cfgs {
- server, tools, err := p.connectServer(cfg)
- if err != nil {
+ if err := p.connectAndRegister(cfg); err != nil {
log.Printf("[mcp] connect %s: %v", cfg.Name, err)
- continue
}
+ }
- for _, tool := range tools {
- toolName := fmt.Sprintf("%s_%s", cfg.Name, tool.Name)
- tDef := sdk.ToolDef{
- Name: toolName,
- Description: fmt.Sprintf("[MCP/%s] %s", cfg.Name, tool.Description),
- Parameters: tool.InputSchema,
- }
- tHandler := p.makeHandler(server, tool.Name)
- if err := s.RegisterTool(toolName, tDef, tHandler); err != nil {
- log.Printf("[mcp] register tool %s: %v", toolName, err)
- continue
- }
- log.Printf("[mcp] registered tool: %s (%s)", toolName, cfg.Name)
- }
-
- p.mu.Lock()
- p.servers = append(p.servers, server)
- p.mu.Unlock()
- log.Printf("[mcp] connected server: %s (%d tools)", cfg.Name, len(tools))
+ tDef := sdk.ToolDef{
+ Name: "mcp_restart_server",
+ Description: "重启 MCP 服务器连接。当 MCP 工具返回 pipe/transport closed 错误时,用此工具重启指定的 MCP 服务器。",
+ Parameters: map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "name": map[string]interface{}{
+ "type": "string",
+ "description": "MCP 服务器名称(如 email)",
+ },
+ },
+ "required": []string{"name"},
+ },
+ }
+ if err := s.RegisterTool("mcp_restart_server", tDef, p.restartServerHandler); err != nil {
+ log.Printf("[mcp] register restart tool: %v", err)
}
return nil
@@ -178,6 +178,91 @@ func (p *Plugin) makeHandler(server *Server, toolName string) sdk.ToolHandler {
}
}
+func (p *Plugin) connectAndRegister(cfg serverConfig) error {
+ server, tools, err := p.connectServer(cfg)
+ if err != nil {
+ return err
+ }
+
+ for _, tool := range tools {
+ toolName := fmt.Sprintf("%s_%s", cfg.Name, tool.Name)
+ tDef := sdk.ToolDef{
+ Name: toolName,
+ Description: fmt.Sprintf("[MCP/%s] %s", cfg.Name, tool.Description),
+ Parameters: tool.InputSchema,
+ }
+ tHandler := p.makeHandler(server, tool.Name)
+ if err := p.sdk.RegisterTool(toolName, tDef, tHandler); err != nil {
+ log.Printf("[mcp] register tool %s: %v", toolName, err)
+ continue
+ }
+ log.Printf("[mcp] registered tool: %s (%s)", toolName, cfg.Name)
+ }
+
+ p.mu.Lock()
+ p.servers = append(p.servers, server)
+ p.mu.Unlock()
+ log.Printf("[mcp] connected server: %s (%d tools)", cfg.Name, len(tools))
+ return nil
+}
+
+func (p *Plugin) restartServerHandler(args map[string]interface{}) (interface{}, error) {
+ name, _ := args["name"].(string)
+ if name == "" {
+ return "参数 name 不能为空", nil
+ }
+
+ p.mu.Lock()
+ var idx int = -1
+ for i, s := range p.servers {
+ if s.Name() == name {
+ idx = i
+ break
+ }
+ }
+ if idx == -1 {
+ p.mu.Unlock()
+ return fmt.Sprintf("MCP 服务器 [%s] 不存在", name), nil
+ }
+ oldServer := p.servers[idx]
+ p.mu.Unlock()
+
+ var cfg *serverConfig
+ for i := range p.configs {
+ if p.configs[i].Name == name {
+ cfg = &p.configs[i]
+ break
+ }
+ }
+ if cfg == nil {
+ return fmt.Sprintf("MCP 服务器 [%s] 的配置未找到", name), nil
+ }
+
+ transport, err := newTransport(*cfg)
+ if err != nil {
+ return fmt.Sprintf("创建 MCP 服务器 [%s] 传输层失败: %v", name, err), nil
+ }
+
+ oldServer.SetTransport(transport)
+
+ _, err = oldServer.ListTools()
+ if err != nil {
+ return fmt.Sprintf("MCP 服务器 [%s] 重启后通信仍异常: %v", name, err), nil
+ }
+
+ return fmt.Sprintf("MCP 服务器 [%s] 已成功重启", name), nil
+}
+
+func newTransport(cfg serverConfig) (Transport, error) {
+ if cfg.URL != "" {
+ return NewSSETransport(cfg.URL), nil
+ }
+ if cfg.Command != "" {
+ return NewStdioTransport(cfg.Command, cfg.Args, cfg.Env)
+ }
+ return nil, fmt.Errorf("neither command nor url specified")
+}
+
func (p *Plugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()