diff --git a/.gitignore b/.gitignore index 6d125731..d94c8acc 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ dist/ # 内核升级 CLI 的本地构建产物(与 /homed 同理,不入库) /homed-upgrade +/kernelplan.md diff --git a/cmd/homed/bootstrap.go b/cmd/homed/bootstrap.go index be9d9955..732d4d97 100644 --- a/cmd/homed/bootstrap.go +++ b/cmd/homed/bootstrap.go @@ -145,19 +145,26 @@ func loadPlugins(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, plugi log.Printf("[homed] stage host ready with %d registered tools", stageHost.ToolCount()) } -// startAgentRuntime 接线技能索引、起日志管理、启动 agent,返回逆序关停的 cleanup。 +// startAgentRuntime 起日志管理、启动 agent,返回逆序关停的 cleanup。 // // 本函数体是 main() 里对应启动阶段的整块平移:语句、日志文本、错误语义不变, // 只把「*dataDir」变成参数、把 defer 变成由调用点注册的 cleanup。 +// +// ★ 2026-11-04 删掉了这里的"技能索引接线"(原 5 行)。 +// +// 那 5 行是「内核能力靠启动代码手工接给根 agent」的典型:skillmgr 实现了 +// agentCore.SkillIndexProvider,main 在插件加载后 type-assert 它、再塞进 +// 根 agent 的字段。于是 rtchild、驻留子各自构造时都得记得再传一遍 —— +// 漏传不报错、不崩,只是那个 agent 的提示词里一条 skill 都没有。 +// +// 生产症状:A2A 分诊助手(驻留子)不知道 cmd 插件要先 plugin_start, +// 拿 terminal_create 当一次性命令执行器,一条命令开一个终端。 +// +// 现在系统提示词段是**插件声明式**的(sdk.SystemPromptAPI):插件声明进 +// 内核级注册表(KernelState.PromptSections),任何 agent 每轮重建提示词时 +// 读同一份 —— 启动代码不需要知道有哪些插件、也不需要 type-assert 任何东西。 func startAgentRuntime(cfgReg *internalConfig.ConfigRegistry, pluginReg *plugin.Registry, agent *agentCore.Agent, logDir string, ctx context.Context) func() { - // 技能索引接线:skillmgr 插件实现 SkillIndexProvider 时注入 agent(方案B prompt 注入) - if sp := pluginReg.Get("skillmgr"); sp != nil { - if prov, ok := sp.(agentCore.SkillIndexProvider); ok { - agent.SetSkillIndexProvider(prov) - log.Printf("[homed] skill index wired from skillmgr plugin") - } - } // 日志管理:层级压缩 + 保留策略 logManager := logpkg.NewManager(logDir, cfgReg) diff --git a/cmd/homed/kmod_runtime.go b/cmd/homed/kmod_runtime.go index bf284308..508c95d8 100644 --- a/cmd/homed/kmod_runtime.go +++ b/cmd/homed/kmod_runtime.go @@ -4,6 +4,7 @@ package main import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "log" "os" "path/filepath" @@ -46,7 +47,7 @@ type kernelAssembleHooks struct { // // 有了本回调,调度器在 **Run 之前**订阅 syncbus ⇒ 内核每完成一层 // 都会触发它重新评估 ⇒ 声明了 config 的插件不必等 agent 层。 - BeforeRun func(bus *kernelboot.SyncBus) error + BeforeRun func(bus *kstate.SyncBus) error // OnLayerDone 在每层完成后调用(供 main 在 **stage-host 就绪** 的那一刻 // 接上插件拉起动作)。 @@ -66,7 +67,7 @@ type kernelAssembleHooks struct { OnLayerDone func(idx int, layer []kernelboot.Module) } -func RunKernelModules(ks *kernelboot.KernelState, hooks kernelAssembleHooks) (*kernelboot.SyncBus, error) { +func RunKernelModules(ks *kstate.State, hooks kernelAssembleHooks) (*kstate.SyncBus, error) { mods, err := buildModuleList() if err != nil { return nil, err @@ -88,7 +89,7 @@ func RunKernelModules(ks *kernelboot.KernelState, hooks kernelAssembleHooks) (*k return nil, err } - bus := kernelboot.NewSyncBus() + bus := kstate.NewSyncBus() // ★★ 先把**全部模块声明的设施**登记为"内核注册过",**再**调用 BeforeRun。 // diff --git a/cmd/homed/main.go b/cmd/homed/main.go index e4d3a617..dc64fc98 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -2,6 +2,8 @@ package main import ( "context" + agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "log" "path/filepath" "strings" @@ -164,10 +166,10 @@ func main() { // 与 bootstrap_order_test.go 的结构性判据。 // // 从 cfg 起全部改为**模块自声明依赖 + 注册表 + BFS 分层**: - // · 公共数据统一在 kernelboot.KernelState(不再散落各包包级变量) + // · 公共数据统一在 kstate.State(不再散落各包包级变量) // · 每个模块在 internal/kernelmod// 里**自己声明** Requires/Provides // · 注册表是乱序的(顺序无意义,仅供查询"内核有哪些模块") - ks := kernelboot.NewKernelState() + ks := kstate.New() ks.CfgReg = cfgReg ks.DataDir = opt.dataDir ks.AgentWorkDir = agentWorkDir @@ -260,7 +262,9 @@ func main() { trk := ks.Tracker ctx := ks.Ctx pluginReg := ks.PluginRegistry - agent := ks.Agent + // Agent 在挂载点里存 any(kstate 不能依赖 agent/core,否则成环)。 + // cmd/homed 本来就 import core,这里断言回具体类型即可。 + agent, _ := ks.Agent.(*agentCore.Agent) // 候选消费管线:常驻 goroutine,把 eventbus 的 memory_candidate 写进 // 文本记忆并喂给蒸馏管线。原顺序里它在 textmem 之后、provider 之前。 diff --git a/internal/agent/api/device.go b/internal/agent/api/device.go index eae9be53..1defa6f5 100644 --- a/internal/agent/api/device.go +++ b/internal/agent/api/device.go @@ -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) +} diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index f0be8386..6f92b14d 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -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() diff --git a/internal/agent/core/promptsections.go b/internal/agent/core/promptsections.go new file mode 100644 index 00000000..28e31281 --- /dev/null +++ b/internal/agent/core/promptsections.go @@ -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{} diff --git a/internal/agent/core/promptsections_test.go b/internal/agent/core/promptsections_test.go new file mode 100644 index 00000000..e5257815 --- /dev/null +++ b/internal/agent/core/promptsections_test.go @@ -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) + } +} diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index a9717bc5..cb7e9ed2 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -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 补)。 // // 驻留子是长期挂着、无人盯的执行体:它自己跑调度器、接自己的输入、 diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index d069ac71..33830b96 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -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 流不禁工具)。 diff --git a/internal/agent/core/skillindex_inherit_test.go b/internal/agent/core/skillindex_inherit_test.go deleted file mode 100644 index 08a350bc..00000000 --- a/internal/agent/core/skillindex_inherit_test.go +++ /dev/null @@ -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] 段") - } -} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index ff114fbc..1fe62744 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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{} { diff --git a/internal/kernelboot/funcmodule.go b/internal/kernelboot/funcmodule.go index ef30f84c..5ad19f28 100644 --- a/internal/kernelboot/funcmodule.go +++ b/internal/kernelboot/funcmodule.go @@ -1,5 +1,7 @@ package kernelboot +import "github.com/JianFeeeee/HologramAgent/internal/kstate" + // 把既有的内核 `init*` 函数**适配**成 Module,而不必重写它们的签名。 // // ## 为什么不直接改 init* 的签名 @@ -20,7 +22,7 @@ package kernelboot // Name: "lua", // Requires: []string{"config"}, // Provides: []string{"lua"}, -// Init: func(ks *kernelboot.KernelState) error { +// Init: func(ks *kstate.State) error { // vm, closeFn := initLuaVM(ks.Cfg) // 依赖的产物:直接读字段 // ks.LuaVM = vm // 自己的产物:写回字段 // ks.AddCloser(closeFn) // 关闭函数统一登记 @@ -45,7 +47,7 @@ type FuncSpec struct { Requires []string Provides []string // Init 执行装配:从 ctx 取依赖、调既有函数、把产物 Put 回 ctx。 - Init func(ks *KernelState) error + Init func(ks *kstate.State) error } // funcModule 是 FuncSpec 的 Module 实现。 @@ -56,7 +58,7 @@ type funcModule struct { func (m *funcModule) Name() string { return m.spec.Name } func (m *funcModule) Provides() []string { return m.spec.Provides } func (m *funcModule) Requires() []string { return m.spec.Requires } -func (m *funcModule) Init(ks *KernelState) error { +func (m *funcModule) Init(ks *kstate.State) error { if m.spec.Init == nil { return nil } diff --git a/internal/kernelboot/kernelboot.go b/internal/kernelboot/kernelboot.go index feb68c69..50600e68 100644 --- a/internal/kernelboot/kernelboot.go +++ b/internal/kernelboot/kernelboot.go @@ -36,6 +36,7 @@ package kernelboot import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "sort" "strings" "sync" @@ -60,7 +61,7 @@ type Module interface { // 模块从中**读**已就绪的依赖,把自己产出的公共数据**写**回它的字段。 // // 返回错误表示装配失败(**必须传播,不得吞掉**)。 - Init(ks *KernelState) error + Init(ks *kstate.State) error } // Plan 是一次依赖解析的结果:分好层的调度计划。 @@ -278,7 +279,7 @@ type RunOptions struct { // // 为何由调用方传入而不是 Run 内部自建:产物在 Run 返回后**仍然要用** // (如 LuaVM、Memory 要一直活着),所以它必须比 Run 活得久。 - State *KernelState + State *kstate.State // Bus 是状态同步总线。非 nil 时,Run 会在**每个层边界**把该层模块贡献的 // 设施标记为就绪(设计 §2.6.2)。 @@ -287,7 +288,7 @@ type RunOptions struct { // 若模块自己 MarkReady,顺序取决于**层内并发的完成顺序** ⇒ 不可复现; // 而且模块可能"忘记标记"(静默漏掉一个设施 ⇒ 插件永远等不到它就绪)。 // 由 Run 在层边界统一写,既确定又不可能漏。 - Bus *SyncBus + Bus *kstate.SyncBus } // RunResult 是执行结果。 @@ -381,7 +382,7 @@ func Run(plan *Plan, opts RunOptions) (*RunResult, error) { ks := opts.State if ks == nil { - ks = NewKernelState() + ks = kstate.New() } // ★ 执行前把**全部**模块声明的设施登记为"内核注册过"。 diff --git a/internal/kernelboot/kernelboot_test.go b/internal/kernelboot/kernelboot_test.go index 1451f71a..e1a5ec48 100644 --- a/internal/kernelboot/kernelboot_test.go +++ b/internal/kernelboot/kernelboot_test.go @@ -15,6 +15,7 @@ package kernelboot import ( "errors" "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "sort" "strings" "sync" @@ -40,7 +41,7 @@ type fakeModule struct { func (m *fakeModule) Name() string { return m.name } func (m *fakeModule) Provides() []string { return m.provides } func (m *fakeModule) Requires() []string { return m.requires } -func (m *fakeModule) Init(_ *KernelState) error { +func (m *fakeModule) Init(_ *kstate.State) error { if m.initCount != nil { atomic.AddInt32(m.initCount, 1) } diff --git a/internal/kernelboot/kernelstate_test.go b/internal/kernelboot/kernelstate_test.go index d6c26799..9432c7fc 100644 --- a/internal/kernelboot/kernelstate_test.go +++ b/internal/kernelboot/kernelstate_test.go @@ -14,6 +14,7 @@ package kernelboot // (判据的"数量"不是指标,"每条原判据都有对应"才是)。 import ( + "github.com/JianFeeeee/HologramAgent/internal/kstate" "strings" "sync" "sync/atomic" @@ -25,7 +26,7 @@ import ( // 为何逆序:先初始化的一般是底层(config → memory → llm), // 停机应**反向拆**,否则可能拆掉别人还在用的东西。 func TestKernelStateCloseAllIsReverseOrder(t *testing.T) { - ks := NewKernelState() + ks := kstate.New() var mu sync.Mutex var order []string @@ -48,7 +49,7 @@ func TestKernelStateCloseAllIsReverseOrder(t *testing.T) { // ★★★ 判据:**CloseAll 不因单个关闭函数 panic 而中断**。 func TestKernelStateCloseAllSurvivesPanic(t *testing.T) { - ks := NewKernelState() + ks := kstate.New() closed := false ks.AddCloser(func() { panic("关闭时炸了") }) ks.AddCloser(func() { closed = true }) @@ -61,7 +62,7 @@ func TestKernelStateCloseAllSurvivesPanic(t *testing.T) { // ★★★ 判据:**CloseAll 幂等**(清空后再次调用不重复执行)。 func TestKernelStateCloseAllIsIdempotent(t *testing.T) { - ks := NewKernelState() + ks := kstate.New() var n int32 ks.AddCloser(func() { atomic.AddInt32(&n, 1) }) @@ -78,7 +79,7 @@ func TestKernelStateCloseAllIsIdempotent(t *testing.T) { // 第 1 层有 8 个模块并行,其中 5 个注册关闭函数 ⇒ 并发 append // ⇒ 数据竞争 + 可能丢失元素(某个模块的关闭函数永不调用 ⇒ 资源泄漏)。 func TestKernelStateAddCloserConcurrent(t *testing.T) { - ks := NewKernelState() + ks := kstate.New() const n = 500 var wg sync.WaitGroup @@ -99,7 +100,7 @@ func TestKernelStateAddCloserConcurrent(t *testing.T) { // ★★★ 判据:**nil 关闭函数被忽略**(不 panic、不占位)。 func TestKernelStateAddCloserIgnoresNil(t *testing.T) { - ks := NewKernelState() + ks := kstate.New() ks.AddCloser(nil) if len(ks.Closers) != 0 { t.Fatalf("nil 关闭函数应被忽略,实际记录 %d 个", len(ks.Closers)) diff --git a/internal/kernelboot/migrate.go b/internal/kernelboot/migrate.go index cb2995cc..88a78e48 100644 --- a/internal/kernelboot/migrate.go +++ b/internal/kernelboot/migrate.go @@ -25,6 +25,7 @@ package kernelboot import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "log" "sync/atomic" "time" @@ -87,7 +88,7 @@ type PluginQuiescer interface { // defer sess.End() // 无论成功失败都唤醒 // // ... 外部脚本迁移 ... type MigrateSession struct { - ks *KernelState + ks *kstate.State plugins PluginQuiescer paused []string stopped []string // 已运行态停掉的插件(按停止顺序,唤醒时逆序) @@ -97,7 +98,7 @@ type MigrateSession struct { // BeginMigration 进入伪关机:停插件(运行态)→ 逆序暂停内核写者 → 等在途归零。 // // 返回的 session 必须由调用方 `defer sess.End()`。 -func (s *KernelState) BeginMigration(pq PluginQuiescer) (*MigrateSession, error) { +func BeginMigration(s *kstate.State, pq PluginQuiescer) (*MigrateSession, error) { sess := &MigrateSession{ks: s, plugins: pq} // ① 先停**插件**(运行态停启,可逆)。 diff --git a/internal/kernelboot/migrate_test.go b/internal/kernelboot/migrate_test.go index b0799ae6..11e8681c 100644 --- a/internal/kernelboot/migrate_test.go +++ b/internal/kernelboot/migrate_test.go @@ -21,9 +21,11 @@ import ( "errors" "sync" "testing" + + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) -func newTestKS() *KernelState { return NewKernelState() } +func newTestKS() *kstate.State { return kstate.New() } // ★★★ 判据:Quiesce 逆序暂停、Resume 逆序恢复。 func TestQuiescePausesInReverseOrder(t *testing.T) { @@ -121,7 +123,7 @@ func TestMigrateSessionEndIsIdempotent(t *testing.T) { return nil }) - sess, err := ks.BeginMigration(nil) + sess, err := BeginMigration(ks, nil) if err != nil { t.Fatal(err) } @@ -185,7 +187,7 @@ func TestMigrationStopsAndRestoresAllPlugins(t *testing.T) { ks := newTestKS() fp := &fakePlugins{running: []string{"webui", "rss", "calendar", "commondev"}} - sess, err := ks.BeginMigration(fp) + sess, err := BeginMigration(ks, fp) if err != nil { t.Fatal(err) } @@ -209,7 +211,7 @@ func TestMigrationRollsBackPluginsOnFailure(t *testing.T) { // 让 calendar 失败 ⇒ 已停的 commondev 必须被拉回 fp := &fakePlugins{running: []string{"webui", "rss", "calendar", "commondev"}, failOn: "calendar"} - if _, err := ks.BeginMigration(fp); err == nil { + if _, err := BeginMigration(ks, fp); err == nil { t.Fatal("停插件失败时 BeginMigration 必须返回错误") } got := fp.RunningPlugins() diff --git a/internal/kernelboot/scheduler.go b/internal/kernelboot/scheduler.go index 96d376aa..6316d791 100644 --- a/internal/kernelboot/scheduler.go +++ b/internal/kernelboot/scheduler.go @@ -29,6 +29,7 @@ package kernelboot import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "sort" "strings" "sync" @@ -56,7 +57,7 @@ type PluginSpec struct { // Scheduler 按 syncbus 状态调度插件。 type Scheduler struct { mu sync.Mutex - bus SyncState + bus kstate.SyncState pending []PluginSpec launched map[string]bool launch func(PluginSpec) error @@ -67,9 +68,9 @@ type Scheduler struct { // NewScheduler 创建调度器。launch 是实际拉起动作(由插件管理器提供)。 // -// ★ bus 参数类型是 **SyncState**(只读接口),不是 *SyncBus。 +// ★ bus 参数类型是 **kstate.SyncState**(只读接口),不是 *kstate.SyncBus。 // 这就是设计 §2.6.3 的权限隔离:调度器**在编译期就没有**写 syncbus 的能力。 -func NewScheduler(bus SyncState, launch func(PluginSpec) error) *Scheduler { +func NewScheduler(bus kstate.SyncState, launch func(PluginSpec) error) *Scheduler { s := &Scheduler{ bus: bus, launch: launch, @@ -82,8 +83,8 @@ func NewScheduler(bus SyncState, launch func(PluginSpec) error) *Scheduler { // // 若 bus 不支持订阅(只传了个只读快照实现),退化为"只在 Submit 时评估"—— // 那种情况下调度器仍可用,只是不会自动跟进后续状态变化。 - if sub, ok := bus.(interface{ OnUpdate(func(SyncState)) }); ok { - sub.OnUpdate(func(SyncState) { s.evaluate() }) + if sub, ok := bus.(interface{ OnUpdate(func(kstate.SyncState)) }); ok { + sub.OnUpdate(func(kstate.SyncState) { s.evaluate() }) } return s } @@ -293,7 +294,7 @@ func (s *Scheduler) WaitString(p PluginSpec) string { if len(p.RequiresFacilities) == 0 { return "等内核完全初始化" } - // AllReady 需要 *SyncBus;这里只做只读渲染,用 Snapshot 判断 + // AllReady 需要 *kstate.SyncBus;这里只做只读渲染,用 Snapshot 判断 snap := s.bus.Snapshot() var missing []string for _, f := range p.RequiresFacilities { diff --git a/internal/kernelboot/stress_test.go b/internal/kernelboot/stress_test.go index 97747144..f70302a3 100644 --- a/internal/kernelboot/stress_test.go +++ b/internal/kernelboot/stress_test.go @@ -25,17 +25,18 @@ package kernelboot import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "sync" "sync/atomic" "testing" "time" ) -// ★★★ 压力:**SyncBus 读写并发**(内核写 + N 个插件读 + 订阅回调)。 +// ★★★ 压力:**kstate.SyncBus 读写并发**(内核写 + N 个插件读 + 订阅回调)。 // // 用 -race 跑时,任何未保护的状态都会报数据竞争。 func TestStressSyncBusConcurrentReadWrite(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() const readers = 64 const facilities = 200 @@ -43,7 +44,7 @@ func TestStressSyncBusConcurrentReadWrite(t *testing.T) { // 订阅者**读状态**(这正是死锁的触发条件:若 fire 持锁调用回调, // 回调里 Snapshot() 会等读锁 ⇒ 死锁。压力下必现) for i := 0; i < 8; i++ { - bus.OnUpdate(func(s SyncState) { + bus.OnUpdate(func(s kstate.SyncState) { _ = s.Snapshot() _ = s.AllReady([]string{"llm"}) atomic.AddInt64(&callbacks, 1) @@ -109,7 +110,7 @@ func TestStressSyncBusConcurrentReadWrite(t *testing.T) { // · 不丢插件(全部最终被拉起) // · 无数据竞争 func TestStressSchedulerConcurrentSubmit(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkDeclared([]string{"config", "llm", "memory"}) // ★ 必须 MarkLayerReady:MarkDeclared 只登记"名字存在", // 不代表就绪。插件要求 config **就绪**才拉起。 @@ -164,7 +165,7 @@ func TestStressSchedulerConcurrentSubmit(t *testing.T) { // ★★★ 压力:**状态变化 + 提交并发**(真实时序:内核在标记就绪的同时插件在提交)。 func TestStressSchedulerConcurrentStateChange(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkDeclared([]string{"config", "llm", "memory", "stagehost"}) var launched int64 @@ -291,9 +292,9 @@ func TestStressRunRespectsMaxParallelAtScale(t *testing.T) { t.Logf("%d 个模块同层,上限 %d,观测峰值 %d", n, maxP, observed) } -// ★★★ 压力:**KernelState 同层并行写**(各写自己字段,无竞争)。 +// ★★★ 压力:**kstate.State 同层并行写**(各写自己字段,无竞争)。 func TestStressKernelStateParallelWrites(t *testing.T) { - ks := NewKernelState() + ks := kstate.New() const n = 256 var wg sync.WaitGroup diff --git a/internal/kernelboot/syncbus_test.go b/internal/kernelboot/syncbus_test.go index 5aed5a73..84f23701 100644 --- a/internal/kernelboot/syncbus_test.go +++ b/internal/kernelboot/syncbus_test.go @@ -9,6 +9,7 @@ package kernelboot // 静默改变启动时机 = 静默引入竞态。 import ( + "github.com/JianFeeeee/HologramAgent/internal/kstate" "reflect" "strings" "sync" @@ -24,7 +25,7 @@ import ( // 反例(若实现错误):把"未声明"当成"依赖集为空",而空集在数学上已满足 // ⇒ 第一层完成后就拉起 ⇒ 比现状早得多。 func TestSchedulerUndeclaredWaitsForKernelComplete(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() var launched []string var mu sync.Mutex sch := NewScheduler(bus, func(p PluginSpec) error { @@ -63,7 +64,7 @@ func TestSchedulerUndeclaredWaitsForKernelComplete(t *testing.T) { // // 断言"早于内核完成" —— 否则这条判据在"等内核完成"的实现下也会通过(假绿)。 func TestSchedulerDeclaredLaunchesEarly(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() var launched []string var mu sync.Mutex sch := NewScheduler(bus, func(p PluginSpec) error { @@ -98,7 +99,7 @@ func TestSchedulerDeclaredLaunchesEarly(t *testing.T) { // ★★★ 判据:**依赖未齐则挂起**,不会误拉起。 func TestSchedulerWaitsForMissingDependency(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() var launched []string sch := NewScheduler(bus, func(p PluginSpec) error { launched = append(launched, p.Name) @@ -128,21 +129,21 @@ func TestSchedulerWaitsForMissingDependency(t *testing.T) { // ★★★ 判据:**只读隔离**(编译期保证,不是约定)。 // -// 断言 Scheduler 拿到的接口**没有**写方法 —— 如果给它 *SyncBus, +// 断言 Scheduler 拿到的接口**没有**写方法 —— 如果给它 *kstate.SyncBus, // 它就能谎报内核状态,让插件在依赖不存在时启动(病 A)。 func TestSchedulerCannotWriteSyncState(t *testing.T) { // ★ 检查必须是**接口类型的方法集**,不是某个具体值。 // - // 我第一次写错了:用 `var st SyncState = NewSyncBus()` 再断言 `st.(writer)` —— - // 那检查的是**动态类型**(*SyncBus 当然有 MarkReady),判据当场报假红。 - // 要约束的是"拿到 SyncState 的人**能不能**调写方法" ⇒ 必须查接口的方法集。 - rt := reflect.TypeOf((*SyncState)(nil)).Elem() + // 我第一次写错了:用 `var st kstate.SyncState = kstate.NewSyncBus()` 再断言 `st.(writer)` —— + // 那检查的是**动态类型**(*kstate.SyncBus 当然有 MarkReady),判据当场报假红。 + // 要约束的是"拿到 kstate.SyncState 的人**能不能**调写方法" ⇒ 必须查接口的方法集。 + rt := reflect.TypeOf((*kstate.SyncState)(nil)).Elem() forbidden := []string{"MarkReady", "MarkKernelComplete", "MarkLayerReady"} for i := 0; i < rt.NumMethod(); i++ { m := rt.Method(i).Name for _, bad := range forbidden { if m == bad { - t.Fatalf("★ SyncState(插件管理器拿到的接口)暴露了写方法 %s —— "+ + t.Fatalf("★ kstate.SyncState(插件管理器拿到的接口)暴露了写方法 %s —— "+ "它能谎报内核状态,让插件在依赖不存在时启动(病 A)", m) } } @@ -150,23 +151,23 @@ func TestSchedulerCannotWriteSyncState(t *testing.T) { // 反向确认:读方法必须在(否则接口退化到没用了) for _, want := range []string{"IsReady", "Snapshot", "AllReady", "KernelComplete"} { if _, ok := rt.MethodByName(want); !ok { - t.Errorf("SyncState 缺少读方法 %s", want) + t.Errorf("kstate.SyncState 缺少读方法 %s", want) } } - // Scheduler 持有的字段类型也必须是 SyncState(不能是 *SyncBus) + // Scheduler 持有的字段类型也必须是 kstate.SyncState(不能是 *kstate.SyncBus) st := reflect.TypeOf(Scheduler{}) f, ok := st.FieldByName("bus") if !ok { t.Fatal("Scheduler 应有 bus 字段") } - if f.Type.String() != "kernelboot.SyncState" { - t.Fatalf("★ Scheduler.bus 的类型是 %s —— 必须是只读的 SyncState", f.Type) + if f.Type.String() != "kstate.SyncState" { + t.Fatalf("★ Scheduler.bus 的类型是 %s —— 必须是只读的 kstate.SyncState", f.Type) } } // ★★★ 判据:**不伴随启动的插件即使依赖齐了也不自动拉起**(§2.6.6)。 func TestSchedulerAutoStartFalseIsNotLaunched(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() var launched []string sch := NewScheduler(bus, func(p PluginSpec) error { launched = append(launched, p.Name) @@ -196,7 +197,7 @@ func TestSyncBusReadyOrderIsDeterministic(t *testing.T) { // 模拟"层内并发完成":同一层 8 个设施,用 goroutine 乱序 MarkReady var first []string for round := 0; round < 5; round++ { - bus := NewSyncBus() + bus := kstate.NewSyncBus() var wg sync.WaitGroup fac := []string{"f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8"} for i, f := range fac { @@ -225,7 +226,7 @@ func TestSyncBusReadyOrderIsDeterministic(t *testing.T) { // ★ 真正的可复现性断言:MarkLayerReady 一次性写入 var order []string for round := 0; round < 3; round++ { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkLayerReady([]string{"a", "b", "c"}) bus.MarkLayerReady([]string{"d", "e"}) got := bus.ReadyInOrder() @@ -250,7 +251,7 @@ func TestSyncBusReadyOrderIsDeterministic(t *testing.T) { // // 这条是补的:变异"去掉幂等检查"第一次没被咬住 —— 因为原判据 // 只跑了一份声明、没重复标记,测不到幂等性。 - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkLayerReady([]string{"a", "b"}) bus.MarkLayerReady([]string{"a", "b"}) // 重复 bus.MarkLayerReady([]string{"c"}) @@ -263,7 +264,7 @@ func TestSyncBusReadyOrderIsDeterministic(t *testing.T) { // // 这条守的是:装配中途失败 ⇒ 未声明依赖的插件**不**被拉起。 func TestRunDoesNotMarkCompleteOnFailure(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() mods := []Module{ &fakeModule{name: "A", provides: []string{"a"}}, &fakeModule{name: "B", provides: []string{"b"}, initErr: errFake}, @@ -285,7 +286,7 @@ func TestRunDoesNotMarkCompleteOnFailure(t *testing.T) { // 为何由 Run 做:模块自己标记会漏(静默漏一个设施 ⇒ 插件永远等不到), // 且顺序取决于层内并发完成序。 func TestRunPublishesFacilitiesAtLayerBoundary(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() mods := []Module{ &fakeModule{name: "config", provides: []string{"config", "config-write"}}, &fakeModule{name: "llm", provides: []string{"llm"}, requires: []string{"config"}}, @@ -311,7 +312,7 @@ func TestRunPublishesFacilitiesAtLayerBoundary(t *testing.T) { // // 若这条反了,未声明依赖的插件会被提前拉起(见第一条判据)。 func TestAllReadyEmptyIsFalse(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkKernelComplete() if bus.AllReady(nil) { t.Fatal("★ AllReady(空) 返回 true —— \"没有依赖\"被当成\"依赖全就绪\",未声明插件会提前拉起") @@ -328,7 +329,7 @@ func TestAllReadyEmptyIsFalse(t *testing.T) { // ★★★ 判据:**拉起失败不阻塞其他插件**(一个坏插件不该拖垮全局)。 func TestSchedulerLaunchFailureDoesNotBlockOthers(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() var okLaunched []string sch := NewScheduler(bus, func(p PluginSpec) error { if p.Name == "bad" { @@ -357,7 +358,7 @@ func TestSchedulerLaunchFailureDoesNotBlockOthers(t *testing.T) { // ★★★ 判据:**重复 Submit 不重复拉起**(幂等)。 func TestSchedulerIsIdempotent(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() n := 0 sch := NewScheduler(bus, func(PluginSpec) error { n++; return nil }) bus.MarkKernelComplete() @@ -378,11 +379,11 @@ func TestSchedulerIsIdempotent(t *testing.T) { // (单个设施的标记),若它持锁回调,只有"既调 MarkReady 又订阅"的场景才会卡住。 // 补这条判据把两条路径都盖住。 func TestSyncBusMarkReadyNotifiesWithoutDeadlock(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() got := make(chan string, 4) // 订阅者**读状态**(真实回调必然会读,这正是死锁触发条件) - bus.OnUpdate(func(s SyncState) { + bus.OnUpdate(func(s kstate.SyncState) { _ = s.Snapshot() // 持锁调用的话,这里会等 RLock ⇒ 死锁 got <- "ok" }) diff --git a/internal/kernelboot/unknownfac_test.go b/internal/kernelboot/unknownfac_test.go index f5c81997..1707ca1f 100644 --- a/internal/kernelboot/unknownfac_test.go +++ b/internal/kernelboot/unknownfac_test.go @@ -12,13 +12,14 @@ package kernelboot import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "strings" "testing" ) // ★★★ 判据:**拼错的设施名在 Submit 时被拒绝**(不再静默挂起)。 func TestSchedulerRejectsUnknownFacility(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkDeclared([]string{"config", "llm", "memory"}) // 内核注册过这些 var launched []string @@ -57,7 +58,7 @@ func TestSchedulerRejectsUnknownFacility(t *testing.T) { // ★★★ 判据:**正确的设施名不受影响**(不能把正常路径也拦了)。 func TestSchedulerAcceptsKnownFacility(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkDeclared([]string{"config", "llm"}) // ★ 必须同时 MarkLayerReady —— 真实路径里 Run 两者都会做: // MarkDeclared(执行前,登记名字)与 MarkLayerReady(每层完成,标记就绪)。 @@ -86,7 +87,7 @@ func TestSchedulerAcceptsKnownFacility(t *testing.T) { // ★ 这条很重要:llm 声明了但还在第 1 层时,Known 为 true、Ready 为 false。 // 若把"未就绪"当"拼错",所有多依赖插件都会在提交时被误拒。 func TestSchedulerAcceptsDeclaredButNotYetReady(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkDeclared([]string{"config", "llm"}) // 两者都已声明 bus.MarkLayerReady([]string{"config"}) // 只有 config 就绪 @@ -111,7 +112,7 @@ func TestSchedulerAcceptsDeclaredButNotYetReady(t *testing.T) { // 那些设施名就从未被 MarkLayerReady 登记 ⇒ 会**误报**"内核从未注册过"。 // 所以 Run 必须**执行前**就 MarkDeclared。 func TestRunDeclaresAllFacilitiesUpfront(t *testing.T) { - bus := NewSyncBus() + bus := kstate.NewSyncBus() mods := []Module{ &fakeModule{name: "a", provides: []string{"fa"}}, &fakeModule{name: "b", provides: []string{"fb"}, requires: []string{"fa"}, diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index 78d7de16..8beb89a1 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -13,6 +13,7 @@ package agent import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "log" "path/filepath" "strings" @@ -21,7 +22,6 @@ import ( agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core" "github.com/JianFeeeee/HologramAgent/internal/config" "github.com/JianFeeeee/HologramAgent/internal/events" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/pkg/types" @@ -58,7 +58,7 @@ func (Module) Requires() []string { } func (Module) Provides() []string { return []string{kernelmod.FacAgent} } -func (Module) Init(k *kernelboot.KernelState) error { +func (Module) Init(k *kstate.State) error { if k.Memory == nil { return errMissing("agent", kernelmod.FacMemory) } @@ -99,7 +99,7 @@ Answer only from what the tools actually return. If something could not be retri Reply in natural language, like a person talking: do not enumerate which tools you called, their parameters, or timings unless the user explicitly asks. For how to address the user and what tone to use, see [Persona].` -func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { +func buildAgent(k *kstate.State) *agentCore.Agent { cfg := k.Cfg cfgReg := k.CfgReg provider := k.LLMProvider @@ -117,7 +117,9 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { multimodalSpace := k.MultimodalSpace mmProviderName := k.MultimodalProviderName mmErr := k.MultimodalErr - stageHost := k.StageHost + // StageHost 在挂载点里存 any(kstate 不能依赖 agent/core,否则成环), + // 取用处断言回具体类型。装配顺序由依赖图保证(FacStageHost)。 + stageHost, _ := k.StageHost.(*agentCore.StageHost) evBus := k.EventBus secRT := k.Security @@ -308,8 +310,12 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent { // pluginhost 同时可能正在加载插件(buildSDK 读 status)⇒ 写读依然并发。 // 所以 registry 侧 Set*/读侧的锁是**真正的修复**,去重只是消除了一处 // 无意义的重复写。两件事都做,缺一不可。 -func wirePluginSDK(k *kernelboot.KernelState) { - k.PluginRegistry.SetStatusProvider(k.Agent) +func wirePluginSDK(k *kstate.State) { + // Agent 是本包自己的类型,构造期已知;不走挂载点断言。 + a, _ := k.Agent.(*agentCore.Agent) + if a != nil { + k.PluginRegistry.SetStatusProvider(a) + } } // loadPersonality 载入人格(原 cmd/homed 的整块平移)。 diff --git a/internal/kernelmod/all/bootgraph_test.go b/internal/kernelmod/all/bootgraph_test.go index 91d5a110..30837bbd 100644 --- a/internal/kernelmod/all/bootgraph_test.go +++ b/internal/kernelmod/all/bootgraph_test.go @@ -131,6 +131,7 @@ package all // 源码怎么组织都无所谓 —— 只认"装配起来之后发生了什么"。 import ( + "github.com/JianFeeeee/HologramAgent/internal/kstate" "go/ast" "go/parser" "go/token" @@ -158,8 +159,8 @@ import ( // machine 是一台**真实装配起来**的内核(全部模块都是 kernelmod 里的真货)。 type machine struct { t *testing.T - ks *kernelboot.KernelState - bus *kernelboot.SyncBus + ks *kstate.State + bus *kstate.SyncBus // plan 是真实登记表算出的分层计划(启动图)。 plan *kernelboot.Plan // res 是执行结果(层数 / 峰值并发 / 完成顺序)。 @@ -203,7 +204,7 @@ func bootMachine(t *testing.T) *machine { // (实测:3 轮后 goroutine 数 2→5,逐轮 +1,全部来自 database/sql)。 // 关闭点放到 shutdown()(与生产的停机序列同处),并由 machine 保证只关一次。 - ks := kernelboot.NewKernelState() + ks := kstate.New() ks.CfgReg = reg ks.DataDir = dir ks.AgentWorkDir = filepath.Join(dir, "agentfs") @@ -225,7 +226,7 @@ func bootMachine(t *testing.T) *machine { t.Fatalf("★ 真实登记表算不出启动图(漏挂模块 / 缺依赖 / 环): %v", err) } - bus := kernelboot.NewSyncBus() + bus := kstate.NewSyncBus() // 与生产 RunKernelModules 同序:先 MarkDeclared(让拼错校验有真相源), // 再 Run(Run 内部还会 MarkDeclared,幂等)。 var declared []string @@ -869,7 +870,7 @@ func removeAllWithRetry(dir string, tries int) error { // 只读反射(不 Set、不 Interface),用于把"关闭时挂订阅"这种泄漏 // 变成可断言的数字。字段名/类型变化时本函数会 fatalf —— 那是判据 // 失去观测点,必须显式修,不能静默变成 0。 -func countBusSubscribers(t *testing.T, bus *kernelboot.SyncBus) int { +func countBusSubscribers(t *testing.T, bus *kstate.SyncBus) int { t.Helper() if bus == nil { return 0 diff --git a/internal/kernelmod/all/runtime_dep_test.go b/internal/kernelmod/all/runtime_dep_test.go index 7fa02e36..63ecd257 100644 --- a/internal/kernelmod/all/runtime_dep_test.go +++ b/internal/kernelmod/all/runtime_dep_test.go @@ -26,6 +26,7 @@ package all import ( "errors" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "path/filepath" "strings" "testing" @@ -68,10 +69,10 @@ func layerNames(layer []kernelboot.Module) []string { // 变异,模块会真的跑到写盘那一行。不填的话 text-memory 会往**仓内** // 当前目录下的 memory/text 写(实测产生过 internal/kernelmod/all/memory/)。 // 夹具不能往仓库里漏文件。 -func emptyState(t *testing.T) *kernelboot.KernelState { +func emptyState(t *testing.T) *kstate.State { t.Helper() dir := t.TempDir() - ks := kernelboot.NewKernelState() + ks := kstate.New() ks.Cfg = &types.Config{} ks.Cfg.Daemon.DataDir = dir ks.Cfg.Plugin.Dir = filepath.Join(dir, "plugins") diff --git a/internal/kernelmod/all/security_wiring_test.go b/internal/kernelmod/all/security_wiring_test.go index 2965bceb..01e14bd0 100644 --- a/internal/kernelmod/all/security_wiring_test.go +++ b/internal/kernelmod/all/security_wiring_test.go @@ -27,6 +27,9 @@ package all import ( "strings" + + agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "testing" agentcore "github.com/JianFeeeee/HologramAgent/internal/agent/core" @@ -118,7 +121,13 @@ func TestAgentAssemblyWiresSecurity(t *testing.T) { const deniedTool = "boot_probe_denied_tool" // ① 先证明这个工具名**没被注册**(否则下面"not permitted"可能来自别处)。 - out, _ := ks.Agent.ExecuteTool(deniedTool, nil) + // ks.Agent 在挂载点里存 any(kstate 不能依赖 agent/core,否则成环)。 + // 本判据要真实执行工具,断言回具体类型。 + agentImpl, _ := ks.Agent.(*agentCore.Agent) + if agentImpl == nil { + t.Fatal("ks.Agent 不是 *agentCore.Agent") + } + out, _ := agentImpl.ExecuteTool(deniedTool, nil) if strings.Contains(out, "not permitted") { t.Fatalf("前置不成立:未配黑名单时就已被拒(%q)—— 检查环境里的实际策略", out) } @@ -127,7 +136,7 @@ func TestAgentAssemblyWiresSecurity(t *testing.T) { // 如果 Agent 里拿的是另一个实例(或 nil),它看不到这条 ⇒ 判据红。 ks.Security.SetPolicy(security.Policy{ToolDeny: []string{deniedTool}}) - out, _ = ks.Agent.ExecuteTool(deniedTool, nil) + out, _ = agentImpl.ExecuteTool(deniedTool, nil) if !strings.Contains(out, "not permitted") { t.Fatalf("★ 已给 ks.Security 配 %q 黑名单,Agent 的执行链却没拦住(实际返回 %q)。\n"+ " 可能原因(按概率):\n"+ @@ -140,7 +149,7 @@ func TestAgentAssemblyWiresSecurity(t *testing.T) { // ③ 反向:未命中的工具必须放行。 // 否则是"全拒"型假绿(闸门写死了),而不是接线正确。 - if out, _ := ks.Agent.ExecuteTool("boot_probe_unlisted_tool", nil); strings.Contains(out, "not permitted") { + if out, _ := agentImpl.ExecuteTool("boot_probe_unlisted_tool", nil); strings.Contains(out, "not permitted") { t.Fatalf("未命中黑名单的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", out) } } @@ -182,7 +191,7 @@ func TestAgentAssemblyRefusesNilSecurity(t *testing.T) { // // 为什么这样做而不是直接用 base:Init 成功时会覆盖 base.Agent, // 破坏后续判据的上下文。新建一份是干净的。 - var probe kernelboot.KernelState + var probe kstate.State probe.Cfg = base.Cfg probe.CfgReg = base.CfgReg probe.DataDir = base.DataDir diff --git a/internal/kernelmod/config/mod.go b/internal/kernelmod/config/mod.go index a10fb28a..a3ca0ed0 100644 --- a/internal/kernelmod/config/mod.go +++ b/internal/kernelmod/config/mod.go @@ -18,8 +18,8 @@ package config import ( "fmt" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) // Module 提供配置快照。 @@ -29,7 +29,7 @@ func (Module) Name() string { return "config" } func (Module) Requires() []string { return nil } // 前提由 Group 0 保证 func (Module) Provides() []string { return []string{kernelmod.FacConfig} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { // ★ 检查而不是假设(用户说的"nil 加读取前检查"): // ks.Cfg 为 nil 说明 Group 0 段没跑或顺序错了。响亮报错远好于让后续 // 模块拿到 nil —— 那会在很远的地方变成"模型没有配置"之类的模糊症状。 diff --git a/internal/kernelmod/device/mod.go b/internal/kernelmod/device/mod.go index d3b6aeff..0084c49a 100644 --- a/internal/kernelmod/device/mod.go +++ b/internal/kernelmod/device/mod.go @@ -30,8 +30,8 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/agent/api" "github.com/JianFeeeee/HologramAgent/internal/events" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" sec "github.com/JianFeeeee/HologramAgent/internal/security" ) @@ -46,9 +46,12 @@ func (Module) Requires() []string { func (Module) Provides() []string { return []string{kernelmod.FacDevice} } -func (Module) Init(ks *kernelboot.KernelState) error { - if ks.Agent == nil { - return fmt.Errorf("device module: ks.Agent is nil (requires %s)", kernelmod.FacAgent) +func (Module) Init(ks *kstate.State) error { + // Agent 在挂载点里存 any(kstate 不能依赖 agent/core,否则成环)。 + // 这里断言成**最小接口**而不是具体类型:device 只需要"能挂数据面"。 + host, ok := ks.Agent.(api.DeviceMounter) + if !ok || host == nil { + return fmt.Errorf("device module: ks.Agent is not a DeviceMounter (requires %s)", kernelmod.FacAgent) } if ks.Security == nil { // 安全模块必须已装配(配置关闭时是 Disabled Runtime,**不是** nil)。 @@ -57,9 +60,15 @@ func (Module) Init(ks *kernelboot.KernelState) error { "assembled first (when disabled it installs a Disabled Runtime, not nil)") } - rt := New(ks.Agent, ks.Security) + // ExecuteTool 面:ToolHost 与 DeviceMounter 是两个不同用途的接口 + // (执行 vs 装配),此处两个断言都要成功。 + toolHost, ok := ks.Agent.(api.ToolHost) + if !ok || toolHost == nil { + return fmt.Errorf("device module: ks.Agent is not a ToolHost (requires %s)", kernelmod.FacAgent) + } + rt := New(toolHost, ks.Security) ks.Device = rt - ks.Agent.MountDevice(rt) + host.MountDevice(rt) if ks.EventBus != nil { ks.EventBus.Publish(&events.Event{ diff --git a/internal/kernelmod/docstore/mod.go b/internal/kernelmod/docstore/mod.go index a244f81f..fb03477f 100644 --- a/internal/kernelmod/docstore/mod.go +++ b/internal/kernelmod/docstore/mod.go @@ -7,8 +7,8 @@ import ( "log" "path/filepath" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" ) @@ -20,7 +20,7 @@ func (Module) Name() string { return "doc-store" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacDocStore} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { coldcontextStore := coldcontext.NewStore( filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords) if err := coldcontextStore.Start(); err != nil { diff --git a/internal/kernelmod/kernelapi/mod.go b/internal/kernelmod/kernelapi/mod.go index 0d979c95..46816da8 100644 --- a/internal/kernelmod/kernelapi/mod.go +++ b/internal/kernelmod/kernelapi/mod.go @@ -12,8 +12,8 @@ import ( agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/events" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) // Module 提供 IO 抽象层与事件总线。 @@ -23,7 +23,7 @@ func (Module) Name() string { return "kernel-api" } func (Module) Requires() []string { return nil } func (Module) Provides() []string { return []string{kernelmod.FacKernelIO} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { iom := agentIO.NewIOManager() evBus := events.NewBus() log.Printf("[homed] kernel API ready: IOManager + EventBus") diff --git a/internal/kernelmod/knowledge/migration.go b/internal/kernelmod/knowledge/migration.go index a29ac56a..5802c5f9 100644 --- a/internal/kernelmod/knowledge/migration.go +++ b/internal/kernelmod/knowledge/migration.go @@ -16,8 +16,8 @@ package knowledge import ( "path/filepath" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) // MigrationModule 做知识库目录改名迁移。 @@ -27,7 +27,7 @@ func (MigrationModule) Name() string { return "knowledge-migration" } func (MigrationModule) Requires() []string { return []string{kernelmod.FacConfig} } func (MigrationModule) Provides() []string { return []string{kernelmod.FacMigrated} } -func (MigrationModule) Init(ks *kernelboot.KernelState) error { +func (MigrationModule) Init(ks *kstate.State) error { root := filepath.Join(ks.Cfg.Daemon.DataDir, "knowledge") runKnowledgeMigration(root) return nil diff --git a/internal/kernelmod/knowledge/store.go b/internal/kernelmod/knowledge/store.go index 90ff788d..8ce0dfd8 100644 --- a/internal/kernelmod/knowledge/store.go +++ b/internal/kernelmod/knowledge/store.go @@ -5,9 +5,9 @@ import ( "log" "path/filepath" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" "github.com/JianFeeeee/HologramAgent/internal/knowledge" + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) // StoreModule 提供知识库。 @@ -20,7 +20,7 @@ func (StoreModule) Requires() []string { } func (StoreModule) Provides() []string { return []string{kernelmod.FacKnowledge} } -func (StoreModule) Init(ks *kernelboot.KernelState) error { +func (StoreModule) Init(ks *kstate.State) error { ks.Knowledge = knowledge.NewStore(filepath.Join(ks.Cfg.Daemon.DataDir, "knowledge")) if err := ks.Knowledge.Start(); err != nil { log.Printf("[homed] warning: knowledge store: %v", err) diff --git a/internal/kernelmod/llm/mod.go b/internal/kernelmod/llm/mod.go index c2da73e1..a125631e 100644 --- a/internal/kernelmod/llm/mod.go +++ b/internal/kernelmod/llm/mod.go @@ -8,8 +8,8 @@ import ( "os" "github.com/JianFeeeee/HologramAgent/internal/agent/api" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" luapkg "github.com/JianFeeeee/HologramAgent/internal/lua" "github.com/JianFeeeee/HologramAgent/pkg/types" ) @@ -21,7 +21,7 @@ func (Module) Name() string { return "llm" } func (Module) Requires() []string { return []string{kernelmod.FacConfig, kernelmod.FacLua} } func (Module) Provides() []string { return []string{kernelmod.FacLLM} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { // ★ 敏感信息防火模块的装配点。 // // 原 main 注释:"必须在任何 provider 注册之前装配"—— provider 的出向 diff --git a/internal/kernelmod/lua/mod.go b/internal/kernelmod/lua/mod.go index 2f43d535..297e9c98 100644 --- a/internal/kernelmod/lua/mod.go +++ b/internal/kernelmod/lua/mod.go @@ -7,8 +7,8 @@ import ( "log" "path/filepath" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" luapkg "github.com/JianFeeeee/HologramAgent/internal/lua" ) @@ -19,7 +19,7 @@ func (Module) Name() string { return "lua" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacLua} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { luaVM := luapkg.NewVM(filepath.Join(ks.Cfg.Daemon.DataDir, "adapters")) if err := luaVM.Start(); err != nil { log.Printf("[homed] warning: lua vm init failed: %v", err) diff --git a/internal/kernelmod/memory/distiller_wiring_test.go b/internal/kernelmod/memory/distiller_wiring_test.go index 21c45cf1..0785a508 100644 --- a/internal/kernelmod/memory/distiller_wiring_test.go +++ b/internal/kernelmod/memory/distiller_wiring_test.go @@ -20,12 +20,12 @@ package memory // 判据改为直接跑真实模块(经 kernelboot.Run),确保测的是**真实入口**。 import ( + "github.com/JianFeeeee/HologramAgent/internal/kstate" "os" "path/filepath" "testing" internalConfig "github.com/JianFeeeee/HologramAgent/internal/config" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" ) // ★★★ 判据:模块 Init 返回后蒸馏器**仍在运行**(无 defer Stop 残留)。 @@ -38,7 +38,7 @@ func TestMemoryModuleKeepsDistillerRunning(t *testing.T) { // 而它对 nil 会现在**明确报错**(而不是 panic 在 strings.Split)。 reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db")) defer reg.Close() - ks := &kernelboot.KernelState{DataDir: dir, CfgReg: reg} + ks := &kstate.State{DataDir: dir, CfgReg: reg} if err := (Module{}).Init(ks); err != nil { t.Fatalf("memory 模块 Init: %v", err) @@ -73,7 +73,7 @@ func TestMemoryModuleRegistersCloser(t *testing.T) { os.MkdirAll(filepath.Join(dir, "memory"), 0o755) reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db")) defer reg.Close() - ks := &kernelboot.KernelState{DataDir: dir, CfgReg: reg} + ks := &kstate.State{DataDir: dir, CfgReg: reg} if err := (Module{}).Init(ks); err != nil { t.Fatal(err) } diff --git a/internal/kernelmod/memory/mod.go b/internal/kernelmod/memory/mod.go index 2165f296..3417bed9 100644 --- a/internal/kernelmod/memory/mod.go +++ b/internal/kernelmod/memory/mod.go @@ -18,8 +18,8 @@ import ( "time" "github.com/JianFeeeee/HologramAgent/internal/config" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/distill" "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline" @@ -34,7 +34,7 @@ func (Module) Name() string { return "memory" } func (Module) Requires() []string { return []string{kernelmod.FacKernelIO} } func (Module) Provides() []string { return []string{kernelmod.FacMemory} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { // ★ 读取前检查(用户指定的做法:"结构体成员初始置 nil 加读取前检查")。 // // 为何要检查 CfgReg:它是 **Group 0** 的产物(不由任何调度模块提供), @@ -78,7 +78,7 @@ func (Module) Init(ks *kernelboot.KernelState) error { distiller.Start() } - ks.Memory = &kernelboot.MemoryBundle{ + ks.Memory = &kstate.MemoryBundle{ DB: memDB, Indexer: memIdx, Social: socialStore, diff --git a/internal/kernelmod/memory/nilcheck_test.go b/internal/kernelmod/memory/nilcheck_test.go index 7042152e..48c037cd 100644 --- a/internal/kernelmod/memory/nilcheck_test.go +++ b/internal/kernelmod/memory/nilcheck_test.go @@ -21,13 +21,13 @@ import ( "testing" internalConfig "github.com/JianFeeeee/HologramAgent/internal/config" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) // ★★★ 判据:**CfgReg 为 nil 时给可操作错误,而不是 panic**。 func TestMemoryModuleRejectsNilCfgReg(t *testing.T) { dir := t.TempDir() - ks := &kernelboot.KernelState{DataDir: dir} // CfgReg 故意为 nil + ks := &kstate.State{DataDir: dir} // CfgReg 故意为 nil defer func() { if r := recover(); r != nil { @@ -54,7 +54,7 @@ func TestMemoryModuleRejectsNilCfgReg(t *testing.T) { func TestMemoryModuleRejectsEmptyDataDir(t *testing.T) { dir := t.TempDir() reg := newTestReg(t, dir) - ks := &kernelboot.KernelState{CfgReg: reg} // DataDir 空 + ks := &kstate.State{CfgReg: reg} // DataDir 空 err := (Module{}).Init(ks) if err == nil { diff --git a/internal/kernelmod/migration/mod.go b/internal/kernelmod/migration/mod.go index 2e7b0d4e..45a580ec 100644 --- a/internal/kernelmod/migration/mod.go +++ b/internal/kernelmod/migration/mod.go @@ -31,8 +31,8 @@ import ( "sort" "syscall" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/systemd" ) @@ -68,7 +68,7 @@ type DirInfo struct { Kind string `json:"kind"` // data | runtime | optional | mount } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { // 体检结果放在 KernelState 上,供工具/WebUI 读(可选能力,nil 安全) ks.MigrationPreflight = func(target string) (interface{}, error) { return Preflight(ks.DataDir, target) diff --git a/internal/kernelmod/multimodal/mod.go b/internal/kernelmod/multimodal/mod.go index 639700d8..12909654 100644 --- a/internal/kernelmod/multimodal/mod.go +++ b/internal/kernelmod/multimodal/mod.go @@ -7,8 +7,8 @@ import ( "log" "strings" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" "github.com/JianFeeeee/HologramAgent/pkg/embedding" ) @@ -20,7 +20,7 @@ func (Module) Name() string { return "multimodal" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacMultimodal} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { cfgReg := ks.CfgReg var multimodalSpace vector.MultimodalEmbedder // 这两个值只用于状态报告(healthcheck_kernel 的 onnx 段): diff --git a/internal/kernelmod/onnx/mod.go b/internal/kernelmod/onnx/mod.go index ecf2a704..3bf0573f 100644 --- a/internal/kernelmod/onnx/mod.go +++ b/internal/kernelmod/onnx/mod.go @@ -17,8 +17,8 @@ import ( "log" "path/filepath" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/nlp" ) @@ -29,7 +29,7 @@ func (Module) Name() string { return "onnx-parser" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacONNX} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { modelPath := ks.CfgReg.GetString("core.agent.onnx_model_path", "") onnxParser, err := nlp.NewONNXParser(nlp.ONNXConfig{ ModelPath: modelPath, diff --git a/internal/kernelmod/pluginhost/catalog_test.go b/internal/kernelmod/pluginhost/catalog_test.go index d59cdf96..ed4b7c35 100644 --- a/internal/kernelmod/pluginhost/catalog_test.go +++ b/internal/kernelmod/pluginhost/catalog_test.go @@ -12,6 +12,7 @@ package pluginhost // ④ 关键顺序:failback 白名单必须在插件拉起前生效 import ( + "github.com/JianFeeeee/HologramAgent/internal/kstate" "go/ast" "go/parser" "go/token" @@ -311,8 +312,8 @@ func TestCatalogConcurrentRead(t *testing.T) { // ⇒ 判据:把 typed-nil 传给 Scheduler 前必须已被拦截。 // 这里直接验证**拦截逻辑本身**(不依赖 produciton 装配)。 func TestNilSyncBusDoesNotPanic(t *testing.T) { - var nb *kernelboot.SyncBus // typed-nil - var iface kernelboot.SyncState = nb + var nb *kstate.SyncBus // typed-nil + var iface kstate.SyncState = nb if iface == nil { t.Skip("接口就是 nil(不会有陷阱)—— 与本判据的前提不符") } @@ -378,7 +379,7 @@ func TestUndeclaredPluginsWaitForKernelComplete(t *testing.T) { t.Fatalf("两个自动启动插件都应进调度器(含**未声明**的),实际 %d 个", len(specs)) } - bus := kernelboot.NewSyncBus() + bus := kstate.NewSyncBus() bus.MarkDeclared([]string{"config", "agent"}) bus.MarkReady("config") diff --git a/internal/kernelmod/pluginhost/mod.go b/internal/kernelmod/pluginhost/mod.go index a059520f..7cf27510 100644 --- a/internal/kernelmod/pluginhost/mod.go +++ b/internal/kernelmod/pluginhost/mod.go @@ -2,6 +2,8 @@ package pluginhost import ( "fmt" + "github.com/JianFeeeee/HologramAgent/internal/kstate" + sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" "log" "os" "path/filepath" @@ -44,7 +46,7 @@ func (Module) Requires() []string { func (Module) Provides() []string { return []string{FacPlugins} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { // ── 前置检查(用户要求:"nil 加读取前检查")── if ks.PluginRegistry == nil || ks.StageHost == nil { return fmt.Errorf("plugin-host:ks.PluginRegistry/StageHost 为 nil —— " + @@ -111,8 +113,8 @@ func (Module) Init(ks *kernelboot.KernelState) error { // // 这样声明的插件仍在依赖就绪时尽早启动(本步的核心收益不变), // 而未声明的**恢复原行为**(等内核完全初始化)。 - var bus kernelboot.SyncState - var busConcrete *kernelboot.SyncBus // 保留具体类型以便订阅 OnUpdate + var bus kstate.SyncState + var busConcrete *kstate.SyncBus // 保留具体类型以便订阅 OnUpdate if ks.SyncBus != nil { bus = ks.SyncBus busConcrete = ks.SyncBus @@ -122,7 +124,7 @@ func (Module) Init(ks *kernelboot.KernelState) error { // 所以这里必须显式判 `!= nil` 再赋给接口变量。 log.Printf("[pluginhost] warning: SyncBus is nil -- non-production assembly path, " + "退化为立即加载(依赖未就绪的插件在 Start 时自行处理)") - fallback := kernelboot.NewSyncBus() + fallback := kstate.NewSyncBus() fallback.MarkKernelComplete() bus = fallback busConcrete = fallback @@ -172,7 +174,7 @@ func (Module) Init(ks *kernelboot.KernelState) error { // 不在 catalog 里,只有 LoadSkipping 的工厂分支能加载它们)。 skip := buildSkipSet(catalog, groups) var loadOnce sync.Once - loadRest := func(st kernelboot.SyncState) { + loadRest := func(st kstate.SyncState) { if !st.KernelComplete() { return } @@ -296,7 +298,7 @@ func buildSkipSet(c *PluginCatalog, g formGroups) map[string]bool { // ★ 这里**刻意不做** SetStatusProvider —— 它依赖 agent, // 由 agent 模块**后补注入**(见其注释)。这不是遗漏,是有意的分层: // 让插件加载不必等 agent 那 35 秒。 -func injectBaseDeps(ks *kernelboot.KernelState) { +func injectBaseDeps(ks *kstate.State) { reg := ks.PluginRegistry reg.SetLuaVM(ks.LuaVM) reg.SetBaseAPIKey(ks.BaseAPIKey) @@ -305,7 +307,17 @@ func injectBaseDeps(ks *kernelboot.KernelState) { } reg.SetTracker(ks.Tracker) reg.SetConfig(ks.Cfg) - reg.SetStageHost(ks.StageHost) + // StageHost 在挂载点里存 any(kstate 不依赖 agent/core,否则成环)。 + // 断言成 sdk.ToolSource(plugin 真正需要的只读工具视图)。 + // + // 断言失败时**不静默跳过**:SetStageHost 不设的后果是插件加载后 + // 工具表为空,而那与"插件没注册工具"无法区分。所以这里响亮记录。 + // (Init 里已检查过 StageHost != nil,此处失败是类型不合约,属装配缺陷。) + if ts, ok := ks.StageHost.(sdk.ToolSource); ok && ts != nil { + reg.SetStageHost(ts) + } else { + log.Printf("[plugin-host] WARNING: ks.StageHost 未实现 sdk.ToolSource —— 插件工具将不可见") + } if ks.Memory != nil { reg.SetIndexer(ks.Memory.Indexer) @@ -350,7 +362,7 @@ func classify(c *PluginCatalog) formGroups { } // loadOne 加载单个插件(走既有 LoadOneDir 路径,不绕过任何机制)。 -func loadOne(ks *kernelboot.KernelState, dir, name string) (bool, error) { +func loadOne(ks *kstate.State, dir, name string) (bool, error) { if err := ks.PluginRegistry.LoadOneDir(filepath.Join(dir, name), name); err != nil { return false, err } @@ -358,7 +370,7 @@ func loadOne(ks *kernelboot.KernelState, dir, name string) (bool, error) { } // planCatalog 取元数据缓存(类型断言集中一处,便于排查"缓存没建起来")。 -func planCatalog(ks *kernelboot.KernelState) *PluginCatalog { +func planCatalog(ks *kstate.State) *PluginCatalog { c, _ := ks.PluginCatalog.(*PluginCatalog) return c } @@ -368,7 +380,7 @@ func planCatalog(ks *kernelboot.KernelState) *PluginCatalog { // 失败只记日志、不阻断:系统本身仍可正常接受输入,只是依赖本事件的 // 订阅者(如 seq 的开机序列)不会自动执行。**必须可见** —— // 静默失败会让"为什么开机序列没跑"完全无从查起。 -func publishToolsReady(ks *kernelboot.KernelState) { +func publishToolsReady(ks *kstate.State) { if ks == nil || ks.EventBus == nil { log.Printf("[pluginhost] tools_ready NOT published: EventBus is nil " + "(subscribers such as seq boot sequences will not run)") diff --git a/internal/kernelmod/pluginhost/toolsready_test.go b/internal/kernelmod/pluginhost/toolsready_test.go index d4c5e333..28520519 100644 --- a/internal/kernelmod/pluginhost/toolsready_test.go +++ b/internal/kernelmod/pluginhost/toolsready_test.go @@ -14,7 +14,7 @@ import ( "testing" "github.com/JianFeeeee/HologramAgent/internal/events" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" + "github.com/JianFeeeee/HologramAgent/internal/kstate" ) // 没有 EventBus 时必须**响亮报错而不是静默返回** —— @@ -26,7 +26,7 @@ func TestPublishToolsReadyWithoutBusDoesNotPanic(t *testing.T) { } }() publishToolsReady(nil) - publishToolsReady(&kernelboot.KernelState{}) + publishToolsReady(&kstate.State{}) } // 有 EventBus 时必须真的发布一条 tools_ready,且带 plugin_count。 @@ -35,7 +35,7 @@ func TestPublishToolsReadyPublishes(t *testing.T) { var got []*events.Event bus.Subscribe(events.EventToolsReady, func(e *events.Event) { got = append(got, e) }) - ks := &kernelboot.KernelState{EventBus: bus} + ks := &kstate.State{EventBus: bus} publishToolsReady(ks) if len(got) != 1 { diff --git a/internal/kernelmod/security/mod.go b/internal/kernelmod/security/mod.go index 51727953..99a672e4 100644 --- a/internal/kernelmod/security/mod.go +++ b/internal/kernelmod/security/mod.go @@ -39,8 +39,8 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/config" "github.com/JianFeeeee/HologramAgent/internal/events" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" sec "github.com/JianFeeeee/HologramAgent/internal/security" ) @@ -51,7 +51,7 @@ func (Module) Name() string { return "security" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacSecurity} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { if ks.DataDir == "" { return fmt.Errorf("security module: ks.DataDir is empty - Group 0 must set the data dir first") } diff --git a/internal/kernelmod/stagehost/mod.go b/internal/kernelmod/stagehost/mod.go index bc255326..7dd5012c 100644 --- a/internal/kernelmod/stagehost/mod.go +++ b/internal/kernelmod/stagehost/mod.go @@ -16,11 +16,12 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/config" agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/plugin" cli "github.com/JianFeeeee/HologramAgent/internal/plugins/cli" webui "github.com/JianFeeeee/HologramAgent/internal/plugins/webui" + "github.com/JianFeeeee/HologramAgent/internal/promptsec" "github.com/JianFeeeee/HologramAgent/internal/sdk" ) @@ -34,9 +35,16 @@ func (Module) Requires() []string { } func (Module) Provides() []string { return []string{kernelmod.FacStageHost} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { stageHost := agentCore.NewStageHost() + // 插件声明式系统提示词段的注册表:本模块产出,挂到内核挂载点。 + // + // ★ 不往下传参:读方(agent 模块、SDK)自己去 ks 取 —— + // 与 StageHost / PluginRegistry 完全同型。往构造点传指针会让 + // "记得接这一根线"成为每种 agent 类型的义务(那正是之前的老毛病)。 + ks.PromptSections = promptsec.NewRegistry() + pluginReg := plugin.NewRegistry() pluginReg.SetIOManager(ks.IO) pluginReg.SetEventBus(ks.EventBus) diff --git a/internal/kernelmod/supervisor/mod.go b/internal/kernelmod/supervisor/mod.go index ee1ff86f..1b6dbea4 100644 --- a/internal/kernelmod/supervisor/mod.go +++ b/internal/kernelmod/supervisor/mod.go @@ -8,8 +8,8 @@ package supervisor import ( "log" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/supervisor" ) @@ -20,7 +20,7 @@ func (Module) Name() string { return "supervisor" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacSupervisor} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { sup := supervisor.New(ks.Cfg) if err := sup.Start(); err != nil { log.Fatalf("start supervisor: %v", err) diff --git a/internal/kernelmod/textmem/mod.go b/internal/kernelmod/textmem/mod.go index 9b57758c..f28e2742 100644 --- a/internal/kernelmod/textmem/mod.go +++ b/internal/kernelmod/textmem/mod.go @@ -24,8 +24,8 @@ import ( "log" "path/filepath" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/memory/text" ) @@ -40,7 +40,7 @@ func (Module) Requires() []string { } func (Module) Provides() []string { return []string{kernelmod.FacTextMem} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { textMem := text.New(filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "text")) if err := textMem.Start(); err != nil { log.Printf("[homed] warning: text memory start: %v", err) diff --git a/internal/kernelmod/tracker/mod.go b/internal/kernelmod/tracker/mod.go index a08dc9d3..f7cfa4f5 100644 --- a/internal/kernelmod/tracker/mod.go +++ b/internal/kernelmod/tracker/mod.go @@ -8,8 +8,8 @@ import ( "log" "time" - "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" + "github.com/JianFeeeee/HologramAgent/internal/kstate" "github.com/JianFeeeee/HologramAgent/internal/tracker" ) @@ -20,7 +20,7 @@ func (Module) Name() string { return "tracker" } func (Module) Requires() []string { return []string{kernelmod.FacConfig} } func (Module) Provides() []string { return []string{kernelmod.FacTracker} } -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { trk := tracker.NewTracker(ks.Cfg.Daemon.DataDir, ks.AgentWorkDir, tracker.WithKeepChangesets(100), tracker.WithMaxChangesetAge(30*24*time.Hour), diff --git a/internal/kernelmod/update/mod.go b/internal/kernelmod/update/mod.go index 2592d27a..5e732410 100644 --- a/internal/kernelmod/update/mod.go +++ b/internal/kernelmod/update/mod.go @@ -18,6 +18,7 @@ package update import ( + "github.com/JianFeeeee/HologramAgent/internal/kstate" "log" "path/filepath" @@ -41,7 +42,7 @@ func (Module) Requires() []string { return nil } // 第 0 层:不依赖任何 func (Module) Provides() []string { return []string{FacilityVersion} } // Init 执行版本自检,必要时回滚上一次升级。 -func (Module) Init(ks *kernelboot.KernelState) error { +func (Module) Init(ks *kstate.State) error { // ⓪ 把内核**真实**的 dataDir 注入 update 包。 // // 必须最先做:pending 标记/备份/回滚验全都靠它。进程内调用方 diff --git a/internal/kernelboot/lifecycle.go b/internal/kstate/lifecycle.go similarity index 93% rename from internal/kernelboot/lifecycle.go rename to internal/kstate/lifecycle.go index ff87568d..4590b684 100644 --- a/internal/kernelboot/lifecycle.go +++ b/internal/kstate/lifecycle.go @@ -1,4 +1,4 @@ -package kernelboot +package kstate import ( "fmt" @@ -50,7 +50,7 @@ type Quiescer struct { } // AddQuiescer 登记一个可暂停/恢复的写者(在模块 Init 里调用)。 -func (s *KernelState) AddQuiescer(name string, pause, resume func() error) { +func (s *State) AddQuiescer(name string, pause, resume func() error) { if pause == nil { return } @@ -64,7 +64,7 @@ func (s *KernelState) AddQuiescer(name string, pause, resume func() error) { // 返回成功暂停的清单 —— Resume 只唤醒这些(没有的不要"凭空唤醒")。 // 任何一个 Pause 失败都会**停止继续暂停**并返回错误:宁可只关一半 // 让调用方放弃迁移,也不要"看起来关了但仍有写者在跑"。 -func (s *KernelState) Quiesce() ([]string, error) { +func (s *State) Quiesce() ([]string, error) { s.quiesceMu.Lock() qs := make([]Quiescer, len(s.Quiescers)) copy(qs, s.Quiescers) @@ -90,7 +90,7 @@ func (s *KernelState) Quiesce() ([]string, error) { } // Resume 唤醒:按**暂停的逆序**(即原启动顺序)恢复。 -func (s *KernelState) Resume(paused []string) error { +func (s *State) Resume(paused []string) error { s.quiesceMu.Lock() qs := make([]Quiescer, len(s.Quiescers)) copy(qs, s.Quiescers) @@ -110,7 +110,7 @@ func (s *KernelState) Resume(paused []string) error { return firstErr } -func (s *KernelState) resumeOne(qs []Quiescer, name string) error { +func (s *State) resumeOne(qs []Quiescer, name string) error { for _, q := range qs { if q.Name == name { if q.Resume == nil { diff --git a/internal/kstate/mount.go b/internal/kstate/mount.go new file mode 100644 index 00000000..3cf7be0e --- /dev/null +++ b/internal/kstate/mount.go @@ -0,0 +1,101 @@ +package kstate + +// 内核统一挂载点:本进程唯一的公共数据持有者 + 它的存取入口。 +// +// ## 用户设计(本包存在的理由) +// +// > 内核通过一个结构体收集全部包级共享变量,其他模块通过这个结构体访问数据, +// > 各个模块的公共数据也统一注册到内核这个公共结构体中。 +// +// > 结构体成员初始置 nil 加读取前检查不就好了 +// +// > 所有共享数据统一挂载一个 kernel 提供的挂载点上暴露。 +// +// ## 为什么本包必须与装配引擎分开(2026-11-04 拆分) +// +// 拆分前 `KernelState` 与装配引擎同居 `internal/kernelboot`。数据结构要认识 +// 各业务类型(`*security.Runtime`、`*plugin.Registry`、`*agentCore.StageHost`…), +// 于是 `kernelboot` 被迫依赖 **19 个业务包**: +// +// kernelboot ──▶ agent/core +// ──▶ plugin +// ──▶ security +// ──▶ memory / knowledge / text / coldcontext / … +// +// 而装配引擎本身(kernelboot.go / scheduler.go / funcmodule.go) +// **一行业务代码都不需要** —— 依赖全在数据结构这一个文件里。 +// +// 后果是所有被依赖的包都无法反向引用 kernelboot,于是它们各自造通道: +// +// core → Agent 字段(靠每种 agent 构造点记得传) +// plugin → 12 个 SetXxx +// sdk → SDKConfig 传参 +// security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec() +// +// 四条通道都指向同一个根因。把数据结构搬出来(本包),环就断了: +// +// kstate ← 纯数据,谁都能 import(它就是挂载点本身) +// kernelboot ← 装配引擎,零业务包 +// +// ## 并发约定 +// +// 状态结构体上的字段**不加锁**(用户设计):同层模块并行 Init 时各自只写 +// **自己那一个字段**,字段互不相同 ⇒ 无数据竞争。读方直接读字段 + 判 nil。 +// +// 但**挂载点本身**要加锁:它是全部调用方共同读的单一变量,而装配期写入 +// 与运行期读取必然跨 goroutine(`Mount` 在启动时一次,`Get` 在热路径)。 + +import "sync" + +var ( + mountMu sync.RWMutex + mount *State +) + +// Mount 装入本进程的挂载点。**装配期调用一次**(由 kernelboot 的 Run 完成)。 +// +// 重复调用会覆盖:便于测试与热重启路径(后装的生效)。 +func Mount(s *State) { + mountMu.Lock() + mount = s + mountMu.Unlock() +} + +// Get 取挂载点。未装配时返回 **nil**(不 panic)。 +// +// ★ 为什么返回 nil 而不是空结构体:调用点散布在内核热路径上,未装配 +// (单元测试、独立工具如 memgc)时必须**零成本跳过**。所有调用方写成: +// +// if ks := kstate.Get(); ks != nil && ks.Xxx != nil { … } +// +// 返回空结构体看似更"安全",实则让"未装配"与"装配了但字段为 nil"两种 +// 完全不同的情况混为一谈 —— 前者是环境问题,后者是装配缺陷。 +func Get() *State { + mountMu.RLock() + defer mountMu.RUnlock() + return mount +} + +// MountForTest 装入一个测试用挂载点,返回**恢复函数**。 +// +// 测试里必须这样写: +// +// restore := kstate.MountForTest(fake) +// t.Cleanup(restore) +// +// 为什么不直接用 Mount:并行测试(`t.Parallel()`)会互相覆盖全局挂载点, +// 表现为随机红——而随机红是判据可信度的头号杀手(本仓已有教训: +// 判据在 -race 下才暴露竞态)。恢复函数让每个测试回到"进入前的状态" +// 而不是"nil",嵌套/串行测试也正确。 +func MountForTest(s *State) (restore func()) { + mountMu.Lock() + prev := mount + mount = s + mountMu.Unlock() + + return func() { + mountMu.Lock() + mount = prev + mountMu.Unlock() + } +} diff --git a/internal/kstate/mount_test.go b/internal/kstate/mount_test.go new file mode 100644 index 00000000..b77f201b --- /dev/null +++ b/internal/kstate/mount_test.go @@ -0,0 +1,217 @@ +package kstate_test + +// 判据:`kernelboot`(装配引擎)不得依赖业务包。 +// +// ## 为什么这条是本计划的总判据 +// +// 拆分前 `KernelState` 与装配引擎同居 `internal/kernelboot`。数据结构要认识 +// 各业务类型,于是 kernelboot 被迫依赖 19 个业务包: +// +// kernelboot ──▶ agent/core ──▶ … +// ──▶ plugin +// ──▶ security +// +// 后果是所有底层包都无法反向引用 kernelboot,只能各自造通道: +// +// core → Agent 字段(每种 agent 构造点都要记得传) +// plugin → 12 个 SetXxx +// sdk → SDKConfig 传参 +// security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec() +// +// 这四条通道是同一个根因的四个表现。**只要 kernelboot 还依赖业务包, +// 任何"把共享数据收敛到统一挂载点"的努力都会退回去**——因为底层包读不到它。 +// +// ## 判据的形态 +// +// 1. **结构判据**:`go list -deps ./internal/kernelboot` 不得含业务包。 +// 2. **`kstate` 叶子性**:`kstate` 不得 import `kernelboot` / `kernelmod` / `cmd`。 +// 3. **挂载点行为**:`Mount` / `Get` / `MountForTest` 的语义。 +// +// ★ 第 1 条在 T1(别名过渡期)会**红**,这是**故意的**: +// 别名文件要 import kstate,而 kstate 认识业务类型 ⇒ 依赖仍在。 +// T2 删掉别名、把 54 处引用改为 kstate.State 后才会转绿。 +// +// 也就是说这条判据现在就在保护 T2:如果 T2 做了一半(漏改几处又把别名 +// 加回去),它会立刻红。 + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/kstate" +) + +// TestCoreCanImportKstate 是**本计划的总判据**(2026-11-04)。 +// +// ## 它守的到底是什么 +// +// 不是"kernelboot 零业务依赖"——那不可能:`kstate.State` 必须认识 +// `*plugin.Registry`、`*memory.GraphDB` 等类型,否则字段就只能是 any +// (而 any 会把"拼错/取错类型"从编译错误降级成运行期断言,违背本结构体 +// 的设计原则:类型化字段让错误编译期暴露)。 +// +// 真正的不变量是:**`internal/agent/core` 能 import `kstate`**。 +// +// 因为 `core` 是环的一极:只要 `kstate` 反过来依赖 `core`, +// `core` 就永远读不到统一挂载点,只能自建通道: +// +// core → Agent 字段(每种 agent 构造点都要记得传) +// core → security.Global() 包级单例 +// +// 实测这两条都真实发生过(skill 索引没进子 agent 提示词、16 处 Global())。 +// +// ## 判据怎么做 +// +// 不能只检查 import 列表(`core` 目前确实没 import `kstate`)—— +// 那样即使环存在也不会红。必须**真的让 core 引 kstate 并编译**。 +func TestCoreCanImportKstate(t *testing.T) { + probe := filepath.Join("..", "agent", "core", "zz_kstate_probe_test.go") + src := `package core + +// 临时探针:证明 core 能 import kstate(无 import 环)。 +import _ "github.com/JianFeeeee/HologramAgent/internal/kstate" +` + if err := os.WriteFile(probe, []byte(src), 0o644); err != nil { + t.Fatalf("写探针失败: %v", err) + } + defer func() { _ = os.Remove(probe) }() + + out, err := exec.Command("go", "build", "github.com/JianFeeeee/HologramAgent/internal/agent/core").CombinedOutput() + if err != nil { + t.Fatalf("★ core 无法 import kstate —— 环又出现了:\n%s\n\n"+ + "原因通常是 kstate.State 里加了 *agentCore.Xxx 类型的字段。\n"+ + "正确做法:其他包把内容挂到 kstate(存最小接口或 any + 断言),\n"+ + "而不是让 kstate 反向认识 core。", out) + } +} + +// TestKstateDoesNotDependOnCore 是上面那条的**静态版**(无需起编译)。 +// +// 两条都要有:静态版能立刻指出"谁把 core 拖进来的", +// 编译版能抓到静态检查漏掉的间接环。 +func TestKstateDoesNotDependOnCore(t *testing.T) { + deps := goListDeps(t, "github.com/JianFeeeee/HologramAgent/internal/kstate") + for _, dep := range deps { + if dep == "github.com/JianFeeeee/HologramAgent/internal/agent/core" { + t.Fatalf("★ kstate 依赖了 internal/agent/core ⇒ core 再也无法 import kstate\n" + + "(具体是哪个字段见 internal/kstate/state.go 里 `agentCore.` 的引用)") + } + } +} + +// TestKstateIsLeaf 钉住 kstate 不得依赖**上层**(装配器 / 模块 / 入口)。 +// +// 允许它依赖业务**类型**包(plugin / memory / security …)——那是 State +// 的类型化字段需要的。不允许的是反向依赖"使用它的上层"。 +func TestKstateIsLeaf(t *testing.T) { + deps := goListDeps(t, "github.com/JianFeeeee/HologramAgent/internal/kstate") + + forbidden := map[string]bool{ + "github.com/JianFeeeee/HologramAgent/internal/kernelboot": true, + "github.com/JianFeeeee/HologramAgent/internal/kernelmod": true, + "github.com/JianFeeeee/HologramAgent/cmd/homed": true, + } + for _, dep := range deps { + if forbidden[dep] { + t.Fatalf("★ kstate 依赖了 %s —— 挂载点必须是叶子", dep) + } + } +} + +// TestMountGetRoundTrip 钉住挂载点基本语义。 +func TestMountGetRoundTrip(t *testing.T) { + prev := kstate.Get() + restore := kstate.MountForTest(&kstate.State{DataDir: "/tmp/rt"}) + defer restore() + + got := kstate.Get() + if got == nil { + t.Fatal("Mount 之后 Get 返回 nil") + } + if got.DataDir != "/tmp/rt" { + t.Fatalf("DataDir = %q,期望 /tmp/rt", got.DataDir) + } + if prev == got { + t.Fatal("MountForTest 没有替换挂载点") + } +} + +// TestGetNilWhenUnmounted 钉住"未装配返回 nil、不 panic"。 +// +// 这是热路径的前提:单测与独立工具(memgc 等)不装挂载点, +// 调用方靠 `if ks := kstate.Get(); ks != nil` 零成本跳过。 +func TestGetNilWhenUnmounted(t *testing.T) { + restore := kstate.MountForTest(nil) + defer restore() + + if got := kstate.Get(); got != nil { + t.Fatalf("未装配时 Get 应返回 nil,实际 %v", got) + } +} + +// TestMountForTestRestores 钉住恢复语义(并行测试隔离的基础)。 +// +// 变红条件:restore 把挂载点恢复成 nil 而不是"进入前的值" ⇒ 嵌套测试互相破坏。 +func TestMountForTestRestores(t *testing.T) { + outer := &kstate.State{DataDir: "/outer"} + restoreOuter := kstate.MountForTest(outer) + defer restoreOuter() + + inner := &kstate.State{DataDir: "/inner"} + restoreInner := kstate.MountForTest(inner) + if kstate.Get().DataDir != "/inner" { + t.Fatal("内层 MountForTest 未生效") + } + restoreInner() + + if got := kstate.Get(); got == nil || got.DataDir != "/outer" { + t.Fatalf("restore 后应回到外层挂载点 /outer,实际 %v", got) + } +} + +// TestMountIsConcurrencySafe 钉住挂载点的跨 goroutine 安全(-race 下有效)。 +func TestMountIsConcurrencySafe(t *testing.T) { + restore := kstate.MountForTest(&kstate.State{DataDir: "/base"}) + defer restore() + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 200; j++ { + _ = kstate.Get() + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + kstate.Mount(&kstate.State{DataDir: "/hot"}) + } + }() + wg.Wait() +} + +// goListDeps 返回某包的完整依赖列表。 +// +// ★ 必须传**完整导入路径**而不是 `./internal/xxx`:`go test` 的进程 cwd +// 是**包目录**(internal/kstate),相对路径会解析成 +// internal/kstate/internal/kernelboot 这种不存在的路径。 +func goListDeps(t *testing.T, pkg string) []string { + t.Helper() + out, err := exec.Command("go", "list", "-deps", pkg).Output() + if err != nil { + var stderr string + if ee, ok := err.(*exec.ExitError); ok { + stderr = string(ee.Stderr) + } + t.Fatalf("go list -deps %s 失败: %v\n%s", pkg, err, stderr) + } + return strings.Fields(string(out)) +} diff --git a/internal/kernelboot/kernelstate.go b/internal/kstate/state.go similarity index 78% rename from internal/kernelboot/kernelstate.go rename to internal/kstate/state.go index 50212e1a..c5c1c48e 100644 --- a/internal/kernelboot/kernelstate.go +++ b/internal/kstate/state.go @@ -1,13 +1,14 @@ -package kernelboot +package kstate import ( "context" + "github.com/JianFeeeee/HologramAgent/internal/plugin" + "github.com/JianFeeeee/HologramAgent/internal/security" "log" "sync" "github.com/JianFeeeee/HologramAgent/internal/agent/api" agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" - agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core" agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" "github.com/JianFeeeee/HologramAgent/internal/config" "github.com/JianFeeeee/HologramAgent/internal/events" @@ -19,8 +20,7 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/memory/social" "github.com/JianFeeeee/HologramAgent/internal/memory/text" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" - "github.com/JianFeeeee/HologramAgent/internal/plugin" - "github.com/JianFeeeee/HologramAgent/internal/security" + "github.com/JianFeeeee/HologramAgent/internal/promptsec" "github.com/JianFeeeee/HologramAgent/internal/supervisor" "github.com/JianFeeeee/HologramAgent/internal/tracker" "github.com/JianFeeeee/HologramAgent/pkg/types" @@ -33,7 +33,7 @@ import ( // 它们的持有者都假设彼此一致)。拆开会让"只填了 db 忘了 indexer"变成可能。 // // ★ 为何放在 kernelboot 而不是 cmd/homed: -// KernelState 要引用它的类型 ⇒ 类型必须与 KernelState 同包 +// State 要引用它的类型 ⇒ 类型必须与 State 同包 // (否则 kernelboot 得 import cmd/homed,那是不可能的)。 type MemoryBundle struct { DB *memory.GraphDB @@ -42,7 +42,7 @@ type MemoryBundle struct { Distiller *pipeline.Distiller } -// KernelState 是内核**唯一的公共数据持有者**(用户设计)。 +// State 是内核**唯一的公共数据持有者**(用户设计)。 // // ## 设计意图(用户原话) // @@ -94,7 +94,7 @@ type MemoryBundle struct { // // 内核**不**在字段上加锁:加锁会给调用方"可以乱写"的错觉, // 正确约束是"各模块只写自己的字段"(由代码审查 + 判据守)。 -type KernelState struct { +type State struct { // ── Group 0 产出(core 自己立起来的东西,所有模块只读) ── // Cfg 是配置快照(config 模块产出)。 @@ -213,11 +213,72 @@ type KernelState struct { UpdateCapabilities any // StageHost 是阶段管道(stage-host 模块产出)。 - StageHost *agentCore.StageHost + // + // ## 为什么是 any 而不是 *agentCore.StageHost(2026-11-04) + // + // 本包是**统一挂载点**,必须能被任何人 import。而 `*agentCore.StageHost` + // 让本包依赖 `internal/agent/core` ⇒ 反过来 `core` 就不能 import 本包: + // + // kstate ──▶ agent/core ──▶ … (core 想读共享数据就只能自建通道) + // + // 实测确认过这是真的环(`go build` 报 import cycle not allowed)。 + // 而"底层包自己把产物挂上来"才是本包的设计意图(用户裁定): + // + // > 是其他包在kstate挂共享内容啊 + // + // 所以:存 any,**取用处断言**回 `*agentCore.StageHost`。 + // 与本包已有的 PluginCatalog / PluginScheduler / UpdateCapabilities 同型, + // 代价是一次断言,收益是本包零业务依赖。 + StageHost any // PluginRegistry 是插件注册表(stage-host 模块产出)。 + // + // ★ 为什么可以是具体类型(2026-11-04 实测,不是推测): + // + // 判据是"它会不会反过来依赖 internal/agent/core"。实测: + // + // go list -deps internal/plugin | grep agent/core → 空 + // + // 所以 plugin 不依赖 core ⇒ 本包依赖 plugin **不构成环**, + // core 仍能 import 本包。类型化字段让"拼错/取错类型"是编译错误 + // (本结构体的设计原则,见包注释)。 + // + // 对照:StageHost / Agent 曾被改成 any 是因为它们**就是** core 的类型 + // (core 是链的一极,那才是真环)。 PluginRegistry *plugin.Registry + // PromptSections 是**插件声明式系统提示词段**的注册表(stage-host 模块产出)。 + // + // ## 为什么挂在这里(用户指出的设计) + // + // > 所有共享数据统一挂载一个 kernel 提供的挂载点上暴露 + // + // 与 StageHost / PluginRegistry / EventBus 同层同型:模块产出、挂上 State、 + // 任何读方直接 ks.PromptSections 取,**没有 Set* 传参、没有构造点传递**。 + // + // ## 它解决什么(真实生产故障) + // + // 此前技能索引是 Agent 上的一个字段(Agent.skillIndex),由 + // cmd/homed/bootstrap.go 用 type-assert 手工接给**根 agent**: + // + // if prov, ok := sp.(agentCore.SkillIndexProvider); ok { + // agent.SetSkillIndexProvider(prov) + // } + // + // 于是 rtchild、驻留子、工作式子各自构造时都得记得再传一遍 —— + // 漏传不报错、不崩,只是那个 agent 的提示词里一条 skill 都没有。 + // + // 生产症状(日志实录):A2A 分诊助手(驻留子)手上没有 cmd_run + // (cmd 插件 auto_start=false,需先 plugin_start),而 cmd-tools skill + // 正文里写着"先 plugin_start cmd"。它看不到这条 skill,于是把 + // terminal_create 当成一次性命令执行器:每条 `cd … && echo … && grep …` + // 开一个新终端,命令结束终端退出,terminal_read 立刻撞 + // `read /dev/ptmx: input/output error`,然后为下一条命令再开一个。 + // + // 现在:插件声明 → 注册表 → 任何 agent 每轮重建提示词时读同一份。 + // 加一种 agent 类型是**零接线**。 + PromptSections *promptsec.Registry + // PluginCatalog 是**插件元数据缓存**(plugin-host 模块产出)。 // // ## 为什么需要它(用户指出的设计) @@ -242,13 +303,13 @@ type KernelState struct { // // ## ★ 为什么它必须在这里(而不只是 Run 的局部变量) // - // 模块的 `Init(ks)` 只能看到 KernelState —— 若 bus 只在 Run 里, + // 模块的 `Init(ks)` 只能看到 State —— 若 bus 只在 Run 里, // **模块拿不到它**,于是无法: // · 订阅状态变化(plugin-host 要据此拉起"等更晚设施"的插件) // · 标记就绪(虽然 Run 会自动做,但模块可能需要主动补标) // // 实测撞到:plugin-host 想在 Init 里挂"等 agent 就绪再拉起某些插件"的 - // 回调,却找不到 bus。把它放进 KernelState(内核公共数据,用户设计) + // 回调,却找不到 bus。把它放进 State(内核公共数据,用户设计) // 是最自然的落点 —— 它本来就是**内核级共享状态**。 SyncBus *SyncBus @@ -268,7 +329,16 @@ type KernelState struct { PluginsReady bool // Agent 是主 agent(agent 模块产出)。 - Agent *agentCore.Agent + // + // 存 any 的理由与 StageHost 完全相同(见上):本包不能依赖 agent/core。 + // 取用处断言回 `*agentCore.Agent`。 + // + // 实际使用面极小,只有两处: + // · device 模块:`ks.Agent.MountDevice(rt)` —— 挂上数据面 + // · agent 模块:`k.PluginRegistry.SetStatusProvider(k.Agent)` —— 状态面 + // 若日后使用面扩大,应抽一个最小接口到叶子包(如 internal/agent/api) + // 而不是把 core 拉回本包。 + Agent any // Security 是**安全防火墙运行时**(security 模块产出)。 // @@ -279,6 +349,10 @@ type KernelState struct { // 而类型化字段能让"取错类型/拼错字段"变成编译错误 —— 这正是本结构体 // 存在的理由(见包注释)。未装配时为 nil;配置显式关闭时是 // Disabled Runtime(所有审查零成本透传),不是 nil。 + // Security 是**安全防火墙运行时**(security 模块产出)。 + // + // ★ 具体类型而非接口/any(实测 plugin 也不依赖 core,同样的判据): + // 类型化字段让取错类型成为编译错误。 Security *security.Runtime // Device 是**工具/通道数据面**(device 模块产出)。 @@ -315,7 +389,7 @@ type KernelState struct { // (两个 goroutine 同时读到旧 len、各自写同一槽)⇒ 某个模块的 // 关闭函数永远不被调用 ⇒ **资源泄漏**(且无任何报错)。 // - // 为什么不用 KernelState 上"模块只写自己字段"的约定来避免: + // 为什么不用 State 上"模块只写自己字段"的约定来避免: // Closers 不属于任一模块 —— 它是**共享的回收站**。 // 共享可变状态就必须有锁(这与"各写各的字段"是两条不同的保证)。 Closers []func() @@ -338,16 +412,16 @@ type KernelState struct { quiesceMu sync.Mutex } -// NewKernelState 创建公共结构体(所有字段零值 = nil,由各模块填充)。 -func NewKernelState() *KernelState { - return &KernelState{} +// NewState 创建公共结构体(所有字段零值 = nil,由各模块填充)。 +func New() *State { + return &State{} } // AddCloser 注册一个关闭函数(模块在 Init 里调用)。 // // 关闭时按**注册顺序的逆序**执行(LIFO,与 `defer` 语义一致): // 先初始化的一般是底层(config → memory → llm),停机应反向拆。 -func (s *KernelState) AddCloser(fn func()) { +func (s *State) AddCloser(fn func()) { if fn == nil { return } @@ -362,10 +436,10 @@ func (s *KernelState) AddCloser(fn func()) { // ★ 失败策略:**继续执行剩余关闭**(返回首个错误)。 // 一个模块关不掉不该阻止其他模块关 —— 那会造成更多泄漏。 // 关闭函数 panic 也被隔离(否则一个模块的问题会连带其他模块都关不掉)。 -func (s *KernelState) CloseAll() error { +func (s *State) CloseAll() error { // ★ 先取快照再执行(不持锁执行回调):关闭函数可能很慢 // (停进程/flush 数据库),持锁会让并发调用 AddCloser 的模块卡住; - // 且回调可能反查 KernelState ⇒ 死锁。 + // 且回调可能反查 State ⇒ 死锁。 s.closerMu.Lock() fns := make([]func(), len(s.Closers)) copy(fns, s.Closers) diff --git a/internal/kernelboot/syncbus.go b/internal/kstate/syncbus.go similarity index 99% rename from internal/kernelboot/syncbus.go rename to internal/kstate/syncbus.go index 62bf9f9a..8c831084 100644 --- a/internal/kernelboot/syncbus.go +++ b/internal/kstate/syncbus.go @@ -1,4 +1,4 @@ -package kernelboot +package kstate // syncbus:内核就绪状态的同步快照,驱动插件调度(设计 §2.6)。 // diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 79f488c8..f10f99fd 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -224,6 +224,7 @@ func (r *Registry) SetIOManager(iom *agentIO.IOManager) { defer r.mu.Unlock() r.iom = iom } + func (r *Registry) SetEventBus(evBus *events.Bus) { r.mu.Lock() defer r.mu.Unlock() diff --git a/internal/plugins/skillmgr/plugin.go b/internal/plugins/skillmgr/plugin.go index 6e524b94..b64a80b6 100644 --- a/internal/plugins/skillmgr/plugin.go +++ b/internal/plugins/skillmgr/plugin.go @@ -104,6 +104,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { log.Printf("[skillmgr] detected skill %s: %v", path, err) return } + p.publishIndex() log.Printf("[skillmgr] registered via %s event: %s", detectEvent, filepath.Base(path)) }) @@ -112,11 +113,18 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // 后续事件重复到达时 loadOne 幂等跳过) n := p.scanExisting() p.registerTools() + // 首次声明:把当前索引推给内核的统一注册表。 + // 不调它的后果:模型看不到任何 skill 索引,而 skill_list 仍能列出 + // —— "工具能列但模型不知道",是最难查的一类降级。 + p.publishIndex() log.Printf("[skillmgr] started, dir=%s, loaded %d native skills", p.skillsDir, n) return nil } func (p *Plugin) Stop() error { + // 先撤声明再退订:顺序反过来的话,退订期间仍有事件在加载 skill, + // 那部分会来不及进索引(虽然进程即将退出,但热重载路径下不退出)。 + p.unpublishIndex() if p.unsub != nil { p.unsub() p.unsub = nil @@ -250,9 +258,26 @@ func (p *Plugin) get(name string) (*SkillEntry, bool) { return e, ok } -// SkillIndex 实现 agentCore.SkillIndexProvider:返回已启用技能的精炼索引。 -// 每次 LLM 调用都会执行,必须轻量(只读锁 + 字符串拼接,无 IO)。 -func (p *Plugin) SkillIndex() string { +// skillSectionName 是本插件声明的系统提示词段名。 +// +// 命名规则由 SDK 强制:`.<名字>`,owner 必须等于插件名。 +// 因此别的插件无法覆盖本段(attempting 会被内核拒绝)。 +const skillSectionName = "skillmgr.skills" + +// skillSectionOrder 让技能索引排在**工具与技能使用说明**区段(100–199)。 +// +// 为什么不是更靠前:索引里列的是工具名,模型要先知道"这轮有哪些工具" +// 再读"怎么用"才有意义。顺序约定见 sdk/systemprompt.go 的 PromptSection.Order。 +const skillSectionOrder = 100 + +// buildIndex 生成已启用技能的精炼索引(名称 + 版本 + 一行描述)。 +// +// 这是**声明内容**,由 publishIndex 推给内核;不再是让内核来 pull 的接口。 +func (p *Plugin) buildIndex() string { + const header = "[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." + entries := p.snapshot() var b strings.Builder for _, e := range entries { @@ -266,7 +291,70 @@ func (p *Plugin) SkillIndex() string { desc = p.truncateDesc(desc) fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc) } - return strings.TrimRight(b.String(), "\n") + list := strings.TrimRight(b.String(), "\n") + if list == "" { + // 没有技能时**不声明**(而不是声明一段只有标题的空段)。 + return "" + } + return header + "\n\n" + list +} + +// publishIndex 把当前技能索引声明到内核的**统一系统提示词注册表**。 +// +// ## 为什么是"声明"而不是"让内核来问" +// +// 此前是内核定义 `SkillIndexProvider` 接口、skillmgr 实现它、bootstrap +// 手工把它接给根 agent。那条路有三个问题: +// +// 1. **双向耦合**:内核硬编码插件的产出,插件又 import 内核接口 +// 2. **子 agent 漏接**:rtchild / 驻留子 / 工作式子各自构造,都要记得传; +// 漏传不报错也不崩,只是那个 agent 的提示词里一条 skill 都没有 +// 3. **顺序不可控**:插件内容只能整块插在内核硬拼流程的某个固定位置 +// +// 现在是声明式:插件声明自己的段,内核每轮重建时按 Order 渲染。 +// 任何 agent(含未来新类型)读同一份注册表 —— **零接线**。 +// +// ## 为什么每个变更点都要调 +// +// 段内容是**静态字符串快照**(不是回调),所以技能集合变了必须重新声明。 +// 漏调的症状是"新装的 skill 在提示词里看不到",而 skill_list 工具却能列出 +// —— 模型于是不知道有这个 skill 可用。这是本仓踩过的同类问题 +// (built-in skill 不更新:机制在,但没推给读到的地方)。 +// +// 内核侧同段重复声明是**覆盖**(幂等),所以无条件调用是安全的。 +func (p *Plugin) publishIndex() { + if p.sdk == nil { + return + } + api := p.sdk.SystemPrompt() + if api == nil { + return + } + content := p.buildIndex() + if err := api.DeclarePromptSection(sdk.PromptSection{ + Section: skillSectionName, + Owner: p.name, + Order: skillSectionOrder, + Content: content, + Volatile: true, // 内容随 skill 增删/启停变化(诊断用) + }); err != nil { + // 不静默:声明失败意味着模型看不到任何 skill 索引, + // 而 skill_list 仍能列出 —— 这种"工具能列但模型不知道"最难查。 + log.Printf("[skillmgr] WARN: 系统提示词段声明失败(模型将看不到技能索引): %v", err) + } +} + +// unpublishIndex 撤销本插件的声明(Stop / 停用时)。 +// +// 不撤销的后果:插件已停用,但提示词里还留着"用 skill_info 读某 skill", +// 模型于是去调一个不存在的工具。那比"没有说明"更糟。 +func (p *Plugin) unpublishIndex() { + if p.sdk == nil { + return + } + if api := p.sdk.SystemPrompt(); api != nil { + _ = api.RemovePromptSection(skillSectionName) + } } // truncateDesc truncates a skill description to 80 runes and appends an ellipsis. diff --git a/internal/plugins/skillmgr/skillmgr_test.go b/internal/plugins/skillmgr/skillmgr_test.go index a743da0c..9f3105e2 100644 --- a/internal/plugins/skillmgr/skillmgr_test.go +++ b/internal/plugins/skillmgr/skillmgr_test.go @@ -357,7 +357,7 @@ func TestSkillIndex(t *testing.T) { // The plugin now materializes the four kernel skills before scanning. They are // part of the built-in skill index, so an empty user registry is not empty. - idx := p.SkillIndex() + idx := p.buildIndex() for _, want := range []string{"kernel-memory", "kernel-output", "kernel-plugins", "kernel-tool-order"} { if !strings.Contains(idx, want) { t.Fatalf("built-in skill missing from index: %q", idx) @@ -370,14 +370,14 @@ func TestSkillIndex(t *testing.T) { }); err != nil { t.Fatal(err) } - idx = p.SkillIndex() + idx = p.buildIndex() if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") { t.Fatalf("index missing name/desc: %q", idx) } // 禁用后不出现在索引里 spy.call(t, "skill_disable", map[string]interface{}{"name": "idx-test"}) - if idx := p.SkillIndex(); strings.Contains(idx, "idx-test") { + if idx := p.buildIndex(); strings.Contains(idx, "idx-test") { t.Fatalf("disabled skill should be excluded from index: %q", idx) } } diff --git a/internal/plugins/skillmgr/tools.go b/internal/plugins/skillmgr/tools.go index 628b8686..11a6fba3 100644 --- a/internal/plugins/skillmgr/tools.go +++ b/internal/plugins/skillmgr/tools.go @@ -111,6 +111,7 @@ func (p *Plugin) registerLoad() { if err != nil { return nil, err } + p.publishIndex() // 索引变了:重新声明,否则新 skill 模型看不到 return fmt.Sprintf("已加载技能 %s v%s", e.sk.Name(), e.sk.Version()), nil }) } @@ -139,6 +140,7 @@ func (p *Plugin) registerUnload() { if !p.removeOne(name) { return nil, fmt.Errorf("skill %q not loaded", name) } + p.publishIndex() // 索引变了 msg := fmt.Sprintf("已卸载技能 %s(内存)", name) if delFiles { if err := os.RemoveAll(path); err != nil { @@ -176,14 +178,20 @@ func (p *Plugin) registerSetEnabled(v bool) { }, }, func(args map[string]interface{}) (interface{}, error) { skillName, _ := args["name"].(string) + // ★ 不用 defer:下面要在**持锁之外**调 publishIndex, + // 而 publishIndex → buildIndex → snapshot 会再取 RLock。 + // sync.RWMutex 不可重入 ⇒ defer 持写锁到函数结束会直接死锁。 p.mu.Lock() - defer p.mu.Unlock() e, ok := p.skills[skillName] if !ok { + p.mu.Unlock() return nil, fmt.Errorf("skill %q not loaded", skillName) } e.enabled = v e.sk.SetEnabled(v) + p.mu.Unlock() + + p.publishIndex() // 启/停改变了索引内容 return fmt.Sprintf("已%s技能 %s", word, skillName), nil }) } @@ -232,6 +240,7 @@ func (p *Plugin) registerCreate() { if err != nil { return nil, err } + p.publishIndex() // 新建/覆盖技能:索引变了 return fmt.Sprintf("技能 %s 已写入并加载生效(v%s)。用 skill_info 可查看全文。", e.sk.Name(), e.sk.Version()), nil } @@ -327,6 +336,7 @@ func (p *Plugin) registerInstall() { os.RemoveAll(dst) return nil, fmt.Errorf("installed but load failed: %w", err) } + p.publishIndex() // 安装:索引变了 return fmt.Sprintf("已从本地目录安装技能 %s v%s", e.sk.Name(), e.sk.Version()), nil case strings.HasSuffix(strings.ToLower(source), PackExt): @@ -351,6 +361,7 @@ func (p *Plugin) registerInstall() { os.RemoveAll(dst) return nil, fmt.Errorf("unpacked %d files but load failed: %w", n, err) } + p.publishIndex() // 安装:索引变了 return fmt.Sprintf("已安装 %d 个文件,技能 %s v%s 生效", n, e.sk.Name(), e.sk.Version()), nil default: diff --git a/internal/promptsec/registry.go b/internal/promptsec/registry.go new file mode 100644 index 00000000..7fd871b7 --- /dev/null +++ b/internal/promptsec/registry.go @@ -0,0 +1,238 @@ +package promptsec + +// 插件**声明式系统提示词段**:插件只声明内容,内核每轮重建时渲染。 +// +// ## 用户模型(2026-11-04) +// +// > 每个插件都可以声明 systemprompt,可运行时更新,内核每轮重建。 +// +// 于是"某个插件想告诉模型怎么用自己的工具"变成**插件自己声明的一段文本**: +// +// skillmgr 声明 "skillmgr.skills" → 技能索引 + "调工具前先读同名 skill" +// qq 声明 "qq.identity" → 身份绑定规则 +// 其他插件 声明 "<自己>.<段名>" → 各自的说明 +// +// 而内核只做两件事:**存**(按 section 覆盖)与**渲染**(每轮按 Order 拼进 +// system prompt)。内核不认识 skill、不认识 qq,也不判内容。 +// +// ## 为什么必须由内核持有(不能是 Agent 字段) +// +// 本仓已有一处栽在这上面:技能索引曾经是 `Agent.skillIndex` 字段, +// 由 `cmd/homed/bootstrap.go` 手工接线到**根 agent**。于是 rtchild、驻留子 +// 各自 `New(AgentConfig{...})` 时都得记得再传一遍 —— 漏传不报错、不崩, +// 只是"那个 agent 看不到任何 skill",然后模型凭工具名瞎猜。 +// +// 生产症状:A2A 分诊助手(驻留子)手上没有 cmd_run(cmd 插件按需拉起), +// 也看不到 cmd-tools skill 里"先 plugin_start cmd"的说明,于是把 +// terminal_create 当成一次性命令执行器,一条命令开一个终端。 +// +// 教训:**能力归属内核,插件只声明**。放在 Registry 里,任何 agent +// (含未来新类型)零改动自动可见;放在 Agent 字段里,每个构造点都是一根 +// 要记得接的线 —— 那正是"洋葱"退化成"莲藕"的形态。 +// +// ## 幂等语义(用户明确要求"可运行时更新") +// +// 同一 Section 重复声明 ⇒ **覆盖**,不是追加。 +// +// 这条不是风格问题,是正确性问题:`stepPrepare` 在一次任务里可能被重入 +// (上下文裁剪后 `f.Step = StepPrepare`),而 system prompt 每轮重建。 +// 若声明是追加,索引会越堆越多,最终挤掉真正的对话历史。 +// 用 map 而不是 slice,让"多份"在结构上不可能发生。 + +import ( + "fmt" + "sort" + "strings" + "sync" +) + +// Section 是插件声明的一段系统提示词内容。 +type Section struct { + // Section 是**全局唯一**的段名,约定 `<插件名>.<名字>`(如 "skillmgr.skills")。 + // + // ★ 强制前缀约定(与 security 的 Profile.Name 同型):拼错能被立刻发现, + // 且插件之间不可能互相覆盖。 + Section string + + // Owner 是声明它的插件名。必须与 Section 的前缀一致。 + Owner string + + // Order 决定渲染顺序(小的在前)。同 Order 按 Section 字典序,保证稳定。 + Order int + + // Content 是正文。**空串表示移除该段**(运行时撤销声明)。 + Content string + + // Volatile 表示内容会频繁变化(如技能索引随 skill 增删变化)。 + // + // 仅用于诊断输出(状态面能看到哪些段每轮都在变),不参与渲染决策。 + // 存在的理由:技能索引是本类声明里唯一"随时可能变"的段, + // 出问题时需要能一眼区分"这段是静态说明"还是"这段每轮重算"。 + Volatile bool +} + +// Registry 是**内核级**的声明表:一个进程一份,所有 agent 共享。 +// +// ★ 并发:pluginmgr 在插件 Start 期间声明,agent 每轮渲染读取。 +// 读多写少,用 RWMutex;渲染时先拷贝快照再排序,不在持锁期做字符串拼接。 +type Registry struct { + mu sync.RWMutex + sections map[string]Section +} + +// NewRegistry 建一个空的声明表。 +func NewRegistry() *Registry { + return &Registry{sections: make(map[string]Section)} +} + +// Declare 声明/更新一段。同 Section 覆盖(幂等)。 +// +// 返回 error 的情形: +// +// · 段名不符合 `.<名字>` 形式 +// · Owner 与段名前缀不一致(试图声明别人的段) +// +// ★ 不做"静默接受":拼错段名却悄悄生效,是这个仓库反复吃过亏的 +// 一类故障(参见 security 档位未声明即报错的理由)。 +func (r *Registry) Declare(s Section) error { + if r == nil { + return fmt.Errorf("promptsec: registry 未初始化") + } + owner := strings.TrimSpace(s.Owner) + section := strings.TrimSpace(s.Section) + if owner == "" { + return fmt.Errorf("promptsec: Owner 不能为空") + } + if section == "" { + return fmt.Errorf("promptsec: Section 不能为空") + } + prefix, _, ok := strings.Cut(section, ".") + if !ok || prefix == "" || !strings.HasPrefix(section, owner+".") { + return fmt.Errorf("promptsec: 段名 %q 必须形如 %q.<名字>(Owner=%q)", + section, owner, owner) + } + if len(section) <= len(owner)+1 { + return fmt.Errorf("promptsec: 段名 %q 缺少名字部分", section) + } + + s.Owner = owner + s.Section = section + + r.mu.Lock() + defer r.mu.Unlock() + if strings.TrimSpace(s.Content) == "" { + // 空正文 = 撤销。保持"map 里没有该键"的语义。 + delete(r.sections, section) + return nil + } + r.sections[section] = s + return nil +} + +// Remove 撤销一段声明(等价于 Declare 传空 Content)。 +// +// 单独提供是因为"撤销"在插件 Stop / 禁用时是常见动作,写成空串容易漏。 +func (r *Registry) Remove(section string) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.sections, section) +} + +// RemoveOwner 撤销某插件的**全部**声明(插件停用/卸载时调用)。 +// +// ★ 为什么必须有:插件停用后它的说明还在提示词里,模型会去调一个 +// 已不存在的工具 —— 这比"没有说明"更糟。单个 Remove 需要插件自己 +// 记住声明过哪些段,插件崩了就没机会记住。 +func (r *Registry) RemoveOwner(owner string) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + for k, v := range r.sections { + if v.Owner == owner { + delete(r.sections, k) + } + } +} + +// Snapshot 返回全部声明段的**副本**,按 Order 升序(同 Order 按 Section 字典序)。 +// +// 与 Render 的分工: +// +// Render = 自己拼成最终文本(注册表独占的场景) +// Snapshot = 交出各段,由调用方与**别的来源**合并(内核的当轮段) +// +// ★ 为什么必须能交出各段而不能只给拼好的字符串:内核自己也有十几块 +// 系统提示词(人格卡、记忆说明、输出规则…),它们要能与插件段按 Order +// 交错排序。只给拼好的字符串,插件段就只能整块插在某个固定位置。 +func (r *Registry) Snapshot() []Section { + if r == nil { + return nil + } + r.mu.RLock() + list := make([]Section, 0, len(r.sections)) + for _, s := range r.sections { + list = append(list, s) + } + r.mu.RUnlock() + sort.Slice(list, func(i, j int) bool { + if list[i].Order != list[j].Order { + return list[i].Order < list[j].Order + } + return list[i].Section < list[j].Section + }) + return list +} + +// Render 渲染全部声明段,按 Order 升序(同 Order 按 Section 字典序)。 +// +// 无声明时返回空串——调用点用 `if s != ""` 判断,不要自己判 nil。 +func (r *Registry) Render() string { + if r == nil { + return "" + } + r.mu.RLock() + list := make([]Section, 0, len(r.sections)) + for _, s := range r.sections { + list = append(list, s) + } + r.mu.RUnlock() + + if len(list) == 0 { + return "" + } + sort.Slice(list, func(i, j int) bool { + if list[i].Order != list[j].Order { + return list[i].Order < list[j].Order + } + return list[i].Section < list[j].Section + }) + + var b strings.Builder + for i, s := range list { + if i > 0 { + _, _ = b.WriteString("\n\n") + } + _, _ = b.WriteString(s.Content) + } + return b.String() +} + +// Names 返回全部段名(诊断/状态面用)。 +func (r *Registry) Names() []string { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.sections)) + for k := range r.sections { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index daaaf436..5c983d44 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -39,6 +39,12 @@ type ToolCall = pubsdk.ToolCall type ToolResult = pubsdk.ToolResult type ToolError = pubsdk.ToolError type ToolDef = pubsdk.ToolDef + +// PromptSection 是插件声明式系统提示词段(见 pubsdk.SystemPromptAPI)。 +// +// 与 ToolDef / Stage 同型:用别名 re-export,避免每个内置插件都去 import +// 对外的 SDK 包(它们已经 import 本包了)。 +type PromptSection = pubsdk.PromptSection type IOInjector = pubsdk.IOInjector type ToolRegistrar = pubsdk.ToolRegistrar type StageRegistrar = pubsdk.StageRegistrar diff --git a/internal/update/state.go b/internal/update/state.go index 301770ad..54911810 100644 --- a/internal/update/state.go +++ b/internal/update/state.go @@ -12,7 +12,7 @@ import ( // // ★ 定义在 internal/update 而不是 kernelmod:三端(CLI/WebAPI/agent 工具) // 都要用,而 KernelState 只有内核启动一条路径。模块通过 -// kernelboot.UpdateStateProvider 接口把它暴露给内核,避免 import 循环。 +// kstate.UpdateStateProvider 接口把它暴露给内核,避免 import 循环。 type State struct { BinaryPath string DepGraphPath string diff --git a/third_party/hologramagentsdk/sdk/plugin.go b/third_party/hologramagentsdk/sdk/plugin.go index 83f51040..73bbd307 100644 --- a/third_party/hologramagentsdk/sdk/plugin.go +++ b/third_party/hologramagentsdk/sdk/plugin.go @@ -627,6 +627,7 @@ type PluginSDK struct { social SocialAPI secrets SecretsAPI security SecurityAPI + sysPrompt SystemPromptAPI rawFiles RawFilesAPI events EventSubscriber plgMgr PluginMgrAPI @@ -740,6 +741,24 @@ func (s *PluginSDK) Security() SecurityAPI { return s.security } +// SystemPrompt 返回**系统提示词声明面**(插件声明自己的段;渲染由内核做)。 +// +// ★ 只声明、不渲染:本接口没有"设置整个 system prompt"的方法。 +// 插件能改的只有自己那段(段名以插件名开头,内核强制), +// 否则任一插件都能改写别人的人格/安全说明。 +func (s *PluginSDK) SystemPrompt() SystemPromptAPI { + s.apiMu.RLock() + defer s.apiMu.RUnlock() + return s.sysPrompt +} + +// SetSystemPromptAPI 由内核注入声明面的实现(插件不调用)。 +func (s *PluginSDK) SetSystemPromptAPI(v SystemPromptAPI) { + s.apiMu.Lock() + defer s.apiMu.Unlock() + s.sysPrompt = v +} + func (s *PluginSDK) Social() SocialAPI { s.apiMu.RLock() defer s.apiMu.RUnlock() diff --git a/third_party/hologramagentsdk/sdk/systemprompt.go b/third_party/hologramagentsdk/sdk/systemprompt.go new file mode 100644 index 00000000..1a4c7a0a --- /dev/null +++ b/third_party/hologramagentsdk/sdk/systemprompt.go @@ -0,0 +1,113 @@ +package sdk + +// SystemPromptAPI 是插件**声明**自身系统提示词段的接口。 +// +// ## 用户模型(2026-11-04) +// +// > 每个插件都可以声明 systemprompt,可运行时更新,内核每轮重建。 +// +// 于是"某个插件想告诉模型怎么用自己的工具"变成**插件自己声明的一段文本**: +// +// skillmgr 声明 "skillmgr.skills" → 技能索引 + "调工具前先读同名 skill" +// qq 声明 "qq.identity" → 身份绑定规则(哪些操作以谁的身份做) +// 任意插件 声明 "<自己>.<段名>" → 各自的说明 +// +// 内核只做两件事:**存**(按段名覆盖)与**渲染**(每轮重建时按 Order 拼进 +// system prompt)。内核不认识 skill、不认识 qq,也不判断内容。 +// +// ## 分工:插件只声明,内核只渲染 +// +// 插件:DeclarePromptSection / RemovePromptSection (只声明) +// 内核:持有注册表 + 每轮重建 system prompt 时渲染 (只渲染) +// +// ★ 为什么声明必须由**内核**持有,而不是各 Agent 自己存一份 +// +// 本仓栽过这个坑:技能索引曾经是内核 Agent 上的一个字段,由启动代码 +// 手工接线到**根 agent**。于是 rtchild、驻留子各自构造时都要记得再传一遍 —— +// 漏传不报错、不崩,只是那个 agent 的提示词里一条技能说明都没有, +// 模型只能凭工具名猜用法。 +// +// 生产症状(真实日志):A2A 分诊助手(驻留子)手上没有 cmd_run +// (cmd 插件 auto_start=false,需先 plugin_start),而 cmd-tools skill +// 正文里写着"先 plugin_start cmd"。它看不到这条 skill,于是把 +// terminal_create 当成一次性命令执行器 —— 每条 `cd … && echo … && grep …` +// 开一个新终端,命令结束终端就退出,terminal_read 立刻撞 +// `read /dev/ptmx: input/output error`,然后为下一条命令再开一个。 +// +// 结论:**能力归属内核(共享注册表),插件只声明**。这样任何 agent +// (含未来新增类型)读同一份注册表,零接线。 +// +// ## 幂等语义("可运行时更新"的正确形态) +// +// 同一 Section 重复声明 ⇒ **覆盖**,不是追加。 +// +// 这不是风格问题:system prompt 每轮重建,一次任务里 stepPrepare 可能 +// 被重入(上下文裁剪后回到该步)。若声明是追加,索引会越堆越多, +// 最终挤掉真正的对话历史。注册表用 map 存段,让"多份"在结构上不可能发生。 +// +// ## 典型用法(skillmgr) +// +// // Start:声明一段静态说明 + 当前索引 +// s.SystemPrompt().DeclarePromptSection(sdk.PromptSection{ +// Section: "skillmgr.skills", +// Owner: "skillmgr", +// Order: 100, +// Content: skillIndexText(), +// }) +// +// // 技能增删/启停后:重新声明同一段即可(自动覆盖) +// s.SystemPrompt().DeclarePromptSection(...) +// +// // Stop / 停用:移除,避免模型去调已不存在的 skill +// s.SystemPrompt().RemovePromptSection("skillmgr.skills") +// +// ## 名字约束 +// +// 段名必须是 `.<名字>`,其中 owner 等于插件名。这条在内核侧强制 +// (否则插件 A 能声明 `skillmgr.skills` 并夺走插件 B 的段)。 +type SystemPromptAPI interface { + // DeclarePromptSection 声明或更新一段系统提示词内容。 + // + // 同 Section 重复声明会覆盖(同一 owner 只能覆盖自己的段, + // 不能覆盖别人的)。Content 为空串等价于移除该段。 + // + // 返回 error 的情形: + // · Section 不符合 `.<名字>` 形式 + // · Owner 与 Section 前缀不一致(试图声明别人的段) + // · 内核未提供注册表(系统提示词声明面未装配) + DeclarePromptSection(s PromptSection) error + + // RemovePromptSection 移除一段声明。 + // + // 单独提供而不是"传空 Content":插件 Stop / 被停用时这是常见动作, + // 写成空串容易漏 —— 漏了就会出现"插件已停用,但它的说明还留在 + // 提示词里,模型去调一个不存在的工具",比没有说明更糟。 + // + // 移除不存在或不属于自己的段是静默成功(幂等,便于无条件收尾)。 + RemovePromptSection(section string) error +} + +// PromptSection 是一段声明的系统提示词内容。 +type PromptSection struct { + // Section 是段名,必须形如 `.<名字>`(如 "skillmgr.skills")。 + Section string + // Owner 是声明者(插件名),必须与 Section 前缀一致。 + Owner string + + // Order 决定渲染顺序(小的在前)。同 Order 按段名字典序,保证稳定。 + // + // 约定给个宽松的区段,便于后续插入: + // 0–99 内核/框架性质(极少用) + // 100–199 工具与技能使用说明(skillmgr 用 100) + // 200–299 身份与权限说明 + // 900+ 其他 + Order int + + // Content 是正文(空串 = 移除该段)。 + Content string + + // Volatile 表示内容会随运行时频繁变化(如技能索引)。 + // + // 只用于诊断(状态面能区分"静态说明"与"每轮重算"),不参与渲染决策。 + Volatile bool +}