mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
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 防腐判据(部分已随本次落地)
This commit is contained in:
@ -71,3 +71,25 @@ type BuiltinToolHandlers interface {
|
||||
ExecutePluginReload() string
|
||||
ExecuteGetPluginTools(pluginName string) string
|
||||
}
|
||||
|
||||
// DeviceMounter 是"能把数据面挂到自己身上"的最小面。
|
||||
//
|
||||
// ## 为什么需要它(2026-11-04)
|
||||
//
|
||||
// `kstate.State.Agent` 存 `any`(挂载点不能依赖 agent/core,否则成环)。
|
||||
// 消费方要调 `MountDevice` 就必须断言回具体类型 —— 但 device 模块断言成
|
||||
// `*agentCore.Agent` 会让 device 依赖 core 的全部实现细节,而它真正需要
|
||||
// 的只有这一个方法。
|
||||
//
|
||||
// 用一个单方法接口把需求显式化:
|
||||
//
|
||||
// · device 只依赖 api.DeviceMounter(叶子包,零环)
|
||||
// · core 的 *Agent 天然实现它(方法名与签名一致)
|
||||
// · 谁想提供"可挂载数据面的宿主",实现这一个方法即可
|
||||
//
|
||||
// ★ 与 ToolHost 的分工:ToolHost 是**执行面**(工具定义与执行),
|
||||
// DeviceMounter 是**装配面**(挂上数据面)。两者用途不同,不合并。
|
||||
type DeviceMounter interface {
|
||||
// MountDevice 挂载工具/通道数据面(装配期调用一次)。
|
||||
MountDevice(d DeviceSurface)
|
||||
}
|
||||
|
||||
@ -286,9 +286,6 @@ type Agent struct {
|
||||
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
|
||||
fusionCfg CrossModalFusionConfig
|
||||
|
||||
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
|
||||
skillIndex SkillIndexProvider
|
||||
|
||||
// device 是挂载上来的工具/通道数据面(kernelmod/device 产出)。
|
||||
// 类型是低层接口:core 不认识具体模块,避免 core ↔ kernelboot 成环。
|
||||
device agentAPI.DeviceSurface
|
||||
@ -323,14 +320,6 @@ type Agent struct {
|
||||
usageLedger usageLedger
|
||||
}
|
||||
|
||||
// SkillIndexProvider 提供已加载技能的精炼索引,供 buildSystemPrompt 注入。
|
||||
// 实现方(skillmgr)需线程安全并快速返回(每次 LLM 调用都会调用)。
|
||||
type SkillIndexProvider interface {
|
||||
// SkillIndex 返回多行文本的技能索引,每行形如 "name vX.Y - description";
|
||||
// 无技能时返回空串。
|
||||
SkillIndex() string
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
ID types.AgentID
|
||||
SystemPrompt string
|
||||
@ -449,8 +438,6 @@ type AgentConfig struct {
|
||||
EventBus *events.Bus
|
||||
ThinkingEnabled bool
|
||||
|
||||
SkillIndexProvider SkillIndexProvider
|
||||
|
||||
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
||||
|
||||
// MaxToolTurns 是单个任务允许的工具轮次上限(0 = 不限)。
|
||||
@ -576,7 +563,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
maxContextSize: cfg.MaxContextSize,
|
||||
ctxTuning: cfg.CtxTuning,
|
||||
stageHost: cfg.StageHost,
|
||||
skillIndex: cfg.SkillIndexProvider,
|
||||
eventBus: cfg.EventBus,
|
||||
selfInputCh: make(chan selfInputMsg, 64),
|
||||
childTasks: make(map[string]*childTaskState),
|
||||
@ -607,9 +593,6 @@ func New(cfg AgentConfig) *Agent {
|
||||
return a
|
||||
}
|
||||
|
||||
// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。
|
||||
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
||||
|
||||
func (a *Agent) Start() {
|
||||
go a.schedulerLoop()
|
||||
go a.interceptLoop()
|
||||
|
||||
286
internal/agent/core/promptsections.go
Normal file
286
internal/agent/core/promptsections.go
Normal file
@ -0,0 +1,286 @@
|
||||
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{}
|
||||
270
internal/agent/core/promptsections_test.go
Normal file
270
internal/agent/core/promptsections_test.go
Normal file
@ -0,0 +1,270 @@
|
||||
package core
|
||||
|
||||
// 判据:**插件声明的系统提示词段对所有 agent 可见**(2026-11-04)。
|
||||
//
|
||||
// ## 这组判据守的是哪类缺陷
|
||||
//
|
||||
// 生产故障:A2A 分诊助手(驻留子)拿 `terminal_create` 当一次性命令执行器,
|
||||
// 每条 `cd … && echo … && grep …` 开一个新终端,命令结束终端就退出,
|
||||
// `terminal_read` 立刻撞 `read /dev/ptmx: input/output error`。
|
||||
//
|
||||
// 链路拆开是四层:
|
||||
//
|
||||
// ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
|
||||
// ② 需要 cmd-tools skill 告诉它"先 plugin_start cmd"
|
||||
// ③ 但 skill 索引**没进那个 agent 的提示词**
|
||||
// ④ 因为 skillIndex 只是根 agent 的字段,由 bootstrap.go 手工接线
|
||||
//
|
||||
// 本文件钉 ③ 与 ④:**声明表挂在统一挂载点上,任何 agent 读同一份**。
|
||||
//
|
||||
// ## 为什么判据必须测"子 agent"而不是只测根
|
||||
//
|
||||
// 只测根 agent 会全绿 —— 那正是它当初上线时的状态。缺陷恰好在"子 agent
|
||||
// 拿不到"这一层,所以判据必须**逐个类型**验证:rtchild / 驻留子 / 工作式子。
|
||||
//
|
||||
// ## 为什么测生成的 prompt 而不是测字段
|
||||
//
|
||||
// 断言 `carrier.promptSections != nil` 是结构判据:字段接上了,但
|
||||
// renderSystemPrompt 里读取那段被删掉照样绿。所以直接看渲染结果。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kstate"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/promptsec"
|
||||
)
|
||||
|
||||
// mountWithSection 装一个带指定段的挂载点,返回清理函数。
|
||||
//
|
||||
// ★ 用 MountForTest 而不是 Mount:并行测试会互相覆盖挂载点,
|
||||
// 表现为随机红(本仓已有教训:判据在 -race 下才暴露)。
|
||||
func mountWithSection(t *testing.T, section, content string) {
|
||||
t.Helper()
|
||||
reg := promptsec.NewRegistry()
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: section,
|
||||
Owner: "skillmgr",
|
||||
Order: 100,
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
t.Fatalf("声明段失败: %v", err)
|
||||
}
|
||||
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
|
||||
t.Cleanup(restore)
|
||||
}
|
||||
|
||||
// rtHostWithIO 造一个父 agent + 成对 rt 通道(不带 promptSections 字段)。
|
||||
func rtHostWithIO(t *testing.T) *Agent {
|
||||
t.Helper()
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{ID: "ps-host", IO: pio, Provider: &rtBlockingProvider{}})
|
||||
if err := pio.RegisterDevice(&rtRecordingDevice{name: "psout"}); err != nil {
|
||||
t.Fatalf("注册输出设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{"psout", "psin"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "pstest",
|
||||
Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// TestRTChildSeesPromptSections 钉住 rtchild 能看到插件声明的段。
|
||||
//
|
||||
// 变红条件:renderSystemPrompt 改回读 `a.promptSections` 字段(子没有)⇒ 红。
|
||||
func TestRTChildSeesPromptSections(t *testing.T) {
|
||||
const marker = "TERMINAL-SKILL-MARKER: always read the terminal skill first"
|
||||
mountWithSection(t, "skillmgr.skills", marker)
|
||||
|
||||
host := rtHostWithIO(t)
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "ps-rt", Input: "psin", Output: "psout"})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
prompt := host.rtChildOf(id).agent.buildSystemPrompt("", "hi")
|
||||
if !strings.Contains(prompt, "TERMINAL-SKILL-MARKER") {
|
||||
t.Fatalf("★ rtchild 的提示词里没有插件声明的段 ——\n"+
|
||||
"它看不到任何 skill 说明,只能凭工具名猜用法(%d 字符)", len(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
// TestResidentSeesPromptSections 钉住驻留子能看到(**生产实际中招的那一类**)。
|
||||
//
|
||||
// A2A 的分诊助手就是驻留子。这条变红 = 故障复现。
|
||||
func TestResidentSeesPromptSections(t *testing.T) {
|
||||
const marker = "CMD-SKILL-MARKER: run plugin_start cmd before cmd_run"
|
||||
mountWithSection(t, "skillmgr.skills", marker)
|
||||
|
||||
parent, _, dir := newRootWith(t, &countingProvider{})
|
||||
spawnTestResident(t, parent, dir, "ps-res")
|
||||
|
||||
rc := parent.residentOf("ps-res")
|
||||
if rc == nil {
|
||||
t.Fatal("驻留子没创建成功")
|
||||
}
|
||||
prompt := rc.agent.buildSystemPrompt("", "hi")
|
||||
if !strings.Contains(prompt, "CMD-SKILL-MARKER") {
|
||||
t.Fatalf("★ 驻留子的提示词里没有插件声明的段 ——\n"+
|
||||
"这正是生产上'拿 terminal_create 当 cmd_run 用'的根因(%d 字符)", len(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootAgentSeesPromptSections 确认根 agent 路径没被改坏(保守侧)。
|
||||
func TestRootAgentSeesPromptSections(t *testing.T) {
|
||||
const marker = "ROOT-MARKER: skills are declared, not pulled"
|
||||
mountWithSection(t, "skillmgr.skills", marker)
|
||||
|
||||
a := New(AgentConfig{ID: "ps-root"})
|
||||
if got := a.buildSystemPrompt("", "hi"); !strings.Contains(got, "ROOT-MARKER") {
|
||||
t.Fatal("根 agent 看不到插件声明的段 —— 本次改造改坏了原有路径")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoPromptSectionsMount 钉住"没装挂载点时不能 panic、也不能凭空出现段"。
|
||||
//
|
||||
// 单测与独立工具(memgc 等)不装挂载点,这是常态而非异常。
|
||||
func TestNoPromptSectionsMount(t *testing.T) {
|
||||
restore := kstate.MountForTest(nil)
|
||||
defer restore()
|
||||
|
||||
a := New(AgentConfig{ID: "ps-none"})
|
||||
prompt := a.buildSystemPrompt("", "hi")
|
||||
if prompt == "" {
|
||||
t.Fatal("无挂载点时提示词为空 —— 基础人格丢了")
|
||||
}
|
||||
if strings.Contains(prompt, "[Skills]") {
|
||||
t.Fatal("没装注册表,提示词里却出现了 [Skills] 段")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclareTwiceRendersOnce 钉住**幂等**(回应用户"会不会多份"的担心)。
|
||||
//
|
||||
// 这条不是理论风险:system prompt 每轮重建,而一次任务里 stepPrepare 可能
|
||||
// 被重入(上下文裁剪后回到该步)。若声明是"追加"而不是"覆盖",
|
||||
// 索引会越堆越多,最终挤掉真正的对话历史。
|
||||
//
|
||||
// 变红条件:promptsec.Registry 用 slice 存段(追加语义)⇒ 出现两份。
|
||||
func TestDeclareTwiceRendersOnce(t *testing.T) {
|
||||
reg := promptsec.NewRegistry()
|
||||
for i := 0; i < 5; i++ { // 反复声明同一段 5 次
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: "skillmgr.skills",
|
||||
Owner: "skillmgr",
|
||||
Order: 100,
|
||||
Content: "IDEMPOTENT-MARKER",
|
||||
}); err != nil {
|
||||
t.Fatalf("declare: %v", err)
|
||||
}
|
||||
}
|
||||
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
|
||||
defer restore()
|
||||
|
||||
prompt := New(AgentConfig{ID: "ps-idem"}).buildSystemPrompt("", "hi")
|
||||
if n := strings.Count(prompt, "IDEMPOTENT-MARKER"); n != 1 {
|
||||
t.Fatalf("★ 同一段声明 5 次后渲染出 %d 份(应为 1)——\n"+
|
||||
"注册表必须用**覆盖**语义,否则每轮重建都会累积", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSectionRemovalTakesEffect 钉住"撤销后段消失"(插件 Stop 的路径)。
|
||||
//
|
||||
// 不撤销的后果:插件已停用,提示词里还留着"用 skill_info 读某 skill",
|
||||
// 模型于是去调不存在的工具 —— 比没有说明更糟。
|
||||
func TestSectionRemovalTakesEffect(t *testing.T) {
|
||||
reg := promptsec.NewRegistry()
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: "skillmgr.skills", Owner: "skillmgr",
|
||||
Order: 100, Content: "REMOVABLE-MARKER",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
|
||||
defer restore()
|
||||
|
||||
a := New(AgentConfig{ID: "ps-rm"})
|
||||
if !strings.Contains(a.buildSystemPrompt("", "hi"), "REMOVABLE-MARKER") {
|
||||
t.Fatal("声明后应能看到")
|
||||
}
|
||||
|
||||
reg.Remove("skillmgr.skills")
|
||||
if strings.Contains(a.buildSystemPrompt("", "hi"), "REMOVABLE-MARKER") {
|
||||
t.Fatal("撤销后段仍在 —— 插件 Stop 后模型会去调已不存在的工具")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeUpdateVisibleToExistingAgent 钉住"可运行时更新"(用户裁定)。
|
||||
//
|
||||
// 关键:**同一个 agent 实例**在注册表更新后,下一轮重建就应看到新内容。
|
||||
// 若 Agent 在构造期拷贝了注册表或其内容,这条会红。
|
||||
func TestRuntimeUpdateVisibleToExistingAgent(t *testing.T) {
|
||||
reg := promptsec.NewRegistry()
|
||||
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
|
||||
defer restore()
|
||||
|
||||
a := New(AgentConfig{ID: "ps-live"})
|
||||
|
||||
// 初始无内容
|
||||
if strings.Contains(a.buildSystemPrompt("", "hi"), "v1-of-skill") {
|
||||
t.Fatal("初始不该有内容")
|
||||
}
|
||||
|
||||
// 运行时声明
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: "skillmgr.skills", Owner: "skillmgr",
|
||||
Order: 100, Content: "v1-of-skill",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(a.buildSystemPrompt("", "hi"), "v1-of-skill") {
|
||||
t.Fatal("运行时声明后,已存在的 agent 应立刻看到")
|
||||
}
|
||||
|
||||
// 运行时更新(覆盖)
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: "skillmgr.skills", Owner: "skillmgr",
|
||||
Order: 100, Content: "v2-of-skill",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prompt := a.buildSystemPrompt("", "hi")
|
||||
if !strings.Contains(prompt, "v2-of-skill") {
|
||||
t.Fatal("更新后应看到新内容")
|
||||
}
|
||||
if strings.Contains(prompt, "v1-of-skill") {
|
||||
t.Fatal("旧内容仍在 —— 声明必须是覆盖语义")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSectionOrderIsRespected 钉住排序(插件的 Order 生效,且稳定)。
|
||||
func TestSectionOrderIsRespected(t *testing.T) {
|
||||
reg := promptsec.NewRegistry()
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: "skillmgr.late", Owner: "skillmgr", Order: 450, Content: "LATE-BLOCK",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.Declare(promptsec.Section{
|
||||
Section: "skillmgr.early", Owner: "skillmgr", Order: 100, Content: "EARLY-BLOCK",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
|
||||
defer restore()
|
||||
|
||||
prompt := New(AgentConfig{ID: "ps-order"}).buildSystemPrompt("", "hi")
|
||||
early := strings.Index(prompt, "EARLY-BLOCK")
|
||||
late := strings.Index(prompt, "LATE-BLOCK")
|
||||
if early < 0 || late < 0 {
|
||||
t.Fatalf("两段都应出现(early=%d late=%d)", early, late)
|
||||
}
|
||||
if early > late {
|
||||
t.Fatalf("★ Order 小的必须在前(early 在 %d,late 在 %d)", early, late)
|
||||
}
|
||||
}
|
||||
@ -266,14 +266,12 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider),
|
||||
IO: childIO,
|
||||
StageHost: a.stageHost,
|
||||
// ★ 技能索引必须继承父的:不继承时子的 system prompt 里没有索引,
|
||||
// 模型看不到"按需插件要先 plugin_start"这类说明(同 rtchild)。
|
||||
SkillIndexProvider: a.skillIndex,
|
||||
LightMemory: light, // 轻量内核:只有图记忆共同面
|
||||
AllowedOutputs: opts.AllowedOutputs,
|
||||
KernelSource: parentID, // 子的 L4 只属于父
|
||||
ParentID: parentID,
|
||||
TaskPrompt: opts.TaskPrompt,
|
||||
// ★ 同一个注册表指针(不是拷贝):插件运行时更新的段立刻对子 agent 生效。
|
||||
LightMemory: light, // 轻量内核:只有图记忆共同面
|
||||
AllowedOutputs: opts.AllowedOutputs,
|
||||
KernelSource: parentID, // 子的 L4 只属于父
|
||||
ParentID: parentID,
|
||||
TaskPrompt: opts.TaskPrompt,
|
||||
// ★ 安全运行时必须继承(2026-10-30 补)。
|
||||
//
|
||||
// 驻留子是长期挂着、无人盯的执行体:它自己跑调度器、接自己的输入、
|
||||
|
||||
@ -627,17 +627,10 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
PreferredProvider: firstNonEmpty(opts.PreferredProvider, a.preferredProvider),
|
||||
IO: childIO,
|
||||
StageHost: a.stageHost,
|
||||
// ★ 技能索引必须**继承父的**(2026-11-04 修)。
|
||||
//
|
||||
// 不继承的后果与 Security 那次同型:子 agent 的 system prompt 里
|
||||
// **完全没有 skill 索引**,于是它不知道该先读哪个 skill 就直接调工具。
|
||||
// 实测症状:A2A 分诊助手手上没有 cmd_run(cmd 插件 auto_start=false),
|
||||
// 又看不到 cmd-tools skill 里"先 plugin_start cmd"的说明,
|
||||
// 就把 terminal_create 当成命令执行器用,一条命令开一个终端。
|
||||
SkillIndexProvider: a.skillIndex,
|
||||
AllowedOutputs: []string{opts.Output},
|
||||
KernelSource: string(a.id),
|
||||
ParentID: string(a.id),
|
||||
// ★ 同一个注册表指针(不是拷贝):插件运行时更新的段立刻对子 agent 生效。
|
||||
AllowedOutputs: []string{opts.Output},
|
||||
KernelSource: string(a.id),
|
||||
ParentID: string(a.id),
|
||||
// ★ 安全运行时**必须继承父的**(2026-10-30 补)。
|
||||
//
|
||||
// rtchild 是高频、低延迟的执行体,且它照样能调工具(R3:rt 流不禁工具)。
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
package core
|
||||
|
||||
// 判据:**子 agent 必须继承技能索引**(2026-11-04 生产缺陷)。
|
||||
//
|
||||
// ## 缺陷原貌
|
||||
//
|
||||
// `SetSkillIndexProvider` 只在 `cmd/homed/bootstrap.go` 里对**根 agent** 调了一次:
|
||||
//
|
||||
// if sp := pluginReg.Get("skillmgr"); sp != nil {
|
||||
// if prov, ok := sp.(agentCore.SkillIndexProvider); ok {
|
||||
// agent.SetSkillIndexProvider(prov)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// 而 rtchild / 驻留子 / 转投驻留子 各自 `New(AgentConfig{...})` 构造载体,
|
||||
// 都**没有**传 `SkillIndexProvider`。于是子的 system prompt 里
|
||||
// **完全没有 skill 索引**。
|
||||
//
|
||||
// ## 生产症状(真实日志)
|
||||
//
|
||||
// A2A 会话由「临时分诊助手」(驻留子)接手,它手上没有 `cmd_run`
|
||||
// (`cmd` 插件 `auto_start=false`),而 `cmd-tools` skill 正文里写着
|
||||
// 「先 plugin_start cmd」。它看不到这条 skill,于是把 `terminal_create`
|
||||
// 当成命令执行器:每条 `cd … && echo … && grep …` 开一个新终端,
|
||||
// 终端随命令结束而退出,`terminal_read` 立刻撞
|
||||
// `read /dev/ptmx: input/output error`。
|
||||
//
|
||||
// ## 与 Security 那次同型
|
||||
//
|
||||
// 父有的能力,子没继承。`Security` 那次也是"只修了主 agent",
|
||||
// 后来补成"构造期落位 + 各子构造点显式传"。
|
||||
//
|
||||
// ## 判据为什么测 prompt 而不是测字段
|
||||
//
|
||||
// 断言 `carrier.skillIndex != nil` 是结构判据:字段接上了,但
|
||||
// `buildSystemPrompt` 里注入那段被删了照样绿。所以直接看**生成的提示词**。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// fakeSkillIndex 是最小 SkillIndexProvider 替身。
|
||||
type fakeSkillIndex struct{ content string }
|
||||
|
||||
func (f *fakeSkillIndex) SkillIndex() string { return f.content }
|
||||
|
||||
// rtHostWithSkills 造一个带技能索引的父 agent + 成对 rt 通道。
|
||||
func rtHostWithSkills(t *testing.T, idx SkillIndexProvider) *Agent {
|
||||
t.Helper()
|
||||
pio := agentIO.NewIOManager()
|
||||
host := New(AgentConfig{
|
||||
ID: "si-host", IO: pio, Provider: &rtBlockingProvider{},
|
||||
SkillIndexProvider: idx,
|
||||
})
|
||||
if err := pio.RegisterDevice(&rtRecordingDevice{name: "siout"}); err != nil {
|
||||
t.Fatalf("注册输出设备: %v", err)
|
||||
}
|
||||
for _, n := range []string{"siout", "siin"} {
|
||||
if err := pio.ChannelRegistry().Register(agentIO.InputChannel{
|
||||
Name: n, Plugin: "sitest",
|
||||
Capabilities: agentIO.CapText | agentIO.CapRT,
|
||||
}); err != nil {
|
||||
t.Fatalf("注册通道 %s: %v", n, err)
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// TestRTChild_InheritsSkillIndex 钉住 rtchild 继承技能索引。
|
||||
//
|
||||
// 变红条件:从 SpawnRTChild 的 AgentConfig 删掉 SkillIndexProvider ⇒ 红。
|
||||
func TestRTChild_InheritsSkillIndex(t *testing.T) {
|
||||
const marker = "kernel-terminal v1.0.0 - Terminal waits for input"
|
||||
host := rtHostWithSkills(t, &fakeSkillIndex{content: marker})
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "si-rt", Input: "siin", Output: "siout"})
|
||||
if err != nil {
|
||||
t.Fatalf("SpawnRTChild: %v", err)
|
||||
}
|
||||
carrier := host.rtChildOf(id).agent
|
||||
|
||||
prompt := carrier.buildSystemPrompt("", "hi")
|
||||
if !strings.Contains(prompt, "kernel-terminal") {
|
||||
t.Fatalf("★ rtchild 的 system prompt 里没有技能索引 —— "+
|
||||
"它看不到任何 skill,只能凭工具名猜用法(%d 字符的 prompt)", len(prompt))
|
||||
}
|
||||
if !strings.Contains(prompt, "/dev/ptmx") && !strings.Contains(prompt, "Terminal waits for input") {
|
||||
t.Fatalf("★ 索引内容没进 prompt(只匹配到了 skill 名?)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResident_InheritsSkillIndex 钉住驻留子继承技能索引。
|
||||
//
|
||||
// 这是**生产实际中招的那一类**:A2A 的临时分诊助手就是驻留子。
|
||||
//
|
||||
// 变红条件:从 SpawnResident 的 AgentConfig 删掉 SkillIndexProvider ⇒ 红。
|
||||
func TestResident_InheritsSkillIndex(t *testing.T) {
|
||||
const marker = "cmd-tools v1.0.0 - on-demand: plugin_start cmd first"
|
||||
parent, _, dir := newRootWith(t, &countingProvider{})
|
||||
parent.skillIndex = &fakeSkillIndex{content: marker}
|
||||
|
||||
spawnTestResident(t, parent, dir, "si-res")
|
||||
rc := parent.residentOf("si-res")
|
||||
if rc == nil {
|
||||
t.Fatal("驻留子没创建成功")
|
||||
}
|
||||
prompt := rc.agent.buildSystemPrompt("", "hi")
|
||||
if !strings.Contains(prompt, "cmd-tools") {
|
||||
t.Fatalf("★ 驻留子的 system prompt 里没有技能索引 —— "+
|
||||
"这正是生产上'拿 terminal_create 当 cmd_run 用'的根因"+
|
||||
"(%d 字符的 prompt)", len(prompt))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRootAgent_SkillIndexStillInjected 钉住根 agent 不受本次改动影响。
|
||||
//
|
||||
// 保守侧:确认修复没有把根 agent 的路径改坏。
|
||||
func TestRootAgent_SkillIndexStillInjected(t *testing.T) {
|
||||
const marker = "kernel-memory v1.0.0 - read before memory tools"
|
||||
a := New(AgentConfig{ID: "root-si", SkillIndexProvider: &fakeSkillIndex{content: marker}})
|
||||
prompt := a.buildSystemPrompt("", "hi")
|
||||
if !strings.Contains(prompt, "kernel-memory") {
|
||||
t.Fatal("根 agent 的技能索引丢了 —— 本次改动改坏了原有路径")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildWithoutSkillIndexIsNotBroken 钉住"父没有索引时子也不炸"。
|
||||
//
|
||||
// 父可能没接 skillmgr(单测、独立工具)。那种情况下子照常工作,
|
||||
// 只是 prompt 里没有索引段 —— 不能因为 nil 就 panic。
|
||||
func TestChildWithoutSkillIndexIsNotBroken(t *testing.T) {
|
||||
host := rtHostWithSkills(t, nil) // 显式不接
|
||||
defer host.StopRTChildren()
|
||||
|
||||
id, err := host.SpawnRTChild(RTChildOptions{ID: "si-none", Input: "siin", Output: "siout"})
|
||||
if err != nil {
|
||||
t.Fatalf("父无技能索引时 SpawnRTChild 不该失败: %v", err)
|
||||
}
|
||||
prompt := host.rtChildOf(id).agent.buildSystemPrompt("", "hi")
|
||||
if strings.Contains(prompt, "[Skills]") {
|
||||
t.Fatal("父没有技能索引,子的 prompt 却出现了 [Skills] 段")
|
||||
}
|
||||
}
|
||||
@ -173,132 +173,26 @@ func appendPromptBlock(prompt, block string) string {
|
||||
return strings.TrimRight(prompt, "\n") + "\n\n" + block
|
||||
}
|
||||
|
||||
// buildSystemPrompt 生成发给 LLM 的 system prompt。
|
||||
//
|
||||
// ★ 2026-11-04 起**不再自己拼块**:所有内容(内核的十几块 + 插件声明的段)
|
||||
// 统一由 renderSystemPrompt 按 Order 排序渲染。本函数只负责把当轮上下文
|
||||
// (memContext)递进去。
|
||||
//
|
||||
// 为什么要统一(用户裁定):
|
||||
//
|
||||
// 「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
|
||||
// systemprompt 管理器上」
|
||||
//
|
||||
// 此前两套并存(内核硬拼 + 插件声明),后果是**顺序不可控**:插件段只能
|
||||
// 整块插在某个固定位置;内核那十几块的相对顺序由函数体书写顺序决定,
|
||||
// 改顺序得挪代码。现在顺序由 Order 常量决定,内核只是"另一个声明者"。
|
||||
//
|
||||
// userInput 参数保留:它是部分内核块(如记忆上下文的相关性)的输入,
|
||||
// 由调用方在 buildMemoryContext 里使用后传入,渲染层不需要它。
|
||||
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt := expandPromptVars(a.systemPrompt)
|
||||
if prompt == "" {
|
||||
prompt = "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."
|
||||
}
|
||||
|
||||
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
|
||||
if a.taskPrompt != "" {
|
||||
prompt = appendPromptBlock(prompt, "[Task] "+a.taskPrompt)
|
||||
}
|
||||
|
||||
if a.personality != nil {
|
||||
if pp := a.personality.InjectPrompt(); pp != "" {
|
||||
// 人格卡同样展开版本占位符:人格里写死版本号会随发版说谎
|
||||
// (实测线上人格卡写 v1.0.3,内核早已 1.3.x)。
|
||||
prompt = appendPromptBlock(prompt, expandPromptVars(pp))
|
||||
}
|
||||
}
|
||||
|
||||
if memContext != "" {
|
||||
prompt = appendPromptBlock(prompt, memContext)
|
||||
}
|
||||
|
||||
// 【三层记忆】是**内核运行时块**(提示词分层规范:内核行为由内核生成)。
|
||||
//
|
||||
// 它讲的是内核的真实执行语义(哪些内容在哪一层、何时会进上下文、
|
||||
// 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。
|
||||
//
|
||||
// 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么,
|
||||
// agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 coldcontext_query,
|
||||
// 即使调用了也只看返回值、不知道原文已被回填到对话时序。
|
||||
// 【三层记忆】只留**模型据此选择工具**的语义。
|
||||
//
|
||||
// 技术性内容(L1/L2/L3 编号、向量空间、消费式删除、缩略语)全部下沉到
|
||||
// 对应工具的 description 与 skill:模型只需要知道"哪里有原文、哪里有
|
||||
// 关系、用什么去取",不需要知道内核怎么存。
|
||||
//
|
||||
// ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,而不是返回
|
||||
// 一段摘要 —— 这与模型对"检索工具"的默认预期相反,不说就会误用。
|
||||
prompt = appendPromptBlock(prompt, "[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_* 的工具描述里
|
||||
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
|
||||
prompt = appendPromptBlock(prompt, "[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(输入路/工具路共用一次),
|
||||
// 这里不再重复注入,否则同一批摘要会出现两遍。
|
||||
|
||||
prompt = appendPromptBlock(prompt, "[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.")
|
||||
|
||||
// 工具执行顺序(阶段 2.5)。必须在**并行执行落地之后**才加:
|
||||
// 反序会让本段对模型说谎——说"并发"而内核仍串行,模型据此推断
|
||||
// 安全性并写出真正依赖顺序的调用。宁可晚改,不可错改。
|
||||
//
|
||||
// ⚠️ 措辞必须与 batchRunnable 的**真实**判据一致(全批 ParallelSafe
|
||||
// 才并发 + 同通道保序),否则只是把"没说"换成"说错"。
|
||||
// 【工具执行顺序】只留**模型必须据此作决定**的三条。
|
||||
//
|
||||
// 技术性内容(ParallelSafe 声明、批内降级、内核如何判据)全部下沉:
|
||||
// 那是实现细节,模型既不需要也改不了 —— 讲它只会让回复带上说明书腔。
|
||||
// 判断依据仍是真实的 batchRunnable 语义(勿写反)。
|
||||
prompt = appendPromptBlock(prompt, "[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.")
|
||||
|
||||
prompt = appendPromptBlock(prompt, "[Output rules]\n")
|
||||
// ★ 这一句是本段最重要的:用户明确要求「必须显式调用 outputch 才会输出给用户」。
|
||||
// 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了**——
|
||||
// 异步通道下那等于自言自语(用户永远收不到)。
|
||||
prompt += "**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"
|
||||
prompt += "- Don't assume a fixed channel: one conversation may span devices and channels. If unsure, call output_list_channels.\n"
|
||||
prompt += "- Sync channels (webui / cli / terminal): return plain text. **The only exception.**\n"
|
||||
prompt += "- Async channels (qq / wechat / groups): call output_send__{channel} with the right user_id or group_id.\n"
|
||||
prompt += "- One call may send several messages: pass an array as payload. Unclear how to send? output_send__{channel}_help.\n"
|
||||
// ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**:
|
||||
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
|
||||
// 先回执后结论、同时通知多个通道都合法)。此前这里写着"每轮对话通常只需调用
|
||||
// 一次 output_send"——那是一条**凭空的限制**,会让模型自己收起合理的多次输出。
|
||||
// 真正需要提醒的只有两件事:单条长度上限(超长拆成完整段落)与"别反复重发
|
||||
// 完全相同的内容"(自律,不是判据)。
|
||||
prompt += "- **You choose how many outputs and which channels.** **No \"one output per turn\" limit.** Keep messages whole; don't resend identical content.\n"
|
||||
prompt += "- Multi-step work: acknowledge on the current channel **first**, then run the real tools. An acknowledgement is not completion - finish and report.\n"
|
||||
|
||||
if a.indexer != nil {
|
||||
prompt += "\n\n" + a.indexer.BuildToolPrompt()
|
||||
}
|
||||
|
||||
// seq 的触发条件**刻意不写在这里**(用户裁定:seq 知识应在 skill 中)。
|
||||
//
|
||||
// 一度加过【加速序列】块,理由是"skill 索引只给一行摘要,模型不知道何时该用"。
|
||||
// 那个理由站不住:紧邻下方的【可用技能】已明确要求"准备调用某个插件的工具时
|
||||
// 先看同名技能",seq 的"什么时候用"写在 skill 正文里会被这条规则正确带到
|
||||
// 模型面前。再加一块提示词 = 同一件事记两处,且必然漂移。
|
||||
//
|
||||
// 副作用证据:加块后 TestSystemPromptStaysCompact 报 2022 > 2000。
|
||||
// 那个超限**本身就是信号**——该去掉的是内容,不是调高上限。
|
||||
|
||||
// 技能索引(方案B):轻量注入已加载技能列表,LLM 匹配到场景时
|
||||
// 主动 skill_info 拉取全文按文档执行
|
||||
if a.skillIndex != nil {
|
||||
if idx := a.skillIndex.SkillIndex(); idx != "" {
|
||||
// ★ 措辞从"用户请求匹配某技能时"改成"**调用插件工具之前**"。
|
||||
//
|
||||
// 旧措辞把人带偏成"只有用户点名要某个技能才去读",而 skill 的真实
|
||||
// 用途是**工具的使用说明**:里面写着参数怎么填、有哪些已知坑
|
||||
// (例如 QQ 的"拉私聊历史只传 user_id,不要传 group_id")、
|
||||
// 以及按需插件要先 plugin_start 拉起。
|
||||
//
|
||||
// 不看 skill 就直接调工具,是这个仓库反复吃过亏的一类错误。
|
||||
prompt = appendPromptBlock(prompt, "[Skills] Usage manuals for plugins. Before calling a plugin's tool (or when it fails), read the same-named skill with skill_info(\"skill-name\") and follow it. "+
|
||||
"Never guess from the name.\n\n"+idx)
|
||||
}
|
||||
}
|
||||
|
||||
// 首启人格门禁(跨通道唯一闸口):人格未确认时,要求模型主动询问用户。
|
||||
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
|
||||
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
|
||||
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
|
||||
prompt = appendPromptBlock(prompt, "[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.")
|
||||
}
|
||||
|
||||
prompt += a.buildToolCatalog()
|
||||
|
||||
return prompt
|
||||
_ = userInput
|
||||
return a.renderSystemPrompt(memContext)
|
||||
}
|
||||
|
||||
func cleanParams(params map[string]interface{}) map[string]interface{} {
|
||||
|
||||
Reference in New Issue
Block a user