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