Files
HomeAgent/internal/kernelmod/agent/mod.go
JianFeeeee eb1deab5dd feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态
## rtchild(R1–R13)

第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。

- R1  CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2  rtSlot 单槽覆盖(latest-wins,从不排队)
- R3  独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4  ChatStream 逐块 → outputch(rt).Execute(text)
- R5  打断 = 原地截断,不重试
- R6  工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7  普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9  CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)

刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。

## 四类 agent 各自可配模型源

core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。

★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。

## 驻留子残余任务四态 + 通道三态

残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。

通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。

一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。

## 修复"手写字段白名单"丢值(7 处)

同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。

- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
  原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)

## 压缩偏离设计

CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。

并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。

## 判据

新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
  每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
  "在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿

## 判据自身的坑(两次偶发红,都不是产品缺陷)

1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
   → "暂存 0 条"。修法:cancel + waitSchedulerStopped。
   (offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
   靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
2026-10-30 17:00:47 +08:00

314 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package agent 是 agent 模块:主 Agent(需在插件加载前创建,
// 因为插件 Configure 需要 StatusProvider)。
//
// ## 依赖
//
// buildAgent 收 provider/providerMgr/iom/mem.db/mem.indexer/trk/coldcontextStore/
// knowledge/mem.social/textMem/personality/pluginReg/embedder/
// multimodalSpace/stageHost/evBus ⇒ 依赖除 supervisor 外的几乎全部下层模块。
//
// 人格设定(loadPersonality)在本模块内完成 —— 它只读配置文件,
// 没有独立产物的价值,不必单列模块。
package agent
import (
"fmt"
"log"
"path/filepath"
"strings"
"time"
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"
agentPkg "github.com/JianFeeeee/HologramAgent/internal/agent"
)
// Module 提供主 Agent。
type Module struct{}
func (Module) Name() string { return "agent" }
func (Module) Requires() []string {
return []string{kernelmod.FacLLM, kernelmod.FacMemory, kernelmod.FacTracker,
kernelmod.FacDocStore, kernelmod.FacKnowledge, kernelmod.FacTextMem,
kernelmod.FacStageHost}
}
func (Module) Provides() []string { return []string{kernelmod.FacAgent} }
func (Module) Init(k *kernelboot.KernelState) error {
if k.Memory == nil {
return errMissing("agent", kernelmod.FacMemory)
}
if k.LLMManager == nil {
return errMissing("agent", kernelmod.FacLLM)
}
if k.PluginRegistry == nil {
return errMissing("agent", kernelmod.FacStageHost)
}
k.Agent = buildAgent(k)
// 插件 SDK 接线(必须在 loadPlugins 之前)。
wirePluginSDK(k)
return nil
}
func init() { kernelmod.Register(Module{}) }
const defaultSystemPrompt = `You are HologramAgent, a continuously running personal assistant.
The system injects the available tools and installed skills on every turn:
- Each tool's description states when to use it, what its parameters mean, and known pitfalls. Read it before calling; never guess usage from the tool name.
- The skill index is only an index. Use skill_info to read a skill in full, and follow its documented steps for multi-step procedures.
Answer only from what the tools actually return. If something could not be retrieved, say so plainly. Never fill gaps by completing, guessing, or inventing.
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 {
cfg := k.Cfg
cfgReg := k.CfgReg
provider := k.LLMProvider
providerMgr := k.LLMManager
iom := k.IO
memDB := k.Memory.DB
memIdx := k.Memory.Indexer
trk := k.Tracker
coldcontextStore := k.DocStore
ks := k.Knowledge
socialStore := k.Memory.Social
textMem := k.TextMemory
pluginReg := k.PluginRegistry
embedder := k.Embedder
multimodalSpace := k.MultimodalSpace
mmProviderName := k.MultimodalProviderName
mmErr := k.MultimodalErr
stageHost := k.StageHost
evBus := k.EventBus
personality := loadPersonality(cfg, cfgReg)
sysPrompt := cfgReg.GetString("core.agent.system_prompt", defaultSystemPrompt)
if sysPrompt == "" {
sysPrompt = defaultSystemPrompt
}
// D4:把"设备是否已授权"的判据注入插件侧(toolImpl.CanUse 用)。
// ⚠️ 必须放在 agent 构造**之后**——判据要用 agent 的 allowedOutputs,
// 而 registry 早于 agent 构造(故这里传的是晚绑定闭包)。
// 未注入时 ToolAPI 路径对设备放行:那是"授权可被绕过"的既成缺口。
// 注入后,cli 的 /terminal、seq 序列等一切走 ToolAPI 的调用都受同一道闸。
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
// 主 agent 的优先模型源(空 = 用总默认 core.llm.provider)。
PreferredProvider: cfgReg.GetString("core.agent.llm.main_provider", ""),
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: coldcontextStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
// MediaStore 已随 CAS 淘汰:块自带负载(见 plan.md)。
Personality: personality,
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
PersonaStore: config.RegistryPersonaStore{Reg: cfgReg},
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
DataDir: cfg.Daemon.DataDir,
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
// 上下文管理的可调阈值。此前这些值全部硬编码在 ComputeTokenBudget 里,
// 界面改不了、不同窗口的实例也没法各自调优。
CtxTuning: agentCore.ContextTuning{
UtilizationPercent: cfgReg.GetInt("core.agent.context.utilization_percent", 80),
MaxTargetTokens: cfgReg.GetInt("core.agent.context.max_target_tokens", 600000),
MemoryRatioPercent: cfgReg.GetInt("core.agent.context.memory_ratio_percent", 0),
ProtectedCount: cfgReg.GetInt("core.agent.context.protected_count", 10),
},
// ⚠️ 这条键此前被注册进配置面板、也有默认值,但**从未被读取** ——
// 界面上改它没有任何效果,且不报任何错。同一类「死配置」正是
// 本次把阈值接入配置系统时要一并修掉的。
MaxContextSize: cfgReg.GetInt("core.agent.max_context_size", 30),
// 默认值**不在这里写**:ConfigRegistry 的 GetXxx 会从已注册的
// ConfigDef.Default 取(见 registry.go 的 defDefault)。这里传的
// 字面量只是「连 ConfigDef 都没注册」时的兜底,正常路径不会用到。
//
// 2026-10-18 的教训:把 busy_after 从 5m 改成 1m 时,同一个默认值
// 散落在 seedDBValues/sedCoreDefs/读取点 三处,得手工对齐一遍,
// 漏一处就是静默的行为漂移。现在只剩 ConfigDef 一处。
Offload: agentCore.OffloadOptions{
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 0),
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 0),
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 0),
},
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
MultimodalSpace: multimodalSpace,
EmbeddingProvider: mmProviderName,
EmbeddingError: mmErr,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
InputProcessing: cfg.InputProcessing,
})
// 图记忆读写钩子 → 事件总线。
//
// ★ 为什么在这里接(而不是让各调用方自己发事件):
// 图读写散在 26 个方法与 180+ 个 db 调用点上,各调用方自报必漏发;
// 而**图库自己就知道它碰了哪些块 ID**。星图此前只能靠工具名猜
// 节点(把 "stablemem_recall" 拆词去匹配中文实体名,实测命中 0),
// 于是每次都点亮同一批无关节点——这正是「星图没有读写反馈」的真身。
//
// ★ 只在 evBus 非 nil 时接:它把「内核进程」与「独立 CLI 工具」区分开
// (homed-graph-migrate / memgc 等也用 NewGraphDB,但没有事件总线,
// 也不该为一次批量迁移向不存在的订阅者发几万条事件)。
//
// ★ 钩子**必须非阻塞**:它在记忆读写的同步路径上,发布到总线
// 是内存操作(各订阅者回调也都很短),实测开销可忽略。
if memDB != nil && evBus != nil {
memDB.SetAccessHook(func(ev memory.AccessEvent) {
payload := map[string]interface{}{
"op": ev.Op,
}
// 只在非空时带上字段:空数组会让前端白白重算一次。
if len(ev.Blocks) > 0 {
payload["blocks"] = ev.Blocks
}
if len(ev.Created) > 0 {
payload["created"] = ev.Created
}
if len(ev.Removed) > 0 {
payload["removed"] = ev.Removed
}
if len(ev.Merged) > 0 {
merged := make([]map[string]string, 0, len(ev.Merged))
for _, m := range ev.Merged {
merged = append(merged, map[string]string{"from": m.From, "to": m.To})
}
payload["merged"] = merged
}
if ev.Tool != "" {
payload["tool"] = ev.Tool
}
evBus.Publish(&events.Event{
Type: events.EventMemoryAccess,
Source: "main",
Payload: payload,
Timestamp: time.Now().Unix(),
})
})
}
// D4:把「设备是否已授权」的判据注入插件侧(toolImpl.CanUse 消费它)。
//
// 为什么必须在这里:判据要用 agent 自己的 allowedOutputs,而
// pluginReg 早于 agent 构造(newStageAndRegistry 在 main() 里先跑),
// 所以 registry 存的是**晚绑定**闭包,注入点必须在 agent 建好之后。
//
// 不注入的后果(已实测的真实缺口):设备授权闸只存在于
// core.executeToolCallInner,即「agent 收到模型 tool_call」那条路径;
// 而 ToolAPI.ExecuteTool 是**另一条**独立入口,不经那道闸 ⇒
// 凡是走 ToolAPI 的调用都能绕过 AllowedOutputs。实测范围不止序列:
// cli 的 /terminal 就直接经 ToolAPI 调 agentcli 的终端工具。
pluginReg.SetDeviceAuthQuery(func(deviceID string) bool {
return agent.IsOutputAllowed("device/" + deviceID)
})
// 方案 B:把 agent 的**内置工具面**注入 ToolAPI。
//
// 缺口背景:`memory_*` / `knowledge_*` / `doc_*` / `person_*` 这 20+ 个
// 在 executeToolCallInner 里按前缀分派,从不进 ToolAPI ⇒ 插件经 ToolAPI
// 既查不到也调不了。真机实跑实证:seq_run 报「工具 knowledge_list
// 不存在或未注册」,而同一轮模型直接调它是成功的。
//
// 注入必须在此处(agent 构造之后):内置工具的可见性由运行期状态门控
// (memory/knowledge 是否就绪),而 provider 持有的是 agent。
agent.InstallBuiltinToolProvider()
return agent
}
// wirePluginSDK 注入**只有 agent 才知道**的那两项(KERNEL-REGISTRY-SETLOCK 去重后)。
//
// ## 为什么这里不再注入那 7 项底层依赖(2026-10-24 去重)
//
// 原先本函数与 pluginhost.injectBaseDeps 各自把 LuaVM/BaseAPIKey/Supervisor/
// Tracker/Config/StageHost/Indexer 写一遍,而两个模块同处启动图第 4 层、
// **并行执行** ⇒ 同一批字段被两个 goroutine 无锁写(实测 4% 概率报 DATA RACE)。
//
// 判掉哪一侧:留 pluginhost,砍这边。理由是**必要性不对称**:
//
// · pluginhost 的注入是**必需**的 —— 它自己紧接着就 LoadSkipping 拉插件,
// 而它刻意 `Requires()` 不含 agent(核心收益:插件不必等 agent 那 35 秒)。
// 砍掉它 ⇒ 第 4 层加载的插件拿到全 nil 依赖。
// · agent 这边注入的这批值是**同一批 ks.* 字段的同一份值**,写第二次
// 没有新增信息;砍掉它 ⇒ 语义不变(pluginhost 侧的 nil 守卫更严,
// Supervisor 只因 FacSupervisor 已在 Requires 里而恒非 nil)。
//
// ## 注意:去重**不能**替代加锁
//
// 去重后本函数仍有 SetStatusProvider / SetDeviceAuthQuery 两项要写,而
// pluginhost 同时可能正在加载插件(buildSDK 读 status)⇒ 写读依然并发。
// 所以 registry 侧 Set*/读侧的锁是**真正的修复**,去重只是消除了一处
// 无意义的重复写。两件事都做,缺一不可。
func wirePluginSDK(k *kernelboot.KernelState) {
k.PluginRegistry.SetStatusProvider(k.Agent)
}
// loadPersonality 载入人格(原 cmd/homed 的整块平移)。
func loadPersonality(cfg *types.Config, cfgReg *config.ConfigRegistry) *agentPkg.Personality {
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
log.Printf("[homed] warning: load personality: %v", err)
}
if personality != nil && personality.Content != "" {
log.Printf("[homed] persona source=file %s (takes precedence over config), %d bytes", personalPath, len(personality.Content))
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
log.Printf("[homed] warning: persona file contains content that will rot %v -- migrate to config entry core.agent.personal_prompt "+
"(the default template has no version number; when asked for the version, use the runtime snapshot)", hints)
}
} else if pv := cfgReg.GetString("core.agent.personal_prompt", config.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
log.Printf("[homed] persona source=config entry core.agent.personal_prompt, %d bytes", len(pv))
} else {
log.Printf("[homed] persona source=none (config entry empty and no persona file)")
}
return personality
}
// errMissing 报告"依赖的模块产物为 nil"。
//
// ★ 为什么立刻报错而不是让 nil 传下去:nil 读出零值后 continue 会在**很远
// 的地方**变成模糊症状(如"模型没有配置")。这里点出**是哪个模块的哪个
// 产物缺失**,把排查成本从"追几小时"降到"看一行"。
//
// 措辞刻意点出"依赖图本应保证它非 nil" —— 若真出现,说明
// ① 模块忘了声明依赖,或 ② Requires/Provides 设施名不一致(拼写漂移)。
func errMissing(moduleName, facility string) error {
return fmt.Errorf("模块 %s 依赖的设施 %q 对应的数据为 nil —— "+
"依赖图本应保证它已就绪。两种可能:① 该模块没在 Requires 里声明这个依赖;"+
"② Provides/Requires 的设施名写得不一致(拼写漂移)", moduleName, facility)
}
var _ = events.EventMemoryAccess
var _ = memory.AccessEvent{}