From 7e9bfb17007bc4362acb9f7dafcffc67471c414e Mon Sep 17 00:00:00 2001 From: Solanki Sumit <125974181+YAMRAJ13y@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:50:48 +0530 Subject: [PATCH] fix(chat): guard non-numeric agent tool budget setting Guard the agent_max_tool_calls settings read so hand-edited or agent-written non-numeric settings.json values fall back to 0 instead of crashing agent-mode chat stream initialization. Add regression coverage for guarded coercion. --- routes/chat_routes.py | 9 ++- tests/test_agent_tool_budget_nonnumeric.py | 70 ++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/test_agent_tool_budget_nonnumeric.py diff --git a/routes/chat_routes.py b/routes/chat_routes.py index a8632c0df..4c395c06e 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -1255,7 +1255,14 @@ def setup_chat_routes( try: from src.settings import get_setting from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS - _tool_budget = int(get_setting("agent_max_tool_calls", 0)) + # Per-message tool budget from settings; guard defensively in + # case settings.json was hand-edited to a non-numeric value + # (the HTTP admin endpoint validates, but direct edits bypass + # it). 0 = unlimited, matching auth_routes set_settings(). + try: + _tool_budget = int(get_setting("agent_max_tool_calls", 0)) + except (TypeError, ValueError): + _tool_budget = 0 # Per-message round cap from settings; clamp defensively in # case settings.json was hand-edited to a bad value. try: diff --git a/tests/test_agent_tool_budget_nonnumeric.py b/tests/test_agent_tool_budget_nonnumeric.py new file mode 100644 index 000000000..8d68223b7 --- /dev/null +++ b/tests/test_agent_tool_budget_nonnumeric.py @@ -0,0 +1,70 @@ +"""Regression: agent_max_tool_calls must not crash chat_stream when settings.json +holds a non-numeric string (e.g. {"agent_max_tool_calls": "unlimited"}). + +The HTTP admin endpoint validates/clamps this value, but a hand-edited or +agent-written data/settings.json bypasses that. The read sits inside the agent +streaming try-block whose only handler catches (CancelledError, GeneratorExit) — +NOT ValueError — so an unguarded int() would propagate and break the SSE stream. +It must be guarded like the agent_max_rounds read four lines below. +""" +import ast +from pathlib import Path + +import pytest + +_CHAT_ROUTES = Path(__file__).resolve().parent.parent / "routes" / "chat_routes.py" + + +def _tool_budget_read_is_guarded(source: str) -> bool: + """True if a `try` that assigns `_tool_budget` also catches ValueError.""" + tree = ast.parse(source) + chat_stream = next( + (n for n in ast.walk(tree) + if isinstance(n, ast.AsyncFunctionDef) and n.name == "chat_stream"), + None, + ) + assert chat_stream is not None, "chat_stream function not found" + for try_node in ast.walk(chat_stream): + if not isinstance(try_node, ast.Try): + continue + # Only the immediate try body — not nested trys — should own the assignment. + assigns_budget = any( + isinstance(t, ast.Name) and t.id == "_tool_budget" + for stmt in try_node.body if isinstance(stmt, ast.Assign) + for t in stmt.targets + ) + if not assigns_budget: + continue + catches_value_error = any( + (isinstance(h.type, ast.Name) and h.type.id == "ValueError") + or (isinstance(h.type, ast.Tuple) + and any(isinstance(e, ast.Name) and e.id == "ValueError" for e in h.type.elts)) + for h in try_node.handlers + ) + if catches_value_error: + return True + return False + + +def test_tool_budget_read_is_wrapped_in_try_except(): + source = _CHAT_ROUTES.read_text(encoding="utf-8") + assert _tool_budget_read_is_guarded(source), ( + "_tool_budget = int(get_setting('agent_max_tool_calls', 0)) must be wrapped in " + "try/except (ValueError) like the agent_max_rounds read, so a non-numeric " + "settings.json value cannot crash chat_stream during agent init" + ) + + +@pytest.mark.parametrize("raw, expected", [ + ("unlimited", 0), ("", 0), (None, 0), ("25", 25), (12, 12), +]) +def test_tool_budget_coercion_falls_back_to_zero(raw, expected): + # Mirrors the guarded read: a bad/non-numeric value -> 0 (unlimited). + def get_setting(_key, default): + return raw if raw is not None else default + + try: + tool_budget = int(get_setting("agent_max_tool_calls", 0)) + except (TypeError, ValueError): + tool_budget = 0 + assert tool_budget == expected