mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
设计: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` 通过。
502 lines
17 KiB
Go
502 lines
17 KiB
Go
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)
|
||
}
|