mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat(resident): N3–N7 驻留式子 agent 全量落地(生命周期/双向投递/处理表/contextfull/e2e+压力)
设计:docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
## N3 生命周期(resident.go)
- `SpawnResident`:主库**受限句柄** + 自己的 temp 实例(`LightMemory`)⇒ 子的轻量内核;
划入 inputch(登记归属)、授权输出通道、注入任务提示词;为父登记 `child/<id>` 入站 inputch;
建独立 `IOManager`(共享通道登记表);启动子。
- `DestroyResident`:停子内核、归还划入的 inputch(回到未分配)、丢弃 temp 目录、出登记表。
- `Stop()` → `StopResidents()`:**父退出必须销毁全部子、不留孤儿**(设计 §10 硬约束)。
- `Residents()` 登记表快照;`ResidentTable(id)` 父 pull 子的处理表(不打断)。
## N4 跨 agent 投递
- 子→父:`notify_parent` → 投进父的 `child/<id>` 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` 通过。
This commit is contained in:
@ -456,6 +456,16 @@ type GraphMemory interface {
|
|||||||
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||||||
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
|
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / 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 的记忆面清单(现状)
|
### 16.1 N2 的记忆面清单(现状)
|
||||||
|
|
||||||
`internal/memory/` 下需要加 space 维度的面:
|
`internal/memory/` 下需要加 space 维度的面:
|
||||||
@ -550,11 +560,11 @@ type GraphMemory interface {
|
|||||||
| — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
|
| — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
|
||||||
| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ |
|
| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ |
|
||||||
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
|
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
|
||||||
| **N3** | **驻留子生命周期**:创建 / 销毁 / 登记表 / 父退出清理 | S12、S16 |
|
| **N3** | ~~驻留子生命周期~~ **已完成**:`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ |
|
||||||
| **N4** | **跨 agent 投递**:子→父 L3、父→子 L4(+ `isKernelLevelSource` 分层) | S4、S6、S17 |
|
| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`(L3,投父的 `child/<id>` inputch);父→子 `SendToResident`(L4,`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ |
|
||||||
| **N5** | **inputch 处理表**:主动写入工具 + 自动写兜底 + 生命周期 | S8、S9 |
|
| **N5** | ~~inputch 处理表~~ **已完成**:`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ |
|
||||||
| **N6** | **contextfull**:检测 + L4 通知(带子标识)+ 三处置(压缩/回收/销毁) | S5、S10、S11 |
|
| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4(`child/<id>`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main)/ `DestroyResident` | S5、S10、S11 ✅ |
|
||||||
| **N7** | **e2e + 压力**:父子互中断、contextfull 处置矩阵、多子并发、父退出清理 | S1–S18 全绿;`-race` 干净 |
|
| **N7** | ~~e2e + 压力~~ **已完成**:`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1–S20 覆盖 ✅ |
|
||||||
|
|
||||||
每步收尾命令:
|
每步收尾命令:
|
||||||
|
|
||||||
|
|||||||
@ -39,13 +39,34 @@ type Agent struct {
|
|||||||
memory *memory.GraphDB
|
memory *memory.GraphDB
|
||||||
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
|
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB;驻留子 = LightMemory)。
|
||||||
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
|
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
|
||||||
graph GraphMemory
|
graph GraphMemory
|
||||||
indexer *memory.Indexer
|
|
||||||
tracker *tracker.Tracker
|
// kernelSource/parentID/taskPrompt/dataDir:驻留子相关的层级信息(见 AgentConfig)。
|
||||||
context *RelevanceContext
|
kernelSource string
|
||||||
systemPrompt string
|
parentID string
|
||||||
ctx context.Context
|
taskPrompt string
|
||||||
cancel context.CancelFunc
|
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
|
docStore *document.Store
|
||||||
@ -217,6 +238,18 @@ type AgentConfig struct {
|
|||||||
Personality *agentPkg.Personality
|
Personality *agentPkg.Personality
|
||||||
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
|
||||||
PluginReg *plugin.Registry
|
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)。
|
// AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。
|
||||||
//
|
//
|
||||||
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
||||||
@ -300,6 +333,10 @@ func New(cfg AgentConfig) *Agent {
|
|||||||
io: cfg.IO,
|
io: cfg.IO,
|
||||||
memory: cfg.Memory,
|
memory: cfg.Memory,
|
||||||
graph: graphMemoryOf(cfg),
|
graph: graphMemoryOf(cfg),
|
||||||
|
kernelSource: cfg.KernelSource,
|
||||||
|
parentID: cfg.ParentID,
|
||||||
|
taskPrompt: cfg.TaskPrompt,
|
||||||
|
dataDir: cfg.DataDir,
|
||||||
indexer: cfg.Indexer,
|
indexer: cfg.Indexer,
|
||||||
tracker: cfg.Tracker,
|
tracker: cfg.Tracker,
|
||||||
context: rc,
|
context: rc,
|
||||||
@ -357,6 +394,8 @@ func (a *Agent) Start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) Stop() {
|
func (a *Agent) Stop() {
|
||||||
|
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||||
|
a.StopResidents()
|
||||||
a.cancel()
|
a.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -451,6 +451,29 @@ func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
|
|||||||
return out
|
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 {
|
func (c *RelevanceContext) Len() int {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
501
internal/agent/core/resident.go
Normal file
501
internal/agent/core/resident.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
367
internal/agent/core/resident_test.go
Normal file
367
internal/agent/core/resident_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
168
internal/agent/core/resident_tools.go
Normal file
168
internal/agent/core/resident_tools.go
Normal file
@ -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 依赖(工具面未来会用通道登记)
|
||||||
@ -686,14 +686,20 @@ func interruptLevel(evt *agentIO.InputEvent, privileged bool) Level {
|
|||||||
//
|
//
|
||||||
// source 的约定是 `插件名` 或 `插件名/实例`(如 webui/<deviceID>),故取第一段。
|
// source 的约定是 `插件名` 或 `插件名/实例`(如 webui/<deviceID>),故取第一段。
|
||||||
func (a *Agent) isKernelLevelSource(source string) bool {
|
func (a *Agent) isKernelLevelSource(source string) bool {
|
||||||
if source == "" || a.pluginReg == nil {
|
if source == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
name := source
|
name := source
|
||||||
if i := strings.IndexByte(name, '/'); i > 0 {
|
if i := strings.IndexByte(name, '/'); i > 0 {
|
||||||
name = name[:i]
|
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")。
|
// parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。
|
||||||
|
|||||||
@ -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)
|
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 {
|
if !isInterrupt {
|
||||||
a.context.Append(ContextEvent{
|
a.context.Append(ContextEvent{
|
||||||
Timestamp: start,
|
Timestamp: start,
|
||||||
@ -434,6 +439,10 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inputch 处理表:本轮**未主动写入**时由系统自动写(保证每轮必有记录)。
|
||||||
|
// 只有驻留子会用到(根 agent 的 children 为 0 时这只是几个空操作)。
|
||||||
|
a.autoRecordInputch(f)
|
||||||
|
|
||||||
elapsed := time.Since(f.StartedAt)
|
elapsed := time.Since(f.StartedAt)
|
||||||
log.Printf("[agent] %s from %s → response (%dms, tools=%v)",
|
log.Printf("[agent] %s from %s → response (%dms, tools=%v)",
|
||||||
evt.Type, evt.Source, elapsed.Milliseconds(), f.ToolsUsed)
|
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
|
f.Step = StepLLM
|
||||||
return outcomeContinue
|
return outcomeContinue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,12 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin
|
|||||||
return a.executeOutputListChannels()
|
return a.executeOutputListChannels()
|
||||||
case tc.Name == "input_channels":
|
case tc.Name == "input_channels":
|
||||||
return a.executeInputChannels(tc)
|
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":
|
case tc.Name == "plgreload":
|
||||||
return a.executePluginReload()
|
return a.executePluginReload()
|
||||||
case tc.Name == "get_plugin_tools":
|
case tc.Name == "get_plugin_tools":
|
||||||
|
|||||||
@ -43,6 +43,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
|||||||
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
|
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
|
||||||
|
if a.taskPrompt != "" {
|
||||||
|
prompt += "\n\n【任务】" + a.taskPrompt
|
||||||
|
}
|
||||||
|
|
||||||
if a.personality != nil {
|
if a.personality != nil {
|
||||||
if pp := a.personality.InjectPrompt(); pp != "" {
|
if pp := a.personality.InjectPrompt(); pp != "" {
|
||||||
prompt += "\n\n" + 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/<id>/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{}{
|
tools = append(tools, map[string]interface{}{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": map[string]interface{}{
|
"function": map[string]interface{}{
|
||||||
|
|||||||
@ -256,6 +256,9 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
// Commit 把三元组写入图库,返回新建的实体数与关系数。
|
||||||
|
// Path 返回本库的存储路径(父 agent 用它为驻留子打开**受限句柄**)。
|
||||||
|
func (g *GraphDB) Path() string { return g.dbPath }
|
||||||
|
|
||||||
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
|
||||||
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
|
||||||
return ec, rc, err
|
return ec, rc, err
|
||||||
|
|||||||
Reference in New Issue
Block a user