feat(resident): 分诊助手定位 + 残余任务由父显式决定

用户澄清(重要定性):这不是「内核替父决定」,而是**及时反馈** ——
主 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 那条已实测「对着静默丢弃的旧实现会失败」。全套绿。
This commit is contained in:
JianFeeeee
2026-09-19 17:43:16 +08:00
parent 01909bb914
commit 943eef01cf
10 changed files with 459 additions and 37 deletions

View File

@ -77,12 +77,16 @@ func (o OffloadOptions) normalized() OffloadOptions {
//
// 它必须**自己说清是系统做的**:用户看到队列里出现一条没人发过的消息时,
// 唯一能解释这件事的就是这句话本身。
//
// 同时要说清"不必重复处理":那些消息已由子 agent 回复(或已回复"忙碌中"),
// 主 agent 再处理一遍会让用户收到重复回复。
func offloadNotice(count int, residentID string) string {
return fmt.Sprintf(
"[系统] %d 条积压任务已转投给驻留子 agent %s 处理(主 agent 正忙于长任务,"+
"内核为它们拉起了独立 agent 并行执行)。它们的回复会由 %s 直接发到对应通道;"+
"本提示仅用于说明「那几条消息不会再由你处理」,无需为它们采取任何行动。",
count, residentID, residentID)
"[系统] %d 条积压消息已在主 agent 忙期间交由临时助手 %s 先行分诊"+
"(简单的已直接处理并回复,需要你的那些已告知用户「忙碌中,请稍候」)。"+
"它们**不需要你再处理**了;若其中有需要你后续跟进的,请查看上述通道的会话记录。"+
"本提示仅用于说明情况,无需回复。",
count, residentID)
}
// offloadCandidate 是一条可被转投的排队任务。
@ -283,13 +287,14 @@ func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
info, err := a.SpawnResident(ResidentOptions{
ID: id,
// 不配 inputch:它是内核的**干活** agent,不接收任何插件的用户输入
// (用户要求"不配输入通道")。它只由父经 sub/<id> 投喂任务。
// (用户要求"不配输入通道")。它只由父经转投拿到任务。
InputChs: nil,
// 全部输出通道:它要能把结果发回 qq/webui 等正确通道
// (用户要求"持有全部输出通道")。nil = 完整授权。
AllowedOutputs: nil,
TempPath: a.residentTempPath(id),
OffloadOwned: true,
TaskPrompt: offloadTaskPrompt(),
})
if err != nil {
return "", err
@ -297,6 +302,42 @@ func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
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"