feat(tools): support per-tool timeout override for council runs

This commit is contained in:
William Valentin
2026-02-23 00:01:27 -08:00
parent 80ce8d9aaf
commit 056b8ce515
6 changed files with 56 additions and 3 deletions
+9
View File
@@ -55,6 +55,15 @@ const config = {
} as const;
describe('council.run tool', () => {
it('sets extended tool timeout for council orchestration', () => {
const tool = createCouncilRunTool({
registry: createRegistry(),
orchestrator: { delegate: vi.fn() as any },
config: config as any,
});
expect(tool.timeoutMs).toBe(180000);
});
it('runs council pipeline and returns output summary', async () => {
const previousDataDir = process.env.FLYNN_DATA_DIR;
const testDataDir = mkdtempSync(join(tmpdir(), 'flynn-council-run-'));
+3
View File
@@ -8,6 +8,8 @@ import { CouncilsOrchestrator, type CouncilsConfig } from '../../councils/orches
import type { CouncilScaffold } from '../../councils/scaffold.js';
import { councilRunInputSchema } from '../../councils/types.js';
const COUNCIL_TOOL_TIMEOUT_MS = 180_000;
interface DelegateRunner {
delegate(request: {
tier: 'fast' | 'default' | 'complex' | 'local';
@@ -152,6 +154,7 @@ export function createCouncilRunTool(deps: CouncilRunDeps): Tool {
name: 'council.run',
description:
'Run the deterministic dual-council pipeline (D/P groups with bridge-only exchange and meta merge).',
timeoutMs: COUNCIL_TOOL_TIMEOUT_MS,
inputSchema: {
type: 'object',
properties: {
+22
View File
@@ -21,6 +21,17 @@ const slowTool: Tool = {
},
};
const slowToolWithCustomTimeout: Tool = {
name: 'test.slow_custom_timeout',
description: 'Takes forever with tool-level timeout override',
timeoutMs: 120,
inputSchema: { type: 'object', properties: {} },
execute: async () => {
await new Promise(r => setTimeout(r, 5000));
return { success: true, output: 'done' };
},
};
const failTool: Tool = {
name: 'test.fail',
description: 'Throws',
@@ -160,6 +171,17 @@ describe('ToolExecutor', () => {
expect(result.error).toContain('timed out');
});
it('honors per-tool timeout override', async () => {
const registry = new ToolRegistry();
registry.register(slowToolWithCustomTimeout);
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
const executor = new ToolExecutor(registry, hooks, { defaultTimeoutMs: 30 });
const result = await executor.execute('test.slow_custom_timeout', {});
expect(result.success).toBe(false);
expect(result.error).toContain('timed out after 120ms');
});
it('aborts cancellable tool work on timeout', async () => {
const registry = new ToolRegistry();
registry.register(cancellableTool);
+3 -2
View File
@@ -288,6 +288,7 @@ export class ToolExecutor {
});
let timeoutHandle: NodeJS.Timeout | undefined;
const timeoutMs = tool.timeoutMs ?? this.defaultTimeoutMs;
const timeoutAbortController = new AbortController();
const externalSignal = options?.signal;
const combinedSignal = externalSignal
@@ -332,9 +333,9 @@ export class ToolExecutor {
timeoutHandle = setTimeout(
() => {
timeoutAbortController.abort();
reject(new Error(`Tool '${toolName}' timed out after ${this.defaultTimeoutMs}ms`));
reject(new Error(`Tool '${toolName}' timed out after ${timeoutMs}ms`));
},
this.defaultTimeoutMs,
timeoutMs,
);
}),
...(externalAbortPromise ? [externalAbortPromise] : []),
+2
View File
@@ -8,6 +8,8 @@ export interface Tool {
name: string;
description: string;
inputSchema: ToolInputSchema;
/** Optional per-tool execution timeout in milliseconds. */
timeoutMs?: number;
/** Secret scopes required to execute this tool (optional). */
requiredSecretScopes?: string[];
execute(args: unknown, context?: ToolExecutionContext): Promise<ToolResult>;