mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert 交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。 ## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,全塞回去会把跨进程消息撑爆。 媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体 要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断 注入,需要自己的签名,且媒体在**本轮**就送到模型。 ## 二、内核桥接层:原先在静默裁字段 `internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil: - 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit` 而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上; - 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用, 于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减 (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。 规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接 层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方 写下的标记。 标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`, 之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。 ## 三、跨进程链路:不接线就是全体外部插件编译失败 `go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。 公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。 六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、 `corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧 实现、以及三个测试替身。 ## 四、统一输入主干:把模态从「函数选择」降级为「字段」 `processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史 产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` + `mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看 `stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**, 不是输入的**种类**。 媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、 `_consolidation_` 路由、正确的 `EventRawInput`。 最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而 `webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。 **用户发的图从来没出现在 WebUI 聊天记录里。** `media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`: 字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。 ## 五、模型可调用的三个工具 `memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节; 连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加 `media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断, 标记通常就在尾部)。 标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误 静默切断引用绑定,而全链路无人报错。 ## 六、WebUI 上传走真实媒体链路 图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回 文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type 时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。 ## 测试 - `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**) - `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体) - `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测) 压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart` 无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine 注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定 只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类 阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。 测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表 是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名 时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。 存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。 17 个 example 插件源码零改动通过类型检查。
595 lines
19 KiB
Go
595 lines
19 KiB
Go
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 == "get_plugin_tools":
|
||
pluginName, _ := tc.Arguments["plugin_name"].(string)
|
||
return a.executeGetPluginTools(pluginName)
|
||
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 "请输入查询关键词"
|
||
}
|
||
// 关键词提取:支持逗号分隔和自然语言
|
||
keywords := strings.Split(query, ",")
|
||
if len(keywords) == 1 {
|
||
keywords = memory.ExtractKeywords(query)
|
||
}
|
||
result, err := a.memory.Recall(keywords, 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))
|
||
}
|
||
// 命中的关系若挂着媒体,把媒体说明附在结果末尾。
|
||
//
|
||
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
|
||
// 媒体挂在句子上(graph_sentence owner),需经关系→句子→media_refs
|
||
// 反查。不附上的后果:agent 显式查了图记忆,却仍然不知道有图。
|
||
if mc := a.mediaContextForRelations(result.Relations); mc != "" {
|
||
parts = append(parts, "", "关联媒体:", mc)
|
||
}
|
||
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"),
|
||
SentenceText: getString(m, "sentence_text"),
|
||
}
|
||
// 模型显式关联的媒体:标记由内核补进句子文本,模型不必知道格式。
|
||
// 没有 sentence_text 时 sentenceWithMediaMarkers 会用标记本身
|
||
// 充当句子——媒体必须有句子落点,否则 media_refs 无从挂起。
|
||
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
|
||
t.SentenceText = a.sentenceWithMediaMarkers(t.SentenceText, digests)
|
||
}
|
||
if t.Subject != "" && t.Relation != "" && t.Object != "" {
|
||
triples = append(triples, t)
|
||
}
|
||
}
|
||
}
|
||
if len(triples) == 0 {
|
||
return "没有有效的三元组"
|
||
}
|
||
// remember 工具是用户/模型显式写入,不涉及归档删除,
|
||
// 因此不需要 mediaBound——没有旧引用要释放。
|
||
ec, rc, mb, err := a.commitTriplesWithMedia(triples, string(a.id), 0)
|
||
if err != nil {
|
||
return fmt.Sprintf("记忆写入失败: %v", err)
|
||
}
|
||
if mb > 0 {
|
||
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系,关联 %d 份媒体", ec, rc, mb)
|
||
}
|
||
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] + "..."
|
||
}
|
||
// 媒体说明单独一行进冷存事件:正文可能被上面的 2000 字截断,
|
||
// 而媒体标记往往在文档末尾——截掉之后模型就不知道这篇文档带过图。
|
||
if mc := a.docMediaContext(d.ID, d.Content); mc != "" {
|
||
content = content + "\n关联媒体: " + mc
|
||
}
|
||
a.context.InsertByTimestamp(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",
|
||
}
|
||
|
||
// 模型显式关联的媒体:标记补进正文后再写入。顺序关键——向量索引用
|
||
// Summary+Content 计算,标记进不去正文就检索不到这份媒体。
|
||
mediaDigests := a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests"))
|
||
doc.Content = a.sentenceWithMediaMarkers(doc.Content, mediaDigests)
|
||
|
||
if err := a.docStore.Insert(doc); err != nil {
|
||
return fmt.Sprintf("文档写入失败: %v", err)
|
||
}
|
||
// 引用必须在拿到 doc.ID 之后挂:owner_id 就是文档 id。
|
||
// 不挂的后果是这些媒体在文档里可见却无主,下一轮 GC 会把它们清掉。
|
||
bound := a.bindDocMedia(doc.ID, mediaDigests)
|
||
if bound > 0 {
|
||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, bound)
|
||
}
|
||
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
|
||
|
||
default:
|
||
return fmt.Sprintf("未知的文档工具: %s", tc.Name)
|
||
}
|
||
}
|