From 056b8ce515b562b74cbade60e4214530f0231179 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 23 Feb 2026 00:01:27 -0800 Subject: [PATCH] feat(tools): support per-tool timeout override for council runs --- docs/plans/state.json | 18 +++++++++++++++++- src/tools/builtin/council-run.test.ts | 9 +++++++++ src/tools/builtin/council-run.ts | 3 +++ src/tools/executor.test.ts | 22 ++++++++++++++++++++++ src/tools/executor.ts | 5 +++-- src/tools/types.ts | 2 ++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 00d4d4f..fda3cb6 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -3,6 +3,21 @@ "updated_at": "2026-02-23", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { + "council-tool-timeout-override": { + "status": "completed", + "date": "2026-02-23", + "updated": "2026-02-23", + "summary": "Added optional per-tool timeout override support in ToolExecutor and set `council.run` to 180000ms so council orchestration in the native tool loop is no longer hard-capped by the global 30000ms tool timeout.", + "files_modified": [ + "src/tools/types.ts", + "src/tools/executor.ts", + "src/tools/executor.test.ts", + "src/tools/builtin/council-run.ts", + "src/tools/builtin/council-run.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/tools/executor.test.ts src/tools/builtin/council-run.test.ts + pnpm typecheck passing" + }, "gmail-filter-scope-correction": { "status": "completed", "date": "2026-02-23", @@ -6340,7 +6355,7 @@ } }, "overall_progress": { - "total_test_count": 1965, + "total_test_count": 1967, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -6362,6 +6377,7 @@ "gmail_filter_creation": "completed — gmail.filter.create tool added with criteria/action validation; gmail-auth requests explicit gmail.settings.basic + gmail.readonly scopes for filter creation and inbox reads", "toolloop_action_intent_recovery": "completed — when a model claims it will execute a tool but emits no tool call, NativeAgent now issues one internal nudge and continues the same turn to execute tools or produce a concrete blocker", "toolloop_execution_claim_recovery": "completed — when a model claims a known tool already succeeded/failed without emitting a tool call, NativeAgent now nudges once and retries the same turn before returning text", + "council_tool_timeout_override": "completed — ToolExecutor supports per-tool timeout overrides and council.run now uses a 180s timeout to avoid false 30s council timeouts in the tool loop", "minimal_tui_multiline_paste_mode": "completed — minimal TUI now supports `/paste`/`/multiline` multiline compose mode ending with single '.' line, preventing newline truncation for pasted prompts", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback, plus 2026-02-23 arg hydration hardening, tool.args_rewritten audit metric, transient fetch retry/timeout hardening, localhost->127.0.0.1 fallback for transcription endpoint connectivity, and whisper docker-compose entrypoint arg fix for port 18801", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", diff --git a/src/tools/builtin/council-run.test.ts b/src/tools/builtin/council-run.test.ts index 3c4b9aa..f3ee420 100644 --- a/src/tools/builtin/council-run.test.ts +++ b/src/tools/builtin/council-run.test.ts @@ -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-')); diff --git a/src/tools/builtin/council-run.ts b/src/tools/builtin/council-run.ts index 912b9bc..a77055b 100644 --- a/src/tools/builtin/council-run.ts +++ b/src/tools/builtin/council-run.ts @@ -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: { diff --git a/src/tools/executor.test.ts b/src/tools/executor.test.ts index e2ad37a..dda8f51 100644 --- a/src/tools/executor.test.ts +++ b/src/tools/executor.test.ts @@ -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); diff --git a/src/tools/executor.ts b/src/tools/executor.ts index ba5a0f0..d7615c9 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -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] : []), diff --git a/src/tools/types.ts b/src/tools/types.ts index a349dd5..14c9b2e 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -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;