mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
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:
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"runtime/debug"
|
||||
"sync"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
@ -93,7 +94,10 @@ func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret i
|
||||
handler, ok := h.tools[name]
|
||||
h.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("tool %s not found in any plugin", name)
|
||||
// 类型化哨兵:工具是动态注册的,调用方需要能**精确**区分
|
||||
// 「不存在」(插件挂了吗)与「执行失败」(本次业务失败)—— 二者对
|
||||
// on_error/retry 的处置完全不同。见 agentIO.ErrToolNotFound。
|
||||
return nil, agentIO.ToolNotFound(name)
|
||||
}
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("tool %s has nil handler", name)
|
||||
|
||||
75
internal/agent/core/stream_flush_order_test.go
Normal file
75
internal/agent/core/stream_flush_order_test.go
Normal file
@ -0,0 +1,75 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// 回归:并行多工具调用的 flush 顺序必须按上游 index 升序,与到达顺序无关。
|
||||
//
|
||||
// 为什么需要这条:flushToolCall 由 `for idx := range accs` 驱动,而 Go 的 map
|
||||
// 迭代顺序是**随机化**的 —— 同一批并行 tool_call 因此可能以任意顺序进入
|
||||
// resp.ToolCalls。对 output_send__ 这类**用户可见消息**通道,结果就是分段
|
||||
// 消息的到达顺序每次运行都可能不同(不可复现的外部行为)。
|
||||
//
|
||||
// 判据的可执行性说明:map 随机化不是「每进程一次」,而是每次遍历都可能不同;
|
||||
// 这里用「工具数 × 重复轮次」放大命中概率,让判据在修复前几乎必然失败、
|
||||
// 修复后必然通过,而不是偶尔抖一下。
|
||||
//
|
||||
// ❗判据只断言**代码真实产出的字段**(ID/Name,由投递顺序决定),
|
||||
// 不断言 StreamIndex —— flushToolCall 并不把 idx 写进 ToolCall
|
||||
// (见 process.go 的 tc := ToolCall{ID, Name, Arguments}),
|
||||
// 拿它当判据会得到一个恒真/恒假的假信号。
|
||||
func TestAccumulateStreamFlushesToolCallsInIndexOrder(t *testing.T) {
|
||||
const (
|
||||
tools = 8 // 单批并行工具数
|
||||
rounds = 200 // 重复轮次,放大 map 随机化命中概率
|
||||
)
|
||||
|
||||
for round := 0; round < rounds; round++ {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch := make(chan agentAPI.StreamChunk, 2*tools+4)
|
||||
// 按 index 升序投递:第 i 个工具声明 StreamIndex=i
|
||||
for i := 0; i < tools; i++ {
|
||||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{{
|
||||
ID: fmt.Sprintf("call_%02d", i),
|
||||
Type: "function",
|
||||
Name: fmt.Sprintf("tool_%02d", i),
|
||||
RawArguments: "{}",
|
||||
StreamIndex: i,
|
||||
}}}
|
||||
}
|
||||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "tool_calls"}
|
||||
close(ch)
|
||||
|
||||
resp, err := accumulateStream(ctx, ch, nil, "cli", 4096)
|
||||
cancel()
|
||||
if err != nil {
|
||||
t.Fatalf("round %d: accumulateStream: %v", round, err)
|
||||
}
|
||||
if len(resp.ToolCalls) != tools {
|
||||
t.Fatalf("round %d: got %d tool calls, want %d", round, len(resp.ToolCalls), tools)
|
||||
}
|
||||
// 判据:必须严格按投递(index)升序出现。
|
||||
for i, tc := range resp.ToolCalls {
|
||||
want := fmt.Sprintf("tool_%02d", i)
|
||||
if tc.Name != want {
|
||||
t.Fatalf("round %d: 第 %d 个 tool call 是 %q,期望 %q;实际顺序 %v —— "+
|
||||
"map 迭代随机化导致 flush 乱序", round, i, tc.Name, want, toolNameOrder(resp.ToolCalls))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolNameOrder 提取实际到达顺序,供失败信息定位。
|
||||
func toolNameOrder(tcs []agentAPI.ToolCall) []string {
|
||||
out := make([]string, len(tcs))
|
||||
for i, tc := range tcs {
|
||||
out[i] = tc.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
@ -101,9 +102,11 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
|
||||
if a.stageHost != nil {
|
||||
if result, err := a.stageHost.ExecuteTool(tc.Name, tc.Arguments); err == nil {
|
||||
return fmt.Sprintf("%v", result)
|
||||
} else if !strings.Contains(err.Error(), "not found in any plugin") {
|
||||
} else if !agentIO.IsToolNotFound(err) {
|
||||
// 非「不存在」= 真的执行失败,如实上报(可被 on_error/retry 处置)。
|
||||
return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
|
||||
}
|
||||
// 是「不存在」:继续往下走 io / 设备路径,两处都没有才报缺工具。
|
||||
}
|
||||
|
||||
// 设备类工具的**授权闸**(最小授权的缺口在这里)。
|
||||
@ -128,11 +131,25 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if agentIO.IsToolNotFound(err) {
|
||||
// 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。
|
||||
// 文案必须让模型知道该做什么,而不是含糊的"执行失败"——
|
||||
// 后者会让模型反复重试同一个不存在的名字。
|
||||
return fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+
|
||||
"先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+
|
||||
"确认名称无误后再调用", tc.Name)
|
||||
}
|
||||
return fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)
|
||||
}
|
||||
return fmt.Sprintf("%v", result)
|
||||
}
|
||||
|
||||
// toolNotFound / isToolNotFound 是 agentIO 哨兵在 core 侧的薄封装,
|
||||
// 便于 core 内部与测试直接使用(core 依赖 io,不反向)。
|
||||
func toolNotFound(name string) error { return agentIO.ToolNotFound(name) }
|
||||
|
||||
func isToolNotFound(err error) bool { return agentIO.IsToolNotFound(err) }
|
||||
|
||||
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string {
|
||||
g := a.graphMem()
|
||||
if g == nil {
|
||||
|
||||
72
internal/agent/core/toolcall_error_test.go
Normal file
72
internal/agent/core/toolcall_error_test.go
Normal file
@ -0,0 +1,72 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// 回归:工具「不存在」必须用**类型化哨兵**判别,不得依赖错误文案匹配。
|
||||
//
|
||||
// 现状(toolcall.go:104):
|
||||
//
|
||||
// if result, err := a.stageHost.ExecuteTool(...); err == nil { ... }
|
||||
// else if !strings.Contains(err.Error(), "not found in any plugin") { ... }
|
||||
//
|
||||
// 这是**约定**不是契约:插件的错误文案只要恰好含 "not found in any plugin"
|
||||
// 这个子串,就会被误判成「工具不存在」而错误地 fallback 到 io 路径。
|
||||
//
|
||||
// 动态注册(plgreload / 插件崩溃 / 卸载)让这条路径比静态场景更常走,
|
||||
// 因此判别必须精确。
|
||||
func TestToolNotFoundIsTypeableNotStringMatched(t *testing.T) {
|
||||
// ① 内核自己产生的「不存在」必须可被 errors.Is 判别
|
||||
err := toolNotFound("qq_get_message")
|
||||
if !errors.Is(err, agentIO.ErrToolNotFound) {
|
||||
t.Fatalf("内核的 not-found 错误必须包裹 ErrToolNotFound,实际: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "qq_get_message") {
|
||||
t.Errorf("错误文案应含工具名,实际: %v", err)
|
||||
}
|
||||
|
||||
// ② 插件自定义错误即使**恰好含** "not found in any plugin" 子串,
|
||||
// 也不得被判为「工具不存在」—— 这正是字符串匹配的缺陷。
|
||||
fake := fmt.Errorf("plugin internal: device not found in any plugin table (busy)")
|
||||
if isToolNotFound(fake) {
|
||||
t.Errorf("含诱饵子串的插件错误被误判为工具不存在: %v", fake)
|
||||
}
|
||||
|
||||
// ③ 包裹后仍能穿透 fmt.Errorf %w
|
||||
wrapped := fmt.Errorf("工具 %s 执行失败: %w", "x", toolNotFound("y"))
|
||||
if !errors.Is(wrapped, agentIO.ErrToolNotFound) {
|
||||
t.Errorf("%%w 包裹后应仍可判别,实际: %v", wrapped)
|
||||
}
|
||||
|
||||
// ④ 普通执行失败不得被判为 not found
|
||||
if isToolNotFound(errors.New("connection refused")) {
|
||||
t.Errorf("普通执行失败被误判为工具不存在")
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:执行期遇到「工具不存在」时,必须**如实报告**而不是静默 fallback。
|
||||
//
|
||||
// 场景:stageHost 抛 not-found,io 也没有 ⇒ 最终错误必须仍是 not-found,
|
||||
// 且文案要让模型看出是"工具不存在/插件可能没加载",而不是含糊的"执行失败"。
|
||||
func TestExecuteToolCallReportsMissingToolClearly(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
got := a.executeToolCall(agentAPI.ToolCall{
|
||||
ID: "c1", Name: "definitely_no_such_tool", Arguments: map[string]interface{}{},
|
||||
}, "cli")
|
||||
|
||||
if !strings.Contains(got, "definitely_no_such_tool") {
|
||||
t.Fatalf("错误文案应含工具名,实际: %s", got)
|
||||
}
|
||||
// 模型需要能据此判断该做什么(换名字 / 查 get_plugin_tools / 加载插件)
|
||||
if !strings.Contains(got, "不存在") && !strings.Contains(got, "未注册") && !strings.Contains(got, "not found") {
|
||||
t.Errorf("错误文案应明示『不存在/未注册』,实际: %s", got)
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime/debug"
|
||||
@ -10,6 +11,24 @@ import (
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// ErrToolNotFound 表示「工具不存在」(未注册 / 所属插件已卸载或崩溃)。
|
||||
//
|
||||
// 存在的理由:工具是**动态注册**的(buildToolDefs 每轮重建、plgreload 即时生效、
|
||||
// 插件崩溃后被摘除),因此「不存在」是运行期常态而非异常。
|
||||
// 判别它必须**类型化**:此前 core 靠 strings.Contains(err, "not found in any plugin")
|
||||
// 匹配错误文案,而插件的错误文案只要恰好含该子串就会被误判为「工具不存在」
|
||||
// 并错误 fallback。errors.Is 才能精确区分「不存在」与「执行失败」——
|
||||
// 二者对 on_error 的处置完全不同(前者工具没了,后者可 retry)。
|
||||
var ErrToolNotFound = errors.New("工具不存在或未注册")
|
||||
|
||||
// ToolNotFound 返回一个包裹 ErrToolNotFound 的错误,带上工具名。
|
||||
func ToolNotFound(name string) error {
|
||||
return fmt.Errorf("tool %s: %w", name, ErrToolNotFound)
|
||||
}
|
||||
|
||||
// IsToolNotFound 报告 err 是否为「工具不存在」。
|
||||
func IsToolNotFound(err error) bool { return errors.Is(err, ErrToolNotFound) }
|
||||
|
||||
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||
type ChannelDef = pubsdk.ChannelDef
|
||||
|
||||
@ -651,15 +670,23 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i
|
||||
|
||||
if len(candidates) == 0 {
|
||||
// 自己没这个设备工具 → 看上级(驻留子的设备工具都在父的 io 上)。
|
||||
//
|
||||
// ⚠️ 父的**执行失败**不得被吞成「工具不存在」:那会让 on_error 的
|
||||
// retry 失效(本该重试的失败被判为工具没了,整组被跳过)。
|
||||
// 因此只把父的「确实不存在」继续向上传递,其余错误如实上抛。
|
||||
m.mu.RLock()
|
||||
parent := m.parent
|
||||
m.mu.RUnlock()
|
||||
if parent != nil {
|
||||
if ret, err := parent.ExecuteTool(name, args); err == nil {
|
||||
ret, err := parent.ExecuteTool(name, args)
|
||||
if err == nil {
|
||||
return ret, nil
|
||||
}
|
||||
if !IsToolNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("tool %s not found", name)
|
||||
return nil, ToolNotFound(name)
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
||||
70
internal/agent/io/channel_error_test.go
Normal file
70
internal/agent/io/channel_error_test.go
Normal file
@ -0,0 +1,70 @@
|
||||
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{} }
|
||||
Reference in New Issue
Block a user