Files
HomeAgent/internal/agent/core/resident_tools.go
JianFeeeee 943eef01cf 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 那条已实测「对着静默丢弃的旧实现会失败」。全套绿。
2026-09-19 17:43:16 +08:00

207 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// 驻留子的**工具面**(对照设计 §7 控制面与 §8 处理表)。
//
// 单工具多动作:父侧一个 `resident_agents`list/create/send/inspect/compress/reclaim/destroy
// 子侧两个小工具:`notify_parent`L3 主动汇报)与 `inputch_note`(主动写处理表)。
import (
"fmt"
"log"
"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 == "" {
anchor := a.dataDir
if anchor == "" {
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
if a.memory != nil && a.memory.Path() != "" {
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
log.Printf("[resident] data_dir 未接线,回退到主图库目录: %s", anchor)
}
}
if anchor == "" {
return "创建驻留子需要 data_dir 或显式 temp_path内核未接线 DataDir"
}
tempPath = filepath.Join(anchor, "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":
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 fmt.Sprintf("残余任务(%d 条):%s\n已回收temp 中选中的记录已合入主记忆,该驻留子已取消): %s",
residual, rmsg, MarshalResidentInfo(info))
case "destroy":
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 fmt.Sprintf("残余任务(%d 条):%s\n已销毁并移除该驻留子", residual, rmsg)
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 }
// 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:
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 依赖(工具面未来会用通道登记)