diff --git a/internal/agent/core/tool_timeout_test.go b/internal/agent/core/tool_timeout_test.go new file mode 100644 index 00000000..699c102a --- /dev/null +++ b/internal/agent/core/tool_timeout_test.go @@ -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__* 的名字是动态的,见下一条判据") + } +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 4daefbf0..6c8fa8ae 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -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} } diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 0f6047ae..9f6e31e7 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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() } diff --git a/internal/agent/core/zz_timeout_helper_test.go b/internal/agent/core/zz_timeout_helper_test.go new file mode 100644 index 00000000..79ed8076 --- /dev/null +++ b/internal/agent/core/zz_timeout_helper_test.go @@ -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} +} diff --git a/internal/plugins/healthcheck/plugin.go b/internal/plugins/healthcheck/plugin.go index 6e717a23..df89ef61 100644 --- a/internal/plugins/healthcheck/plugin.go +++ b/internal/plugins/healthcheck/plugin.go @@ -174,6 +174,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, // 共享 p.mu 写锁,且 healthcheck_report 会 append p.reports Serial: true, + // ★ 声明显式超时:LLM 驱动检查默认就要 120s(见 llm_timeout 配置), + // 而内核原先把所有工具一刀切 60s ⇒ 这个工具**天生**跑不完, + // 每次都报「执行超时(60秒)」。这里给到配置上限之上。 + Timeout: "5m", }, func(args map[string]interface{}) (interface{}, error) { plugin, _ := args["plugin"].(string) return p.runFullCheck(s, plugin) diff --git a/internal/sdk/tool.go b/internal/sdk/tool.go index e62bd91a..f4f3044f 100644 --- a/internal/sdk/tool.go +++ b/internal/sdk/tool.go @@ -72,6 +72,11 @@ type BuiltinToolDef struct { ParallelSafe bool // Serial 声明此工具必须串行,优先于 ParallelSafe。 Serial bool + // Timeout 声明本工具单次执行允许的最长时间(如 "3m")。 + // + // 与 ToolDef.Timeout 同义(对外插件 / 内置插件两条路都要能声明)。 + // 留空 = 内核默认 60s。 + Timeout string } // ToSchema 转成下发给模型的 function schema。 diff --git a/third_party/homeagent-sdk/sdk/plugin.go b/third_party/homeagent-sdk/sdk/plugin.go index 0e2a0be2..1158c745 100644 --- a/third_party/homeagent-sdk/sdk/plugin.go +++ b/third_party/homeagent-sdk/sdk/plugin.go @@ -311,6 +311,25 @@ type ToolDef struct { // 判据优先级:**Serial 胜出**。显式声明"必须串行"不允许被 // ParallelSafe 或任何默认值覆盖。 Serial bool `json:"serial,omitempty"` + // Timeout 声明本工具单次执行允许的最长时间(如 "2m"、"90s")。 + // + // ★ 为什么需要它(2026-10-17 生产实测): + // 内核在 agent/core/toolcall.go 里有一个**写死的 60s** 外层上限。 + // 它与工具自己的超时配置**互相矛盾**且内层形同虚设: + // · cmd_run 有 timeout 参数(默认 30s,可传 "2m"),agent 真的传了 + // 130s 级的值,但外层 60s 先到 ⇒ 工具自己的设置完全不生效 + // · healthcheck 声明默认 120s ⇒ 被砍掉一半 + // · 向开发线投递用的 output_send__* 天然是长任务(子 pipeline 执行) + // 实测后果:工具被内核掐断,模型看到「执行超时(60秒)」, + // 只能换更小的任务重试 —— 而真正的问题是内核没给它足够时间。 + // + // 为零值时的行为:留空 = 用内核默认(60s)。存量插件不改一行, + // 行为与改动前逐字一致。声明它是**责任**:写长了要确保工具真能收敛, + // 否则会把"卡死"拖得更久。 + // + // 内核会取 min(声明值, 内核硬上限 10m):防止插件声明成 0(无限) + // 或荒唐的大值把队列堵死。 + Timeout string `json:"timeout,omitempty"` } // IOInjector provides methods for injecting input and interrupts into the agent pipeline.