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;