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