diff --git a/src/task_scheduler.py b/src/task_scheduler.py index beeb4dee5..50613ae72 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -355,6 +355,36 @@ def _normalize_chat_endpoint(url: str) -> str: return url +def _resolve_research_settings(get_setting_fn): + """Resolve the scheduled-research settings with crash-proof fallbacks. + + A hand-edited or agent-written data/settings.json can hold non-numeric + values for the research_* keys; the bare int() reads previously raised + inside TaskScheduler._execute_research_task and killed every scheduled + research run until settings.json was fixed by hand (#5049). + src/research_handler.py already guards the same reads defensively — + this mirrors that for the scheduler. Valid values pass through + unchanged (numeric strings like "4096" still parse). + """ + try: + max_tokens = int(get_setting_fn("research_max_tokens", 8192)) + except (TypeError, ValueError, OverflowError): + max_tokens = 8192 + try: + extraction_timeout = int( + get_setting_fn("research_extraction_timeout_seconds", 90) or 90 + ) + except (TypeError, ValueError, OverflowError): + extraction_timeout = 90 + try: + extraction_concurrency = int( + get_setting_fn("research_extraction_concurrency", 3) or 3 + ) + except (TypeError, ValueError, OverflowError): + extraction_concurrency = 3 + return max_tokens, extraction_timeout, extraction_concurrency + + class TaskScheduler: def __init__(self, session_manager): self._session_manager = session_manager @@ -2187,9 +2217,9 @@ class TaskScheduler: pass endpoint_url = _normalize_chat_endpoint(endpoint_url) - max_tokens = int(get_setting("research_max_tokens", 8192)) - extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90) - extraction_concurrency = int(get_setting("research_extraction_concurrency", 3) or 3) + max_tokens, extraction_timeout, extraction_concurrency = ( + _resolve_research_settings(get_setting) + ) researcher = DeepResearcher( llm_endpoint=endpoint_url, diff --git a/tests/test_scheduler_research_settings_guard.py b/tests/test_scheduler_research_settings_guard.py new file mode 100644 index 000000000..a6448fc9c --- /dev/null +++ b/tests/test_scheduler_research_settings_guard.py @@ -0,0 +1,102 @@ +"""Guarded resolution of scheduled-research settings (#5049). + +The scheduler reads ``research_max_tokens`` / +``research_extraction_timeout_seconds`` / ``research_extraction_concurrency`` +from settings.json with bare ``int()`` casts. A hand-edited value like +``{"research_max_tokens": "invalid"}`` used to raise inside +``TaskScheduler._execute_research_task`` and kill every scheduled research +run until settings.json was fixed by hand. + +``_resolve_research_settings`` must fall back to the documented defaults +(8192 / 90 / 3) for non-numeric input while preserving valid-value behavior +(numeric strings still parse). ``src/research_handler.py`` already guards the +same reads defensively; this is the scheduler's equivalent. +""" + +from __future__ import annotations + +import json + +from src.task_scheduler import _resolve_research_settings + + +def _fake_get_setting(values): + """A get_setting stand-in backed by a plain dict.""" + + def get(key, default=None): + return values.get(key, default) + + return get + + +def test_non_numeric_values_fall_back_to_documented_defaults(): + get = _fake_get_setting( + { + "research_max_tokens": "invalid", + "research_extraction_timeout_seconds": "bogus", + "research_extraction_concurrency": "not-a-number", + } + ) + + assert _resolve_research_settings(get) == (8192, 90, 3) + + +def test_none_values_fall_back_to_documented_defaults(): + get = _fake_get_setting( + { + "research_max_tokens": None, + "research_extraction_timeout_seconds": None, + "research_extraction_concurrency": None, + } + ) + + assert _resolve_research_settings(get) == (8192, 90, 3) + + +def test_numeric_strings_still_parse(): + get = _fake_get_setting( + { + "research_max_tokens": "4096", + "research_extraction_timeout_seconds": "45", + "research_extraction_concurrency": "2", + } + ) + + assert _resolve_research_settings(get) == (4096, 45, 2) + + +def test_missing_keys_keep_documented_defaults(): + assert _resolve_research_settings(_fake_get_setting({})) == (8192, 90, 3) + + +def test_valid_ints_pass_through(): + get = _fake_get_setting( + { + "research_max_tokens": 2048, + "research_extraction_timeout_seconds": 120, + "research_extraction_concurrency": 6, + } + ) + + assert _resolve_research_settings(get) == (2048, 120, 6) + + +def test_non_finite_json_numbers_fall_back_individually(): + """JSON exponent overflow produces infinity, which int() cannot convert.""" + keys = ( + "research_max_tokens", + "research_extraction_timeout_seconds", + "research_extraction_concurrency", + ) + valid = (4096, 45, 2) + defaults = (8192, 90, 3) + + for non_finite in (json.loads("1e309"), json.loads("-1e309")): + for index, key in enumerate(keys): + values = dict(zip(keys, valid)) + values[key] = non_finite + expected = list(valid) + expected[index] = defaults[index] + assert _resolve_research_settings( + _fake_get_setting(values) + ) == tuple(expected)