mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 18:08:04 +00:00
用户澄清(重要定性):这不是「内核替父决定」,而是**及时反馈** —— 主 agent 忙时不该让用户干等十几分钟。子 agent 是**分诊助手**: 简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」、不勉强作答。 三处补齐: 1. 分诊助手的职责提示词(之前完全没给 ⇒ 子不知道自己为什么存在): 两条路(直接办 / 报忙碌)、拿不准时报忙碌、必须 output_send 到原通道。 2. 驻留子继承父的 SystemPrompt(之前没传 ⇒ 子只用一句兜底文案, 拿不到「异步通道必须显式 output_send,否则回复被静默丢弃」这条铁律。 webui 这类同步通道能回是因为走 ResponseCh,掩盖了这个缺陷)。 3. 残余任务由父显式决定(用户要求):reclaim/destroy 时子手头未处理的消息 不再由内核悄悄处置 —— 内核只负责列清楚,父用 residual=keep/drop 决定。 之前 pendingEvents 只收带 ResponseCh 的,异步(qq)残余任务完全不在内, 被销毁时静默消失、用户零反馈且日志无痕。 配套: - scheduler.takeAllPendingEvents:取走全部未执行事件(不筛通道) - ApplyResidual(keep|drop):keep 转回父队列(保留 ResponseCh), drop 逐条记日志 + 给同步调用方补终态(否则 cli/a2a 永久挂起) - 状态面暴露 offload_owned,让父分清「我建的子」与「内核临时拉的助手」 - 工具 schema 加 residual 参数并说明 drop 的代价 这也是用户观察到的「机制很自然」的落点:分诊助手就在同一张登记表里, 父能 inspect/send/compress/reclaim/destroy,控制面 6 动作按 id 生效不区分来源。 测试 +7(残余 keep 转回且保留 ResponseCh / drop 通知同步调用方 / 空残余如实报告 / 分诊提示词 / 继承 SystemPrompt), 其中 drop 那条已实测「对着静默丢弃的旧实现会失败」。全套绿。
412 lines
17 KiB
Go
412 lines
17 KiB
Go
package core
|
||
|
||
// 积压任务的**自动转投**:主 agent 长时间忙时,把排队中的任务改投给内核拉起的
|
||
// 驻留子,并在原队列位置留一条"已转投"提示。
|
||
//
|
||
// 为什么要这个(用户 2026-09-19 提出的实际需求):
|
||
// 实测主 agent 被一条长任务占住时(当天现场:12 分 8 秒、69 次工具调用),
|
||
// 后来的 QQ 消息全部以 "level insufficient" 排进中断队列干等 —— 同级中断
|
||
// 不能抢占同级运行任务(scheduler.canPreempt),只能等前一个跑完。
|
||
// 而内核明明有驻留子(独立 agent + 独立调度器 + 共享输出通道视图)可以并行干活。
|
||
//
|
||
// 与设计文档 §7 的关系(**这是刻意的例外,必须显式记录**):
|
||
// 设计原文写「创建/销毁/回收/查看/发送是父可调用的原语;**决策在父的模型手里**——
|
||
// 内核不替父决定」。本特性让**内核**主动创建并使用驻留子,属于对该原则的例外。
|
||
// 之所以可接受:父此刻正忙(无法做决策),而积压任务**本来就是空的**——
|
||
// 转投只是把"排队干等"换成"有人在做",不改变任何已提交决策的语义。
|
||
// 若不做例外,这个能力就只能由父的模型发起,而它恰恰是忙不过来的那个。
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"runtime/debug"
|
||
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
)
|
||
|
||
// OffloadOptions 是自动转投的判定与执行参数。
|
||
type OffloadOptions struct {
|
||
// BusyAfter:运行任务已持续多久算"长时间工作"(0 = 用默认)。
|
||
BusyAfter time.Duration
|
||
// MinPending:至少要积压多少条才值得拉起驻留子(0 = 用默认)。
|
||
MinPending int
|
||
// MaxResidents:为转投而拉起的驻留子上限(0 = 用默认)。
|
||
MaxResidents int
|
||
// Enabled 为 false 时完全关闭(默认关:见 DefaultOffloadOptions 的说明)。
|
||
Enabled bool
|
||
}
|
||
|
||
// 默认参数。
|
||
//
|
||
// 为什么默认**关闭**:自动拉起是"内核替父做决策",改变的是系统行为而非修 bug;
|
||
// 且它会让日志/账单里凭空多出一个 agent 在干活。默认关闭、由部署方显式打开,
|
||
// 与「显式才是特权」(scheduler.DefaultLevel 的同一条理由)一致。
|
||
const (
|
||
defaultOffloadBusyAfter = 5 * time.Minute
|
||
defaultOffloadMinPending = 3
|
||
defaultOffloadMaxResident = 2
|
||
)
|
||
|
||
// DefaultOffloadOptions 返回默认参数(Enabled=false)。
|
||
func DefaultOffloadOptions() OffloadOptions {
|
||
return OffloadOptions{
|
||
BusyAfter: defaultOffloadBusyAfter,
|
||
MinPending: defaultOffloadMinPending,
|
||
MaxResidents: defaultOffloadMaxResident,
|
||
Enabled: false,
|
||
}
|
||
}
|
||
|
||
func (o OffloadOptions) normalized() OffloadOptions {
|
||
if o.BusyAfter <= 0 {
|
||
o.BusyAfter = defaultOffloadBusyAfter
|
||
}
|
||
if o.MinPending <= 0 {
|
||
o.MinPending = defaultOffloadMinPending
|
||
}
|
||
if o.MaxResidents <= 0 {
|
||
o.MaxResidents = defaultOffloadMaxResident
|
||
}
|
||
return o
|
||
}
|
||
|
||
// offloadNotice 是替换被转投任务的那条提示的正文。
|
||
//
|
||
// 它必须**自己说清是系统做的**:用户看到队列里出现一条没人发过的消息时,
|
||
// 唯一能解释这件事的就是这句话本身。
|
||
//
|
||
// 同时要说清"不必重复处理":那些消息已由子 agent 回复(或已回复"忙碌中"),
|
||
// 主 agent 再处理一遍会让用户收到重复回复。
|
||
func offloadNotice(count int, residentID string) string {
|
||
return fmt.Sprintf(
|
||
"[系统] %d 条积压消息已在主 agent 忙期间交由临时助手 %s 先行分诊"+
|
||
"(简单的已直接处理并回复,需要你的那些已告知用户「忙碌中,请稍候」)。"+
|
||
"它们**不需要你再处理**了;若其中有需要你后续跟进的,请查看上述通道的会话记录。"+
|
||
"本提示仅用于说明情况,无需回复。",
|
||
count, residentID)
|
||
}
|
||
|
||
// offloadCandidate 是一条可被转投的排队任务。
|
||
//
|
||
// 只有**纯排队输入**(TaskQueued + KindInput)可转投:
|
||
// - 中断任务带级别语义(可能正在等待抢占时机),转投会打乱中断阶梯;
|
||
// - self 任务是内核内部记账(记忆整理等),与父的记忆面绑定,不能换 agent。
|
||
type offloadCandidate struct {
|
||
Event *agentIO.InputEvent
|
||
}
|
||
|
||
// drainInboxToQueue 把 io 输入 channel 里**已经到达但尚未被搬运**的输入
|
||
// 搬进就绪队列(非阻塞;取空为止)。
|
||
//
|
||
// 它与 pumpInbox 做的事一样,但**不要求 hasRoom**:pumpInbox 在队列满时
|
||
// 会停下以保留背压,而转投场景恰恰是「队列空/不满、但输入堵在 channel 里」
|
||
// (因为调度器正忙于执行任务、根本回不到 pumpInbox)。
|
||
//
|
||
// 队列上限仍由 enqueue 把关:满了就停下,超出的输入留在 channel 里。
|
||
func (a *Agent) drainInboxToQueue() {
|
||
for {
|
||
select {
|
||
case evt := <-a.io.InputChan():
|
||
if !a.sched.enqueue(newInputTask(evt)) {
|
||
// 队列满:放不进去。不能丢,也不能阻塞(我们是后台 goroutine,
|
||
// 阻塞会把这个循环永远卡住)——给同步调用方一个终态后丢弃,
|
||
// 与 pumpInbox 的 queue_full 处置一致。
|
||
a.sched.noteBackpressure()
|
||
a.emitSkippedReply(evt, "queue_full")
|
||
return
|
||
}
|
||
default:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// 注:转投不走 inputch 名字(那会在投递时重建事件、丢掉 ResponseCh),
|
||
// 而是直接跨 agent 推原事件 —— 见 forwardInputToResident。
|
||
|
||
// offloadPendingTasks 检查是否需要转投,需要则拉起/复用一个驻留子并搬运任务。
|
||
//
|
||
// 返回实际转投的任务条数(0 = 未触发/未转投)。
|
||
//
|
||
// ❗并发前提:本函数会被 offloadLoop 在**任务执行期间**调用(那正是它的意义),
|
||
// 因此它与 schedulerLoop 是并发跑的。所有对队列的读写都经 scheduler 的锁,
|
||
// 而"取走哪些任务"与"放回什么"都在同一次锁内完成,不存在丢任务的窗口。
|
||
func (a *Agent) offloadPendingTasks(opts OffloadOptions) int {
|
||
opts = opts.normalized()
|
||
if !opts.Enabled {
|
||
return 0
|
||
}
|
||
|
||
// ① 判定:运行任务是否已忙够久。运行任务为空说明压根不忙,不做。
|
||
running := a.sched.runningTask()
|
||
if running == nil {
|
||
return 0
|
||
}
|
||
busyFor := a.sched.runningFor()
|
||
if busyFor < opts.BusyAfter {
|
||
return 0
|
||
}
|
||
|
||
// ② 先把积压从 io 的输入 channel **搬进就绪队列**。
|
||
//
|
||
// !!这是本特性最容易写错的一步(我第一版就错了,写完后线上实测永不触发):
|
||
// schedulerLoop 是**同步执行**任务的,所以「正忙」期间它根本不会回到循环顶部
|
||
// 去调 pumpInbox —— 这时后到的输入全部堆在 io.inputCh(容量 256)里,
|
||
// **压根没进 sched.queue**。只数 s.queue 会得到 0,转投就永远不触发。
|
||
//
|
||
// 仓库里早记过同一个坑:armStop 的注释写着「pending 是还没被 pumpInbox 搬进
|
||
// 队列的那一段……只数 s.queue 会得到 0(实测),配额随之失效」。
|
||
// 这里必须在同一层把这件事做对,而不是重犯。
|
||
a.drainInboxToQueue()
|
||
|
||
// ③ 收集可转投的排队任务;不够量就不值得拉起一个 agent。
|
||
cands := a.sched.takeQueuedInputs(opts.MinPending)
|
||
if len(cands) == 0 {
|
||
return 0
|
||
}
|
||
|
||
// ③ 找或拉起一个"转投专用"驻留子。
|
||
residentID, err := a.ensureOffloadResident(opts)
|
||
if err != nil {
|
||
// 拉不起来就把任务**放回队列**,绝不能丢:丢一条输入比多处理一条更糟
|
||
// (与 routeInputByOwner 的兜底同一条理由)。
|
||
a.sched.requeueFront(cands)
|
||
log.Printf("[offload] 无法为 %d 条积压任务准备驻留子,已放回队列: %v", len(cands), err)
|
||
return 0
|
||
}
|
||
|
||
// ④ 搬运:逐条投进子的 inputch。
|
||
//
|
||
// 注意这里**逐条转发原文**而不是打包成一条:任务本身带 Source/OutputChannel
|
||
// 等路由信息,打包会让子无法把回复发回正确的通道(qq 私聊 vs 群聊不同)。
|
||
var moved int
|
||
for _, c := range cands {
|
||
if c.Event == nil {
|
||
continue
|
||
}
|
||
if err := a.forwardInputToResident(residentID, c.Event); err != nil {
|
||
// 某条投不进去:放回原队列,其余继续(部分成功好过全部回滚)。
|
||
a.sched.requeueFront([]offloadCandidate{c})
|
||
log.Printf("[offload] 转发任务给驻留子 %s 失败,已放回队列: %v", residentID, err)
|
||
continue
|
||
}
|
||
moved++
|
||
}
|
||
if moved == 0 {
|
||
return 0
|
||
}
|
||
|
||
// ⑤ 在**原队列位置**留下提示(用户要求的那条说明)。
|
||
//
|
||
// 为什么留在队列里而不是只记日志:队列顺序就是主 agent 接下来要处理的事;
|
||
// 用户看会话记录时,需要在这里就看到"那几条去哪儿了",而不是去翻内核日志。
|
||
a.sched.requeueFront([]offloadCandidate{
|
||
{Event: a.syntheticEvent(offloadNotice(moved, residentID))},
|
||
})
|
||
|
||
log.Printf("[offload] 主 agent 已忙 %s,把 %d 条积压任务转投给驻留子 %s(队列留 1 条说明)",
|
||
busyFor.Truncate(time.Second), moved, residentID)
|
||
return moved
|
||
}
|
||
|
||
// forwardInputToResident 把一条输入**原文**投给指定驻留子的队列。
|
||
//
|
||
// ❗必须推**原事件**(DeliverRouted),不能重建:原事件带 ResponseCh,
|
||
// 而 cli / a2a / webui 这些**同步**调用方正阻塞等它。重建事件(如用
|
||
// InjectInputTo)会把 ResponseCh 丢掉 ⇒ 任务被子处理完、调用方却永远收不到回执。
|
||
// 实测:转投生效、子也正常处理(各 ~3s 日志可见),但 HTTP 请求一直挂着不返回。
|
||
// 仓库反复警告同一件事(见 Agent.Stop 对 drainPendingInterrupts 的注释:
|
||
// “带 ResponseCh 的同步注入方会永久挂起”),这里必须走既有的跨 agent 投递原语。
|
||
//
|
||
// 用排队语义(isInterrupt=false):转投的是"待办工作",不是"打断子"。
|
||
func (a *Agent) forwardInputToResident(residentID string, evt *agentIO.InputEvent) error {
|
||
a.residentMu.Lock()
|
||
rc := a.residents[residentID]
|
||
a.residentMu.Unlock()
|
||
if rc == nil || rc.agent == nil || rc.agent.io == nil {
|
||
return fmt.Errorf("驻留子 %s 不存在或不可用", residentID)
|
||
}
|
||
|
||
// 带上来源线索(不重建事件,只补充 payload,保留 ResponseCh/RequestID)。
|
||
if evt.Payload == nil {
|
||
evt.Payload = map[string]interface{}{}
|
||
}
|
||
evt.Payload["offloaded_from"] = string(a.id)
|
||
evt.Payload["offloaded_at"] = time.Now().Format(time.RFC3339)
|
||
|
||
rc.agent.io.DeliverRouted(evt, false)
|
||
return nil
|
||
}
|
||
|
||
// ensureOffloadResident 返回一个可用于转投的驻留子 id,必要时拉起一个新的。
|
||
//
|
||
// 复用规则:优先复用"内核为转投而建"且仍 running、还没满的驻留子;
|
||
// 都不可用时(在 MaxResidents 内)新建一个。
|
||
func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
|
||
a.residentMu.Lock()
|
||
var reusable []string
|
||
for id, rc := range a.residents {
|
||
if rc == nil || !rc.offloadOwned {
|
||
continue
|
||
}
|
||
rc.mu.Lock()
|
||
state := rc.state
|
||
rc.mu.Unlock()
|
||
if state == "running" {
|
||
reusable = append(reusable, id)
|
||
}
|
||
}
|
||
a.residentMu.Unlock()
|
||
|
||
// 复用:按 id 稳定排序后取第一个,避免每次挑到不同的子(可预测性)。
|
||
if len(reusable) > 0 {
|
||
sortStrings(reusable)
|
||
return reusable[0], nil
|
||
}
|
||
|
||
// 计数:只为转投而建的子是否已达上限(人工建的子不计入)。
|
||
a.residentMu.Lock()
|
||
owned := 0
|
||
for _, rc := range a.residents {
|
||
if rc != nil && rc.offloadOwned {
|
||
owned++
|
||
}
|
||
}
|
||
a.residentMu.Unlock()
|
||
if owned >= opts.MaxResidents {
|
||
return "", fmt.Errorf("转投专用驻留子已达上限 %d", opts.MaxResidents)
|
||
}
|
||
|
||
id := fmt.Sprintf("offload-%d", time.Now().Unix())
|
||
if a.dataDir == "" {
|
||
return "", fmt.Errorf("未配置 DataDir,无法为驻留子分配 temp 图库路径")
|
||
}
|
||
info, err := a.SpawnResident(ResidentOptions{
|
||
ID: id,
|
||
// 不配 inputch:它是内核的**干活** agent,不接收任何插件的用户输入
|
||
// (用户要求"不配输入通道")。它只由父经转投拿到任务。
|
||
InputChs: nil,
|
||
// 全部输出通道:它要能把结果发回 qq/webui 等正确通道
|
||
// (用户要求"持有全部输出通道")。nil = 完整授权。
|
||
AllowedOutputs: nil,
|
||
TempPath: a.residentTempPath(id),
|
||
OffloadOwned: true,
|
||
TaskPrompt: offloadTaskPrompt(),
|
||
})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return info.ID, nil
|
||
}
|
||
|
||
// offloadTaskPrompt 是转投专用驻留子的**分诊职责**说明。
|
||
//
|
||
// 为什么必须给:不给的话子完全不知道自己为什么存在(只知道自己是"小宅"),
|
||
// 拿到一条转投消息时不知道它是"用户正在等回复的请求",
|
||
// 也不知道自己只有两条路可走(直接办 / 报忙碌)。
|
||
//
|
||
// 用户的定位(2026-09-19 明确):这不是"内核替父决定",而是**及时反馈** ——
|
||
// 主 agent 忙时不该让用户干等(实测有 13 分钟的现场)。
|
||
// 子的职责是**分诊**(triage):
|
||
// - 简单、不需主 agent 介入的 → 直接办完并回复;
|
||
// - 需要主 agent 介入的 → 立刻回「忙碌中,请稍候」,**不要勉强做**。
|
||
func offloadTaskPrompt() string {
|
||
return `你是主 agent 的临时助手,负责在主 agent 忙不过来时**分诊**它的积压消息。
|
||
|
||
背景:主 agent 正在执行一个长任务,短时间无法处理新消息。你被临时拉起,
|
||
专门承接这些积压的请求,**避免用户干等**(此前用户可能要等十几分钟)。
|
||
|
||
对每一条消息,你只有两条路:
|
||
|
||
1. 【直接办】如果这件事简单、明确、不需要主 agent 的全局上下文或长期规划
|
||
(例如:查个信息、跑个小命令、读个文件、简单问答)——
|
||
**直接做完,并把结果发回原通道**。
|
||
|
||
2. 【报忙碌】如果这件事需要主 agent 介入(需要它的长期记忆、正在进行的任务上下文、
|
||
需要它做多步决策,或你无法确定怎么做)——
|
||
**不要勉强尝试**。立刻回复用户:主 agent 当前忙碌中,请稍候。
|
||
|
||
重要约束:
|
||
- **必须把回复发到用户原本的通道**。面向 qq、wechat 等异步通道时,
|
||
纯文本返回会被丢弃 —— 必须显式调用 output_send__{通道名},否则用户收不到,
|
||
而你会以为已经回过了。
|
||
- 不要向用户暴露"我是被临时拉起的助手"这类内部细节,用主 agent 的口吻回复。
|
||
- 拿不准属于哪一类时,选【报忙碌】。宁可让用户稍后得到准确答复,
|
||
也不要给出错误的直接回答。`
|
||
}
|
||
|
||
// residentTempPath 计算某个驻留子的 temp 图记忆路径(与既有约定一致)。
|
||
func (a *Agent) residentTempPath(id string) string {
|
||
return strings.TrimRight(a.dataDir, "/") + "/residents/" + id + "/graph.db"
|
||
}
|
||
|
||
// syntheticEvent 造一条"内核自己发的"输入事件(用于队列里的转投说明)。
|
||
//
|
||
// Source 取 kernel:这条消息不是任何用户发来的,日志与用户界面里都应看得出。
|
||
// 不带 ResponseCh:没有同步调用方在等它(它只是给主 agent 看的一句说明)。
|
||
func (a *Agent) syntheticEvent(text string) *agentIO.InputEvent {
|
||
return &agentIO.InputEvent{
|
||
Source: "kernel",
|
||
Type: "text",
|
||
OutputChannel: "kernel",
|
||
Payload: map[string]interface{}{"content": text},
|
||
}
|
||
}
|
||
|
||
// sortStrings 是一个不引入 sort 依赖的小排序(候选集极小,插入排序足够)。
|
||
func sortStrings(s []string) {
|
||
for i := 1; i < len(s); i++ {
|
||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||
s[j], s[j-1] = s[j-1], s[j]
|
||
}
|
||
}
|
||
}
|
||
|
||
// offloadLoop 周期性检查「主 agent 是否被长任务占住 + 是否有积压」。
|
||
//
|
||
// 为什么必须是**独立 goroutine**而不是 schedulerLoop 里的一步:
|
||
//
|
||
// schedulerLoop 是**同步执行**任务的(executeNewTask 会一直阻塞到任务结束),
|
||
// 所以「正忙」期间它根本不会回到循环顶部 —— 把检查放在那里等于永不触发。
|
||
// 这正是本特性存在的理由(主 agent 忙时无人处理积压),不能在实现上重犯。
|
||
//
|
||
// 检查间隔取 BusyAfter 的 1/5(不低于 1 秒):保证在跨过阈值后能在合理时间内
|
||
// 触发,又不至于空转打日志。
|
||
func (a *Agent) offloadLoop() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("[agent] offloadLoop panic recovered: %v\n%s", r, debug.Stack())
|
||
time.Sleep(time.Second)
|
||
go a.offloadLoop()
|
||
}
|
||
}()
|
||
|
||
if !a.offload.Enabled {
|
||
return // 未启用:不占 goroutine,也不打日志(默认关闭是常态)
|
||
}
|
||
// 只让**根 agent** 做转投:驻留子自己也可能忙,但让子再去拉孙子会形成
|
||
// 无界增殖(每层都能拉 MAX 个),而积压的源头是根那一条调度链。
|
||
if a.parentID != "" {
|
||
return
|
||
}
|
||
|
||
interval := a.offload.BusyAfter / 5
|
||
if interval < time.Second {
|
||
interval = time.Second
|
||
}
|
||
ticker := time.NewTicker(interval)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
a.offloadPendingTasks(a.offload)
|
||
case <-a.ctx.Done():
|
||
return
|
||
}
|
||
}
|
||
}
|