From 8a46a78a4e92de691412dec3bf3e42277d4e8f21 Mon Sep 17 00:00:00 2001 From: Z User Date: Sun, 29 Mar 2026 18:11:43 +0000 Subject: [PATCH] Fix: add robust parsing, logging, and safety net for empty responses Three fixes for the 'I apologize, couldnt generate a response' bug: 1. Safety net: if _clean_tool_syntax strips ALL content (e.g. the LLM output only the JSON tool call block and nothing else), return the original content instead of the useless error message. 2. Detailed logging: now logs the first 300 chars of every LLM response so we can see exactly what the model outputs. Also logs which parse pattern matched and which tool names were found. 3. Desperate fallback parser (Pattern 4): if none of the regex/brace patterns match, tries to json.loads() the entire content and looks for known tool names. Catches LLMs that output the array directly or use slightly different formatting. --- main.py | 45 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index b116fcd..b3559dc 100755 --- a/main.py +++ b/main.py @@ -750,9 +750,7 @@ def _parse_tool_calls(content: str) -> list[dict]: """Parse tool calls from LLM response content. Expects the LLM to output a JSON block like: - ```json {"tool_calls": [{"name": "tool_name", "arguments": {...}}, ...]} - ``` Returns a list of tool call dicts, each with 'name' and 'arguments' keys. """ @@ -803,6 +801,7 @@ def _parse_tool_calls(content: str) -> list[dict]: if isinstance(tc, dict) and "name" in tc: tool_calls.append(tc) if tool_calls: + log.info(f"Parsed tool_calls from code fence: {len(tool_calls)} calls") return tool_calls # --- Pattern 2: {"tool_calls": [...]} bare JSON (outside code fences) --- @@ -813,10 +812,10 @@ def _parse_tool_calls(content: str) -> list[dict]: if isinstance(tc, dict) and "name" in tc: tool_calls.append(tc) if tool_calls: + log.info(f"Parsed tool_calls from bare JSON: {len(tool_calls)} calls") return tool_calls # --- Pattern 3 (legacy fallback): {"tool_call": {...}} single tool --- - # Also support the old format in case the LLM ignores instructions for block_text in fence_matches: obj = _extract_json_object(block_text, '"tool_call"') if obj and "tool_call" in obj and isinstance(obj["tool_call"], dict) and "name" in obj["tool_call"]: @@ -825,6 +824,33 @@ def _parse_tool_calls(content: str) -> list[dict]: obj = _extract_json_object(stripped, '"tool_call"') if obj and "tool_call" in obj and isinstance(obj["tool_call"], dict) and "name" in obj["tool_call"]: tool_calls.append(obj["tool_call"]) + if tool_calls: + log.info(f"Parsed tool_call (legacy format): {len(tool_calls)} calls") + return tool_calls + + # --- Pattern 4 (desperate fallback): try to find any JSON with tool names --- + # Look for patterns like {"name": "some_tool_name", "arguments": {...}} + # This catches LLMs that output the array directly without the wrapper + known_tools = set(state.tool_manager._tools.keys()) if state.tool_manager else set() + if known_tools: + # Try extracting the entire content as JSON + try: + maybe_json = json.loads(content.strip()) + candidates = [] + if isinstance(maybe_json, list): + candidates = maybe_json + elif isinstance(maybe_json, dict) and "tool_calls" in maybe_json: + candidates = maybe_json["tool_calls"] + elif isinstance(maybe_json, dict) and "name" in maybe_json: + candidates = [maybe_json] + for tc in candidates: + if isinstance(tc, dict) and "name" in tc and tc["name"] in known_tools: + tool_calls.append(tc) + except (json.JSONDecodeError, TypeError): + pass + if tool_calls: + log.info(f"Parsed tool_calls (desperate fallback): {len(tool_calls)} calls") + return tool_calls return tool_calls @@ -878,12 +904,16 @@ async def generate_response( content = response.choices[0].message.content or "" log.info(f"LLM response: content_len={len(content)}") + # Log first 500 chars of response for debugging + log.debug(f"LLM response content preview: {content[:500]}") + if content: + log.info(f"LLM response (first 300 chars): {content[:300]!r}") # --- Parse tool calls from content --- tool_calls = _parse_tool_calls(content) if tool_calls: - log.info(f"Parsed {len(tool_calls)} tool calls from content") + log.info(f"Parsed {len(tool_calls)} tool calls: {[tc.get('name') for tc in tool_calls]}") # Execute ALL tools concurrently if state.tool_manager: @@ -930,6 +960,13 @@ async def generate_response( # --- No tool calls — return the final response --- cleaned_content = _clean_tool_syntax(content) + + # Safety net: if cleaning stripped everything, return original content + # (better to show raw JSON than the useless "I apologize" message) + if not cleaned_content.strip() and content.strip(): + log.warning(f"_clean_tool_syntax stripped all content! Original had {len(content)} chars. Returning original.") + cleaned_content = content.strip() + log.info(f"Returning final response (len={len(cleaned_content)})") return cleaned_content or "I apologize, but I couldn't generate a response."