mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 09:32:42 +00:00
feat(agents): configurable default runTimeoutSeconds for subagent spawns
When sessions_spawn is called without runTimeoutSeconds, subagents previously defaulted to 0 (no timeout). This adds a config key at agents.defaults.subagents.runTimeoutSeconds so operators can set a global default timeout for all subagent runs. The agent-provided value still takes precedence when explicitly passed. When neither the agent nor the config specifies a timeout, behavior is unchanged (0 = no timeout), preserving backwards compatibility. Updated for the subagent-spawn.ts refactor (logic moved from sessions-spawn-tool.ts to spawnSubagentDirect). Closes #19288 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Peter Steinberger
parent
803e02d8df
commit
5710d72527
@@ -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<string, unknown> };
|
||||||
|
|
||||||
|
async function getGatewayCalls(): Promise<GatewayCall[]> {
|
||||||
|
const { callGateway } = await import("../gateway/call.js");
|
||||||
|
return (callGateway as unknown as ReturnType<typeof vi.fn>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> };
|
||||||
|
|
||||||
|
async function getGatewayCalls(): Promise<GatewayCall[]> {
|
||||||
|
const { callGateway } = await import("../gateway/call.js");
|
||||||
|
return (callGateway as unknown as ReturnType<typeof vi.fn>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,14 +193,22 @@ export async function spawnSubagentDirect(
|
|||||||
threadId: ctx.agentThreadId,
|
threadId: ctx.agentThreadId,
|
||||||
});
|
});
|
||||||
const hookRunner = getGlobalHookRunner();
|
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 =
|
const runTimeoutSeconds =
|
||||||
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
|
typeof params.runTimeoutSeconds === "number" && Number.isFinite(params.runTimeoutSeconds)
|
||||||
? Math.max(0, Math.floor(params.runTimeoutSeconds))
|
? Math.max(0, Math.floor(params.runTimeoutSeconds))
|
||||||
: 0;
|
: cfgSubagentTimeout;
|
||||||
let modelApplied = false;
|
let modelApplied = false;
|
||||||
let threadBindingReady = false;
|
let threadBindingReady = false;
|
||||||
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
const { mainKey, alias } = resolveMainSessionAlias(cfg);
|
||||||
const requesterSessionKey = ctx.agentSessionKey;
|
const requesterSessionKey = ctx.agentSessionKey;
|
||||||
const requesterInternalKey = requesterSessionKey
|
const requesterInternalKey = requesterSessionKey
|
||||||
|
|||||||
@@ -247,6 +247,8 @@ export type AgentDefaultsConfig = {
|
|||||||
model?: AgentModelConfig;
|
model?: AgentModelConfig;
|
||||||
/** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */
|
/** Default thinking level for spawned sub-agents (e.g. "off", "low", "medium", "high"). */
|
||||||
thinking?: string;
|
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). */
|
/** Gateway timeout in ms for sub-agent announce delivery calls (default: 60000). */
|
||||||
announceTimeoutMs?: number;
|
announceTimeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const AgentDefaultsSchema = z
|
|||||||
archiveAfterMinutes: z.number().int().positive().optional(),
|
archiveAfterMinutes: z.number().int().positive().optional(),
|
||||||
model: AgentModelSchema.optional(),
|
model: AgentModelSchema.optional(),
|
||||||
thinking: z.string().optional(),
|
thinking: z.string().optional(),
|
||||||
|
runTimeoutSeconds: z.number().min(0).optional(),
|
||||||
announceTimeoutMs: z.number().int().positive().optional(),
|
announceTimeoutMs: z.number().int().positive().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|||||||
Reference in New Issue
Block a user