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:
Mitch McAlister
2026-02-23 17:25:08 +00:00
committed by Peter Steinberger
parent 803e02d8df
commit 5710d72527
5 changed files with 162 additions and 3 deletions

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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