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

@ -313,15 +313,28 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
return blocks, alt
}
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
// emitSkippedReply 给被跳过任务的调用方一个终态。
//
// 为什么要单独一条路径而不是复用 emitResponse跳过意味着“我们没有处理这条输入”
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
//
// ❗异步来源qq / wechat / rss 等)**没有 ResponseCh**,于是这里以前是直接 return。
// 后果是任务被丢弃时**完全无声**:用户什么都没收到、日志里也没痕迹,
// 他只会以为消息丢了。转投子被回收/销毁时队列里的积压正落在这个盲区里
// (父可随时对子 reclaim/destroy而子手上可能还握着几条 QQ 消息)。
// 现在至少留一条带来源与通道的日志,让“这条消息为什么没回”可被追溯。
//
// 非阻塞写ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
if evt == nil || evt.ResponseCh == nil {
if evt == nil {
return
}
if evt.ResponseCh == nil {
// 无可回执的通道:不静默。异步来源本就靠 agent 主动 output_send
// 丢弃后没有任何东西会告诉用户,因此这条日志是唯一的线索。
log.Printf("[agent] %s: 丢弃一条无回执通道的输入source=%s channel=%s request=%s reason=%s",
a.id, evt.Source, evt.OutputChannel, evt.RequestID, reason)
return
}
ch := evt.OutputChannel

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"

View File

@ -137,7 +137,8 @@ func TestRequeueFrontKeepsAllTasks(t *testing.T) {
// 唯一能解释这件事的就是这句话本身。
func TestOffloadNoticeExplainsItself(t *testing.T) {
msg := offloadNotice(3, "offload-123")
for _, want := range []string{"系统", "3 条", "offload-123", "转投"} {
// 用词按用户口径:这是**分诊**(及时反馈),不是"内核替父决定"。
for _, want := range []string{"系统", "3 条", "offload-123", "分诊", "不需要你再处理"} {
if !strings.Contains(msg, want) {
t.Errorf("说明缺少 %q%s", want, msg)
}
@ -232,7 +233,7 @@ func TestOffloadMovesTasksToResidentEndToEnd(t *testing.T) {
t.Fatalf("留下的应是内核说明,实际 %+v", notice.Event)
}
content, _ := notice.Event.Payload["content"].(string)
for _, want := range []string{"2 条", resident.ID, "转投"} {
for _, want := range []string{"2 条", resident.ID, "分诊"} {
if !strings.Contains(content, want) {
t.Errorf("说明缺少 %q%s", want, content)
}
@ -408,3 +409,174 @@ func TestForwardKeepsResponseCh(t *testing.T) {
t.Fatal("★ 同步调用方没收到回执:转投丢了 ResponseCh线上表现为 HTTP 挂起 504")
}
}
// ★★ 回归:转投子必须拿到**分诊职责**提示词。
//
// 我第一版没给 TaskPrompt于是子完全不知道自己为什么存在只知道自己叫小宅
// 用户对这个特性的定位是**及时反馈**:主 agent 忙时不能让用户干等十几分钟
// (实测现场 785,951ms。子的职责是分诊 —— 简单的直接办,需要主 agent 的
// 立刻回「忙碌中,请稍候」,而不是勉强作答。
func TestOffloadResidentGetsTriagePrompt(t *testing.T) {
p := offloadTaskPrompt()
for _, want := range []string{"分诊", "直接办", "忙碌中", "output_send"} {
if !strings.Contains(p, want) {
t.Errorf("分诊提示词缺少 %q", want)
}
}
// 拿不准时的默认动作必须是保守的那条(报忙碌),不能是"勉强作答"
if !strings.Contains(p, "选【报忙碌】") {
t.Error("必须写明拿不准时选报忙碌(避免给用户错误答复)")
}
}
// ★★ 回归:驻留子必须**继承父的 SystemPrompt**。
//
// 我第一版没传 SystemPrompt子只能用 buildSystemPrompt 的一句兜底文案。
// 而父的提示词里有「回复投递规则」:面向 qq/wechat 等**异步**通道时,
// 纯文本返回会被静默丢弃,必须显式 output_send__{通道名}。
// 缺了它,子处理完 QQ 积压却发不出去且自己不会意识到实测webui 这类
// **同步**通道能回是因为走 ResponseCh掩盖了这个缺陷
func TestResidentInheritsParentSystemPrompt(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
root.systemPrompt = "父的提示词:异步通道必须显式 output_send"
info, err := root.SpawnResident(ResidentOptions{
ID: "inherit-test", TempPath: root.residentTempPath("inherit-test"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
root.residentMu.Lock()
rc := root.residents[info.ID]
root.residentMu.Unlock()
if rc == nil || rc.agent == nil {
t.Fatal("驻留子不可用")
}
if rc.agent.systemPrompt != root.systemPrompt {
t.Fatalf("★ 驻留子未继承父的 SystemPrompt子=%q 父=%q",
rc.agent.systemPrompt, root.systemPrompt)
}
// 真正要看的是提示词里确实带上了投递规则
built := rc.agent.buildSystemPrompt("", "x")
if !strings.Contains(built, "output_send") {
t.Errorf("子拼出的系统提示词里没有投递规则:%s", built)
}
}
// ★★ 残余任务必须由父**显式**决定保留还是丢弃(用户 2026-09-19 要求)。
//
// 现场问题:回收/销毁驻留子时它手头可能还有没处理的消息。异步通道qq
// 没有 ResponseCh静默丢弃时用户零反馈、日志也无痕迹 —— 消息就像没发过一样。
// 所以内核只负责「把残余任务列清楚」,处置由父的模型决定(设计 §7
func TestResidualKeepReturnsTasksToParent(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
info, err := root.SpawnResident(ResidentOptions{
ID: "res-keep", TempPath: root.residentTempPath("res-keep"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
root.residentMu.Lock()
child := root.residents[info.ID].agent
root.residentMu.Unlock()
// 给子塞两条尚未处理的残余任务(一条带同步回执、一条不带=模拟 qq
syncCh := make(chan *agentIO.OutputEvent, 1)
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
RequestID: "r1", Source: "webui", OutputChannel: "webui",
Payload: map[string]interface{}{"content": "a"}, ResponseCh: syncCh,
}))
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
RequestID: "r2", Source: "qq", OutputChannel: "qq",
Payload: map[string]interface{}{"content": "b"},
}))
n, msg, err := root.ApplyResidual(info.ID, ResidualKeep)
if err != nil {
t.Fatalf("ApplyResidual(keep): %v", err)
}
if n != 2 {
t.Fatalf("应处置 2 条,实际 %dmsg=%s", n, msg)
}
// keep = 转回父自己:两条都要出现在父的队列里,且**带 ResponseCh 的那条仍带**
if len(root.sched.queue) != 2 {
t.Fatalf("两条残余任务应转回父队列,实际 %d", len(root.sched.queue))
}
var hasResponseCh bool
for _, task := range root.sched.queue {
if task.Event != nil && task.Event.ResponseCh != nil {
hasResponseCh = true
}
}
if !hasResponseCh {
t.Error("★ keep 丢了 ResponseCh同步调用方会永久挂起")
}
// keep 后子队列应清空(已交出去):再取一次应为空
if left, _ := root.TakeResidual(info.ID); len(left) != 0 {
t.Errorf("交接后子队列应清空,实际剩 %d", len(left))
}
}
// drop 也必须给同步调用方一个终态,否则 cli/a2a 会永久挂起。
func TestResidualDropNotifiesSyncCaller(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
info, err := root.SpawnResident(ResidentOptions{
ID: "res-drop", TempPath: root.residentTempPath("res-drop"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
root.residentMu.Lock()
child := root.residents[info.ID].agent
root.residentMu.Unlock()
respCh := make(chan *agentIO.OutputEvent, 1)
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
RequestID: "d1", Source: "cli", OutputChannel: "cli",
Payload: map[string]interface{}{"content": "x"}, ResponseCh: respCh,
}))
n, _, err := root.ApplyResidual(info.ID, ResidualDrop)
if err != nil {
t.Fatalf("ApplyResidual(drop): %v", err)
}
if n != 1 {
t.Fatalf("应处置 1 条,实际 %d", n)
}
select {
case out := <-respCh:
if out == nil || !out.Done {
t.Error("drop 应给同步调用方一个终态")
}
case <-time.After(3 * time.Second):
t.Fatal("★ drop 未通知同步调用方cli/a2a 会永久挂起")
}
// drop 后父队列不应多出东西
if len(root.sched.queue) != 0 {
t.Errorf("drop 不应把任务转回父队列,实际 %d", len(root.sched.queue))
}
}
// 无残余任务时应明确说"无",而不是让父以为丢了什么。
func TestResidualEmptyIsReported(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
info, err := root.SpawnResident(ResidentOptions{
ID: "res-empty", TempPath: root.residentTempPath("res-empty"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
n, msg, err := root.ApplyResidual(info.ID, ResidualKeep)
if err != nil {
t.Fatalf("ApplyResidual: %v", err)
}
if n != 0 || msg != "无残余任务" {
t.Errorf("应报告无残余任务,实际 n=%d msg=%q", n, msg)
}
}

View File

@ -14,6 +14,7 @@ package core
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -58,15 +59,17 @@ type ResidentOptions struct {
// 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"`
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"`
// OffloadOwned 标记这是内核为承接积压而拉起的**临时分诊助手**(见 offload.go
OffloadOwned bool `json:"offload_owned,omitempty"`
Table []InputchRecord `json:"table,omitempty"`
// Sched* 是这个驻留子**自己的**输入调度器积压摘要(排队 / 待处理中断 /
// 中断栈 / 四级中断队列)。
@ -89,7 +92,7 @@ type residentChild struct {
dir string
inputChs []string
allowed []string
createdAt time.Time
createdAt time.Time
// offloadOwned 标记这是内核为转投拉起的子(见 offload.go
offloadOwned bool
@ -165,7 +168,19 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
parentID := string(a.id)
child := New(AgentConfig{
ID: types.AgentID(opts.ID),
ID: types.AgentID(opts.ID),
// SystemPrompt 必须**继承父的**
//
// 拿不到它时子只能用 buildSystemPrompt 里的兜底句(一句人格描述)。
// 而父的提示词里写着**回复投递规则** ——「面向 qq、wechat、a2a、acp 等异步
// 通道时,必须显式调用 output_send__{通道名};只返回纯文本会被直接丢弃,
// 用户永远收不到,而你会误以为已经回复过了」,以及事实性约束、命令与文件
// 操作策略等。缺了这些,子于异步通道(如 QQ 积压)处理完却发不出去,
// 而且它自己不会意识到(提示词没告诉它)。
//
// 实测2026-09-19webui 这类**同步**通道能回,是因为走 ResponseCh
// 而 QQ 是异步通道、必须子主动 output_send —— 这正是本字段必须接上的理由。
SystemPrompt: a.systemPrompt,
Provider: a.provider,
ProviderManager: a.providerManager,
IO: childIO,
@ -263,6 +278,88 @@ func (a *Agent) DestroyResident(id string) error {
return nil
}
// ResidualPolicy 是父对**残余任务**的显式决定。
//
// 为什么需要它(用户 2026-09-19 明确要求):回收/销毁驻留子时,
// 它手头可能还有**尚未处理的消息**。这些消息的处置不能由内核悄悄决定:
// - 直接丢弃 → 用户消息无声消失异步通道更是零反馈qq 无 ResponseCh
// 用户不知道发生了什么,系统里也没任何痕迹);
// - 无条件转回父 → 父本来就很忙,把一堆活重新塞回去可能反而加剧积压。
//
// 因此与其它控制面动作一致:**原语在内核,决定在父的模型**(设计 §7
// 内核负责把残余任务**列清楚**(来源、通道、内容),父选 keep转回自己/ drop。
//
// 无论选哪个,内核都会**逐条留日志**:丢弃必须可追溯。
// 默认(不传 policy取 keep宁可多做一件不可默默丢一条。
type ResidualPolicy string
const (
// ResidualKeep 把残余任务转回父自己的队列,父稍后处理。
ResidualKeep ResidualPolicy = "keep"
// ResidualDrop 明确丢弃残余任务(父已看过清单并确认)。
ResidualDrop ResidualPolicy = "drop"
)
// TakeResidual 取走一个驻留子手头**尚未处理**的残余任务(不处置)。
//
// 调它**会**从子的队列里移除这些任务,所以父必须先看返回值再决定:
// 一旦取走,不再调 ApplyResidual 就等于把它们丢了。控制面工具走 ApplyResidual
// (取+处置一步完成)以避免这个误用。
func (a *Agent) TakeResidual(id string) ([]*agentIO.InputEvent, error) {
a.residentMu.Lock()
rc := a.residents[id]
a.residentMu.Unlock()
if rc == nil || rc.agent == nil {
return nil, fmt.Errorf("驻留子 %s 不存在", id)
}
return rc.agent.sched.takeAllPendingEvents(), nil
}
// ApplyResidual 按父给出的策略处置一个驻留子的残余任务。
//
// keep逐条转回父自己的队列保留原事件含 ResponseCh 与来源/通道);
// drop逐条记录日志后丢弃不可追溯的丢弃是不允许的
//
// 无论哪种,带 ResponseCh 的都要给终态,否则 cli/a2a 这类无超时同步调用方
// 会永久挂起(设计 §7 I5
func (a *Agent) ApplyResidual(id string, policy ResidualPolicy) (int, string, error) {
events, err := a.TakeResidual(id)
if err != nil {
return 0, "", err
}
if len(events) == 0 {
return 0, "无残余任务", nil
}
if policy == ResidualDrop {
for _, evt := range events {
// 丢弃必须留痕异步通道qq/wechat没有 ResponseCh
// 不记日志的话“这条消息为什么没人回”永远查不出来。
log.Printf("[resident] %s 残余任务按父的决定丢弃source=%s channel=%s request=%s content=%s",
id, evt.Source, evt.OutputChannel, evt.RequestID, truncateStr(inputTextOf(evt), 80))
a.emitSkippedReply(evt, "residual_dropped_by_parent")
}
return len(events), fmt.Sprintf("已按父的决定丢弃 %d 条残余任务(逐条已记日志)", len(events)), nil
}
// 默认 keep转回父自己
for _, evt := range events {
if evt == nil {
continue
}
a.sched.enqueue(newInputTask(evt))
}
a.sched.signalWake()
return len(events), fmt.Sprintf("已把 %d 条残余任务转回主 agent 队列", len(events)), nil
}
// inputTextOf 取一条输入事件的正文(供日志描述残余任务用)。
func inputTextOf(evt *agentIO.InputEvent) string {
if evt == nil || evt.Payload == nil {
return ""
}
s, _ := evt.Payload["content"].(string)
return s
}
// teardownResident 停内核、放通道、丢 temp销毁与回收共用
func (a *Agent) teardownResident(rc *residentChild) {
rc.mu.Lock()

View File

@ -123,17 +123,31 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
return fmt.Sprintf("已压缩子 agent 上下文(丢弃 %d 条旧事件,并发清理其 inputch 处理表);子继续存在", n)
case "reclaim":
info, err := a.ReclaimResident(strArg(tc, "id"), reclaimKeepAll)
id := strArg(tc, "id")
// 残余任务先由父**显式**决定保留还是丢弃(用户 2026-09-19 要求)。
// 不传 residual 时默认 keep宁可多做一件不可默默丢一条。
residual, rmsg, rerr := a.ApplyResidual(id, residualPolicyOf(tc))
if rerr != nil {
return fmt.Sprintf("处置残余任务失败: %v", rerr)
}
info, err := a.ReclaimResident(id, reclaimKeepAll)
if err != nil {
return fmt.Sprintf("回收失败: %v", err)
}
return "已回收temp 中选中的记录已合入主记忆,该驻留子已取消): " + MarshalResidentInfo(info)
return fmt.Sprintf("残余任务(%d 条):%s\n已回收temp 中选中的记录已合入主记忆,该驻留子已取消): %s",
residual, rmsg, MarshalResidentInfo(info))
case "destroy":
if err := a.DestroyResident(strArg(tc, "id")); err != nil {
id := strArg(tc, "id")
// 同上:销毁前先把残余任务交出去,否则它们会随子一起无声消失。
residual, rmsg, rerr := a.ApplyResidual(id, residualPolicyOf(tc))
if rerr != nil {
return fmt.Sprintf("处置残余任务失败: %v", rerr)
}
if err := a.DestroyResident(id); err != nil {
return fmt.Sprintf("销毁失败: %v", err)
}
return "已销毁并移除该驻留子"
return fmt.Sprintf("残余任务(%d 条):%s\n已销毁并移除该驻留子", residual, rmsg)
default:
return fmt.Sprintf("未知 action=%q可用list | create | send | inspect | compress | reclaim | destroy", action)
@ -144,6 +158,19 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
// "哪些纳入"由父的模型决定——这里给的是"全要"这一档)。
func reclaimKeepAll(_ []InputchRecord, triples []memory.Triple) []memory.Triple { return triples }
// residualPolicyOf 从工具参数读残余任务的处置策略keep/drop
//
// 默认 keep父没明确说丢时一律转回自己而不是丢弃。
// "宁可多做一件,不可默默丢一条"——吞掉一条输入比多处理一条更糟。
func residualPolicyOf(tc agentAPI.ToolCall) ResidualPolicy {
switch strings.ToLower(strArg(tc, "residual")) {
case "drop", "discard":
return ResidualDrop
default:
return ResidualKeep
}
}
func intArg(tc agentAPI.ToolCall, key string) int {
switch v := tc.Arguments[key].(type) {
case float64:

View File

@ -1011,6 +1011,41 @@ func (s *scheduler) pendingEvents() []*agentIO.InputEvent {
return out
}
// takeAllPendingEvents 取走**全部尚未执行**的输入事件(含无回执通道的异步输入),
// 并从队列中移除它们。返回的事件不再会被本调度器执行。
//
// 与 pendingEvents 的区别(两者用途完全不同,不要混用):
// - pendingEvents 只挑**带 ResponseCh** 的,用途是给同步调用方补终态;
// - 本函数**不筛通道**,因为回收/销毁驻留子时要向父交代的是"手头还有哪些活"
// 而 QQ/微信这类异步消息本来就没有 ResponseCh —— 它们恰恰是最容易被无声丢掉的。
//
// 只取 TaskQueued/KindInput 与中断队列里的输入事件self 任务是内核内部记账
// (记忆整理),换 agent 没有意义,原地丢弃即可(不计入返回)。
func (s *scheduler) takeAllPendingEvents() []*agentIO.InputEvent {
s.mu.Lock()
defer s.mu.Unlock()
var out []*agentIO.InputEvent
for _, t := range s.queue {
if t != nil && t.Kind == TaskKindInput && t.Event != nil {
out = append(out, t.Event)
}
}
s.queue = nil
for lv := LevelBackground; lv <= LevelCritical; lv++ {
for _, t := range s.interruptQueues[lv] {
if t != nil && t.Event != nil {
out = append(out, t.Event)
}
}
s.interruptQueues[lv] = nil
}
if s.immediate != nil && s.immediate.Event != nil {
out = append(out, s.immediate.Event)
}
s.immediate = nil
return out
}
// done 标记任务执行结束。
func (s *scheduler) done(t *Task) {
s.mu.Lock()

View File

@ -331,6 +331,7 @@ func residentStatuses(list []ResidentInfo) []ResidentStatus {
InputChs: r.InputChs,
AllowedOutputs: r.AllowedOutputs,
InputChTable: r.TableSize,
OffloadOwned: r.OffloadOwned,
// 子自己的调度器积压per-agent 负载图靠这四项,缺了就只能画根。
ReadyQueueDepth: r.SchedReady,
PendingInterrupts: r.SchedPending,

View File

@ -508,7 +508,9 @@ func (a *Agent) buildToolDefs() []interface{} {
tools = append(tools, toolDef("resident_agents", "管理驻留子 agent长期派驻的下属list 列出 / create 创建(划入 inputch + "+
"授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)"+
"/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)"+
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。", map[string]interface{}{
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。"+
"reclaim/destroy 时它手头**尚未处理的消息**(残余任务)由你用 residual 决定:"+
"keep=转回你自己的队列(默认)/ drop=明确丢弃,两者都会逐条记日志。", map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"},
@ -520,6 +522,13 @@ func (a *Agent) buildToolDefs() []interface{} {
"capacity": map[string]interface{}{"type": "number", "description": "create划入 inputch 的队列容量"},
"temp_path": map[string]interface{}{"type": "string", "description": "createtemp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db"},
"text": map[string]interface{}{"type": "string", "description": "send要发给子 agent 的消息"},
"residual": map[string]interface{}{
"type": "string",
"description": "reclaim/destroy该子手头未处理消息的处置。" +
"keep=转回主 agent 队列(默认,宁可多做一件不可默默丢一条)/ drop=丢弃(已确认不要)。" +
"无论哪种都会逐条记日志;异步通道(如 qq被丢时用户收不到任何回复请慎重选 drop。",
"enum": []string{"keep", "drop"},
},
}, "action"))
}

View File

@ -122,6 +122,13 @@ type ResidentStatus struct {
InputChTable int `json:"input_ch_table"`
CreatedAt string `json:"created_at,omitempty"`
// OffloadOwned 标记这是**内核为承接积压而拉起**的临时助手(见 core/offload.go
//
// 为什么要暴露给状态面/工具面:父的模型需要分清"我建的子"与
// "内核临时拉的分诊助手" —— 前者该按需回收,后者在空闲、无积压时
// 可以安全回收,而**正忙时不该回收**(会让用户的消息再次无声丢失)。
OffloadOwned bool `json:"offload_owned,omitempty"`
// 以下四项是该驻留子**自己的**调度器积压摘要,用于 per-agent 负载环形图。
//
// 根 agent 的积压看 KernelStatus.Scheduler每个驻留子是独立 agent、