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 防腐判据(部分已随本次落地)
271 lines
9.8 KiB
Go
271 lines
9.8 KiB
Go
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)
|
||
}
|
||
}
|