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:
parent
a2285d3a48
commit
8a46a78a4e
45
main.py
45
main.py
@ -750,9 +750,7 @@ def _parse_tool_calls(content: str) -> list[dict]:
|
|||||||
"""Parse tool calls from LLM response content.
|
"""Parse tool calls from LLM response content.
|
||||||
|
|
||||||
Expects the LLM to output a JSON block like:
|
Expects the LLM to output a JSON block like:
|
||||||
```json
|
|
||||||
{"tool_calls": [{"name": "tool_name", "arguments": {...}}, ...]}
|
{"tool_calls": [{"name": "tool_name", "arguments": {...}}, ...]}
|
||||||
```
|
|
||||||
|
|
||||||
Returns a list of tool call dicts, each with 'name' and 'arguments' keys.
|
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:
|
if isinstance(tc, dict) and "name" in tc:
|
||||||
tool_calls.append(tc)
|
tool_calls.append(tc)
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
|
log.info(f"Parsed tool_calls from code fence: {len(tool_calls)} calls")
|
||||||
return tool_calls
|
return tool_calls
|
||||||
|
|
||||||
# --- Pattern 2: {"tool_calls": [...]} bare JSON (outside code fences) ---
|
# --- 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:
|
if isinstance(tc, dict) and "name" in tc:
|
||||||
tool_calls.append(tc)
|
tool_calls.append(tc)
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
|
log.info(f"Parsed tool_calls from bare JSON: {len(tool_calls)} calls")
|
||||||
return tool_calls
|
return tool_calls
|
||||||
|
|
||||||
# --- Pattern 3 (legacy fallback): {"tool_call": {...}} single tool ---
|
# --- 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:
|
for block_text in fence_matches:
|
||||||
obj = _extract_json_object(block_text, '"tool_call"')
|
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"]:
|
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"')
|
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"]:
|
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"])
|
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
|
return tool_calls
|
||||||
|
|
||||||
@ -878,12 +904,16 @@ async def generate_response(
|
|||||||
|
|
||||||
content = response.choices[0].message.content or ""
|
content = response.choices[0].message.content or ""
|
||||||
log.info(f"LLM response: content_len={len(content)}")
|
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 ---
|
# --- Parse tool calls from content ---
|
||||||
tool_calls = _parse_tool_calls(content)
|
tool_calls = _parse_tool_calls(content)
|
||||||
|
|
||||||
if tool_calls:
|
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
|
# Execute ALL tools concurrently
|
||||||
if state.tool_manager:
|
if state.tool_manager:
|
||||||
@ -930,6 +960,13 @@ async def generate_response(
|
|||||||
|
|
||||||
# --- No tool calls — return the final response ---
|
# --- No tool calls — return the final response ---
|
||||||
cleaned_content = _clean_tool_syntax(content)
|
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)})")
|
log.info(f"Returning final response (len={len(cleaned_content)})")
|
||||||
return cleaned_content or "I apologize, but I couldn't generate a response."
|
return cleaned_content or "I apologize, but I couldn't generate a response."
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user