Files
HomeAgent/internal/agent/core/promptsections.go
JianFeeeee cc0eb51286 refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段
## 起因(生产故障)

A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:

    terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
    → read /dev/ptmx: input/output error
    → 再开 term_7、term_8、term_9 …

链路拆开四层:
  ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
  ② 需要 cmd-tools skill 说明"先 plugin_start cmd"
  ③ 但 skill 索引没进那个 agent 的提示词
  ④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线

④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。

## 环的精确形状(实测,非推测)

kernelboot 因为持有 KernelState 而依赖 19 个业务包:

    kernelboot ──▶ agent/core ──▶ …
               ──▶ plugin
               ──▶ security
               ──▶ memory / knowledge / text / coldcontext / …

而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。

后果是四个底层包各自造通道:

    core      → Agent 字段(每种 agent 构造点都要记得传)
    plugin    → 12 个 SetXxx
    sdk       → SDKConfig 传参
    security  → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()

四条通道是同一个根因的四个表现。

## 本次做的事

### 1. 抽出 internal/kstate(断环)

kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:

    internal/kstate     纯数据 + 唯一挂载点(叶子,谁都能 import)
    internal/kernelboot 装配引擎,零业务包

挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。

验证:core 现在能 import kstate(此前 import cycle not allowed)。

### 2. StageHost / Agent 字段存 any

它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:

    > 是其他包在kstate挂共享内容啊

存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。

★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。

### 3. 插件声明式系统提示词段

SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:

    kstate.State.PromptSections *promptsec.Registry   ← 唯一事实来源
    Agent.renderSystemPrompt() 每轮重建时读它

skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":

    DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}

并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。

### 4. 内核自身的提示词段也走同一管理器

按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。

顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。

## 判据(均经变异验证)

  TestCoreCanImportKstate            真编译探针;环重现则红(实测)
  TestKstateDoesNotDependOnCore      静态版,指向具体字段
  TestKstateIsLeaf                   kstate 不得反依赖上层
  TestRTChildSeesPromptSections      子 agent 能看到插件段
  TestResidentSeesPromptSections     生产实际中招的那一类;变异后红(实测)
  TestRootAgentSeesPromptSections    确认没改坏根路径
  TestNoPromptSectionsMount          未装配时不 panic、不凭空出现段
  TestDeclareTwiceRendersOnce        ★ 回应"会不会多份":声明 5 次渲染 1 份
  TestSectionRemovalTakesEffect      Stop 后段消失
  TestRuntimeUpdateVisibleToExistingAgent  同一实例下一轮看到新内容
  TestSectionOrderIsRespected        Order 生效且稳定

## 验证

  go build ./...                   PASS
  go vet ./...                     PASS
  gofmt -l internal/ cmd/ pkg/     干净
  go test ./... -count=1           63 包全过,0 FAIL
  干净检出(git worktree)           build + 4 包测试全绿

## 未做(见 kernelplan.md)

  T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
  T5 plugin Setter 收敛
  T6 防腐判据(部分已随本次落地)
2026-11-04 11:23:05 +08:00

287 lines
14 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
// 内核自身声明的系统提示词段(2026-11-04 统一到 promptsec)。
//
// ## 用户裁定
//
// > 同时把现在内核所有 systemprompt 的相关注入都接入到这个统一的
// > systemprompt 管理器上。
//
// 在此之前的形态是两套并存:
//
// ① 内核在 buildSystemPrompt 里用 appendPromptBlock 硬拼十几块
// ② 插件(skillmgr)走 promptSections 声明自己的段
//
// 两套并存的问题不是"不好看",而是**顺序不可控**:插件段只能整体插在
// 内核硬拼流程的某个固定位置,没法说"我要排在某两块之间";而内核那十几块
// 的相对顺序又完全由代码书写顺序决定,改一处顺序得动函数体。
//
// 现在统一走一个注册表:内核把自己那十几块也**声明**进去,由 Order 决定
// 全部顺序。内核只是"另一个声明者",插件看到的渲染规则与内核完全一致。
//
// ## 与插件段的区别(只有一点)
//
// 插件段:插件声明 → 常驻注册表(跨轮存活,可运行时更新)
// 内核段:每轮根据当前 agent 实例状态实时计算 → 当轮渲染
//
// 内核块大多依赖实例状态(人格卡、记忆上下文、任务提示词、索引器、首启门禁),
// 不能预先存进共享注册表。所以内核走"当轮临时渲染":
//
// kernelSections(a, memContext) → 当轮快照 → 与注册表合并 → 渲染
//
// ★ 合并时**不写回**共享注册表:注册表是插件声明的家,内核的每轮快照
// 写进去会让它无限增长(每个 agent 一份),且下一轮读到上一轮的陈旧块。
import (
"sort"
"strings"
"github.com/JianFeeeee/HologramAgent/internal/kstate"
"github.com/JianFeeeee/HologramAgent/internal/promptsec"
)
// 内核段的 Order 区段。
//
// 区段划分与 SDK 的 PromptSection.Order 约定一致(见 sdk/systemprompt.go):
//
// 0–99 内核框架(基础人格、人格卡、任务提示词)
// 100–199 工具与技能使用说明(插件段:skillmgr 等)
// 200–299 记忆与上下文说明
// 300–399 输出与通道规则
// 400–499 工具目录
//
// ★ 为什么把"记忆"排在"工具"之后:"什么时候用哪个工具"要在模型已经知道
// 有哪些工具之后读才有效;反过来(先讲记忆工具、后列工具)会让前面那段
// 引用还不存在的工具名。这是既有顺序,此处只是把它显式化。
const (
orderKernelBase = 0 // 基础人格(systemPrompt 兜底句)
orderKernelTask = 10 // 驻留子的任务提示词
orderKernelPersona = 20 // 人格卡
orderKernelMemoryCtx = 200 // 记忆上下文(由 memoryPass 产出)
orderKernelMemory = 210 // 【Memory】三层记忆说明
orderKernelMemMaint = 220 // 【Memory maintenance】
orderKernelInterrupt = 230 // 【Interrupt】
orderKernelToolOrder = 240 // 【Tool order】
orderKernelOutput = 300 // 【Output rules】
orderKernelIndexer = 320 // 索引器(工具提示)
orderKernelFirstRun = 400 // 首启人格门禁
orderKernelToolCata = 500 // 工具目录
)
// kernelSection 是内核当轮计算出来的一段。
type kernelSection struct {
order int
section string // 仅用于同 Order 的稳定排序(按名字典序)
content string
}
// kernelSections 按**当前实例状态**算出内核本轮要注入的段。
//
// 顺序由 order 决定,与函数体里的书写顺序无关 —— 这是本次统一的要点:
// 调整提示词结构变成调常量,而不是挪代码块。
func (a *Agent) kernelSections(memContext string) []kernelSection {
base := expandPromptVars(a.systemPrompt)
if base == "" {
base = "You are HologramAgent, a household-manager AI assistant. Never use Unicode emoji; express affect with kaomoji only, and end sentences with a soft particle. The WebUI overview page shows your portrait."
}
out := []kernelSection{
{order: orderKernelBase, section: "kernel.base", content: base},
}
// 驻留子:在固定提示词之上注入任务提示词(设计 §7「创建」)。
if a.taskPrompt != "" {
out = append(out, kernelSection{
order: orderKernelTask, section: "kernel.task",
content: "[Task] " + a.taskPrompt,
})
}
// 人格卡:同样展开版本占位符(人格里写死版本号会随发版说谎 ——
// 实测线上人格卡写 v1.0.3,内核早已 1.3.x)。
if a.personality != nil {
if pp := a.personality.InjectPrompt(); pp != "" {
out = append(out, kernelSection{
order: orderKernelPersona, section: "kernel.persona",
content: expandPromptVars(pp),
})
}
}
if memContext != "" {
out = append(out, kernelSection{
order: orderKernelMemoryCtx, section: "kernel.memory_context",
content: memContext,
})
}
// 【三层记忆】只留"模型据此选择工具"的语义。
//
// 它讲的是内核真实执行语义(哪些内容在哪一层、何时进上下文、哪个工具
// 会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。
//
// 为什么必须有:此前系统提示词从未解释 L1/L2/L3,agent 只能从工具名猜。
// 生产日志实测:agent 几乎从不调 coldcontext_query,即使调了也只看返回值、
// 不知道原文已被回填到对话时序。
//
// ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,
// 而不是返回摘要 —— 这与模型对"检索工具"的默认预期相反,不说就误用。
out = append(out, kernelSection{
order: orderKernelMemory, section: "kernel.memory",
content: "[Memory] Recent turns are above. For older conversations use `coldcontext_query` - it **re-inserts them into your timeline at their original timestamps**, so read them there, not in the return value. " +
"Facts and relations: `stablemem_recall`. Written knowledge: `knowledge_search`.",
})
// 记忆维护:具体选哪个工具、各有什么副作用都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
out = append(out, kernelSection{
order: orderKernelMemMaint, section: "kernel.memory_maintenance",
content: "[Memory maintenance] To tidy or clear memory, call the memory_* tools - a described plan is not the work. Report the result in one sentence.",
})
// L2(冷上下文)预填充已并入 buildMemoryContext(输入路/工具路共用一次),
// 这里不再重复注入,否则同一批摘要会出现两遍。
out = append(out, kernelSection{
order: orderKernelInterrupt, section: "kernel.interrupt",
content: "[Interrupt] During long tasks a system message prefixed [Interrupt] may arrive (new message, terminal output, timer). It is not user speech, but handle it: pause, respond, or consciously continue.",
})
// 【工具执行顺序】只留**模型必须据此作决定**的三条。
//
// 必须在并行执行落地之后才加:反序会让本段对模型说谎 —— 说"并发"而
// 内核仍串行,模型据此推断安全性并写出真正依赖顺序的调用。
// ⚠️ 措辞必须与 batchRunnable 的**真实**判据一致。
out = append(out, kernelSection{
order: orderKernelToolOrder, section: "kernel.tool_order",
content: "[Tool order] Calls in one reply run **in parallel** by default. **Never depend on order**: if one needs another's result, use two turns. " +
"Outputs to the same channel **keep their order**, so \"acknowledge, then conclude\" fits in one turn.",
})
out = append(out, kernelSection{
order: orderKernelOutput, section: "kernel.output_rules",
content: kernelOutputRules(),
})
if a.indexer != nil {
out = append(out, kernelSection{
order: orderKernelIndexer, section: "kernel.indexer",
content: a.indexer.BuildToolPrompt(),
})
}
// seq 的触发条件**刻意不写在这里**(用户裁定:seq 知识应在 skill 中)。
//
// 一度加过【加速序列】块,理由是"skill 索引只给一行摘要,模型不知道何时该用"。
// 那个理由站不住:插件声明的技能索引已明确要求"准备调用某个插件的工具时
// 先看同名技能",seq 的"什么时候用"写在 skill 正文里会被这条规则正确带到
// 模型面前。再加一块提示词 = 同一件事记两处,且必然漂移。
//
// 副作用证据:加块后 TestSystemPromptStaysCompact 报 2022 > 2000。
// 那个超限**本身就是信号** —— 该去掉的是内容,不是调高上限。
// 首启人格门禁(跨通道唯一闸口):人格未确认时要求模型主动询问用户。
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会
// 带上它;模型调用 persona_set(或用户在向导里选)落地后,标记置位,本段消失。
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
out = append(out, kernelSection{
order: orderKernelFirstRun, section: "kernel.first_run",
content: "[First-run persona setup] Your **persona is not confirmed**. Ask once this turn: default persona, or a custom one? Then **call persona_set**: " +
"default -> mode=default; custom -> mode=custom with the text in content; later -> mode=later. Until answered, don't assume it is set and don't repeat the question.",
})
}
out = append(out, kernelSection{
order: orderKernelToolCata, section: "kernel.tool_catalog",
content: a.buildToolCatalog(),
})
return out
}
// kernelOutputRules 是【Output rules】的正文。
//
// 单独成函数只为了让 kernelSections 保持可读;渲染结果与原先逐字一致。
func kernelOutputRules() string {
var b strings.Builder
// ★ 这一句是本段最重要的:用户明确要求「必须显式调用 outputch 才会输出给用户」。
// 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** ——
// 异步通道下那等于自言自语(用户永远收不到)。
_, _ = b.WriteString("[Output rules]\n")
_, _ = b.WriteString("**Content reaches the user only when you explicitly call `output_send__{channel}`.** " +
"Returning plain text only speaks inside this reasoning step; the user cannot see it - except on the synchronous channels described below.\n")
_, _ = b.WriteString("- Don't assume a fixed channel: one conversation may span devices and channels. If unsure, call output_list_channels.\n")
_, _ = b.WriteString("- Sync channels (webui / cli / terminal): return plain text. **The only exception.**\n")
_, _ = b.WriteString("- Async channels (qq / wechat / groups): call output_send__{channel} with the right user_id or group_id.\n")
_, _ = b.WriteString("- One call may send several messages: pass an array as payload. Unclear how to send? output_send__{channel}_help.\n")
// ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**:
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
// 先回执后结论、同时通知多个通道都合法)。此前这里写着"每轮对话通常只需
// 调用一次 output_send" —— 那是一条**凭空的限制**,会让模型自己收起合理
// 的多次输出。真正需要提醒的只有两件事:单条长度上限(超长拆成完整段落)
// 与"别反复重发完全相同的内容"(自律,不是判据)。
_, _ = b.WriteString("- **You choose how many outputs and which channels.** **No \"one output per turn\" limit.** Keep messages whole; don't resend identical content.\n")
_, _ = b.WriteString("- Multi-step work: acknowledge on the current channel **first**, then run the real tools. An acknowledgement is not completion - finish and report.")
return b.String()
}
// renderSystemPrompt 把内核当轮段与插件声明段合并渲染成最终 system prompt。
//
// ★ 这是**唯一**的渲染入口:buildSystemPrompt 只负责算出 memContext 递进来,
// 不再自己拼块。这样"提示词长什么样"只有一处可看。
//
// 合并语义:
// - 内核段与插件段按 order 统一排序(同 order 按段名字典序,保证稳定)
// - 段间用空行分隔(与 appendPromptBlock 的语义一致,保证输出逐字不变)
// - 空内容段跳过(插件可能声明了段但暂时没内容)
func (a *Agent) renderSystemPrompt(memContext string) string {
all := make([]renderableSection, 0, 16)
for _, k := range a.kernelSections(memContext) {
if strings.TrimSpace(k.content) == "" {
continue
}
all = append(all, renderableSection{order: k.order, name: k.section, content: k.content})
}
// 插件声明的段从**统一挂载点**读(2026-11-04)。
//
// ★ 为何不是 Agent 字段:字段方案要求每种 agent(rtchild / 驻留子 /
// 工作式子)在构造时都记得传一遍,漏传不报错也不崩,只是那个 agent
// 的提示词里一条插件说明都没有 —— 生产上就是这样让分诊助手不知道
// "cmd 插件要先 plugin_start",于是拿 terminal_create 当命令执行器。
//
// 挂在 kstate 上后,任何 agent(含未来新类型)读同一份,**零接线**。
if ks := kstate.Get(); ks != nil && ks.PromptSections != nil {
for _, s := range ks.PromptSections.Snapshot() {
if strings.TrimSpace(s.Content) == "" {
continue
}
all = append(all, renderableSection{order: s.Order, name: s.Section, content: s.Content})
}
}
sort.SliceStable(all, func(i, j int) bool {
if all[i].order != all[j].order {
return all[i].order < all[j].order
}
return all[i].name < all[j].name
})
var b strings.Builder
for i, s := range all {
if i > 0 {
_, _ = b.WriteString("\n\n")
}
_, _ = b.WriteString(strings.TrimSpace(s.content))
}
return b.String()
}
// renderableSection 是排序用的临时结构(内核段与插件段合并后)。
type renderableSection struct {
order int
name string
content string
}
// 编译期确认:内核用的段类型与 registry 的段类型同源,避免两处各自演化。
var _ = promptsec.Section{}