Files
HomeAgent/internal/agent/core/toolcall.go
JianFeeeee 46e014a8ca feat(persona): 首启人格门禁跨通道化 + 内核 persona_set 工具
WebUI 首启向导只覆盖 WebUI 这一条通道,而「人格该问一次」是所有通道的事:
走 QQ / CLI / ACP / 邮件来的人永远见不到那个向导,人格就永远是没确认过。

- 门禁移到 buildSystemPrompt(每轮重建 → WebUI/QQ/CLI/ACP/邮件全覆盖),
  以 core.internal.persona_initialized 为准:未确认时要求模型主动询问用户
  (默认 / 自定义 / 以后再说),确认后该段消失;personaStore 为 nil 时静默关闭。
- 新增内核内置工具 persona_set(mode=default|custom|later[, content]),
  落库逻辑与 WebUI 向导**共用 internal/config**(一个实现 + 两个薄入口:
  ConfigRegistry 直连 / 插件侧 SettingsAPI),避免两套语义各自漂移。
- AgentConfig 增加 PersonaStore 接口,cmd/homed 用 RegistryPersonaStore 实现。
- 非法输入(未知 mode / custom 空内容)在打标记**之前**拒绝:否则标记置位、
  向导被跳过,用户再没机会设。

E2E(隔离实例 + 真实 LLM 往返走 /v1/chat/completions,/var/tmp/persona/e2e.sh)9/9 PASS:
未确认时模型主动询问 → 用户答「用默认的」→ 模型调用 persona_set 落库并置位标记
→ 之后不再追问;反向对照(清标记 + 清会话上下文 + 重启)重新开始询问,
排除了「同一段对话里已问过」这一混淆。
2026-09-12 13:57:06 +08:00

603 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 tc.Name == "persona_set":
return a.executePersonaTool(tc)
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))
}
// 命中的关系若挂着媒体块,把媒体说明附在结果末尾。
//
// 关系行只有实体名和关系类型,看不出"这条记忆当时还带了一张图"。
// 媒体块以结构边与句子相连,需经关系→句子反查。
// 不附上的后果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"),
}
// 模型显式关联的媒体:结构化字段随三元组一起提交,
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 不再把 marker 写进句子文本。
if digests := getStringSlice(m, "media_digests"); len(digests) > 0 {
t.MediaDigests = a.resolveMediaDigests(digests)
// 块边需要句子作端点。模型没给原句时用三元组本身拼一句
// 自然语言——不能造一段 marker 文本,那正是被废弃的东西。
if t.SentenceText == "" && len(t.MediaDigests) > 0 {
t.SentenceText = fmt.Sprintf("%s%s%s。", t.Subject, t.Relation, t.Object)
}
}
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, nil)
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 labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
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",
}
// 模型显式关联的媒体:直接变成文档持有的一等块。
// 不再往正文写 marker——文档向量会融合这些块的媒体向量
// 图片按自己的向量被检索。
for _, d := range a.resolveMediaDigests(getStringSlice(tc.Arguments, "media_digests")) {
if b, ok := a.blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}
}
if err := a.docStore.Insert(doc); err != nil {
return fmt.Sprintf("文档写入失败: %v", err)
}
if n := len(doc.Blocks); n > 0 {
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s, 关联 %d 份媒体)", doc.ID, summary, n)
}
return fmt.Sprintf("文档已提交 (id: %s, 摘要: %s)", doc.ID, summary)
default:
return fmt.Sprintf("未知的文档工具: %s", tc.Name)
}
}