fix(toolcall): 工具「不存在」类型化 + 修父 io 兜底吞错误 + 修并行 tool_call 落序随机

主线:工具调用并行化改造(阶段 0 与 0.2)。

① flush 顺序随机(process.go)
   flushToolCall 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化
   ⇒ 同一批并行 tool_call 进入 resp.ToolCalls 的顺序每次运行都可能不同。
   对 output_send__ 这类用户可见通道,分段消息到达顺序不可复现。
   改为收集 index 后 sort.Ints 再 flush(两个调用点统一走 flushAll)。
   判据 stream_flush_order_test.go(8 工具 × 200 轮),已变异验证可检测。

② 工具「不存在」类型化(io/channel.go、core/stages.go、core/toolcall.go)
   工具是动态注册的,「不存在」是运行期常态而非异常。原先内核用
   strings.Contains(err, "not found in any plugin") 判别——约定而非契约,
   插件文案含该子串即被误判。改用哨兵 ErrToolNotFound + errors.Is
   (沿用仓内 ErrInputChannelUnknown 的先例)。

   ⚠️ 顺带修一个静默 bug:IOManager 向父兜底时吞掉父的执行失败,
   误报为「工具不存在」。后果是设备离线这类本该 retry 的失败被判为
   「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。改为只传递
   「确实不存在」,其余如实上抛。

   「不存在」的文案改为可执行指引(get_plugin_tools / output_list_channels),
   而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。

判据:toolcall_error_test.go(类型化 vs 诱饵子串、%w 穿透、执行期文案)、
channel_error_test.go(父失败不吞、真的不存在仍可判别)。
两者均经变异验证。回归:internal/agent/... 与 internal/plugins/... 全绿(14 包)。

设计文档:docs/zh/toolcall-contract-and-sequence-design.md
执行计划:docs/zh/toolcall-parallel-execution-plan.md
This commit is contained in:
JianFeeeee
2026-09-27 08:55:25 +08:00
parent 762d442844
commit ab1a17a74f
9 changed files with 1275 additions and 10 deletions

View File

@ -7,6 +7,7 @@ import (
"fmt"
"log"
"regexp"
"sort"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
@ -287,13 +288,31 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
delete(accs, idx)
}
// flushAll 按 index 升序 flush 所有累积中的 tool call。
//
// ❗必须排序,不能直接 `for idx := range accs`:Go 的 map 迭代顺序是**随机化**的,
// 同一批并行 tool_call 因此会以任意顺序进入 resp.ToolCalls。对 output_send__
// 这类**用户可见消息**通道,后果是分段消息的到达顺序每次运行都可能不同
// (不可复现的外部行为);对 tool_call_id 配对虽无影响(靠 id 而非位置),
// 但让「同一输入产生同一执行序列」这条最基本的可复现性失守。
//
// 排序也让判据可写:stream_flush_order_test 断言输出严格按 index 升序。
flushAll := func() {
idxs := make([]int, 0, len(accs))
for idx := range accs {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
for _, idx := range idxs {
flushToolCall(idx)
}
}
for {
select {
case ck, ok := <-ch:
if !ok {
for idx := range accs {
flushToolCall(idx)
}
flushAll()
if lastFinish != "" {
resp.FinishReason = lastFinish
}
@ -357,9 +376,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
}
case <-ctx.Done():
for idx := range accs {
flushToolCall(idx)
}
flushAll()
return resp, ctx.Err()
}
}