diff --git a/src/research_handler.py b/src/research_handler.py index 7719e9db8..3484c46f9 100644 --- a/src/research_handler.py +++ b/src/research_handler.py @@ -216,15 +216,25 @@ class ResearchHandler: """ # Resolve the hard wall-clock timeout from settings when the caller # didn't pin one. Local / edge models routinely need more than the - # old 600s default to finish a deep-research synthesis. + # old 600s default to finish a deep-research synthesis. A setting of + # 0 disables the cap entirely (unlimited run); any other value is + # bounded to [60, 86400] so a misconfigured settings.json can't + # explode into a multi-day hang. if hard_timeout is None: from src.settings import get_setting - hard_timeout = _bounded_int( - get_setting("research_run_timeout_seconds", 1800), - default=1800, - minimum=60, - maximum=86400, - ) + try: + raw_timeout = int(get_setting("research_run_timeout_seconds", 1800)) + except (TypeError, ValueError): + raw_timeout = 1800 + if raw_timeout <= 0: + hard_timeout = None # 0 = no wall-clock cap (asyncio.wait_for timeout=None) + else: + hard_timeout = _bounded_int( + raw_timeout, + default=1800, + minimum=60, + maximum=86400, + ) # Cancel any existing research for this session if session_id in self._active_tasks: diff --git a/src/settings.py b/src/settings.py index b64010e23..570465643 100644 --- a/src/settings.py +++ b/src/settings.py @@ -89,7 +89,10 @@ DEFAULT_SETTINGS = { # Hard wall-clock cap on a single deep-research run. The previous 600s # (10 min) default cut off slow local / edge LLMs mid-synthesis; 1800s # (30 min) is comfortable for most local setups while still bounding - # runaway jobs. Tune via Settings or by editing data/settings.json. + # runaway jobs. Set to 0 to disable the cap entirely (unlimited) — only + # for very long deep-research runs, since a stalled job then runs an + # unbounded model/API bill. Other values are bounded to [60, 86400]. + # Tune via Settings or by editing data/settings.json. "research_run_timeout_seconds": 1800, "agent_max_tool_calls": 0, "agent_input_token_budget": 6000, diff --git a/static/index.html b/static/index.html index 6ed076926..f944240a9 100644 --- a/static/index.html +++ b/static/index.html @@ -1463,6 +1463,10 @@ +
+ + +
diff --git a/static/js/settings.js b/static/js/settings.js index 27febf76d..36d6c6984 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -1402,6 +1402,7 @@ async function initResearchSettings() { var tokensInput = el('set-researchMaxTokens'); var extractTimeoutInput = el('set-researchExtractTimeout'); var extractConcurrencyInput = el('set-researchExtractConcurrency'); + var runTimeoutInput = el('set-researchRunTimeout'); var msg = el('set-researchMsg'); var endpoints = []; @@ -1424,6 +1425,9 @@ async function initResearchSettings() { if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens; if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds; if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency; + if (settings.research_run_timeout_seconds !== undefined && settings.research_run_timeout_seconds !== null) { + runTimeoutInput.value = settings.research_run_timeout_seconds; + } } catch (e) { console.warn('Failed to load research settings', e); } function showStatus() { @@ -1442,6 +1446,12 @@ async function initResearchSettings() { if (extractConcurrencyInput.value) { parts.push('Parallel: ' + extractConcurrencyInput.value); } + if (runTimeoutInput.value !== '') { + var rtv = parseInt(runTimeoutInput.value, 10); + if (!isNaN(rtv)) { + parts.push(rtv === 0 ? 'Max time: no limit' : 'Max time: ' + rtv + 's'); + } + } if (parts.length) { msg.textContent = parts.join(' · '); msg.style.color = 'var(--fg)'; @@ -1463,6 +1473,13 @@ async function initResearchSettings() { if (et && et >= 15 && et <= 3600) payload.research_extraction_timeout_seconds = et; var ec = parseInt(extractConcurrencyInput.value, 10); if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec; + if (runTimeoutInput.value !== '') { + var rt = parseInt(runTimeoutInput.value, 10); + // 0 = no limit (disables the hard timeout); otherwise 60s..86400s (24h) + if (!isNaN(rt) && (rt === 0 || (rt >= 60 && rt <= 86400))) { + payload.research_run_timeout_seconds = rt; + } + } try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, @@ -1481,6 +1498,7 @@ async function initResearchSettings() { tokensInput.addEventListener('change', saveResearch); extractTimeoutInput.addEventListener('change', saveResearch); extractConcurrencyInput.addEventListener('change', saveResearch); + runTimeoutInput.addEventListener('change', saveResearch); _registerAiEndpointRefresh(function(nextEndpoints) { endpoints = nextEndpoints;