refactor: split agent.go into 12 files + add mcp_restart_server tool

This commit is contained in:
JianFeeeee
2026-07-22 15:40:27 +08:00
parent 71c1025342
commit 613d4a62a3
20 changed files with 3150 additions and 3080 deletions

View File

@ -163,7 +163,7 @@ internal/
├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版) ├── memory/ 三层记忆:Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
├── knowledge/ 知识库(文件系统 + TF-IDF) ├── knowledge/ 知识库(文件系统 + TF-IDF)
├── plugin/ 插件注册表 + .so 动态加载器 ├── 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 三通道) ├── sdk/ PluginSDK(Tool/Stage/Event 三通道)
├── config/ SQLite 配置中心 ├── config/ SQLite 配置中心
├── events/ 事件总线 ├── 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 事件已上线。
## 文档 ## 文档

View File

@ -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) ├── 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) ├── knowledge/ Knowledge base (filesystem + TF-IDF)
├── plugin/ Plugin registry + .so/.dll dynamic loader ├── 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) ├── sdk/ PluginSDK (Tool/Stage/Event three channels)
├── config/ SQLite config center ├── config/ SQLite config center
├── events/ Event bus ├── events/ Event bus
@ -173,7 +173,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
## Project Status ## 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 ## Documentation

View File

@ -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. | | 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. | | 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. | | 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. 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")`. 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 │ ├── timer/ — Timer
│ ├── cmd/ — Command execution │ ├── cmd/ — Command execution
│ ├── mcp/ — MCP protocol │ ├── mcp/ — MCP protocol
│ ├── openclaw/ — OpenClaw compatible │ ├── files/ — File operations
│ ├── clawhubadapter/ — ClawHub adapter (OC plugin/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY terminal │ ├── agentcli/ — PTY terminal
│ ├── healthcheck/ — Health check │ ├── healthcheck/ — Health check
│ └── pluginmgr/ — Plugin manager │ ├── pluginmgr/ — Plugin manager
│ └── cfgmgr/ — Config manager
├── sdk/ — PluginSDK definitions ├── sdk/ — PluginSDK definitions
│ ├── plugin.go — Plugin interface + PluginSDK │ ├── plugin.go — Plugin interface + PluginSDK
│ ├── memory.go — MemoryAPI │ ├── memory.go — MemoryAPI

View File

@ -69,12 +69,19 @@ Code is in the project root, implemented in Go.
- OpenAI API-compatible `/v1/chat/completions` endpoint - OpenAI API-compatible `/v1/chat/completions` endpoint
- SSE event stream `/api/v1/chat/events` - 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
<img src="../../branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> : <img src="../../branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## Project Status ## Project Status
Core functionality is operational. Plugin system and SDK are ready for independent external plugin development. 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 - 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 - Distribution: `.hmap` plugin package format, installable via WebUI

View File

@ -256,7 +256,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 | | 内置插件 | `init()` → `RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部 `.so` | C ABI 动态加载 | `-buildmode=c-shared` + bridge | qq/files/web/memo 等 | | 外部 `.so` | C ABI 动态加载 | `-buildmode=c-shared` + bridge | qq/files/web/memo 等 |
| Lua 脚本插件 | 解析 `main.lua` 注册工具 | 无需编译,热加载 | luaplugintest/testlua 等 | | Lua 脚本插件 | 解析 `main.lua` 注册工具 | 无需编译,热加载 | luaplugintest/testlua 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | OpenClaw 兼容 | | SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()` → `Registry.Load()` 扫描目录匹配工厂。 内置插件注册:`internal/plugins/all.go` 空白导入 → 各插件 `init()` → `Registry.Load()` 扫描目录匹配工厂。
外部插件加载:`internal/plugin/dynamic.go` → 复制到 SHA256 临时路径(绕过 `plugin.Open` 路径缓存)→ `Open` + `Lookup("NewPlugin")`。 外部插件加载:`internal/plugin/dynamic.go` → 复制到 SHA256 临时路径(绕过 `plugin.Open` 路径缓存)→ `Open` + `Lookup("NewPlugin")`。
@ -403,10 +403,12 @@ internal/
│ ├── timer/ — 定时器 │ ├── timer/ — 定时器
│ ├── cmd/ — 命令执行 │ ├── cmd/ — 命令执行
│ ├── mcp/ — MCP 协议 │ ├── mcp/ — MCP 协议
│ ├── openclaw/ — OpenClaw 兼容 │ ├── files/ — 文件操作
│ ├── clawhubadapter/ — ClawHub 适配器(OC 插件/SKILL/JS/Python sidecar)
│ ├── agentcli/ — PTY 终端 │ ├── agentcli/ — PTY 终端
│ ├── healthcheck/ — 健康检查 │ ├── healthcheck/ — 健康检查
│ └── pluginmgr/ — 插件管理器 │ ├── pluginmgr/ — 插件管理器
│ └── cfgmgr/ — 配置管理
├── sdk/ — PluginSDK 定义 ├── sdk/ — PluginSDK 定义
│ ├── plugin.go — Plugin 接口 + PluginSDK │ ├── plugin.go — Plugin 接口 + PluginSDK
│ ├── memory.go — MemoryAPI │ ├── memory.go — MemoryAPI

View File

@ -69,12 +69,19 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
- 兼容 OpenAI API 格式的 `/v1/chat/completions` 端点 - 兼容 OpenAI API 格式的 `/v1/chat/completions` 端点
- SSE 事件流 `/api/v1/chat/events` - 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 设备,支持文本/文件/图片/音频能力标志
<img src="../../branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> : <img src="../../branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 项目状态 ## 项目状态
核心功能已可运行。插件系统和 SDK 已就绪,可独立开发外部插件。 核心功能已可运行。插件系统和 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 - 外部插件示例([homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库 `example/`,含 Go 和 Lua 两种类型):qq / files / web / memo / bili / editdoc / a2a / ocr / sanitizer / luaplugintest / testlua
- 打包分发:`.hmap` 插件包格式,通过 WebUI 安装 - 打包分发:`.hmap` 插件包格式,通过 WebUI 安装

File diff suppressed because it is too large Load Diff

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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"
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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,
})
}

View File

@ -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)
}
}

View File

@ -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
}

View File

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

View File

@ -134,3 +134,11 @@ func (s *Server) CallTool(name string, args map[string]interface{}) (string, err
func (s *Server) Close() error { func (s *Server) Close() error {
return s.transport.Close() 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
}

View File

@ -30,6 +30,8 @@ func init() {
type Plugin struct { type Plugin struct {
name string name string
servers []*Server servers []*Server
configs []serverConfig
sdk *sdk.PluginSDK
mu sync.Mutex mu sync.Mutex
wg sync.WaitGroup wg sync.WaitGroup
} }
@ -42,41 +44,39 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error { func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true) s.SetAutoRestart(true)
p.sdk = s
cfgs, err := p.loadConfig(s) cfgs, err := p.loadConfig(s)
if err != nil { if err != nil {
return fmt.Errorf("load mcp config: %w", err) return fmt.Errorf("load mcp config: %w", err)
} }
p.configs = cfgs
if len(cfgs) == 0 { if len(cfgs) == 0 {
log.Printf("[mcp] no servers configured, idle") log.Printf("[mcp] no servers configured, idle")
return nil return nil
} }
for _, cfg := range cfgs { for _, cfg := range cfgs {
server, tools, err := p.connectServer(cfg) if err := p.connectAndRegister(cfg); err != nil {
if err != nil {
log.Printf("[mcp] connect %s: %v", cfg.Name, err) log.Printf("[mcp] connect %s: %v", cfg.Name, err)
continue
} }
}
for _, tool := range tools { tDef := sdk.ToolDef{
toolName := fmt.Sprintf("%s_%s", cfg.Name, tool.Name) Name: "mcp_restart_server",
tDef := sdk.ToolDef{ Description: "重启 MCP 服务器连接。当 MCP 工具返回 pipe/transport closed 错误时,用此工具重启指定的 MCP 服务器。",
Name: toolName, Parameters: map[string]interface{}{
Description: fmt.Sprintf("[MCP/%s] %s", cfg.Name, tool.Description), "type": "object",
Parameters: tool.InputSchema, "properties": map[string]interface{}{
} "name": map[string]interface{}{
tHandler := p.makeHandler(server, tool.Name) "type": "string",
if err := s.RegisterTool(toolName, tDef, tHandler); err != nil { "description": "MCP 服务器名称(如 email)",
log.Printf("[mcp] register tool %s: %v", toolName, err) },
continue },
} "required": []string{"name"},
log.Printf("[mcp] registered tool: %s (%s)", toolName, cfg.Name) },
} }
if err := s.RegisterTool("mcp_restart_server", tDef, p.restartServerHandler); err != nil {
p.mu.Lock() log.Printf("[mcp] register restart tool: %v", err)
p.servers = append(p.servers, server)
p.mu.Unlock()
log.Printf("[mcp] connected server: %s (%d tools)", cfg.Name, len(tools))
} }
return nil 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 { func (p *Plugin) Stop() error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()