diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts new file mode 100644 index 00000000000..947c83333fd --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout-absent.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + maxConcurrent: 8, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-456" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds (config absent)", () => { + it("falls back to 0 (no timeout) when config key is absent", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(0); + }); +}); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts new file mode 100644 index 00000000000..8186b8bde95 --- /dev/null +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-default-timeout.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; + +vi.mock("../config/config.js", async () => { + const actual = await vi.importActual("../config/config.js"); + return { + ...actual, + loadConfig: () => ({ + agents: { + defaults: { + subagents: { + runTimeoutSeconds: 900, + }, + }, + }, + routing: { + sessions: { + mainKey: "agent:test:main", + }, + }, + }), + }; +}); + +vi.mock("../gateway/call.js", () => { + return { + callGateway: vi.fn(async ({ method }: { method: string }) => { + if (method === "agent") { + return { runId: "run-123" }; + } + return {}; + }), + }; +}); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => null, +})); + +type GatewayCall = { method: string; params?: Record }; + +async function getGatewayCalls(): Promise { + const { callGateway } = await import("../gateway/call.js"); + return (callGateway as unknown as ReturnType).mock.calls.map( + (call) => call[0] as GatewayCall, + ); +} + +function findLastCall(calls: GatewayCall[], predicate: (call: GatewayCall) => boolean) { + for (let i = calls.length - 1; i >= 0; i -= 1) { + const call = calls[i]; + if (call && predicate(call)) { + return call; + } + } + return undefined; +} + +describe("sessions_spawn default runTimeoutSeconds", () => { + it("uses config default when agent omits runTimeoutSeconds", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-1", { task: "hello" }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(900); + }); + + it("explicit runTimeoutSeconds wins over config default", async () => { + const tool = createSessionsSpawnTool({ agentSessionKey: "agent:test:main" }); + const result = await tool.execute("call-2", { task: "hello", runTimeoutSeconds: 300 }); + expect(result.details).toMatchObject({ status: "accepted" }); + + const calls = await getGatewayCalls(); + const agentCall = findLastCall(calls, (call) => call.method === "agent"); + expect(agentCall?.params?.timeout).toBe(300); + }); +}); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index d033c78bc3e..7d4f672f2f1 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -193,14 +193,22 @@ export async function spawnSubagentDirect( threadId: ctx.agentThreadId, }); const hookRunner = getGlobalHookRunner(); + const cfg = loadConfig(); + + // When agent omits runTimeoutSeconds, use the config default. + // Falls back to 0 (no timeout) if config key is also unset, + // preserving current behavior for existing deployments. + const cfgSubagentTimeout = + typeof cfg?.agents?.defaults?.subagents?.runTimeoutSeconds === "number" && + Number.isFinite(cfg.agents.defaults.subagents.runTimeoutSeconds) + ? Math.max(0, Math.floor(cfg.agents.defaults.subagents.runTimeoutSeconds)) + : 0; const runTimeoutSeconds = typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds) ? Math.max(0, Math.floor(params.runTimeoutSeconds)) - : 0; + : cfgSubagentTimeout; let modelApplied = false; let threadBindingReady = false; - - const cfg = loadConfig(); const { mainKey, alias } = resolveMainSessionAlias(cfg); const requesterSessionKey = ctx.agentSessionKey; const requesterInternalKey = requesterSessionKey diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 7ecfc6d4193..e8eac685086 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -247,6 +247,8 @@ export type AgentDefaultsConfig = { model?: AgentModelConfig; /** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */ thinking?: string; + /** Default run timeout in seconds for spawned sub-agents (0 = no timeout). */ + runTimeoutSeconds?: number; /** Gateway timeout in ms for sub-agent announce delivery calls (default: 60000). */ announceTimeoutMs?: number; }; diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index a4fb3c2443b..6f80698f079 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -146,6 +146,7 @@ export const AgentDefaultsSchema = z archiveAfterMinutes: z.number().int().positive().optional(), model: AgentModelSchema.optional(), thinking: z.string().optional(), + runTimeoutSeconds: z.number().min(0).optional(), announceTimeoutMs: z.number().int().positive().optional(), }) .strict()