Files
HomeAgent/internal/agent/io/channel_error_test.go
JianFeeeee 7a566d50b7 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
2026-09-27 08:55:25 +08:00

71 lines
2.7 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 io
import (
"errors"
"testing"
)
// 回归:驻留子的 IOManager 向父兜底时,父的**执行失败**不得被吞成「工具不存在」。
//
// 原实现(channel.go:655 附近):
//
// if ret, err := parent.ExecuteTool(name, args); err == nil { return ret, nil }
// // 父的 err 被丢弃 ⇒ 落到 return "tool X not found"
//
// 后果放大:设备离线、插件崩溃这类「本该 retry 的失败」被上报为「工具没了」,
// 于是 on_error 整组跳过 —— 与「插件真的没加载」无法区分。
func TestExecuteTool_DoesNotSwallowParentFailureAsNotFound(t *testing.T) {
parent := NewIOManager()
// 父持有一个会在执行时失败的设备:工具存在,但 Execute 报错。
failing := &failingDevice{name: "dev", toolName: "boom_tool", err: errors.New("device offline")}
if err := parent.RegisterDevice(failing); err != nil {
t.Fatalf("注册失败: %v", err)
}
child := NewIOManager()
child.SetParentIO(parent)
// 工具不在 child 上 → 向父兜底;父执行失败必须**如实上抛**。
_, err := child.ExecuteTool("boom_tool", map[string]interface{}{})
if err == nil {
t.Fatal("期望父的失败被上抛,实际 err=nil(被吞了)")
}
if IsToolNotFound(err) {
t.Fatalf("父的执行失败被误报为『工具不存在』: %v", err)
}
if err.Error() != "device offline" {
t.Errorf("应如实上抛父的错误文案,实际: %v", err)
}
}
// 真正的「不存在」仍须保持可判别(子与父都没有)。
func TestExecuteTool_NotFoundStillTypeable(t *testing.T) {
parent := NewIOManager()
child := NewIOManager()
child.SetParentIO(parent)
_, err := child.ExecuteTool("no_such_tool", map[string]interface{}{})
if !IsToolNotFound(err) {
t.Fatalf("两级都没有时应为 ErrToolNotFound,实际: %v", err)
}
}
// failingDevice 是一个 Execute 恒定报错的测试设备。
type failingDevice struct {
name string
toolName string
err error
}
func (d *failingDevice) Name() string { return d.name }
func (d *failingDevice) Type() DeviceType { return DeviceOutput }
func (d *failingDevice) Description() string { return "failing test device" }
func (d *failingDevice) Tools() []ToolDef { return []ToolDef{{Name: d.toolName}} }
func (d *failingDevice) Execute(string, map[string]interface{}) (interface{}, error) {
return nil, d.err
}
func (d *failingDevice) Start() error { return nil }
func (d *failingDevice) Stop() error { return nil }
func (d *failingDevice) OutputCapabilities() OutputCapability { return CapText }
func (d *failingDevice) ChannelDef() ChannelDef { return ChannelDef{} }