mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
- IO abstraction layer with OutputChannel routing and capability validation - Three-layer memory (Context-Document-Graph) with TF-IDF relevance pruning - OneBot V11 QQ protocol plugin with Reverse WebSocket client - Plugin system with hot-reload (SKILL.md + native factories) - Knowledge system with TF-IDF vector indexing - Personality system (personal.md) - Text memory (JSONL with rotation) - Change tracker (overlayfs) with rollback - Lua adapter VM - Design document (DESIGN.md) Module: gitcode.com/JianFeeeee/HomeAgent
1067 lines
29 KiB
Go
1067 lines
29 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/skill"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||
)
|
||
|
||
// ContextEvent 和 RelevanceContext 定义在 context.go
|
||
|
||
// Agent — 单 agent,不区分会话/实例
|
||
type Agent struct {
|
||
mu sync.Mutex
|
||
id types.AgentID
|
||
provider agentAPI.Provider
|
||
io *agentIO.IOManager
|
||
memory *memory.GraphDB
|
||
indexer *memory.Indexer
|
||
skills *skill.Manager
|
||
tracker *tracker.Tracker
|
||
context *RelevanceContext
|
||
systemPrompt string
|
||
ctx context.Context
|
||
cancel context.CancelFunc
|
||
maxTurns int
|
||
|
||
// 文档记忆(第二层)
|
||
docStore *document.Store
|
||
|
||
// 知识库
|
||
knowledge *knowledge.Store
|
||
|
||
// 人格设定
|
||
personality *agentPkg.Personality
|
||
|
||
// 插件注册表(用于 plgreload)
|
||
pluginReg *plugin.Registry
|
||
|
||
// 定期心跳蒸馏
|
||
distillInterval time.Duration
|
||
|
||
// 上下文裁剪:活跃上下文最大条数,超出按相关性裁剪
|
||
maxContextSize int
|
||
|
||
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
||
currentOutputChannel string
|
||
}
|
||
|
||
type AgentConfig struct {
|
||
ID types.AgentID
|
||
SystemPrompt string
|
||
Provider agentAPI.Provider
|
||
IO *agentIO.IOManager
|
||
Memory *memory.GraphDB
|
||
Indexer *memory.Indexer
|
||
Skills *skill.Manager
|
||
Tracker *tracker.Tracker
|
||
MaxToolTurns int
|
||
|
||
DocStore *document.Store
|
||
Knowledge *knowledge.Store
|
||
Personality *agentPkg.Personality
|
||
PluginReg *plugin.Registry
|
||
PluginDir string
|
||
DistillInterval time.Duration
|
||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||
}
|
||
|
||
func New(cfg AgentConfig) *Agent {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
if cfg.MaxToolTurns <= 0 {
|
||
cfg.MaxToolTurns = 10
|
||
}
|
||
if cfg.DistillInterval <= 0 {
|
||
cfg.DistillInterval = 30 * time.Minute
|
||
}
|
||
if cfg.MaxContextSize <= 0 {
|
||
cfg.MaxContextSize = 30
|
||
}
|
||
return &Agent{
|
||
id: cfg.ID,
|
||
provider: cfg.Provider,
|
||
io: cfg.IO,
|
||
memory: cfg.Memory,
|
||
indexer: cfg.Indexer,
|
||
skills: cfg.Skills,
|
||
tracker: cfg.Tracker,
|
||
context: NewRelevanceContext(),
|
||
systemPrompt: cfg.SystemPrompt,
|
||
ctx: ctx,
|
||
cancel: cancel,
|
||
maxTurns: cfg.MaxToolTurns,
|
||
docStore: cfg.DocStore,
|
||
knowledge: cfg.Knowledge,
|
||
personality: cfg.Personality,
|
||
pluginReg: cfg.PluginReg,
|
||
distillInterval: cfg.DistillInterval,
|
||
maxContextSize: cfg.MaxContextSize,
|
||
}
|
||
}
|
||
|
||
func (a *Agent) Start() {
|
||
go a.eventLoop()
|
||
go a.distillLoop()
|
||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||
}
|
||
|
||
func (a *Agent) Stop() {
|
||
a.cancel()
|
||
}
|
||
|
||
func (a *Agent) ID() types.AgentID { return a.id }
|
||
|
||
func (a *Agent) eventLoop() {
|
||
for {
|
||
select {
|
||
case evt := <-a.io.InputChan():
|
||
a.handleInput(evt)
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
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 "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) processTextInput(evt *agentIO.InputEvent, input string) {
|
||
start := time.Now()
|
||
|
||
// 设置该请求的输出通道(默认 = 输入事件配套的通道)
|
||
a.currentOutputChannel = evt.OutputChannel
|
||
if a.currentOutputChannel == "" {
|
||
a.currentOutputChannel = evt.Source
|
||
}
|
||
|
||
a.context.Append(ContextEvent{
|
||
Timestamp: start,
|
||
Source: evt.Source,
|
||
Input: input,
|
||
})
|
||
|
||
response, toolsUsed, err := a.process(input)
|
||
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,
|
||
})
|
||
|
||
// 基于相关性裁剪上下文:保留与当前输入最相关的 maxContextSize 条
|
||
archived := a.context.Prune(response, a.maxContextSize, a.docStore)
|
||
if archived > 0 {
|
||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||
}
|
||
|
||
a.emitResponse(evt, response)
|
||
|
||
a.emitMemoryCandidate(evt.Source, input, response, toolsUsed)
|
||
}
|
||
|
||
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
|
||
// 读取当前输出通道(可能已被 AI 通过 output_set_channel 切换)
|
||
ch := a.currentOutputChannel
|
||
if ch == "" {
|
||
ch = evt.OutputChannel
|
||
}
|
||
if ch == "" {
|
||
ch = evt.Source
|
||
}
|
||
|
||
a.io.EmitOutputTo(evt.Source, ch, "text", map[string]interface{}{
|
||
"content": response,
|
||
"request_id": evt.RequestID,
|
||
})
|
||
|
||
if evt.ResponseCh != nil {
|
||
evt.ResponseCh <- &agentIO.OutputEvent{
|
||
RequestID: evt.RequestID,
|
||
Target: evt.Source,
|
||
Type: "text",
|
||
Payload: map[string]interface{}{"content": response},
|
||
Done: true,
|
||
OutputChannel: ch,
|
||
}
|
||
}
|
||
}
|
||
|
||
// process — 内部处理,带工具循环
|
||
func (a *Agent) process(input string) (response string, toolsUsed []string, err error) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
|
||
memContext := a.buildMemoryContext(input)
|
||
sysPrompt := a.buildSystemPrompt(memContext, input)
|
||
tools := a.buildToolDefs()
|
||
|
||
msgs := a.buildMessages(sysPrompt, input)
|
||
|
||
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())
|
||
|
||
for turn := 0; turn < a.maxTurns; turn++ {
|
||
req := &agentAPI.CompletionRequest{
|
||
Messages: msgs,
|
||
MaxTokens: 4096,
|
||
Tools: tools,
|
||
ToolChoice: "auto",
|
||
ExtraBody: map[string]interface{}{
|
||
"thinking": map[string]interface{}{"type": "disabled"},
|
||
},
|
||
}
|
||
|
||
resp, err := a.provider.Chat(a.ctx, req)
|
||
if err != nil {
|
||
return "", toolsUsed, fmt.Errorf("provider: %w", err)
|
||
}
|
||
|
||
if len(resp.ToolCalls) == 0 {
|
||
return resp.Content, toolsUsed, nil
|
||
}
|
||
|
||
for _, tc := range resp.ToolCalls {
|
||
toolsUsed = append(toolsUsed, tc.Name)
|
||
log.Printf("[agent] executing tool: %s (id=%s)", tc.Name, tc.ID)
|
||
result := a.executeToolCall(tc)
|
||
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
|
||
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})
|
||
}
|
||
}
|
||
|
||
return "", toolsUsed, fmt.Errorf("tool execution exceeded %d turns", a.maxTurns)
|
||
}
|
||
|
||
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) buildMessages(sysPrompt, input string) []agentAPI.Message {
|
||
msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}}
|
||
|
||
ctxStr := a.context.Format()
|
||
if 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) string {
|
||
switch {
|
||
case strings.HasPrefix(tc.Name, "memory_"):
|
||
return a.executeMemoryTool(tc)
|
||
case strings.HasPrefix(tc.Name, "knowledge_"):
|
||
return a.executeKnowledgeTool(tc)
|
||
case strings.HasPrefix(tc.Name, "doc_"):
|
||
return a.executeDocTool(tc)
|
||
case tc.Name == "output_set_channel":
|
||
return a.executeOutputChannelTool(tc)
|
||
case tc.Name == "output_send":
|
||
return a.executeOutputSendTool(tc)
|
||
case tc.Name == "output_list_channels":
|
||
return a.executeOutputListChannels()
|
||
}
|
||
|
||
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 "未找到相关记忆"
|
||
}
|
||
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_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)
|
||
|
||
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
|
||
}
|
||
parts = append(parts, fmt.Sprintf("[%s]\n%s", k.Name, 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":
|
||
names := a.knowledge.List()
|
||
if len(names) == 0 {
|
||
return "知识库为空"
|
||
}
|
||
return "知识分类: " + strings.Join(names, ", ")
|
||
|
||
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.Query(query, topK)
|
||
if len(docs) == 0 {
|
||
return "未找到相关文档记忆"
|
||
}
|
||
var parts []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, ", "))
|
||
}
|
||
}
|
||
return strings.Join(parts, "\n")
|
||
|
||
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 = "你是一个智能家庭管家,持续运行。"
|
||
}
|
||
|
||
// 人格设定 — 固定,不变
|
||
if a.personality != nil {
|
||
if pp := a.personality.InjectPrompt(); pp != "" {
|
||
prompt += "\n\n" + pp
|
||
}
|
||
}
|
||
|
||
// 图记忆上下文(索引摘要)
|
||
if memContext != "" {
|
||
prompt += "\n\n" + memContext
|
||
}
|
||
|
||
// 文档记忆 — 查询相关文档摘要注入
|
||
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")
|
||
}
|
||
}
|
||
|
||
if a.skills != nil {
|
||
if sp := a.skills.GetInjectedPrompt(); sp != "" {
|
||
prompt += "\n\n" + sp
|
||
}
|
||
}
|
||
|
||
if a.indexer != nil {
|
||
prompt += "\n\n" + a.indexer.BuildToolPrompt()
|
||
}
|
||
|
||
return prompt
|
||
}
|
||
|
||
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": td.Parameters,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
if a.indexer != nil {
|
||
for _, td := range a.indexer.GetToolDefinitions() {
|
||
tools = append(tools, td)
|
||
}
|
||
}
|
||
|
||
// 知识库工具
|
||
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"},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
// 文档记忆工具
|
||
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"},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
// 输出通道工具
|
||
tools = append(tools, map[string]interface{}{
|
||
"type": "function",
|
||
"function": map[string]interface{}{
|
||
"name": "output_set_channel",
|
||
"description": "切换当前对话的输出通道。例如从 voice 切换到 email,后续所有回复将通过新通道发送。",
|
||
"parameters": map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"channel": map[string]interface{}{
|
||
"type": "string",
|
||
"description": "输出通道名称: voice (语音), email (邮件), screen (屏幕), http (HTTP)",
|
||
"enum": []interface{}{"voice", "email", "screen", "http"},
|
||
},
|
||
},
|
||
"required": []string{"channel"},
|
||
},
|
||
},
|
||
})
|
||
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{}{},
|
||
},
|
||
},
|
||
})
|
||
tools = append(tools, map[string]interface{}{
|
||
"type": "function",
|
||
"function": map[string]interface{}{
|
||
"name": "output_send",
|
||
"description": "通过指定输出通道立即发送一条消息,不等待主回复。用于异步通知、中间进度等场景。",
|
||
"parameters": map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"channel": map[string]interface{}{
|
||
"type": "string",
|
||
"description": "输出通道: voice, email, screen, http",
|
||
},
|
||
"content": map[string]interface{}{
|
||
"type": "string",
|
||
"description": "消息内容",
|
||
},
|
||
},
|
||
"required": []string{"channel", "content"},
|
||
},
|
||
},
|
||
})
|
||
|
||
return tools
|
||
}
|
||
|
||
// distillLoop — 定期心跳:上下文→文档 + 图→文档 + 图重整
|
||
func (a *Agent) 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()
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *Agent) distillContext() {
|
||
if a.docStore == nil {
|
||
return
|
||
}
|
||
// 心跳时执行一次安全裁剪(兜底)
|
||
// 上下文的主要裁剪在 processTextInput 中基于相关性执行
|
||
_ = a.context.Len()
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
}
|
||
|
||
doc := &document.Doc{
|
||
Summary: fmt.Sprintf("图记忆索引 (%d 实体, %d 关系)", len(result.Entities), len(result.Relations)),
|
||
Content: strings.Join(summaryParts, "\n"),
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. 实体向量同义合并
|
||
result, err := a.memory.Recall(nil, nil, 1, "")
|
||
if err != nil || result == nil || len(result.Entities) < 2 {
|
||
return
|
||
}
|
||
|
||
merged := 0
|
||
for i := 0; i < len(result.Entities); i++ {
|
||
for j := i + 1; j < len(result.Entities); j++ {
|
||
if isSimilarName(result.Entities[i].Name, result.Entities[j].Name) {
|
||
if result.Entities[i].MentionCount >= result.Entities[j].MentionCount {
|
||
log.Printf("[agent] reorg: merging '%s' → '%s'", result.Entities[j].Name, result.Entities[i].Name)
|
||
} else {
|
||
log.Printf("[agent] reorg: merging '%s' → '%s'", result.Entities[i].Name, result.Entities[j].Name)
|
||
}
|
||
merged++
|
||
}
|
||
}
|
||
}
|
||
|
||
if merged > 0 {
|
||
log.Printf("[agent] graph reorg: merged %d similar entities", merged)
|
||
} else {
|
||
log.Printf("[agent] graph reorg: no merges needed")
|
||
}
|
||
}
|
||
|
||
// isSimilarName — 使用字符 bigram Jaccard 相似度判断实体名是否同义
|
||
// docToTriples 将文档转为图记忆三元组
|
||
func docToTriples(doc *document.Doc) []memory.Triple {
|
||
var triples []memory.Triple
|
||
if doc == nil {
|
||
return triples
|
||
}
|
||
|
||
triples = append(triples, memory.Triple{
|
||
Subject: "文档",
|
||
Relation: "包含内容",
|
||
Object: doc.Summary,
|
||
})
|
||
|
||
for _, entity := range doc.Entities {
|
||
triples = append(triples, memory.Triple{
|
||
Subject: "文档",
|
||
Relation: "提及实体",
|
||
Object: entity,
|
||
})
|
||
}
|
||
|
||
for _, tag := range doc.Tags {
|
||
triples = append(triples, memory.Triple{
|
||
Subject: "文档",
|
||
Relation: "标签",
|
||
Object: tag,
|
||
})
|
||
}
|
||
|
||
if doc.Source != "" {
|
||
triples = append(triples, memory.Triple{
|
||
Subject: "文档",
|
||
Relation: "来源",
|
||
Object: doc.Source,
|
||
})
|
||
}
|
||
|
||
return triples
|
||
}
|
||
|
||
func isSimilarName(a, b string) bool {
|
||
if a == b {
|
||
return false // 自带跳过
|
||
}
|
||
runesA, runesB := []rune(a), []rune(b)
|
||
if len(runesA) < 2 || len(runesB) < 2 {
|
||
return false
|
||
}
|
||
|
||
setA := make(map[string]bool)
|
||
for i := 0; i < len(runesA)-1; i++ {
|
||
setA[string(runesA[i:i+2])] = true
|
||
}
|
||
|
||
intersect := 0
|
||
for i := 0; i < len(runesB)-1; i++ {
|
||
if setA[string(runesB[i:i+2])] {
|
||
intersect++
|
||
}
|
||
}
|
||
|
||
union := len(setA) + len(runesB) - 1 - intersect
|
||
if union <= 0 {
|
||
return false
|
||
}
|
||
|
||
jaccard := float64(intersect) / float64(union)
|
||
return jaccard > 0.5
|
||
}
|
||
|
||
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 保护,只有一个请求在执行
|
||
func (a *Agent) executeOutputChannelTool(tc agentAPI.ToolCall) string {
|
||
channel, _ := tc.Arguments["channel"].(string)
|
||
if channel == "" {
|
||
return "请指定输出通道名称,可选: voice, email, screen, http"
|
||
}
|
||
a.currentOutputChannel = channel
|
||
return fmt.Sprintf("输出通道已切换至: %s,后续输出将通过此通道", channel)
|
||
}
|
||
|
||
// executeOutputSendTool — AI 通过指定通道发送消息(校验通道能力)
|
||
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
|
||
channel, _ := tc.Arguments["channel"].(string)
|
||
content, _ := tc.Arguments["content"].(string)
|
||
if channel == "" || content == "" {
|
||
return "channel 和 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())
|
||
}
|
||
|
||
a.io.EmitTextTo("agent_io", channel, content)
|
||
return fmt.Sprintf("已通过 [%s] 通道发送", 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 ""
|
||
}
|
||
|
||
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 {
|
||
runes := []rune(s)
|
||
if len(runes) > max {
|
||
return string(runes[:max]) + "..."
|
||
}
|
||
return s
|
||
}
|