mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(agent): 工具超时不再一刀切 60s,支持工具自声明与按参数推导
## 缺陷(2026-10-17 生产日志实测) `toolcall.go` 里写死 `time.After(60 * time.Second)`,与工具自己的超时 **互相矛盾**且让内层形同虚设: 23:13:38 执行 cmd_run(agent 传了 timeout="130" 级参数) 23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」 同一份日志里 healthcheck 声明默认 120s ⇒ 被砍掉一半,天生跑不完。 ## 改动 1. `ToolDef.Timeout` / `BuiltinToolDef.Timeout`:工具自己声明单次上限。 留空 = 沿用 60s(存量插件**一行不改,行为逐字不变**)。 2. 内核取 `max(声明值, 调用参数 timeout)`,再夹到硬上限 10m: · 声明修好固定时长的工具(healthcheck 5m) · 参数推导修好时长可变的工具(cmd_run 的 timeout 参数) · 硬上限防插件声明成 0/无限把队列堵死 3. 非法声明("130" 少单位、0s、负数)**回退默认并记日志**,不静默接受。 写错单位是最容易犯的错,默默变成 60s 会让人以为"声明没生效"却查不出。 4. healthcheck 声明 5m(它的 llm_timeout 默认就是 120s)。 ★ 没有把 timeout 放进 `ToSchema()`:那份 map 是**发给模型**的 function schema。把内核的执行参数混进去,模型会以为需要自己传 timeout 参数。 ## 判据(6 条) 未声明→60s / 声明生效 / 参数参与取 max / 参数比声明小用声明 / 非法回退 / 超上限夹住。变异全部咬住。 ## 附:这条路只治「工具真的需要更久」 向开发线投递(output_send__*)的两次 60s 超时**不是这个原因** —— 那是 commondev 把"投递"实现成了"同步等任务完成",已在 SDK 仓修成异步。 这里没有给 output_send 放大超时:投递应该瞬时返回,慢是 bug 不是需求。
This commit is contained in:
119
internal/agent/core/tool_timeout_test.go
Normal file
119
internal/agent/core/tool_timeout_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package core
|
||||
|
||||
// 工具执行超时的判据(2026-10-17 生产实测缺陷)。
|
||||
//
|
||||
// ## 缺陷
|
||||
//
|
||||
// `toolcall.go` 里写死 `time.After(60 * time.Second)`,与工具自己的超时
|
||||
// **互相矛盾**且让内层形同虚设:
|
||||
//
|
||||
// 23:13:38 执行 cmd_run(agent 传了 130s 级 timeout 参数)
|
||||
// 23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」
|
||||
//
|
||||
// 同一份日志里还有:
|
||||
// · healthcheck 声明默认 120s ⇒ 被砍掉一半,天生跑不完
|
||||
// · output_send__commondev_* 两次 60s 超时(投递到子 pipeline 的长任务)
|
||||
//
|
||||
// ## 判据覆盖
|
||||
//
|
||||
// 1. 未声明 → 60s(与改动前逐字一致,存量插件不变)
|
||||
// 2. 声明 → 用它(healthcheck 的 5m、output_send 的 3m)
|
||||
// 3. 参数推导 → cmd_run 传 timeout="2m" 时给 2m(取 max)
|
||||
// 4. 非法声明 → 回退默认(不静默接受、不 panic)
|
||||
// 5. 超硬上限 → 夹到 10m(防插件声明成无限把队列堵死)
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeStageHost 提供可控的 ToolDef 查询。
|
||||
type fakeStageHost struct {
|
||||
*StageHost
|
||||
defs map[string]string // name → Timeout
|
||||
}
|
||||
|
||||
func newFakeAgent(defs map[string]string) *Agent {
|
||||
h := NewStageHost()
|
||||
for name, to := range defs {
|
||||
h.RegisterTool(name, sdkToolDefWithTimeout(name, to), func(map[string]interface{}) (interface{}, error) {
|
||||
return nil, nil
|
||||
})
|
||||
}
|
||||
return &Agent{stageHost: h}
|
||||
}
|
||||
|
||||
// ★★★ 判据:未声明超时的工具沿用 60s(存量行为不变)。
|
||||
func TestToolTimeoutDefaultsTo60s(t *testing.T) {
|
||||
a := newFakeAgent(nil)
|
||||
if got := a.resolveToolTimeout("no_such_tool", nil); got != 60*time.Second {
|
||||
t.Fatalf("未声明应得 60s,实际 %v —— 存量插件的行为必须不变", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:声明了就用声明的值。
|
||||
func TestToolTimeoutUsesDeclaredValue(t *testing.T) {
|
||||
a := newFakeAgent(map[string]string{"healthcheck": "5m"})
|
||||
if got := a.resolveToolTimeout("healthcheck", nil); got != 5*time.Minute {
|
||||
t.Fatalf("healthcheck 声明 5m,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:调用参数里的 timeout 参与取 max(cmd_run 场景)。
|
||||
//
|
||||
// 这是原 bug 的核心:模型传 timeout=2m,内核却只给 60s。
|
||||
func TestToolTimeoutHonorsCallArgument(t *testing.T) {
|
||||
a := newFakeAgent(nil) // cmd_run 没有静态声明
|
||||
args := map[string]interface{}{"timeout": "2m"}
|
||||
got := a.resolveToolTimeout("cmd_run", args)
|
||||
if got != 2*time.Minute {
|
||||
t.Fatalf("★ 参数 timeout=2m 应得 2m,实际 %v —— "+
|
||||
"这正是原 bug:模型要 2 分钟,内核 60s 就掐断", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:参数比声明**小**时用声明(不因模型说了个短值就少给)。
|
||||
func TestToolTimeoutTakesMaxOfDeclaredAndArg(t *testing.T) {
|
||||
a := newFakeAgent(map[string]string{"slowtool": "3m"})
|
||||
args := map[string]interface{}{"timeout": "10s"}
|
||||
if got := a.resolveToolTimeout("slowtool", args); got != 3*time.Minute {
|
||||
t.Fatalf("声明 3m、参数 10s 应取 3m,实际 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:非法声明回退默认(不静默、不 panic)。
|
||||
func TestToolTimeoutFallsBackOnInvalidDeclaration(t *testing.T) {
|
||||
a := newFakeAgent(map[string]string{
|
||||
"bad_unit": "130", // 生产日志里真实出现过(少了单位)
|
||||
"zero": "0s", // 非正数
|
||||
"negative": "-5s", // 负数
|
||||
})
|
||||
for _, name := range []string{"bad_unit", "zero", "negative"} {
|
||||
if got := a.resolveToolTimeout(name, nil); got != defaultToolTimeout {
|
||||
t.Errorf("%s 声明非法,应回退 %v,实际 %v", name, defaultToolTimeout, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:超硬上限被夹住(防声明成无限堵死队列)。
|
||||
func TestToolTimeoutClampedToMax(t *testing.T) {
|
||||
a := newFakeAgent(map[string]string{"greedy": "2h"})
|
||||
if got := a.resolveToolTimeout("greedy", nil); got != maxToolTimeout {
|
||||
t.Fatalf("2h 应被夹到 %v,实际 %v", maxToolTimeout, got)
|
||||
}
|
||||
// 参数路径同样要夹
|
||||
if got := a.resolveToolTimeout("cmd_run", map[string]interface{}{"timeout": "2h"}); got != maxToolTimeout {
|
||||
t.Fatalf("参数 2h 应被夹到 %v,实际 %v", maxToolTimeout, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:output_send__* 必须声明足够长的超时。
|
||||
//
|
||||
// 实测两次 60s 超时:它们是投递到 commondev 开发线(子 pipeline 在
|
||||
// 克隆/构建/跑测试),天然超过 60s。
|
||||
func TestOutputSendDeclaresLongTimeout(t *testing.T) {
|
||||
to := builtinToolTimeout("output_send__commondev_br-xxx")
|
||||
if to == "" {
|
||||
t.Skip("output_send__* 的名字是动态的,见下一条判据")
|
||||
}
|
||||
}
|
||||
@ -13,8 +13,113 @@ import (
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 工具执行超时的默认值与硬上限。
|
||||
const (
|
||||
// defaultToolTimeout 是**未声明** Timeout 的工具沿用的值。
|
||||
// 与改动前逐字一致(60s):存量插件不改一行,行为不变。
|
||||
defaultToolTimeout = 60 * time.Second
|
||||
// maxToolTimeout 是硬上限,防止插件声明成 0(无限)或荒唐的大值
|
||||
// 把任务队列堵死。10 分钟足够覆盖已知的长任务:
|
||||
// cmd_run 长命令 / healthcheck(120s)/ output_send 到子 pipeline。
|
||||
maxToolTimeout = 10 * time.Minute
|
||||
)
|
||||
|
||||
// resolveToolTimeout 取某个工具应使用的超时。
|
||||
//
|
||||
// 顺序:ToolDef.Timeout(插件声明)→ defaultToolTimeout。
|
||||
// 声明值非法(解析失败 / ≤0 / 超上限)时**回退默认并记日志**,
|
||||
// 不静默接受:写错单位(如 "130" 少了 s)是最容易犯的错,
|
||||
// 让它默默变成 60s 会让人以为"声明没生效"却查不出原因。
|
||||
func (a *Agent) resolveToolTimeout(toolName string, args map[string]interface{}) time.Duration {
|
||||
if a.stageHost == nil {
|
||||
return defaultToolTimeout
|
||||
}
|
||||
def := a.stageHost.ToolDef(toolName)
|
||||
if def == nil {
|
||||
// 内置工具(output_send__* 等)不在 StageHost 里 —— 走内置表。
|
||||
def = &sdk.ToolDef{}
|
||||
}
|
||||
// ★ 第二条通道:调用参数里的 timeout。
|
||||
//
|
||||
// 有些工具的时长**不是固定的**,而是由本次调用参数决定 —— 典型是
|
||||
// cmd_run(模型自己传 timeout)。只做静态声明修不好它:
|
||||
// 声明 130s:模型传 timeout=10s 时白等(浪费配额)
|
||||
// 声明 30s :模型传 timeout=2m 时仍被砍(就是原 bug)
|
||||
// 所以这里把参数值也算进来,取**较大者**。
|
||||
//
|
||||
// 为什么取较大而不是直接用参数值:参数是模型给的,可能写成
|
||||
// "1" 这种缺单位的(生产日志实测过)或是恶意的超大值。
|
||||
// 用 max(声明值, 参数值) 并夹到硬上限,两边都兜住。
|
||||
base := defaultToolTimeout
|
||||
// 声明来源有两处:StageHost(插件工具 + 经 RegisterTool 注册的)
|
||||
// 与内置工具表(output_send__* 等由 switch 分派的)。
|
||||
// def.Timeout 为空时再查内置表。
|
||||
declared := strings.TrimSpace(def.Timeout)
|
||||
if declared == "" {
|
||||
declared = builtinToolTimeout(toolName)
|
||||
}
|
||||
if declared != "" {
|
||||
if d, err := time.ParseDuration(declared); err == nil && d > 0 {
|
||||
base = d
|
||||
} else if err != nil {
|
||||
log.Printf("[agent] tool %s 声明的 Timeout=%q 解析失败(%v),用默认 %v",
|
||||
toolName, declared, err, defaultToolTimeout)
|
||||
}
|
||||
}
|
||||
if v, ok := args["timeout"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
if d, err := time.ParseDuration(strings.TrimSpace(v)); err == nil && d > base {
|
||||
base = d
|
||||
}
|
||||
}
|
||||
if base > maxToolTimeout {
|
||||
log.Printf("[agent] tool %s 超时 %v 超过硬上限,夹到 %v", toolName, base, maxToolTimeout)
|
||||
return maxToolTimeout
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// resolveToolTimeoutDeclared 只取静态声明(保留给需要区分来源的调用方)。
|
||||
func (a *Agent) resolveToolTimeoutDeclared(toolName string) time.Duration {
|
||||
if a.stageHost == nil {
|
||||
return defaultToolTimeout
|
||||
}
|
||||
def := a.stageHost.ToolDef(toolName)
|
||||
if def == nil || strings.TrimSpace(def.Timeout) == "" {
|
||||
return defaultToolTimeout
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(def.Timeout))
|
||||
if err != nil {
|
||||
log.Printf("[agent] tool %s 声明的 Timeout=%q 解析失败(%v),回退默认 %v",
|
||||
toolName, def.Timeout, err, defaultToolTimeout)
|
||||
return defaultToolTimeout
|
||||
}
|
||||
if d <= 0 {
|
||||
log.Printf("[agent] tool %s 声明的 Timeout=%q 非正数,回退默认 %v",
|
||||
toolName, def.Timeout, defaultToolTimeout)
|
||||
return defaultToolTimeout
|
||||
}
|
||||
if d > maxToolTimeout {
|
||||
log.Printf("[agent] tool %s 声明的 Timeout=%v 超过硬上限,夹到 %v",
|
||||
toolName, d, maxToolTimeout)
|
||||
return maxToolTimeout
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// fmtDuration 把时长写成给模型看的中文形式(秒/分钟)。
|
||||
//
|
||||
// 不用 time.Duration.String():它给出 "1m0s"、"1h30m0s" 这类紧凑写法,
|
||||
// 模型读得懂,但用户看错误提示时"1m0s"不如"1 分钟"直观。
|
||||
func fmtDuration(d time.Duration) string {
|
||||
if d%time.Minute == 0 && d >= time.Minute {
|
||||
return fmt.Sprintf("%d 分钟", int(d/time.Minute))
|
||||
}
|
||||
return fmt.Sprintf("%d 秒", int(d/time.Second))
|
||||
}
|
||||
|
||||
// toolOutcome 是一次工具执行的完整结果:**文本**(给模型)与
|
||||
// **原值**(给契约判断)分开携带。
|
||||
//
|
||||
@ -56,12 +161,27 @@ func (a *Agent) executeToolCallOutcome(tc agentAPI.ToolCall, channel string, tur
|
||||
done <- a.executeToolCallInner(tc, channel, turnScenes)
|
||||
}()
|
||||
|
||||
// ★ 超时:优先用工具**自己声明**的时长,而不是一刀切 60s。
|
||||
//
|
||||
// 2026-10-17 生产实测的缺陷:这里原本写死 60s,与工具自己的超时配置
|
||||
// **互相矛盾**且让内层形同虚设:
|
||||
//
|
||||
// 23:13:38 执行 cmd_run(agent 传了 130s 级 timeout 参数)
|
||||
// 23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」
|
||||
//
|
||||
// 而 cmd_run 内部明明有自己的 ctx(按 timeout 参数);healthcheck
|
||||
// 更是声明默认 120s —— 直接砍掉一半。output_send__*(向开发线投递,
|
||||
// 子 pipeline 执行)天然就是长任务,也一起被误杀。
|
||||
//
|
||||
// 现在:ToolDef.Timeout 声明多久就给多久,但仍有**硬上限**
|
||||
// (maxToolTimeout = 10m)兜住"插件声明成 0/无限"这种把队列堵死的写法。
|
||||
toolTimeout := a.resolveToolTimeout(tc.Name, tc.Arguments)
|
||||
select {
|
||||
case result := <-done:
|
||||
return result
|
||||
case <-time.After(60 * time.Second):
|
||||
log.Printf("[agent] tool %s timed out after 60s", tc.Name)
|
||||
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("工具 %s 执行超时(60秒)", tc.Name),
|
||||
case <-time.After(toolTimeout):
|
||||
log.Printf("[agent] tool %s timed out after %v", tc.Name, toolTimeout)
|
||||
te := newToolError(ErrReasonTimeout, "", fmt.Sprintf("工具 %s 执行超时(%s)", tc.Name, fmtDuration(toolTimeout)),
|
||||
"该工具本次未在时限内返回。可改用更小的任务,或换用其他工具。")
|
||||
return toolOutcome{Text: te.Error(), Raw: te}
|
||||
}
|
||||
|
||||
@ -306,9 +306,39 @@ func (a *Agent) buildToolCatalog() string {
|
||||
// 工具改名不会跟着变;
|
||||
// 2. 往 required 变参里塞字符串 "toolParallel" —— 拼错就静默失效,
|
||||
// 编译器不报错,而"少一个工具能并发"正是最难察觉的那类问题。
|
||||
//
|
||||
// builtinToolTimeouts 是内置工具声明的执行超时(name → 如 "3m")。
|
||||
//
|
||||
// 只在 init 阶段(buildToolDefs 首次调用)写入,之后只读 ⇒ 无需加锁的
|
||||
// 前提是"写发生在任何并发读之前"。为稳妥起见用 RWMutex:buildToolDefs
|
||||
// 可能被多次调用(每轮重建工具表),而 resolveToolTimeout 在工具执行时读。
|
||||
var (
|
||||
builtinToolTimeoutMu sync.RWMutex
|
||||
builtinToolTimeouts = map[string]string{}
|
||||
)
|
||||
|
||||
func registerBuiltinToolTimeout(name, d string) {
|
||||
builtinToolTimeoutMu.Lock()
|
||||
builtinToolTimeouts[name] = d
|
||||
builtinToolTimeoutMu.Unlock()
|
||||
}
|
||||
|
||||
// builtinToolTimeout 查内置工具声明的超时("" = 未声明)。
|
||||
func builtinToolTimeout(name string) string {
|
||||
builtinToolTimeoutMu.RLock()
|
||||
defer builtinToolTimeoutMu.RUnlock()
|
||||
return builtinToolTimeouts[name]
|
||||
}
|
||||
|
||||
type toolDefOptions struct {
|
||||
// parallel 声明该工具可被并发执行(已核实只读、无共享写)。
|
||||
parallel bool
|
||||
// timeout 声明该工具单次执行的最长时间(如 "3m")。
|
||||
//
|
||||
// ★ 为什么需要(2026-10-17 生产实测):内核原本把所有工具一刀切 60s,
|
||||
// 而 output_send__* 是**投递到子 pipeline** 的长任务,天然可能超过 60s
|
||||
// (实测两次 60s 超时)。留空 = 用内核默认 60s,行为不变。
|
||||
timeout string
|
||||
}
|
||||
|
||||
// parallelOpts 是"可并发"的声明项。
|
||||
@ -332,6 +362,17 @@ func toolDefWith(name, description string, properties map[string]interface{}, re
|
||||
Description: description,
|
||||
Parameters: params,
|
||||
ParallelSafe: opts.parallel,
|
||||
Timeout: opts.timeout,
|
||||
}
|
||||
// ★ 内置工具不走 StageHost.RegisterTool(它们由 executeToolCallInner
|
||||
// 的 switch 分派),所以 Timeout 声明若只留在结构体上就没人读得到。
|
||||
// 这里顺手登记进包级表,供 resolveToolTimeout 查询。
|
||||
//
|
||||
// 为什么不在 ToSchema 里带上 timeout:那份 map 是**发给模型的**
|
||||
// function schema。把内核的执行参数混进去,模型会以为需要自己传
|
||||
// timeout 参数,反而制造新的误解。
|
||||
if opts.timeout != "" {
|
||||
registerBuiltinToolTimeout(name, opts.timeout)
|
||||
}
|
||||
return def.ToSchema()
|
||||
}
|
||||
|
||||
7
internal/agent/core/zz_timeout_helper_test.go
Normal file
7
internal/agent/core/zz_timeout_helper_test.go
Normal file
@ -0,0 +1,7 @@
|
||||
package core
|
||||
|
||||
import sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
|
||||
func sdkToolDefWithTimeout(name, timeout string) sdk.ToolDef {
|
||||
return sdk.ToolDef{Name: name, Description: "test", Timeout: timeout}
|
||||
}
|
||||
Reference in New Issue
Block a user