From 7318a10828e3aa4375fbbe8a7f5720c758fab604 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 12:38:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(persona):=20=E4=BA=BA=E6=A0=BC=E8=AE=BE?= =?UTF-8?q?=E5=AE=9A=E9=85=8D=E7=BD=AE=E9=A1=B9=E5=8C=96=20+=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E6=A8=A1=E6=9D=BF=E5=A5=91=E7=BA=A6=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20+=20=E8=85=90=E5=9D=8F=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因(v1.2.0 压测):线上实例内核日志/接口都报 1.2.0,agent 被问版本时却按人格卡 自述 v0.9.0 + C ABI v2(该机制 v1.0.0 已删除)。根因是人格只有「文件」一个来源且无人 维护——写死的版本号必然随发版腐坏。 改动: 1. 新增配置项 core.agent.personal_prompt(多行文本),默认值为内置模板 config.DefaultPersonaPrompt,随其它默认值同批播种(老安装不注入,语义不变) 2. 默认模板**不含任何版本号字面量**,并显式要求「被问到版本/构建信息时以运行时快照 (healthcheck_kernel)为准」——从根上消掉这类腐坏 3. 人格来源优先级:personal/personal.md(存在且非空)> 配置项 > 无 启动日志明确打印来源;文件含腐坏内容(版本号字面量 / 已删除机制的说法)时告警并 建议迁移到配置项 4. internal/agent.PersonaStaleHints:腐坏检测(版本号正则 + 已删除机制词表) 契约测试(防复发): - TestDefaultPersonaPromptHasNoVersionLiterals:默认模板不得含 v?\d+\.\d+\.\d+, 且必须含「运行时快照」要求 - TestPersonaPromptRegisteredWithDefault:注册存在、默认值一致、播种真的写入 - TestPersonaStaleHints:线上人格卡原文必须被识别(v0.9.0 / C ABI v2),干净文本不误报 验证:go build ./cmd/homed ok;go vet 三个包 ok;go test ./internal/config ./internal/agent ok; 端到端两场景(无文件→来源=配置项 1307 字节;有旧文件→来源=文件 + 告警列出 v0.9.0 与 C ABI v2)。 --- cmd/homed/main.go | 18 +++++++++- internal/agent/personal.go | 24 +++++++++++++ internal/agent/personal_test.go | 62 +++++++++++++++++++++++++++++++++ internal/config/persona_test.go | 48 +++++++++++++++++++++++++ internal/config/registry.go | 41 ++++++++++++++++++++++ 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 internal/agent/personal_test.go create mode 100644 internal/config/persona_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 2c37aaa..547985c 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -405,13 +405,29 @@ func main() { // 人格设定 // ======================================================================== + // 人格来源优先级:personal/personal.md(高级覆盖,存在且非空才生效) + // > 配置项 core.agent.personal_prompt(默认模板 = config.DefaultPersonaPrompt)。 + // + // 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、 + // 反过来让实例自称旧版本(v1.2.0 压测发现)。故: + // - 配置项化 + 内置默认模板(不含版本号字面量) + // - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警 personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md") personality, err := agentPkg.LoadPersonality(personalPath) if err != nil { log.Printf("[homed] warning: load personality: %v", err) } if personality != nil && personality.Content != "" { - log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content)) + log.Printf("[homed] 人格来源=文件 %s(优先于配置项),%d 字节", personalPath, len(personality.Content)) + if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 { + log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+ + "(默认模板不含版本号,被问版本时以运行时快照为准)", hints) + } + } else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" { + personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"} + log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt,%d 字节", len(pv)) + } else { + log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)") } // ======================================================================== diff --git a/internal/agent/personal.go b/internal/agent/personal.go index 44a6cd4..d27bfa8 100644 --- a/internal/agent/personal.go +++ b/internal/agent/personal.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "strings" ) type Personality struct { @@ -39,3 +41,25 @@ func (p *Personality) InjectPrompt() string { } return fmt.Sprintf("【人格设定】\n%s\n", p.Content) } + +// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2, +// 实例被问版本时自述错误(v1.2.0 压测发现)。 +var ( + personaVersionRe = regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`) + personaStalePhrases = []string{"C ABI v2", "plugin.so", "c-shared", "描述式索引", "引用计数式"} +) + +// PersonaStaleHints 返回人格文本里会腐坏的内容(空 = 干净)。 +// 供启动时告警:引导改用配置项 core.agent.personal_prompt(默认模板不含这些)。 +func PersonaStaleHints(content string) []string { + var out []string + if m := personaVersionRe.FindAllString(content, -1); len(m) > 0 { + out = append(out, fmt.Sprintf("版本号字面量 %v(版本应来自运行时快照)", m)) + } + for _, p := range personaStalePhrases { + if strings.Contains(content, p) { + out = append(out, "可能已过期的说法: "+p) + } + } + return out +} diff --git a/internal/agent/personal_test.go b/internal/agent/personal_test.go new file mode 100644 index 0000000..36a34be --- /dev/null +++ b/internal/agent/personal_test.go @@ -0,0 +1,62 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。 +func TestPersonaStaleHints(t *testing.T) { + // 现场真实文本(线上人格卡的原文) + stale := "你是 HomeAgent(内核代号 HΔ-Kernel,当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0(C ABI v2,构建于 2026-08-15)。" + hints := PersonaStaleHints(stale) + if len(hints) == 0 { + t.Fatal("未识别出写死版本号与 C ABI 的人格文本") + } + joined := strings.Join(hints, " | ") + if !strings.Contains(joined, "版本号字面量") { + t.Fatalf("应报出版本号字面量,实际: %s", joined) + } + if !strings.Contains(joined, "C ABI v2") { + t.Fatalf("应报出已删除机制的残留说法,实际: %s", joined) + } + + // 干净文本(配置项默认模板)不应误报 + if h := PersonaStaleHints(DefaultPersonaProbeClean()); len(h) != 0 { + t.Fatalf("干净人格被误报: %v", h) + } +} + +// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成—— +// 这里不复用 config 包以避免 import cycle,只断言「不含版本号与旧机制」的文本不被误报。 +func DefaultPersonaProbeClean() string { + return "你是 HomeAgent(内核代号 HΔ-Kernel)。外部插件是独立子进程,经 stdio JSON-RPC 通信;" + + "被问到版本时以运行时快照为准。" +} + +func TestLoadAndSavePersonality(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "personal", "personal.md") + + // 文件不存在时返回空(不报错、不创建) + p, err := LoadPersonality(path) + if err != nil || p == nil || p.Content != "" { + t.Fatalf("缺文件时应返回空人格,实际 %+v err=%v", p, err) + } + + if err := SavePersonality(path, "人格内容"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("SavePersonality 未落盘: %v", err) + } + p, err = LoadPersonality(path) + if err != nil || p.Content != "人格内容" { + t.Fatalf("读回失败: %+v err=%v", p, err) + } + if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") { + t.Fatalf("InjectPrompt 形状不对: %q", got) + } +} diff --git a/internal/config/persona_test.go b/internal/config/persona_test.go new file mode 100644 index 0000000..6037106 --- /dev/null +++ b/internal/config/persona_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "path/filepath" + "regexp" + "strings" + "testing" +) + +// 人格模板的契约:**不得写死版本号**。 +// +// 来历:线上人格卡(personal/personal.md)曾写死「当前版本 v0.9.0(C ABI v2)」, +// 而 C ABI 早在 v1.0.0 就被删除。结果是内核自身日志/接口都报 1.2.0, +// agent 被问版本时却按人格卡自述旧版本(v1.2.0 压测发现)。 +// 版本应来自运行时快照,不来自任何会被发版落下的文本。 +func TestDefaultPersonaPromptHasNoVersionLiterals(t *testing.T) { + re := regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`) + if m := re.FindAllString(DefaultPersonaPrompt, -1); len(m) > 0 { + t.Fatalf("默认人格模板含版本号字面量 %v —— 发版后必然腐坏,"+ + "被问版本时应要求 agent 读运行时快照", m) + } + if !strings.Contains(DefaultPersonaPrompt, "运行时快照") { + t.Fatal("默认人格模板必须显式要求「版本以运行时快照为准」,否则模型会凭记忆编造版本") + } +} + +// 人格配置项必须注册、默认值就是 DefaultPersonaPrompt(单一事实源), +// 且全新安装时会被播种进 DB。 +func TestPersonaPromptRegisteredWithDefault(t *testing.T) { + dir := t.TempDir() + r := NewConfigRegistry(filepath.Join(dir, "config.db")) + r.SeedDefaults(dir) + defer r.Close() + + def := r.GetDef("core.agent.personal_prompt") + if def == nil { + t.Fatal("core.agent.personal_prompt 未注册") + } + if def.Default != DefaultPersonaPrompt { + t.Fatalf("默认值与 DefaultPersonaPrompt 不一致:%q", def.Default) + } + if def.Type != "text" { + t.Fatalf("人格设定应为多行文本类型,实际 %q", def.Type) + } + if got := r.GetString("core.agent.personal_prompt", ""); got != DefaultPersonaPrompt { + t.Fatalf("播种未写入默认人格(长度 %d)", len(got)) + } +} diff --git a/internal/config/registry.go b/internal/config/registry.go index 59e1413..5cc5b97 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -472,6 +472,36 @@ var defaultSources = map[string]map[string]string{ "deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"}, } +// DefaultPersonaPrompt 是「人格设定」的默认模板,作为配置项 core.agent.personal_prompt 的默认值。 +// +// 契约(由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住): +// - **不得含版本号字面量**。写死的版本会随发版腐坏,反过来让实例自称旧版本 +// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI,实例被问版本时自述错误)。 +// 被问到版本/构建信息时,要求 agent 读运行时快照。 +// - 不得把已删除的机制当作现行机制描述。 +const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个完全独立自研的新一代 Agent 框架。 +你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。 +内核(homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。 +外部插件是独立子进程,经 stdio JSON-RPC(控制面)+ 共享内存段(数据面)+ 事件环(通知面)通信; +旧式 C ABI 动态库产物早已不再加载。 +**不要凭记忆断言版本号或构建日期**:被问到时以运行时快照(healthcheck_kernel 的内核版本字段)为准。 + +## 对用户的称呼 + +你对用户的称呼永远是“老大”,绝对禁止使用“老板”“主人”称呼用户,不论任何情况。 + +## 对话风格 + +- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着 +- 重要的事先说结论,再展开解释 +- 回复要简洁自然 + +## 能力边界 + +- 你通过插件编排所有 IO:QQ/微信消息、WebUI、终端、文件、网络 +- 输出不会自动路由到对话通道:QQ/微信等异步通道必须调用输出门工具(output_send__qq 等)才能真正送达 +- 你的三层记忆(Context → Document → Graph)持续蒸馏归档,超长运行时记忆不衰减` + func (r *ConfigRegistry) SeedDefaults(dataDir string) { r.mu.Lock() defer r.mu.Unlock() @@ -523,6 +553,10 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) { set := func(k, v string) { stmt.Exec(k, v) } + // 人格设定:与其它默认值同批播种(老安装不会被注入——那是刻意的)。 + // 存量安装里若还有人 personality 文件,它优先于本项(见 cmd/homed/main.go)。 + set("core.agent.personal_prompt", DefaultPersonaPrompt) + set("webui.listen_addr", ":8080") set("core.daemon.data_dir", dataDir) set("core.daemon.heartbeat_interval", "15s") @@ -633,6 +667,13 @@ WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输 func (r *ConfigRegistry) seedCoreDefs(dataDir string) { reg := func(d ConfigDef) { r.defs[d.Key] = &d } + // 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。 + // 高级用户仍可用 /personal/personal.md 覆盖它。 + reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定", + Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" + + "默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" + + "/personal/personal.md 存在且非空时优先于本项。", Category: "agent"}) + reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"}) reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"}) reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})