Files
HomeAgent/internal/agent/core/toolbatch.go
JianFeeeee a02feaaff7 refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。

## 1. 抽出 child 包(类型抽象 + 单一登记表)

新增 internal/agent/core/child/:
  child.go      Kind / Child(嵌入用)/ State / 残余与通道处置
  registry.go   单一 Registry + Entry 接口 + Cleanup 骨架
  kind.go       ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
  kinds.go      四种子 agent 的清单与迁移状态

此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。

现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。

## 2. rt 的输出改为异步(用户的原始诉求)

rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。

新增 outputpump.go(输出通道的异步壳):
  · Push 立即返回,交付由独立消费 goroutine 完成
  · 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
  · 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
  · Close 排空后退出,不吞最后半句
  · 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)

## 3. 工具钩子统一(本轮最大发现)

实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。

⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。

新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。

## 判据(均经变异验证)

  TestRTChild_OutputIsAsync              改回同步 ⇒ 红(实测确认)
  TestOutputPump_DropsOldestOnFull       改阻塞/无界 ⇒ 红
  TestOutputPump_CloseDrains             丢弃队列 ⇒ 红
  TestOutputPump_ResolvesDeviceEachTime  缓存指针 ⇒ 红
  TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
  TestRTChild_StageCanDenyTool           同上
  TestWorkerToolsGoThroughPluginStages   同上(变异实测确认)
  TestWorkerToolsStaySerial              改成并发 ⇒ 红
  TestNoChildTypeTablesInCore            源码扫描,禁类型专属表回潮
  TestRTChild_HasGraphMemory 等 5 条     rt 接入图记忆

## 顺带修复

  · rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
    memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
    默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
    闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
  · rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。

## 保留的语义(逐项确认仍在)

  驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
  三种通道处置:ChannelRelease / Suspend / Takeover
  残余 keep/drop + harvestResidentMemory
  child.Start() 的 7 个 goroutine

验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
2026-11-04 08:21:15 +08:00

257 lines
8.5 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
// toolbatch.go 是**唯一**的工具批执行实现(骨架级基础设施)。
//
// # 它解决什么(实测缺陷,2026-11-04)
//
// 全仓有**三**条工具执行路径,其中两条绕过了插件的 before/after_toolcall:
//
// 路径 before_toolcall after_toolcall 并发
// 主 agent ✅ ✅ ✅ stepToolBatch
// 驻留子 ✅ ✅ ✅ 同主 agent(走 runInputTask)
// rtchild ❌ ❌ ❌ 直调 executeToolCallOutcome
// 工作式子 ❌ ❌ ❌ 直调 executeToolCall
//
// 实测依据:`StageBeforeToolcall` / `StageAfterToolcall` 全仓只出现在
// task.go:1022 / 1079 / 1194,三处都在 runOneTool / stepToolAfter 里;
// 而 `executeToolCallOutcome` 内部**没有任何 runStage 调用**。
//
// 后果:rt 与普通子的工具调用对插件**完全不可见** —— 不能审计、不能拒绝、
// 不能改参、不能脱敏。README 承诺的 before_toolcall「审计、拒绝、改参」
// 在这两条路径上不成立。
//
// # 为什么不做成"让它们复用 TaskFrame"
//
// TaskFrame 承载 7 个 Step + 挂起/恢复/中断栈 + 场景解析 + 上下文预算,
// 那是为「可抢占、现场可恢复」设计的。rt 明确不要这些(见 rtchild.go 的
// 743 行注释),工作式子也不要(它是一次性局部循环)。
// 复用 TaskFrame 会让两边都被拖进对方的复杂度里。
//
// ⇒ 抽出**最小公共核**:批并发 + 两个钩子 + 顺序落结果。
// 三种类型都用它,差异通过参数传入。
import (
"context"
"fmt"
"sort"
"sync"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// batchOpts 是批执行的可变部分。其余全部共享。
type batchOpts struct {
// Channel 是本次批量要回的通道(进 StageContext.Extra,插件据此判权限)。
Channel string
// Scenes 是本轮的场景键(rt / 工作式子传 nil)。
Scenes []string
// StageCtx 是基底 StageContext;内部按 idx 拆分(Extra 逐份复制)。
// nil 时用一份空基底(仍然带 comefrom 戳)。
StageCtx *sdk.StageContext
// NoBatch 为 true 时强制串行(工作式子历史上是逐条串行)。
NoBatch bool
// ToolFilter 可在执行前改写/拒绝单个工具(工作式子用它禁 output_send__*)。
//
// 返回非空字符串表示"拒绝并以此作为结果",不回落到真实执行。
ToolFilter func(tc agentAPI.ToolCall) string
}
// batchResult 是单条工具的结果。
type batchResult struct {
CallID string
Name string
Text string
Raw interface{}
Denied bool
}
// runToolBatch 执行一批工具,返回与 calls **同序**的结果。
//
// ## 为什么必须同序
//
// assistant(tool_calls) 与其后的 tool 结果消息必须相邻且一一对应。
// 按完成顺序落消息会被上游以 400 拒绝(insufficient tool messages)。
// 所以并发只作用于"执行",落结果一律按索引顺序。
//
// ## 并发条件
//
// 走与主 agent 相同的判据(batchRunnable):批内 >1、全部声明 ParallelSafe、
// 不含同通道多次输出。这里额外允许 opts.NoBatch 强制串行 ——
// 工作式子历史上是串行的,改成并发会改变它已上线行为的时序假设,
// 不在本次重构范围内(判据 TestWorkerToolsStaySerial 钉住)。
func (a *Agent) runToolBatch(ctx context.Context, calls []agentAPI.ToolCall, opts batchOpts) []batchResult {
if len(calls) == 0 {
return nil
}
// 预建每份 StageContext:Extra 必须逐份复制(插件会写它,共享即竞争)。
ctxs := make([]sdk.StageContext, len(calls))
for i := range calls {
// ★ 不复制 StageContext 整体:它内含 mutex(go vet 会报
// "assignment copies lock value")。只逐字段取真正需要的那两项,
// 与 task.go 的 buildToolContexts 同一写法。
var extra map[string]interface{}
var noMemory bool
if opts.StageCtx != nil {
extra = opts.StageCtx.Extra
noMemory = opts.StageCtx.NoMemory
}
ctxs[i] = sdk.StageContext{
Extra: copyExtraMap(extra),
NoMemory: noMemory,
}
if ctxs[i].Extra == nil {
ctxs[i].Extra = map[string]interface{}{}
}
if opts.Channel != "" {
ctxs[i].Extra["output_channel"] = opts.Channel
}
// ★ 工具调用帧必须带来源标识(comefrom):通道插件的权限门靠它区分
// "用户请求" 与 "agent 内部委派"。缺了它,子 agent 的工具调用会被按
// **用户**权限模型判定 ⇒ 合法工作全被拦(comefrom.go 有 2026-10-16 实证)。
//
// 子 agent 是内部委派 ⇒ comeFromInternal。主 agent 传 comeFromUser
// 由调用方在 StageCtx 里已标好(这里只在缺失时补保守值)。
if _, ok := ctxs[i].Extra["comefrom"]; !ok {
stampComeFrom(ctxs[i].Extra, comeFromInternal)
}
}
results := make([]batchResult, len(calls))
runOne := func(i int) {
tc := calls[i]
res := batchResult{CallID: tc.ID, Name: tc.Name}
// ① 类型级过滤(工作式子禁 output_send__* / spawn_child 等)。
if opts.ToolFilter != nil {
if deny := opts.ToolFilter(tc); deny != "" {
res.Text = deny
res.Denied = true
results[i] = res
return
}
}
tctx := &ctxs[i]
tctx.ToolCalls = []sdk.ToolCall{{
ID: tc.ID, Name: tc.Name,
Plugin: a.resolveToolPlugin(tc.Name), Arguments: tc.Arguments,
}}
tctx.ToolResults = nil
// ② before_toolcall —— **这一步就是本文件存在的理由**。
if a.runStage(sdk.StageBeforeToolcall, tctx) {
res.Text = denialResultText(tctx, tc.Name)
res.Denied = true
results[i] = res
return
}
// 插件可以改参(before_toolcall 的契约之一)。
tc.Arguments = tctx.ToolCalls[0].Arguments
// ③ 插件崩溃态:不执行(与主 agent 一致)。
if pluginName := a.resolveToolPlugin(tc.Name); pluginName != "" {
if !a.pluginHealth.isHealthy(pluginName) {
res.Text = pluginNameCrashedText(pluginName)
res.Denied = true
results[i] = res
return
}
}
// ④ 真实执行。
out := a.executeToolCallOutcome(tc, opts.Channel, opts.Scenes...)
res.Text = out.Text
res.Raw = out.Raw
tctx.ToolResults = []sdk.ToolResult{{
CallID: tc.ID, Name: tc.Name,
Success: !isToolError(out.Raw), Result: out.Raw,
}}
// ⑤ after_toolcall —— 插件可脱敏/改写结果。
a.runStage(sdk.StageAfterToolcall, tctx)
if len(tctx.ToolResults) > 0 {
switch r := tctx.ToolResults[0].Result.(type) {
case string:
res.Text = r
case nil:
// 插件清空:保持原样,不覆盖。
default:
res.Raw = r
res.Text = toolErrorText(tc.Name, r)
if !isToolError(r) {
res.Text = fmt.Sprintf("%v", r)
}
}
}
results[i] = res
}
// 串行 or 并发。
if opts.NoBatch || !a.batchParallelSafe(calls) {
for i := range calls {
runOne(i)
}
return results
}
var wg sync.WaitGroup
for i := range calls {
wg.Add(1)
go func(idx int) { defer wg.Done(); runOne(idx) }(i)
}
wg.Wait()
return results
}
// batchParallelSafe 判断这批工具能否并发(与主 agent 的 batchRunnable 同判据)。
func (a *Agent) batchParallelSafe(calls []agentAPI.ToolCall) bool {
if len(calls) <= 1 {
return false
}
chans := map[string]bool{}
for _, tc := range calls {
if !a.toolParallelSafe(tc.Name) {
return false
}
if isOutputDeliveryTool(tc.Name) {
ch := trimOutputPrefix(tc.Name)
if chans[ch] {
return false // 同通道多次输出必须保序
}
chans[ch] = true
}
}
return true
}
// sortBatchByIndex 是给调用方落消息用的辅助:按原始索引排(防御性)。
//
// 现有实现本来就按索引写 results,这里提供它是因为"按完成顺序落消息"
// 是本仓踩过的坑,留一个显式函数让纪律可见。
func sortBatchByIndex(rs []batchResult, order []string) []batchResult {
if len(order) == 0 {
return rs
}
idx := map[string]int{}
for i, id := range order {
idx[id] = i
}
out := append([]batchResult(nil), rs...)
sort.SliceStable(out, func(i, j int) bool { return idx[out[i].CallID] < idx[out[j].CallID] })
return out
}
// 以下两个小工具避免在热路径里引 fmt 的多次分配。
func pluginNameCrashedText(p string) string {
return "插件 " + p + " 处于崩溃状态,已跳过执行,等待自动恢复重载"
}
func trimOutputPrefix(name string) string {
const p = "output_send__"
if len(name) > len(p) {
return name[len(p):]
}
return name
}