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()