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.
This commit is contained in:
Z User 2026-03-29 18:11:43 +00:00
parent a2285d3a48
commit 8a46a78a4e

45
main.py
View File

@ -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."