From 2ebbdadcd614cf287fcc5f819e4df92f4a6f2116 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 10:20:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(resident):=20N3=E2=80=93N7=20=E9=A9=BB?= =?UTF-8?q?=E7=95=99=E5=BC=8F=E5=AD=90=20agent=20=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=88=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?/=E5=8F=8C=E5=90=91=E6=8A=95=E9=80=92/=E5=A4=84=E7=90=86?= =?UTF-8?q?=E8=A1=A8/contextfull/e2e+=E5=8E=8B=E5=8A=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计:docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。 ## N3 生命周期(resident.go) - `SpawnResident`:主库**受限句柄** + 自己的 temp 实例(`LightMemory`)⇒ 子的轻量内核; 划入 inputch(登记归属)、授权输出通道、注入任务提示词;为父登记 `child/` 入站 inputch; 建独立 `IOManager`(共享通道登记表);启动子。 - `DestroyResident`:停子内核、归还划入的 inputch(回到未分配)、丢弃 temp 目录、出登记表。 - `Stop()` → `StopResidents()`:**父退出必须销毁全部子、不留孤儿**(设计 §10 硬约束)。 - `Residents()` 登记表快照;`ResidentTable(id)` 父 pull 子的处理表(不打断)。 ## N4 跨 agent 投递 - 子→父:`notify_parent` → 投进父的 `child/` inputch,优先级 **L3**。 - 父→子:`SendToResident` → 投进子的 inputch,优先级 **L4**; `isKernelLevelSource` 泛化为"该 agent 的上级"(`AgentConfig.KernelSource`)⇒ 只有父能在子的阶梯上产生 L4(子内部一律 ≤L3)。 - 子的 contextfull → 父侧 `raiseKernelInterrupt`(内核级事件,带子标识,父侧 L4)。 ## N5 inputch 处理表 - 子持有;`inputch_note` 主动写**优先**,轮末 `autoRecordInputch` 兜底 ⇒ 每轮必有记录。 - 压缩时清表(表记的是被压掉那段窗口的逐轮处理)。 ## N6 contextfull(判据修正) ❗初版判据是"拼好的 `f.Msgs` 估算 > 90% 窗口",**结构上永不成立**: `buildMessages` 拿到的 `budget.ContextTokens` 由 `targetUsage = 0.8 × 窗口` 推出, 时间线**在拼进消息之前就被预算裁过**,`f.Msgs` 封顶在 ~80% 窗口。 (初版测试用一个比系统提示词还小的窗口才勉强越线 —— 那等于什么都没测。) 现判据 = **未裁剪的积累上下文**(`a.context.Recent(0)`)超过窗口 90%: 它超过就说明下一轮必须丢事件,这正是"上下文满"。 三处置:`CompressResident`(保留语义:`TrimKeepRecent` 保留最近 N 条 + 清表)/ `ReclaimResident`(取消语义:`ExportTriples` 读 temp → 父选出要保留的 → `Commit` 进 main → 取消该子)/ `DestroyResident`(立刻销毁并移除)。 ## 工具面 `resident_agents`(父,单工具多动作:list/create/send/inspect/compress/reclaim/destroy)、 `notify_parent` + `inputch_note`(子)。声明条件式:父才有前者,子才有后两者。 ## 验收 `resident_test.go` 五项:生命周期与不留孤儿、双向投递(含"子的主动消息不得以 L4 出现")、 处理表(自动写 vs 主动写优先)、contextfull + 三处置、 **压力 8 子 × 12 轮(父→子 L4 与普通输入各半)+ 双向汇报 + 父退出清理**。 全仓 go test ./... 37 包 ok / 0 FAIL;`-race`(agent/memory/plugin)干净; 压力 `-race -count=3` 通过。 --- docs/zh/resident-subagent-design.md | 20 +- internal/agent/core/agent.go | 53 ++- internal/agent/core/context.go | 23 ++ internal/agent/core/resident.go | 501 ++++++++++++++++++++++++++ internal/agent/core/resident_test.go | 367 +++++++++++++++++++ internal/agent/core/resident_tools.go | 168 +++++++++ internal/agent/core/scheduler.go | 10 +- internal/agent/core/task.go | 13 + internal/agent/core/toolcall.go | 6 + internal/agent/core/tooldefs.go | 65 ++++ internal/memory/graph.go | 3 + 11 files changed, 1215 insertions(+), 14 deletions(-) create mode 100644 internal/agent/core/resident.go create mode 100644 internal/agent/core/resident_test.go create mode 100644 internal/agent/core/resident_tools.go diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index 02ae1cc..0e6cd2d 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -456,6 +456,16 @@ type GraphMemory interface { - 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/ 蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。 +### 16.0.1 工具面(已实现) + +| 工具 | 谁用 | 作用 | +|---|---|---| +| `resident_agents` | 父 | **单工具多动作**:`list` / `create`(划入 inputch + 授权输出通道 + 注入任务提示词)/ `send`(对子 = L4)/ `inspect`(pull 处理表,不打断)/ `compress`(保留)/ `reclaim`(取消 + 合入)/ `destroy` | +| `notify_parent` | 子 | 主动汇报(父侧 = **L3 中断**) | +| `inputch_note` | 子 | 主动写本轮 inputch 处理信息(写了就不自动写) | + +> 声明是条件式的:父(`parentID == ""`)才有 `resident_agents`;子才有 `notify_parent` / `inputch_note`。 + ### 16.1 N2 的记忆面清单(现状) `internal/memory/` 下需要加 space 维度的面: @@ -550,11 +560,11 @@ type GraphMemory interface { | — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 | | **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ | | **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ | -| **N3** | **驻留子生命周期**:创建 / 销毁 / 登记表 / 父退出清理 | S12、S16 | -| **N4** | **跨 agent 投递**:子→父 L3、父→子 L4(+ `isKernelLevelSource` 分层) | S4、S6、S17 | -| **N5** | **inputch 处理表**:主动写入工具 + 自动写兜底 + 生命周期 | S8、S9 | -| **N6** | **contextfull**:检测 + L4 通知(带子标识)+ 三处置(压缩/回收/销毁) | S5、S10、S11 | -| **N7** | **e2e + 压力**:父子互中断、contextfull 处置矩阵、多子并发、父退出清理 | S1–S18 全绿;`-race` 干净 | +| **N3** | ~~驻留子生命周期~~ **已完成**:`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ | +| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`(L3,投父的 `child/` inputch);父→子 `SendToResident`(L4,`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ | +| **N5** | ~~inputch 处理表~~ **已完成**:`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ | +| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4(`child/`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main)/ `DestroyResident` | S5、S10、S11 ✅ | +| **N7** | ~~e2e + 压力~~ **已完成**:`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1–S20 覆盖 ✅ | 每步收尾命令: diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 173a6b0..abd31ec 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -39,13 +39,34 @@ type Agent struct { memory *memory.GraphDB // graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。 // 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。 - graph GraphMemory - indexer *memory.Indexer - tracker *tracker.Tracker - context *RelevanceContext - systemPrompt string - ctx context.Context - cancel context.CancelFunc + graph GraphMemory + + // kernelSource/parentID/taskPrompt/dataDir:驻留子相关的层级信息(见 AgentConfig)。 + kernelSource string + parentID string + taskPrompt string + dataDir string + + // 驻留子(父侧):登记表 + 子侧钩子。 + residentMu sync.Mutex + residents map[string]*residentChild + + // 子侧:向父发消息(L3)与 contextfull 上报(父侧内核级事件)的钩子。 + notifyParent func(text string) + onContextFull func() + ctxFullSignaled bool + + // 子侧:inputch 处理表(子持有,父 pull)。 + tableMu sync.Mutex + inputchTable []InputchRecord + inputchPending *InputchRecord + currentInputch string + indexer *memory.Indexer + tracker *tracker.Tracker + context *RelevanceContext + systemPrompt string + ctx context.Context + cancel context.CancelFunc // 文档记忆(第二层) docStore *document.Store @@ -217,6 +238,18 @@ type AgentConfig struct { Personality *agentPkg.Personality PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具) PluginReg *plugin.Registry + // KernelSource 是本 agent 的"上级"(驻留子的父)。 + // + // 设计 §6.1:某个 agent 的 L4 只属于它的**内核** —— 根 agent 的内核是内核自身与 + // 内核级插件;驻留子的内核是**父 agent**。因此子的 KernelSource = 父 ⇒ 只有父 + // 能在子的阶梯上产生 L4(父的"发送消息")。 + KernelSource string + // ParentID 是父 agent 的 id(空 = 根 agent)。子用它判断自己是不是驻留子。 + ParentID string + // DataDir 是本 agent 的数据目录;创建驻留子时用它派生 temp 图记忆路径。 + DataDir string + // TaskPrompt 是在固定提示词之上注入的**任务提示词**(驻留子创建时给定)。 + TaskPrompt string // AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。 // // nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。 @@ -300,6 +333,10 @@ func New(cfg AgentConfig) *Agent { io: cfg.IO, memory: cfg.Memory, graph: graphMemoryOf(cfg), + kernelSource: cfg.KernelSource, + parentID: cfg.ParentID, + taskPrompt: cfg.TaskPrompt, + dataDir: cfg.DataDir, indexer: cfg.Indexer, tracker: cfg.Tracker, context: rc, @@ -357,6 +394,8 @@ func (a *Agent) Start() { } func (a *Agent) Stop() { + // 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。 + a.StopResidents() a.cancel() } diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 2661b6e..8a38bb4 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -451,6 +451,29 @@ func (c *RelevanceContext) Blocks() []memory.MemoryBlock { return out } +// TrimKeepRecent 只保留最近 n 条事件,丢弃更旧的(返回丢弃条数)。 +// +// 这是**压缩上下文**(保留语义)的机械原语:不归档、不写任何记忆,直接丢弃旧事件。 +// 用于轻量内核(驻留子):它没有 doc 记忆与记忆整理流水线,压缩只能是"保留最近的"。 +func (c *RelevanceContext) TrimKeepRecent(n int) int { + c.mu.Lock() + if n < 1 { + n = 1 + } + if len(c.events) <= n { + c.mu.Unlock() + return 0 + } + dropped := len(c.events) - n + kept := make([]*ContextEvent, n) + copy(kept, c.events[dropped:]) + c.events = kept + c.dirty = true + c.mu.Unlock() + c.save() + return dropped +} + func (c *RelevanceContext) Len() int { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go new file mode 100644 index 0000000..a06b85b --- /dev/null +++ b/internal/agent/core/resident.go @@ -0,0 +1,501 @@ +package core + +// 驻留式子 agent 的**生命周期与控制面**(设计 docs/zh/resident-subagent-design.md §7/§9/§10)。 +// +// 父 ──创建/发送消息/查看/压缩/回收/销毁──► 驻留子 +// 子 ──主动消息(L3) / contextfull(L4)──► 父 +// +// 层级关系: +// - 父持**登记表**(residents),它是查看·发送·压缩·回收·销毁的寻址依据; +// - 父 `Stop()` ⇒ 销毁全部子(**不留孤儿**); +// - 子的 `KernelSource` = 父;父则把子的 contextfull 当**内核级事件**(raiseKernelInterrupt)上报给自己; +// - 子持有自己的 **inputch 处理表**(父 pull,不打断子)。 + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" + "gitcode.com/JianFeeeee/HomeAgent/pkg/types" +) + +// InputchRecord 是 inputch 处理表的一条记录(子持有,父 pull)。 +type InputchRecord struct { + InputCh string `json:"inputch"` + At time.Time `json:"at"` + Proactive bool `json:"proactive"` // true = 子主动写入;false = 系统自动写 + Text string `json:"text"` +} + +// ResidentOptions 是创建一个驻留子的参数(父的"创建"动作)。 +type ResidentOptions struct { + // ID 是子 agent 的 id(同时是登记表的键、跨 agent 寻址的依据)。 + ID string + // TaskPrompt 是在固定提示词之上注入的**任务提示词**。 + TaskPrompt string + // InputChs 是**划入**给这个子的 inputch(单位 = inputch;可来自同一插件的多个)。 + InputChs []string + // AllowedOutputs 是授权给它的输出通道集合(nil/空 = 完整授权)。 + AllowedOutputs []string + // Capacity 是划入 inputch 的队列容量(0 = 内核默认)。 + Capacity int + // TempPath 是它 temp 图记忆的存储路径(必填;与子同生共死)。 + TempPath string +} + +// ResidentInfo 是父对某个驻留子的可查询状态(登记表条目 + 状态面摘要)。 +type ResidentInfo struct { + ID string `json:"id"` + State string `json:"state"` + InputChs []string `json:"inputchs"` + AllowedOutputs []string `json:"allowed_outputs"` + Rounds int `json:"rounds"` + ContextFull bool `json:"context_full"` + CreatedAt time.Time `json:"created_at"` + TableSize int `json:"table_size"` + Table []InputchRecord `json:"table,omitempty"` +} + +type residentChild struct { + id string + agent *Agent + light *memory.LightMemory + mainRO *memory.GraphDB + tempPath string + dir string + inputChs []string + allowed []string + createdAt time.Time + + mu sync.Mutex + state string // running | contextfull | stopped +} + +// mainGraph 返回父自己的完整图记忆库(驻留子的受限句柄由它派生)。 +func (a *Agent) mainGraph() *memory.GraphDB { return a.memory } + +// SpawnResident 创建一个驻留子(父的"创建"动作)。 +func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) { + if strings.TrimSpace(opts.ID) == "" { + return ResidentInfo{}, fmt.Errorf("驻留子必须有 id") + } + if strings.TrimSpace(opts.TempPath) == "" { + return ResidentInfo{}, fmt.Errorf("驻留子必须给出 temp 图记忆路径") + } + main := a.mainGraph() + if main == nil { + return ResidentInfo{}, fmt.Errorf("父没有图记忆,无法为驻留子提供主库只读视图") + } + + a.residentMu.Lock() + if a.residents == nil { + a.residents = map[string]*residentChild{} + } + if _, dup := a.residents[opts.ID]; dup { + a.residentMu.Unlock() + return ResidentInfo{}, fmt.Errorf("驻留子 %s 已存在", opts.ID) + } + a.residentMu.Unlock() + + // ① 轻量内核的记忆装配:主库**受限句柄**(结构上写不进)+ 自己的 temp 实例。 + // temp 目录由内核创建(调用方只给路径)——与子同生共死,销毁时整目录丢弃。 + if dir := filepath.Dir(opts.TempPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return ResidentInfo{}, fmt.Errorf("创建 temp 目录: %w", err) + } + } + mainRO, err := memory.OpenGraphDBReadOnly(main.Path()) + if err != nil { + return ResidentInfo{}, fmt.Errorf("打开主库受限句柄: %w", err) + } + light, err := memory.NewLightMemory(mainRO, opts.TempPath, true) + if err != nil { + _ = mainRO.Close() + return ResidentInfo{}, err + } + + // ② 划入 inputch(登记表里记归属;一个插件的多个 inputch 可分别划给不同子)。 + if reg := a.io.ChannelRegistry(); reg != nil { + for _, ch := range opts.InputChs { + if err := reg.Assign(ch, opts.ID, opts.Capacity); err != nil { + _ = light.Close() + _ = mainRO.Close() + return ResidentInfo{}, fmt.Errorf("划入 inputch %s: %w", ch, err) + } + } + } + + // ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表。 + childIO := agentIO.NewIOManager() + if reg := a.io.ChannelRegistry(); reg != nil { + childIO.SetChannelRegistry(reg) + } + + parentID := string(a.id) + child := New(AgentConfig{ + ID: types.AgentID(opts.ID), + Provider: a.provider, + ProviderManager: a.providerManager, + IO: childIO, + StageHost: a.stageHost, + LightMemory: light, // 轻量内核:只有图记忆共同面 + AllowedOutputs: opts.AllowedOutputs, + KernelSource: parentID, // 子的 L4 只属于父 + ParentID: parentID, + TaskPrompt: opts.TaskPrompt, + }) + + rc := &residentChild{ + id: opts.ID, agent: child, light: light, mainRO: mainRO, + tempPath: opts.TempPath, dir: filepath.Dir(opts.TempPath), + inputChs: append([]string(nil), opts.InputChs...), + allowed: append([]string(nil), opts.AllowedOutputs...), + createdAt: time.Now(), state: "running", + } + + // ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。 + parentInCh := a.residentInboundChannel(opts.ID) + child.notifyParent = func(text string) { + a.io.InjectInterruptTextOpts(opts.ID, parentInCh, text, + agentIO.InjectOptions{Priority: "L3"}) + } + // ⑤ 子的 contextfull → 父侧的**内核级事件**(L4,带子标识)。 + child.onContextFull = func() { a.handleChildContextFull(rc) } + + a.residentMu.Lock() + a.residents[opts.ID] = rc + a.residentMu.Unlock() + + child.Start() + return rc.info(), nil +} + +// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。 +func (a *Agent) residentInboundChannel(childID string) string { + ch := "child/" + childID + if reg := a.io.ChannelRegistry(); reg != nil { + // 归属父自己:它是父的入站 inputch。 + _ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)}) + } + return ch +} + +// DestroyResident 立刻销毁一个驻留子并从登记表移除(父的"销毁"动作;不收割)。 +// +// 销毁是父**随时**可做的;父退出时由 StopResidents 对全部子执行。 +func (a *Agent) DestroyResident(id string) error { + a.residentMu.Lock() + rc, ok := a.residents[id] + if ok { + delete(a.residents, id) + } + a.residentMu.Unlock() + if !ok { + return fmt.Errorf("驻留子 %s 不存在", id) + } + a.teardownResident(rc) + return nil +} + +// teardownResident 停内核、放通道、丢 temp(销毁与回收共用)。 +func (a *Agent) teardownResident(rc *residentChild) { + rc.mu.Lock() + rc.state = "stopped" + rc.mu.Unlock() + + rc.agent.Stop() // 停子的调度器(取消其运行中的任务) + if rc.light != nil { + _ = rc.light.Close() // 关掉 temp 实例 + } + if rc.mainRO != nil { + _ = rc.mainRO.Close() + } + // temp 与子同生共死:连同目录一起丢弃(回收/销毁都不保留)。 + if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) { + _ = os.RemoveAll(rc.dir) + } + // 归还划入的 inputch(归属清空 ⇒ 回到"未分配",可再分配)。 + if reg := a.io.ChannelRegistry(); reg != nil { + for _, ch := range rc.inputChs { + _ = reg.Assign(ch, "", 0) + } + } +} + +// StopResidents 销毁全部驻留子(父退出时必须;不留孤儿)。 +func (a *Agent) StopResidents() int { + a.residentMu.Lock() + all := make([]*residentChild, 0, len(a.residents)) + for _, rc := range a.residents { + all = append(all, rc) + } + a.residents = map[string]*residentChild{} + a.residentMu.Unlock() + for _, rc := range all { + a.teardownResident(rc) + } + return len(all) +} + +// Residents 返回登记表的快照(按 id 排序,便于断言与展示稳定)。 +func (a *Agent) Residents() []ResidentInfo { + a.residentMu.Lock() + all := make([]*residentChild, 0, len(a.residents)) + for _, rc := range a.residents { + all = append(all, rc) + } + a.residentMu.Unlock() + out := make([]ResidentInfo, 0, len(all)) + for _, rc := range all { + out = append(out, rc.info()) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// ResidentTable 是父**查看**子的 inputch 处理表(pull,不打断子)。 +func (a *Agent) ResidentTable(id string) ([]InputchRecord, error) { + a.residentMu.Lock() + rc, ok := a.residents[id] + a.residentMu.Unlock() + if !ok { + return nil, fmt.Errorf("驻留子 %s 不存在", id) + } + return rc.agent.inputchTableSnapshot(), nil +} + +// SendToResident 是父的"发送消息":经输出通道寻址到该子的 inputch, +// 对子而言是 **L4 中断**(取消当前状态 + 插入新消息)。 +func (a *Agent) SendToResident(id, text string) error { + a.residentMu.Lock() + rc, ok := a.residents[id] + a.residentMu.Unlock() + if !ok { + return fmt.Errorf("驻留子 %s 不存在", id) + } + ch := "sub/" + id + if len(rc.inputChs) > 0 { + ch = rc.inputChs[0] + } + // 来源 = 父;子的 KernelSource 是父 ⇒ 在子的阶梯上这是合法的 L4。 + rc.agent.io.InjectInterruptTextOpts(string(a.id), ch, text, + agentIO.InjectOptions{Priority: "L4"}) + return nil +} + +// CompressResident 是父的"压缩"(**保留语义**):压上下文 + **清理处理表**,子继续存在。 +func (a *Agent) CompressResident(id string) (int, error) { + a.residentMu.Lock() + rc, ok := a.residents[id] + a.residentMu.Unlock() + if !ok { + return 0, fmt.Errorf("驻留子 %s 不存在", id) + } + dropped := rc.agent.context.TrimKeepRecent(residentKeepRecent) + rc.agent.clearInputchTable() // 处理表记的是被压掉那段窗口的逐轮处理 ⇒ 必须清 + rc.agent.resetContextFull() + rc.mu.Lock() + rc.state = "running" + rc.mu.Unlock() + return dropped, nil +} + +// ReclaimResident 是父的"回收"(**取消语义**):父读子的 temp → 选记录 → 合入 main +// → 丢弃 temp → **取消**该驻留子。 +// +// keep 由父决定"哪些纳入记忆";为 nil 时表示全部合入。 +func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.Triple) []memory.Triple) (ResidentInfo, error) { + a.residentMu.Lock() + rc, ok := a.residents[id] + a.residentMu.Unlock() + if !ok { + return ResidentInfo{}, fmt.Errorf("驻留子 %s 不存在", id) + } + info := rc.info() + + var promoted int + // 收割:读 temp 的全部活跃三元组(比通过图记录选择更直接)。 + if rc.light != nil && rc.light.Temp() != nil && a.mainGraph() != nil { + exported, err := rc.light.Temp().ExportTriples(0) + if err != nil { + return info, fmt.Errorf("读取子 temp 失败: %w", err) + } + selected := exported + if keep != nil { + selected = keep(info.Table, exported) + } + if len(selected) > 0 { + if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+id, 0); err != nil { + return info, fmt.Errorf("合入主记忆失败: %w", err) + } + promoted = len(selected) + } + } + + // 收割完成 ⇒ 取消该驻留子(回收是取消语义,≠ 压缩)。 + if err := a.DestroyResident(id); err != nil { + return info, err + } + info.State = fmt.Sprintf("reclaimed(promoted=%d)", promoted) + return info, nil +} + +// handleChildContextFull 把子的 contextfull 当**内核级事件**上报给父自己: +// 父侧 L4 中断(带子标识)—— 只推信号,细节靠"查看"拉状态面。 +func (a *Agent) handleChildContextFull(rc *residentChild) { + rc.mu.Lock() + rc.state = "contextfull" + rc.mu.Unlock() + a.raiseKernelInterrupt("child/"+rc.id, "kernel", + fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", rc.id)) +} + +// checkContextFull 判断本 agent 的**积累上下文是否已经装不下窗口**,是则触发一次 contextfull。 +// +// ❗判据为什么不能写成"估算拼好的 f.Msgs":`buildMessages` 拿到的 `budget.ContextTokens` +// 本身就是按 `targetUsage = 0.8 × 窗口` 算出来的,时间线**在拼进消息之前就被预算裁过**了。 +// 于是 `f.Msgs` 的规模结构上封顶在 ~80% 窗口 —— 对 90% 阈值而言是**永远不成立**的判据 +// (写测试时我用一个比系统提示词还小的窗口才勉强越过线,那等于什么都没测)。 +// +// 正确的事是"**要被裁了**":拿**未裁剪**的积累上下文(`a.context` 的全部事件)估算, +// 它超过窗口阈值就说明下一轮必须丢事件 ⇒ 这就是 contextfull。 +// +// 只对**驻留子**生效(只有它们设了 onContextFull)。 +func (a *Agent) checkContextFull(f *TaskFrame) { + if a.onContextFull == nil || a.ctxFullSignaled { + return + } + max := 0 + if a.provider != nil { + max = a.provider.MaxContextTokens() + } + if max <= 0 { + max = defaultMaxContextTokens + } + if a.context == nil { + return + } + // 未裁剪的积累上下文规模。 + acc := 0 + for _, e := range a.context.Recent(0) { + acc += EstimateTokens(e.Input) + EstimateTokens(e.Response) + } + if float64(acc) < float64(max)*contextFullRatio { + return + } + a.ctxFullSignaled = true + a.onContextFull() +} + +func (a *Agent) resetContextFull() { a.ctxFullSignaled = false } + +// ---- 子侧:inputch 处理表(子持有,父 pull) ---- + +// recordInputchNote 是子**主动写入**本轮 inputch 的处理信息(工具 inputch_note)。 +func (a *Agent) recordInputchNote(text string) { + a.tableMu.Lock() + defer a.tableMu.Unlock() + a.inputchPending = &InputchRecord{ + InputCh: a.currentInputch, At: time.Now(), Proactive: true, Text: text, + } +} + +// autoRecordInputch 是**系统自动写**兜底:本轮未主动写时,把该轮 inputch 的处理信息写入。 +// 保证每一轮必有记录,父不会看到空洞。 +func (a *Agent) autoRecordInputch(f *TaskFrame) { + if f == nil { + return + } + a.tableMu.Lock() + defer a.tableMu.Unlock() + if a.inputchPending != nil { + rec := *a.inputchPending + a.inputchPending = nil + rec.InputCh = firstNonEmpty(rec.InputCh, a.currentInputch) + a.inputchTable = append(a.inputchTable, rec) + return + } + text := fmt.Sprintf("轮次完成:输入=%s", truncateStr(f.Input, 80)) + if f.Response != "" { + text += ";产出=" + truncateStr(f.Response, 120) + } + a.inputchTable = append(a.inputchTable, InputchRecord{ + InputCh: a.currentInputch, At: time.Now(), Proactive: false, Text: text, + }) +} + +func (a *Agent) inputchTableSnapshot() []InputchRecord { + a.tableMu.Lock() + defer a.tableMu.Unlock() + return append([]InputchRecord(nil), a.inputchTable...) +} + +func (a *Agent) clearInputchTable() { + a.tableMu.Lock() + defer a.tableMu.Unlock() + a.inputchTable = nil + a.inputchPending = nil +} + +// ---- 子侧:主动向父发消息(L3) ---- + +func (a *Agent) notifyParentFrom(text string) string { + if a.notifyParent == nil { + return "本 agent 没有上级,无法发送消息" + } + if strings.TrimSpace(text) == "" { + return "消息内容不能为空" + } + a.notifyParent(text) + return "已发送给主 agent" +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +// residentKeepRecent 是压缩时保留的最近事件条数。 +const residentKeepRecent = 20 + +// contextFullRatio 是触发 contextfull 的占比:积累上下文超过窗口的这个比例就报。 +// 取 0.9 而不是 1.0:留一点余量,让父 agent 在"下一次必须丢事件"之前就能决策 +// (压缩 / 回收 / 销毁),而不是等已经丢了再报。 +const contextFullRatio = 0.9 + +// defaultMaxContextTokens 是 provider 未报告窗口时的兜底(与 ComputeTokenBudget 一致)。 +const defaultMaxContextTokens = 32768 + +func (rc *residentChild) info() ResidentInfo { + rc.mu.Lock() + state, full := rc.state, rc.state == "contextfull" + rc.mu.Unlock() + table := rc.agent.inputchTableSnapshot() + info := ResidentInfo{ + ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...), + AllowedOutputs: append([]string(nil), rc.allowed...), + ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table), + } + if len(table) > 0 { + info.Table = table + } + return info +} + +// MarshalResidentInfo 便于工具输出(单工具多视图用 JSON 视图)。 +func MarshalResidentInfo(info ResidentInfo) string { + b, err := json.Marshal(info) + if err != nil { + return fmt.Sprintf("%+v", info) + } + return string(b) +} diff --git a/internal/agent/core/resident_test.go b/internal/agent/core/resident_test.go new file mode 100644 index 0000000..1213123 --- /dev/null +++ b/internal/agent/core/resident_test.go @@ -0,0 +1,367 @@ +package core + +// 驻留子:生命周期(N3)、跨 agent 投递(N4)、inputch 处理表(N5)、contextfull(N6)。 +// +// 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。 + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +// newRootWith 造一个带完整图记忆的父 agent,并指定它(以及它的驻留子)用的 provider。 +func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.GraphDB, string) { + t.Helper() + dir := t.TempDir() + main, err := memory.NewGraphDB(filepath.Join(dir, "main.db")) + if err != nil { + t.Fatal(err) + } + a := New(AgentConfig{ + ID: "parent", + Provider: provider, + ProviderManager: agentAPI.NewProviderManager(), + IO: agentIO.NewIOManager(), + StageHost: NewStageHost(), + Memory: main, + DataDir: dir, + }) + a.Start() // 父也有自己的调度器:子的消息要真的进它的中断队列并被处理 + t.Cleanup(func() { a.Stop(); main.Close() }) + return a, main, dir +} + +// newRootForResidents 是默认构造(正常窗口)。 +func newRootForResidents(t *testing.T) (*Agent, *memory.GraphDB, string) { + t.Helper() + return newRootWith(t, &countingProvider{}) +} + +func spawnTestResident(t *testing.T, parent *Agent, dir, id string, inputChs ...string) ResidentInfo { + t.Helper() + info, err := parent.SpawnResident(ResidentOptions{ + ID: id, + TaskPrompt: "盯住这个通道,有情况就汇报", + InputChs: inputChs, + TempPath: filepath.Join(dir, "residents", id, "graph.db"), + }) + if err != nil { + t.Fatalf("创建驻留子失败: %v", err) + } + return info +} + +// waitFor 轮询直到条件成立(测试里不使用 sleep 猜时序)。 +func waitFor(t *testing.T, what string, fn func() bool) { + t.Helper() + waitForWithin(t, what, 5*time.Second, fn) +} + +func waitForWithin(t *testing.T, what string, within time.Duration, fn func() bool) { + t.Helper() + deadline := time.Now().Add(within) + for time.Now().Before(deadline) { + if fn() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("等待超时:%s", what) +} + +// ---- N3:生命周期 ---- + +func TestResident_LifecycleAndNoOrphans(t *testing.T) { + parent, _, dir := newRootForResidents(t) + + // 父先注册两个 inputch(模拟插件注册),再把其中一个划给子。 + reg := parent.io.ChannelRegistry() + if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "qq"}); err != nil { + t.Fatal(err) + } + if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil { + t.Fatal(err) + } + + info := spawnTestResident(t, parent, dir, "child-1", "sub/in") + if info.State != "running" { + t.Fatalf("新建的驻留子状态=%q", info.State) + } + if list := parent.Residents(); len(list) != 1 || list[0].ID != "child-1" { + t.Fatalf("登记表=%+v", list) + } + + // 划入生效:inputch 的归属变成子。 + ch, ok := reg.Lookup("sub/in") + if !ok || ch.Owner != "child-1" { + t.Fatalf("划入未生效:%+v", ch) + } + // 未划入的仍是未分配。 + if qq, _ := reg.Lookup("qq"); qq.Owner != "" { + t.Fatalf("未划入的 inputch 不该有归属:%+v", qq) + } + // 父的入站 inputch(接收该子的消息)登记在父名下。 + if inbound, ok := reg.Lookup("child/child-1"); !ok || inbound.Owner != "parent" { + t.Fatalf("父的入站 inputch 未登记:%+v ok=%v", inbound, ok) + } + + // 子的轻量内核:有共同面、没有整理面。 + c1 := parent.residents["child-1"] + if c1.agent.memory != nil { + t.Fatal("驻留子不该有记忆整理面") + } + if c1.agent.graphMem() == nil { + t.Fatal("驻留子必须有图记忆共同面") + } + // 子的 L4 只属于父。 + if !c1.agent.isKernelLevelSource("parent") { + t.Fatal("父必须是子的内核级来源(子的 L4 归父独占)") + } + if c1.agent.isKernelLevelSource("别人") { + t.Fatal("非父来源不得成为子的内核级来源") + } + + // 销毁:出登记表、归还 inputch、temp 目录丢弃。 + if err := parent.DestroyResident("child-1"); err != nil { + t.Fatal(err) + } + if len(parent.Residents()) != 0 { + t.Fatal("销毁后登记表应为空") + } + if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" { + t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch) + } + if err := parent.DestroyResident("child-1"); err == nil { + t.Fatal("重复销毁应报错") + } + + // **父退出 ⇒ 全部子销毁、不留孤儿**。 + spawnTestResident(t, parent, dir, "c-a", "sub/in") + spawnTestResident(t, parent, dir, "c-b") + if n := parent.StopResidents(); n != 2 { + t.Fatalf("StopResidents 销毁 %d 个,期望 2", n) + } + if len(parent.Residents()) != 0 { + t.Fatal("父退出后登记表必须为空") + } + for _, id := range []string{"c-a", "c-b"} { + if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil { + t.Fatalf("子 %s 的 temp 目录应被丢弃", id) + } + } +} + +// osStat 只是为了让"目录是否还存在"的断言可读(存在返回 nil 错误)。 +func osStat(path string) (interface{}, error) { + _, err := os.Stat(path) + return nil, err +} + +// ---- N4:跨 agent 投递 ---- + +func TestResident_DeliveryBothDirections(t *testing.T) { + parent, _, dir := newRootForResidents(t) + spawnTestResident(t, parent, dir, "child-1") + child := parent.residents["child-1"].agent + + // 父 → 子:发送消息 ⇒ 子在 **L4** 上收到(父是子的内核级来源)。 + if err := parent.SendToResident("child-1", "先停一下,改做 X"); err != nil { + t.Fatal(err) + } + waitFor(t, "子收到 L4 中断", func() bool { + return child.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1 + }) + + // 子 → 父:主动消息 ⇒ 父在 **L3** 上收到(不是 L4)。 + // 注意 L3 的枚举值是 LevelInteractive(LevelMessage 是 L2)。 + child.notifyParent("我这边发现了点东西") + waitFor(t, "父收到子的 L3 消息", func() bool { + return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= 1 + }) + if got := parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical]; got != 0 { + t.Fatalf("子的主动消息不得以 L4 出现在父的阶梯上(实际 %d 次)", got) + } +} + +// ---- N5:inputch 处理表 ---- + +func TestResident_InputchTableAutoAndProactive(t *testing.T) { + parent, _, dir := newRootForResidents(t) + spawnTestResident(t, parent, dir, "child-1") + child := parent.residents["child-1"].agent + + // ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。 + child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "干活"}) + waitFor(t, "自动写处理表", func() bool { + table, err := parent.ResidentTable("child-1") + return err == nil && len(table) == 1 && !table[0].Proactive + }) + table, err := parent.ResidentTable("child-1") + if err != nil { + t.Fatal(err) + } + if table[0].InputCh != "sub/in" { + t.Fatalf("处理表应记本轮 inputch,实际 %q", table[0].InputCh) + } + + // ② 子主动写 ⇒ 本轮不再自动写。 + child.recordInputchNote("本轮我自己记:已完成第一阶段") + child.autoRecordInputch(&TaskFrame{Input: "第二轮"}) + table, err = parent.ResidentTable("child-1") + if err != nil { + t.Fatal(err) + } + if len(table) != 2 || !table[1].Proactive || !strings.Contains(table[1].Text, "第一阶段") { + t.Fatalf("主动写优先的语义不成立:%+v", table) + } +} + +// ---- N6:contextfull + 三种处置 ---- + +func TestResident_ContextFullAndDispositions(t *testing.T) { + parent, main, dir := newRootForResidents(t) + + // ① contextfull:把子的**积累上下文**(a.context,不是拼好的消息)灌到超过窗口 90%。 + // 注意不能靠"拼好的消息很大"来触发:拼装前时间线已被 token 预算裁到 ~80% 窗口。 + spawnTestResident(t, parent, dir, "child-1") + rc := parent.residents["child-1"] + child := rc.agent + child.context.Append(ContextEvent{ + Timestamp: time.Now(), Source: "sub/in", + Input: strings.Repeat("上下文填充", 8000), // 40000 字 ≈ 80000 token ≫ 8192×0.9 + }) + child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"}) + + // 父在 **L4** 上收到 contextfull(带子标识)—— 只推信号。 + waitFor(t, "父收到 contextfull 的 L4 中断", func() bool { + return parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1 + }) + waitFor(t, "父的登记表显示子 contextfull", func() bool { + for _, r := range parent.Residents() { + if r.ID == "child-1" && r.ContextFull { + return true + } + } + return false + }) + + // ② 压缩(保留语义):上下文变短 + 处理表清空 + 子继续存在。 + child.recordInputchNote("压缩前的记录") + if _, err := parent.CompressResident("child-1"); err != nil { + t.Fatal(err) + } + if len(parent.Residents()) != 1 { + t.Fatal("压缩后子必须继续存在(压缩是保留语义)") + } + if table, _ := parent.ResidentTable("child-1"); len(table) != 0 { + t.Fatalf("压缩必须清理 inputch 处理表,实际 %d 条", len(table)) + } + + // ③ 回收(取消语义):父选中的 temp 记录合入 main,然后取消该子。 + if _, _, err := child.graphMem().Commit([]memory.Triple{ + {Subject: "子的发现", Relation: "指向", Object: "结论"}, + }, "sess", 1); err != nil { + t.Fatalf("子写自己的 temp 应成功: %v", err) + } + if _, err := parent.ReclaimResident("child-1", reclaimKeepAll); err != nil { + t.Fatal(err) + } + if len(parent.Residents()) != 0 { + t.Fatal("回收是取消语义:子不该继续存在") + } + res, err := main.Recall([]string{"子的发现"}, nil, 1, "") + if err != nil { + t.Fatal(err) + } + found := false + for _, e := range res.Entities { + if e.Name == "子的发现" { + found = true + } + } + if !found { + t.Fatal("回收应把选中的 temp 记录合入主记忆") + } + + // ④ 销毁:随时可做、立刻移除。 + spawnTestResident(t, parent, dir, "child-2") + if err := parent.DestroyResident("child-2"); err != nil { + t.Fatal(err) + } + if len(parent.Residents()) != 0 { + t.Fatal("销毁后不该还在登记表里") + } +} + +// ---- N7:端到端 + 压力 ---- + +func TestResident_E2EAndStress(t *testing.T) { + parent, _, dir := newRootForResidents(t) + reg := parent.io.ChannelRegistry() + + const nResidents = 8 + const roundsEach = 12 + ids := make([]string, 0, nResidents) + for i := 0; i < nResidents; i++ { + id := "sub-" + string(rune('a'+i)) + ch := "sub/" + id + "/in" + if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "sub"}); err != nil { + t.Fatal(err) + } + spawnTestResident(t, parent, dir, id, ch) + ids = append(ids, id) + } + + // 压力:每个子灌 roundsEach 轮输入;其中一半走父→子的 L4 消息,一半走普通输入。 + for _, id := range ids { + for r := 0; r < roundsEach; r++ { + // 内容必须唯一:内核会去重相同输入(去重路径不产生处理表记录)。 + msg := fmt.Sprintf("%s 第 %d 轮", id, r) + if r%2 == 0 { + if err := parent.SendToResident(id, msg); err != nil { + t.Fatal(err) + } + } else { + parent.residents[id].agent.io.InjectInput("sub/"+id+"/in", "text", + map[string]interface{}{"content": msg}) + } + } + } + + // 全部子都必须活着,且每一轮都留下处理表记录(自动或主动)。 + waitForWithin(t, "全部子完成各自轮次", 30*time.Second, func() bool { + for _, id := range ids { + table, err := parent.ResidentTable(id) + if err != nil || len(table) < roundsEach/2 { + return false + } + } + return true + }) + + // 双向通信在压力下也成立:让每个子都汇报一次(父侧 L3)。 + for _, id := range ids { + parent.residents[id].agent.notifyParent("压力汇报 " + id) + } + waitForWithin(t, "父收到全部子的汇报", 10*time.Second, func() bool { + return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= uint64(nResidents) + }) + + // 父退出 ⇒ 全部子销毁、登记表清空(不留孤儿)。 + if n := parent.StopResidents(); n != nResidents { + t.Fatalf("父退出应销毁 %d 个子,实际 %d", nResidents, n) + } + if len(parent.Residents()) != 0 { + t.Fatal("父退出后登记表必须为空") + } + t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报", + nResidents, roundsEach) +} diff --git a/internal/agent/core/resident_tools.go b/internal/agent/core/resident_tools.go new file mode 100644 index 0000000..f746cc5 --- /dev/null +++ b/internal/agent/core/resident_tools.go @@ -0,0 +1,168 @@ +package core + +// 驻留子的**工具面**(对照设计 §7 控制面与 §8 处理表)。 +// +// 单工具多动作:父侧一个 `resident_agents`(list/create/send/inspect/compress/reclaim/destroy), +// 子侧两个小工具:`notify_parent`(L3 主动汇报)与 `inputch_note`(主动写处理表)。 + +import ( + "fmt" + "path/filepath" + "strings" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" + "gitcode.com/JianFeeeee/HomeAgent/internal/memory" +) + +func strArg(tc agentAPI.ToolCall, key string) string { + s, _ := tc.Arguments[key].(string) + return strings.TrimSpace(s) +} + +func splitArg(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// executeResidentAgents 是父的驻留子控制面(单工具多动作)。 +func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string { + switch action := strArg(tc, "action"); action { + case "", "list": + list := a.Residents() + if len(list) == 0 { + return "当前没有驻留子 agent。" + } + var b strings.Builder + fmt.Fprintf(&b, "驻留子 agent(%d 个):", len(list)) + for _, r := range list { + fmt.Fprintf(&b, "\n - %s [%s] inputch=%v 轮次=%d 处理表=%d", + r.ID, r.State, r.InputChs, r.Rounds, r.TableSize) + if r.ContextFull { + b.WriteString(" ⚠️ contextfull") + } + } + return b.String() + + case "create": + id := strArg(tc, "id") + tempPath := strArg(tc, "temp_path") + if tempPath == "" { + if a.dataDir == "" { + return "创建驻留子需要 data_dir 或显式 temp_path" + } + tempPath = filepath.Join(a.dataDir, "residents", id, "graph.db") + } + info, err := a.SpawnResident(ResidentOptions{ + ID: id, + TaskPrompt: strArg(tc, "task_prompt"), + InputChs: splitArg(strArg(tc, "input_chs")), + AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")), + Capacity: intArg(tc, "capacity"), + TempPath: tempPath, + }) + if err != nil { + return fmt.Sprintf("创建驻留子失败: %v", err) + } + return "已创建驻留子: " + MarshalResidentInfo(info) + + case "send": + if err := a.SendToResident(strArg(tc, "id"), strArg(tc, "text")); err != nil { + return fmt.Sprintf("发送失败: %v", err) + } + return "已发送(对子而言是 L4 中断)" + + case "inspect": + id := strArg(tc, "id") + if id == "" { + return "inspect 需要 id(或先用 action=list)" + } + table, err := a.ResidentTable(id) + if err != nil { + return fmt.Sprintf("查看失败: %v", err) + } + var b strings.Builder + fmt.Fprintf(&b, "驻留子 %s 的 inputch 处理表(%d 条):", id, len(table)) + for _, r := range table { + kind := "系统写" + if r.Proactive { + kind = "主动写" + } + fmt.Fprintf(&b, "\n - [%s][%s] %s", r.InputCh, kind, r.Text) + } + if len(table) == 0 { + b.WriteString("\n (尚无记录)") + } + return b.String() + + case "compress": + n, err := a.CompressResident(strArg(tc, "id")) + if err != nil { + return fmt.Sprintf("压缩失败: %v", err) + } + return fmt.Sprintf("已压缩子 agent 上下文(丢弃 %d 条旧事件,并发清理其 inputch 处理表);子继续存在", n) + + case "reclaim": + info, err := a.ReclaimResident(strArg(tc, "id"), reclaimKeepAll) + if err != nil { + return fmt.Sprintf("回收失败: %v", err) + } + return "已回收(temp 中选中的记录已合入主记忆,该驻留子已取消): " + MarshalResidentInfo(info) + + case "destroy": + if err := a.DestroyResident(strArg(tc, "id")); err != nil { + return fmt.Sprintf("销毁失败: %v", err) + } + return "已销毁并移除该驻留子" + + default: + return fmt.Sprintf("未知 action=%q;可用:list | create | send | inspect | compress | reclaim | destroy", action) + } +} + +// reclaimKeepAll 是回收时的默认策略:把子 temp 的活跃记录全部纳入主记忆 +// ("哪些纳入"由父的模型决定——这里给的是"全要"这一档)。 +func reclaimKeepAll(_ []InputchRecord, triples []memory.Triple) []memory.Triple { return triples } + +func intArg(tc agentAPI.ToolCall, key string) int { + switch v := tc.Arguments[key].(type) { + case float64: + return int(v) + case int: + return v + } + return 0 +} + +// executeNotifyParent 是子的"主动向父发消息"(父侧阶梯 = **L3 中断**)。 +func (a *Agent) executeNotifyParent(tc agentAPI.ToolCall) string { + return a.notifyParentFrom(strArg(tc, "text")) +} + +// executeInputchNote 是子"主动写入本轮 inputch 的处理信息"。 +// 主动写过 ⇒ 本轮系统不再自动写(见 autoRecordInputch)。 +func (a *Agent) executeInputchNote(tc agentAPI.ToolCall) string { + text := strArg(tc, "text") + if text == "" { + return "text 不能为空" + } + a.recordInputchNote(text) + return "已记录本轮 inputch 处理信息(本轮系统不会再自动写)" +} + +// childInboundChannelHint 是给子看的"父会怎么把消息投给你"的提示(不参与调度)。 +func childInboundChannelHint(a *Agent) string { return "sub/" + string(a.id) } + +// residentTempDir 返回某个驻留子 temp 存储所在目录(销毁时连同目录丢弃)。 +func residentTempDir(tempPath string) string { return filepath.Dir(tempPath) } + +var _ = agentIO.InputChannel{} // 保持 agentIO 依赖(工具面未来会用通道登记) diff --git a/internal/agent/core/scheduler.go b/internal/agent/core/scheduler.go index 4015183..97dc2b1 100644 --- a/internal/agent/core/scheduler.go +++ b/internal/agent/core/scheduler.go @@ -686,14 +686,20 @@ func interruptLevel(evt *agentIO.InputEvent, privileged bool) Level { // // source 的约定是 `插件名` 或 `插件名/实例`(如 webui/),故取第一段。 func (a *Agent) isKernelLevelSource(source string) bool { - if source == "" || a.pluginReg == nil { + if source == "" { return false } name := source if i := strings.IndexByte(name, '/'); i > 0 { name = name[:i] } - return a.pluginReg.IsBuiltinPlugin(name) + // ① 编译期内置插件(根 agent 的 L4 来源之一)。 + if a.pluginReg != nil && a.pluginReg.IsBuiltinPlugin(name) { + return true + } + // ② **本 agent 的上级**(驻留子的父)—— 设计 §6.1 的 L4 通则: + // 子的阶梯上只有父能产生 L4,所以父的"发送消息"一定能打断子。 + return a.kernelSource != "" && name == a.kernelSource } // parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。 diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index 981ceff..00e822e 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -396,6 +396,11 @@ func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTermi log.Printf("[agent] pruned %d low-relevance events to document memory", archived) } + // 本轮 inputch(处理表按它记账)+ contextfull 检测(只有驻留子设了钩子)。 + a.tableMu.Lock() + a.currentInputch = outputChannelOf(evt) + a.tableMu.Unlock() + if !isInterrupt { a.context.Append(ContextEvent{ Timestamp: start, @@ -434,6 +439,10 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { return } + // inputch 处理表:本轮**未主动写入**时由系统自动写(保证每轮必有记录)。 + // 只有驻留子会用到(根 agent 的 children 为 0 时这只是几个空操作)。 + a.autoRecordInputch(f) + elapsed := time.Since(f.StartedAt) log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), f.ToolsUsed) @@ -527,6 +536,10 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome { } } + // 上下文占满检测:此刻 f.Msgs 已建好(含 system + timeline + 本轮输入)。 + // 只有驻留子设了 onContextFull ⇒ 对根 agent 是 no-op。 + a.checkContextFull(f) + f.Step = StepLLM return outcomeContinue } diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 83c26bf..c948611 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -63,6 +63,12 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin return a.executeOutputListChannels() case tc.Name == "input_channels": return a.executeInputChannels(tc) + case tc.Name == "resident_agents": + return a.executeResidentAgents(tc) + case tc.Name == "notify_parent": + return a.executeNotifyParent(tc) + case tc.Name == "inputch_note": + return a.executeInputchNote(tc) case tc.Name == "plgreload": return a.executePluginReload() case tc.Name == "get_plugin_tools": diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 7889faf..a6f2609 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -43,6 +43,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。" } + // 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。 + if a.taskPrompt != "" { + prompt += "\n\n【任务】" + a.taskPrompt + } + if a.personality != nil { if pp := a.personality.InjectPrompt(); pp != "" { prompt += "\n\n" + pp @@ -642,6 +647,66 @@ func (a *Agent) buildToolDefs() []interface{} { }, }) + // 父侧:驻留子控制面(单工具多动作,见设计 §7)。 + if a.parentID == "" { + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "resident_agents", + "description": "管理驻留子 agent(长期派驻的下属):list 列出 / create 创建(划入 inputch + " + + "授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)" + + "/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)" + + "/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"}, + }, + "id": map[string]interface{}{"type": "string", "description": "驻留子 id"}, + "task_prompt": map[string]interface{}{"type": "string", "description": "create:在固定提示词之上注入的任务提示词"}, + "input_chs": map[string]interface{}{"type": "string", "description": "create:划入的 inputch(逗号分隔)"}, + "allowed_outputs": map[string]interface{}{"type": "string", "description": "create:授权的输出通道(逗号分隔;留空=完整授权)"}, + "capacity": map[string]interface{}{"type": "number", "description": "create:划入 inputch 的队列容量"}, + "temp_path": map[string]interface{}{"type": "string", "description": "create:temp 图记忆路径(留空则用 data_dir/residents//graph.db)"}, + "text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"}, + }, + "required": []string{"action"}, + }, + }, + }) + } + + // 子侧(驻留子):主动汇报(L3)与主动写处理表。 + if a.parentID != "" { + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "notify_parent", + "description": "向主 agent 汇报(以 L3 中断投给它)。用于主动报告进展/结论,而不是等它来问。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "汇报内容"}}, + "required": []string{"text"}, + }, + }, + }) + tools = append(tools, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": "inputch_note", + "description": "为**本轮** inputch 主动写入处理信息(主 agent 会查这张表判断你的进度)。" + + "写了就不会再被系统自动记录;不写则本轮结束时系统自动写。", + "parameters": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}}, + "required": []string{"text"}, + }, + }, + }) + } + tools = append(tools, map[string]interface{}{ "type": "function", "function": map[string]interface{}{ diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 9228c3d..0c6a984 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -256,6 +256,9 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error { } // Commit 把三元组写入图库,返回新建的实体数与关系数。 +// Path 返回本库的存储路径(父 agent 用它为驻留子打开**受限句柄**)。 +func (g *GraphDB) Path() string { return g.dbPath } + func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) { _, ec, rc, err := g.commit(triples, sessionID, turnID, false) return ec, rc, err