|
43 | 43 | HARD_RECAP_CHARS = 1000 |
44 | 44 |
|
45 | 45 |
|
| 46 | +_OVERFLOW_PHRASES = ( |
| 47 | + "prompt is too long", # Anthropic |
| 48 | + "context_length_exceeded", # OpenAI |
| 49 | + "maximum context length", # OpenAI (alt phrasing) |
| 50 | + "context window", # Generic / Bedrock |
| 51 | + "too many tokens", # Generic fallback |
| 52 | +) |
| 53 | + |
| 54 | + |
46 | 55 | def _is_context_overflow(exc: BaseException) -> bool: |
47 | | - """True if `exc` looks like an LLM context-window-exceeded error. |
| 56 | + """True if `exc` (or anything it wraps) looks like a context-window error. |
48 | 57 |
|
49 | | - Provider-agnostic by string-matching the canonical error phrasings. |
| 58 | + Walks the cause/context chain so we catch wrapped errors raised from |
| 59 | + inside LangGraph / LangChain / Anthropic SDK layers. |
50 | 60 | """ |
51 | | - msg = str(exc).lower() |
52 | | - return ( |
53 | | - "prompt is too long" in msg # Anthropic |
54 | | - or "context_length_exceeded" in msg # OpenAI |
55 | | - or "maximum context length" in msg # OpenAI (alt phrasing) |
56 | | - or "context window" in msg # Generic / Bedrock |
57 | | - or "too many tokens" in msg # Generic fallback |
58 | | - ) |
| 61 | + seen: set[int] = set() |
| 62 | + current: BaseException | None = exc |
| 63 | + while current is not None and id(current) not in seen: |
| 64 | + seen.add(id(current)) |
| 65 | + msg = str(current).lower() |
| 66 | + if any(phrase in msg for phrase in _OVERFLOW_PHRASES): |
| 67 | + return True |
| 68 | + # Anthropic SDK BadRequestError exposes the API body in .body or .response |
| 69 | + body = getattr(current, "body", None) |
| 70 | + if isinstance(body, dict): |
| 71 | + body_text = str(body).lower() |
| 72 | + if any(phrase in body_text for phrase in _OVERFLOW_PHRASES): |
| 73 | + return True |
| 74 | + current = current.__cause__ or current.__context__ |
| 75 | + return False |
59 | 76 |
|
60 | 77 |
|
61 | 78 | def _content_length(content: Any) -> int: |
@@ -228,23 +245,88 @@ def drop_largest_tool_message( |
228 | 245 | # --------------------------------------------------------------------------- |
229 | 246 |
|
230 | 247 |
|
231 | | -GRACEFUL_DEGRADE_TEXT = ( |
232 | | - "I had to shorten this conversation to keep it within the model's " |
233 | | - "context window. Some earlier tool responses were dropped or " |
234 | | - "summarized. If your last question depended on data I just dropped, " |
235 | | - "please rephrase it more specifically — for example, ask for a single " |
236 | | - "record by id rather than a list, or narrow the time range." |
237 | | -) |
| 248 | +def _content_to_text(content: Any) -> str: |
| 249 | + """Best-effort flatten of message content to a plain string for inspection.""" |
| 250 | + if isinstance(content, str): |
| 251 | + return content |
| 252 | + if isinstance(content, list): |
| 253 | + parts: List[str] = [] |
| 254 | + for item in content: |
| 255 | + if isinstance(item, dict): |
| 256 | + parts.append(item.get("text", "") or str(item)) |
| 257 | + else: |
| 258 | + parts.append(str(item)) |
| 259 | + return "".join(parts) |
| 260 | + return str(content) |
238 | 261 |
|
239 | 262 |
|
240 | | -def graceful_failure_message() -> AIMessage: |
| 263 | +def _summarize_last_tool_result(messages: List[BaseMessage]) -> str | None: |
| 264 | + """Inspect the last ToolMessage and produce a one-line human summary. |
| 265 | +
|
| 266 | + Goal: tell the user *what came back* from the bank, so a failed LLM call |
| 267 | + doesn't look like "Opey did nothing". Best-effort and safe — returns |
| 268 | + None if we can't extract anything useful. |
| 269 | + """ |
| 270 | + last_tool: ToolMessage | None = None |
| 271 | + for msg in reversed(messages): |
| 272 | + if isinstance(msg, ToolMessage): |
| 273 | + last_tool = msg |
| 274 | + break |
| 275 | + if last_tool is None: |
| 276 | + return None |
| 277 | + |
| 278 | + text = _content_to_text(last_tool.content) |
| 279 | + if not text: |
| 280 | + return None |
| 281 | + |
| 282 | + # Heuristic: parse JSON, look for an obvious list of records. |
| 283 | + try: |
| 284 | + import json |
| 285 | + parsed = json.loads(text) |
| 286 | + except (ValueError, TypeError): |
| 287 | + return f"the last tool call returned {len(text):,} characters of data" |
| 288 | + |
| 289 | + response = parsed.get("response", parsed) if isinstance(parsed, dict) else parsed |
| 290 | + if isinstance(response, dict): |
| 291 | + for key, value in response.items(): |
| 292 | + if isinstance(value, list): |
| 293 | + return f"the OBP API returned {len(value)} `{key}` (≈ {len(text):,} chars)" |
| 294 | + return f"the OBP API returned an object with keys: {', '.join(list(response.keys())[:6])}" |
| 295 | + if isinstance(response, list): |
| 296 | + return f"the OBP API returned {len(response)} items (≈ {len(text):,} chars)" |
| 297 | + return f"the OBP API returned {len(text):,} characters of data" |
| 298 | + |
| 299 | + |
| 300 | +def graceful_failure_message(messages: List[BaseMessage] | None = None) -> AIMessage: |
241 | 301 | """Synthetic AIMessage used when every recovery step has failed. |
242 | 302 |
|
243 | 303 | Returning this to state lets the stream end cleanly with a useful |
244 | 304 | next-action for the user instead of bubbling a 400 to the portal. |
| 305 | +
|
| 306 | + If `messages` is provided, inspects the most recent ToolMessage and |
| 307 | + reports what data was actually retrieved — so the user knows the bank |
| 308 | + call succeeded, only the summarization step failed. |
245 | 309 | """ |
246 | 310 | logger.error( |
247 | 311 | "recovery step 4: all recovery steps exhausted — emitting graceful " |
248 | 312 | "degrade message to caller" |
249 | 313 | ) |
250 | | - return AIMessage(content=GRACEFUL_DEGRADE_TEXT) |
| 314 | + |
| 315 | + retrieved_line = "" |
| 316 | + if messages: |
| 317 | + summary = _summarize_last_tool_result(messages) |
| 318 | + if summary: |
| 319 | + retrieved_line = ( |
| 320 | + f"\n\n**Good news — the bank call succeeded:** " |
| 321 | + f"{summary}.\n" |
| 322 | + f"It was too large for me to summarize in one go." |
| 323 | + ) |
| 324 | + |
| 325 | + text = ( |
| 326 | + "I couldn't fit the result of this turn into my context window." |
| 327 | + + retrieved_line |
| 328 | + + "\n\nPlease try a more specific question — for example, ask for " |
| 329 | + "a single record by id, narrow the time range, or filter by a " |
| 330 | + "specific bank or account." |
| 331 | + ) |
| 332 | + return AIMessage(content=text) |
0 commit comments