mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
239 lines
7.8 KiB
Go
239 lines
7.8 KiB
Go
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>.<名字>` 形式
|
||
// · 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
|
||
}
|