From 999ecc97c4e8a3eb5de285bbeeb6cdcc1c615a85 Mon Sep 17 00:00:00 2001 From: Sean Turner Date: Sun, 19 Apr 2026 09:50:35 +0100 Subject: [PATCH] fix: recover from BadRequestError caused by oversized conversation payloads When tool results (e.g. large config files, CIS benchmark YAML) accumulate in conversation history, the serialized payload can exceed the provider's request size limit, causing a persistent HTTP 400. Previously this was not retried, failing the scan immediately. Now handles BadRequestError in two stages: 1. Bare retry after 2s (transient 400s from provider hiccups) 2. If STRIX_TRUNCATE_ON_OVERSIZE=true, truncates the largest tool_result XML blocks to 1000 chars with a "requires manual review" notice, then retries. This is opt-in to avoid lossy recovery for users who don't want it. Observed in practice on repos with 50KB+ CIS defaults YAML and 94KB tfvars.json files hitting Bedrock payload limits at ~6M tokens. Co-Authored-By: Claude Opus 4.6 --- strix/llm/llm.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/strix/llm/llm.py b/strix/llm/llm.py index 4f624956..57790e40 100644 --- a/strix/llm/llm.py +++ b/strix/llm/llm.py @@ -159,12 +159,28 @@ class LLM: messages = self._prepare_messages(conversation_history) max_retries = int(Config.get("strix_llm_max_retries") or "5") + bad_request_retried = False + bad_request_truncated = False + for attempt in range(max_retries + 1): try: async for response in self._stream(messages): yield response return # noqa: TRY300 except Exception as e: # noqa: BLE001 + if self._is_bad_request(e): + if not bad_request_retried: + bad_request_retried = True + await asyncio.sleep(2) + continue + truncate_enabled = Config.get("strix_truncate_on_oversize") or "" + if ( + not bad_request_truncated + and truncate_enabled.lower() in ("1", "true", "yes") + and self._truncate_large_tool_results(messages) + ): + bad_request_truncated = True + continue if attempt >= max_retries or not self._should_retry(e): self._raise_error(e) wait = min(90, 2 * (2**attempt)) @@ -314,6 +330,53 @@ class LLM: except Exception: # noqa: BLE001 return 0.0 + @staticmethod + def _truncate_large_tool_results( + messages: list[dict[str, Any]], max_chars: int = 2000 + ) -> bool: + """Aggressively truncate large tool results in messages to recover from BadRequestError. + + Scans messages in reverse for tool_result XML blocks that exceed max_chars and + replaces their content with a truncated version plus a skip notice. Returns True + if any truncation was performed (caller should retry the request). + """ + import re + + truncated_any = False + pattern = re.compile( + r"(\s*[^<]*\s*)(.*?)(\s*)", + re.DOTALL, + ) + + for msg in reversed(messages): + content = msg.get("content") + if not isinstance(content, str) or "" not in content: + continue + + def _truncate_match(m: re.Match) -> str: + prefix, body, suffix = m.group(1), m.group(2), m.group(3) + if len(body) <= max_chars: + return m.group(0) + nonlocal truncated_any + truncated_any = True + kept = body[:1000] + return ( + f"{prefix}{kept}\n\n... [content truncated from {len(body)} to {len(kept)} chars " + f"due to request size limit — file requires manual review] ...{suffix}" + ) + + msg["content"] = pattern.sub(_truncate_match, content) + if truncated_any: + break + + return truncated_any + + def _is_bad_request(self, e: Exception) -> bool: + code = getattr(e, "status_code", None) or getattr( + getattr(e, "response", None), "status_code", None + ) + return code == 400 + def _should_retry(self, e: Exception) -> bool: code = getattr(e, "status_code", None) or getattr( getattr(e, "response", None), "status_code", None