diff --git a/discord_glhf/api.py b/discord_glhf/api.py index b27ac24..a4767cd 100644 --- a/discord_glhf/api.py +++ b/discord_glhf/api.py @@ -255,17 +255,18 @@ class APIManager: } ) - # Import tools + # Import tools and tool response parser from .handlers.function_tools import TOOLS, parse_tool_response - # Prepare request data + # Prepare request data with OpenAI function calling format data = { "model": params["model"], "messages": messages, "temperature": params["temperature"], "max_tokens": params["max_tokens"], "stream": False, # Disable streaming for all requests - "tools": TOOLS # Add available tools + "tools": TOOLS, # Add function definitions + "tool_choice": "auto" # Let model decide when to use tools } logger.debug( "API Request Details:\n" @@ -321,27 +322,36 @@ class APIManager: # Process the response try: json_response = await response.json() - if json_response.get("choices"): - choice = json_response["choices"][0] - if "message" in choice: - message = choice["message"] - - # Check for tool calls first - if "tool_calls" in message: - tool_response = parse_tool_response(json_response) - if tool_response: - return True, json.dumps(tool_response) - - # Fall back to regular content - content = message.get("content", "") - if content and content.strip(): - return True, content - - # Log if no valid response found - logger.error("No valid content or tool calls in response") - logger.debug(f"Response headers: {response.headers}") - logger.debug(f"Response status: {response.status}") - return False, None + logger.debug(f"Raw API response: {json_response}") + + if not json_response.get("choices"): + logger.error("No choices in response") + return False, None + + choice = json_response["choices"][0] + if not choice.get("message"): + logger.error("No message in choice") + return False, None + + message = choice["message"] + + # Check for tool calls first + if message.get("tool_calls"): + tool_data = parse_tool_response(json_response) + if tool_data: + logger.debug(f"Found tool calls: {tool_data}") + return True, json.dumps(tool_data) + + # Fall back to regular content if present + content = message.get("content", "").strip() + if content: + logger.debug(f"Found content: {content[:100]}") + return True, content + + # No valid content or tool calls + logger.error("No valid content or tool calls found") + logger.debug(f"Full message: {message}") + return False, None except json.JSONDecodeError as e: logger.error(f"Failed to decode response: {e}") return False, None diff --git a/discord_glhf/handlers/function_tools.py b/discord_glhf/handlers/function_tools.py index 26481c7..34629bb 100644 --- a/discord_glhf/handlers/function_tools.py +++ b/discord_glhf/handlers/function_tools.py @@ -94,7 +94,7 @@ def function_to_tool_call(function_name: str, arguments: Dict[str, Any]) -> Dict } def parse_tool_response(response: Dict[str, Any]) -> Dict[str, Any]: - """Parse an API response looking for tool calls.""" + """Parse an API response looking for function calls in tool_calls format.""" if not response or "choices" not in response: return {} @@ -109,9 +109,11 @@ def parse_tool_response(response: Dict[str, Any]) -> Dict[str, Any]: tool_calls = [] for tool_call in message["tool_calls"]: if tool_call["type"] == "function": - tool_calls.append(function_to_tool_call( - tool_call["function"]["name"], - tool_call["function"]["arguments"] - )) + # Extract function name and arguments + function_call = { + "name": tool_call["function"]["name"], + "arguments": json.loads(tool_call["function"]["arguments"]) + } + tool_calls.append(function_call) return {"tool_calls": tool_calls} if tool_calls else {} \ No newline at end of file diff --git a/discord_glhf/handlers/tool_handler.py b/discord_glhf/handlers/tool_handler.py index 43f5518..a122745 100644 --- a/discord_glhf/handlers/tool_handler.py +++ b/discord_glhf/handlers/tool_handler.py @@ -2,6 +2,7 @@ """Tool execution and response handling logic.""" import re +import json from typing import Dict, Any, Optional, List, Tuple from discord import TextChannel, Message, utils, Embed, PartialEmoji @@ -15,6 +16,129 @@ class ToolHandler: self.bot = bot self.mentioned_users = {} # Map usernames to their mention formats + def _extract_json_data(self, json_str: str) -> Tuple[str, Dict[str, Any]]: + """Extract tool name and arguments from various JSON formats.""" + # Try full JSON parsing first + try: + data = json.loads(json_str) + name = data.get("name", "") + args = data.get("parameters") or data.get("arguments") or {} + return name, args + except json.JSONDecodeError: + pass + + # Fallback to regex extraction + try: + name_match = re.search(r'"name":\s*"([^"]+)"', json_str) + if not name_match: + return "", {} + + name = name_match.group(1) + + # Try to extract parameters/arguments + args_match = re.search(r'"(?:parameters|arguments)":\s*({[^}]+})', json_str) + if args_match: + try: + args = json.loads(args_match.group(1)) + except json.JSONDecodeError: + args = {} + else: + args = {} + + return name, args + except Exception as e: + logger.error(f"Error extracting JSON data: {e}") + return "", {} + + def _convert_to_natural_format(self, tool_name: str, args: Dict[str, Any]) -> str: + """Convert tool call data to natural language format.""" + try: + if tool_name == "mention_user" and "name" in args: + return f"[TOOL:mention]@{args['name']}[/TOOL]" + elif tool_name == "add_reaction" and "emoji" in args: + return f"[TOOL:reaction]{args['emoji']}[/TOOL]" + elif tool_name == "create_embed": + title = args.get("title", "") + desc = args.get("description", "") + content = f"{title}\n{desc}" if title and desc else title or desc + return f"[TOOL:embed]\n{content}\n[/TOOL]" + elif tool_name == "create_thread": + name = args.get("name", args.get("topic", "")) + if name: + return f"[TOOL:thread]{name}[/TOOL]" + except Exception as e: + logger.error(f"Error converting to natural format: {e}") + return "" + + def parse_tool_calls( + self, + response: str, + message_id: Optional[int] = None, + channel_id: Optional[int] = None, + ) -> Tuple[List[Tuple[str, Dict[str, Any]]], str]: + """Parse all tool calls from a response string.""" + try: + # Initialize variables + tool_calls = [] + final_response = response + + # Find potential JSON tool calls + pattern = r'\{(?:[^{}]|{[^{}]*})*\}' # Matches JSON objects, including nested ones + matches = list(re.finditer(pattern, response)) + + for match in matches: + try: + json_str = match.group(0) + tool_name, args = self._extract_json_data(json_str) + + if not tool_name: + continue + + # Add channel and message IDs where needed + if tool_name in ["add_reaction", "create_thread"]: + args["channel_id"] = channel_id + args["message_id"] = message_id + + # Add to tool calls + tool_calls.append((tool_name, args)) + + # Convert to natural format in the response + natural_format = self._convert_to_natural_format(tool_name, args) + if natural_format: + final_response = final_response.replace(json_str, natural_format) + else: + final_response = final_response.replace(json_str, "") + + except Exception as e: + logger.error(f"Error processing tool call: {e}") + continue + + # Clean up the response + final_response = "\n".join(line for line in final_response.split("\n") if line.strip()) + + # Add default message if response is empty + if not final_response and tool_calls: + action_map = { + "create_thread": "Starting a new discussion thread...", + "add_reaction": "Adding reaction...", + "create_embed": "Creating embed...", + "mention_user": "Mentioning user..." + } + + for tool_name, _ in tool_calls: + if tool_name in action_map: + final_response = action_map[tool_name] + break + + if not final_response: + final_response = "Processing your request..." + + return tool_calls, final_response, self.mentioned_users + + except Exception as e: + logger.error(f"Error parsing tool calls: {e}") + return [], response, {} + async def find_user_by_name( self, name: str, guild_id: Optional[int] = None ) -> Optional[str]: @@ -105,18 +229,15 @@ class ToolHandler: if guild_emoji: await message.add_reaction(guild_emoji) else: - logger.warning(f"Tool Use - Add Reaction: Could not find emoji '{emoji_name}' in guild emojis") + logger.warning(f"Could not find emoji '{emoji_name}' in guild emojis") else: - logger.warning(f"Tool Use - Add Reaction: Invalid emoji format '{emoji}' - must be Unicode emoji, custom emoji (<:name:id>), or standard emoji (:name:)") + logger.warning(f"Invalid emoji format '{emoji}' - must be Unicode emoji, custom emoji (<:name:id>), or standard emoji (:name:)") return - logger.info(f"Tool Use - Add Reaction: Successfully added '{emoji}' to message {message_id}") + logger.info(f"Successfully added '{emoji}' to message {message_id}") except Exception as e: - logger.error(f"Tool Use - Add Reaction: Failed to add reaction: {e}") + logger.error(f"Failed to add reaction: {e}") - logger.info( - f"Tool Use - Add Reaction: Successfully added '{emoji}' to message {message_id}" - ) except Exception as e: logger.error(f"Tool Use - Add Reaction: Failed to add reaction: {e}") @@ -167,22 +288,18 @@ class ToolHandler: try: # Clean and format thread name - # Remove mentions and clean up text formatted_name = re.sub(r'<@!?\d+>', '', name) # Remove mentions formatted_name = re.sub(r'\s+', ' ', formatted_name) # Normalize whitespace formatted_name = formatted_name.strip() - # Extract core topic - # Look for key comparison or topic patterns + # Extract core topic and format name if "vs" in formatted_name.lower() or "versus" in formatted_name.lower(): - # Extract the comparison parts parts = re.split(r'\bvs\.?\b|\bversus\b', formatted_name, flags=re.IGNORECASE) if len(parts) >= 2: formatted_name = f"{parts[0].strip()} vs {parts[1].strip()}" if not formatted_name.lower().endswith(" debate"): formatted_name += " debate" elif "overrated" in formatted_name.lower() or "underrated" in formatted_name.lower(): - # Extract just the coaster name and rating type match = re.search(r'(.*?)\b(over|under)rated\b', formatted_name, re.IGNORECASE) if match: formatted_name = f"{match.group(1).strip()} {match.group(2)}rated discussion" @@ -202,7 +319,7 @@ class ToolHandler: auto_archive_duration=1440 # 24 hours ) if thread: - logger.info(f"Tool Use - Create Thread: Successfully created thread '{formatted_name}' from message") + logger.info(f"Successfully created thread '{formatted_name}' from message") return thread else: thread = await channel.create_thread( @@ -211,154 +328,16 @@ class ToolHandler: auto_archive_duration=1440 # 24 hours ) if thread: - logger.info(f"Tool Use - Create Thread: Successfully created thread '{formatted_name}'") + logger.info(f"Successfully created thread '{formatted_name}'") return thread - logger.error("Tool Use - Create Thread: Failed to create thread - no thread object returned") + logger.error("Failed to create thread - no thread object returned") return None except Exception as e: - logger.error(f"Tool Use - Create Thread: Failed to create thread: {e}") + logger.error(f"Failed to create thread: {e}") return None + except Exception as e: logger.error(f"Tool Use - Create Thread: Failed to create thread: {e}") - - def validate_tool_response(self, response: str) -> bool: - """Validate proper tool marker closure. - - Args: - response: The response string to validate - - Returns: - bool: True if all tool markers are properly closed, False otherwise - """ - from .tools import TOOL_MARKERS - try: - stack = [] - - # Find all tool markers - pattern = f"{re.escape(TOOL_MARKERS['start'])}\\w+\\]|{re.escape(TOOL_MARKERS['end'])}" - markers = re.finditer(pattern, response) - - for match in markers: - marker = match.group(0) - if TOOL_MARKERS['end'] in marker: - if not stack: - logger.warning(f"Tool Use - Validate: Found end marker without matching start: {marker}") - return False - start_marker = stack.pop() - logger.debug(f"Tool Use - Validate: Matched {start_marker} with {marker}") - else: - stack.append(marker) - logger.debug(f"Tool Use - Validate: Found start marker: {marker}") - - if stack: - logger.warning(f"Tool Use - Validate: Unclosed tool markers found: {stack}") - return False - - return True - - except Exception as e: - logger.error(f"Tool Use - Validate: Error validating tool markers: {e}") - return False - - def parse_tool_calls( - self, - response: str, - message_id: Optional[int] = None, - channel_id: Optional[int] = None, - ) -> Tuple[List[Tuple[str, Dict[str, Any]]], str]: - """Parse all tool calls from a response string. - - Args: - response: The response string to parse - message_id: Optional ID of the message being responded to - channel_id: Optional ID of the channel the message is in - """ - from .tools import TOOL_MARKERS - - try: - # Validate tool markers first - if not self.validate_tool_response(response): - logger.warning("Tool Use - Parse: Invalid tool marker structure, skipping tool parsing") - return [], response, {} - - tool_calls = [] - final_response = response - - # Process tool markers in order of appearance - for tool_type, markers in TOOL_MARKERS["patterns"].items(): - pattern = f"{re.escape(markers['start'])}(.*?){re.escape(markers['end'])}" - matches = list(re.finditer(pattern, response, re.DOTALL)) - - for match in matches: - content = match.group(1).strip() - if not content: - continue - - logger.info(f"Tool Use - Parse: Found {tool_type} tool usage: {content[:50]}...") - - # Process based on tool type - if tool_type == "mention": - # Extract username from @mention - if "@" in content: - name = re.search(r"@(\w+(?:\s+\w+)*)", content) - if name: - name = name.group(1).strip() - tool_calls.append(("find_user", {"name": name})) - logger.info(f"Tool Use - Parse: Adding find_user tool call for '{name}'") - - elif tool_type == "reaction": - # Process emoji reactions - emoji_pattern = r'([😀-🙏🌀-🗿]||:[a-zA-Z0-9_]+:)' - emoji_matches = re.finditer(emoji_pattern, content) - for emoji_match in emoji_matches: - emoji = emoji_match.group(1) - if emoji.strip(): - tool_calls.append(("add_reaction", { - "emoji": emoji, - "message_id": message_id, - "channel_id": channel_id - })) - logger.info(f"Tool Use - Parse: Adding add_reaction tool call for emoji '{emoji}'") - - elif tool_type == "embed": - # Process embed content - lines = content.strip().split("\n") - if lines: - title = lines[0] - description = "\n".join(lines[1:]) if len(lines) > 1 else "" - tool_calls.append(("create_embed", { - "title": title, - "description": description, - "color": 0xFF0000 # Default red color - })) - logger.info(f"Tool Use - Parse: Adding create_embed tool call with title '{title}'") - - elif tool_type == "thread": - # Process thread creation - thread_name = content.strip() - if thread_name: - tool_calls.append(("create_thread", { - "channel_id": channel_id, - "name": thread_name, - "message_id": message_id - })) - logger.info(f"Tool Use - Parse: Adding create_thread tool call for '{thread_name}'") - - # Remove the tool marker and its content from the final response - final_response = final_response.replace(match.group(0), "") - - # Clean up the final response - final_response = "\n".join(line for line in final_response.split("\n") if line.strip()) - - # Log summary of detected tools - if tool_calls: - logger.info(f"Tool Use - Parse: Detected {len(tool_calls)} tool calls: {[call[0] for call in tool_calls]}") - - return tool_calls, final_response, self.mentioned_users - - except Exception as e: - logger.error(f"Tool Use - Parse: Error parsing tool calls: {e}") - # Return empty tool calls but preserve original response - return [], response, {} + return None diff --git a/discord_glhf/handlers/tools.py b/discord_glhf/handlers/tools.py index af62732..5b0c945 100644 --- a/discord_glhf/handlers/tools.py +++ b/discord_glhf/handlers/tools.py @@ -1,25 +1,32 @@ """Tools available for LLM use and their documentation.""" -# Tool Markers Configuration +# Tool Format Configuration TOOL_MARKERS = { - "start": "[TOOL:", - "end": "[/TOOL]", - "patterns": { - "mention": { - "start": "[TOOL:mention]", - "end": "[/TOOL]" - }, - "reaction": { - "start": "[TOOL:reaction]", - "end": "[/TOOL]" - }, - "embed": { - "start": "[TOOL:embed]", - "end": "[/TOOL]" - }, - "thread": { - "start": "[TOOL:thread]", - "end": "[/TOOL]" + "json": { + "start": "", + "end": "", + "format": "{\"name\": \"%s\", \"arguments\": %s}" + }, + "natural": { + "start": "[TOOL:", + "end": "[/TOOL]", + "patterns": { + "mention_user": { + "start": "[TOOL:mention]", + "end": "[/TOOL]" + }, + "add_reaction": { + "start": "[TOOL:reaction]", + "end": "[/TOOL]" + }, + "create_embed": { + "start": "[TOOL:embed]", + "end": "[/TOOL]" + }, + "create_thread": { + "start": "[TOOL:thread]", + "end": "[/TOOL]" + } } } } @@ -31,8 +38,8 @@ TOOLS = { "parameters": { "name": "The username or nickname to mention", }, - "example": "[TOOL:mention]@username[/TOOL]", - "usage": "Wrap mentions with [TOOL:mention] markers: [TOOL:mention]@john[/TOOL]" + "example": "Hey [TOOL:mention]@username[/TOOL], what do you think?", + "usage": "Use [TOOL:mention] to mention users: [TOOL:mention]@john[/TOOL]" }, "add_reaction": { @@ -40,8 +47,8 @@ TOOLS = { "parameters": { "emoji": "The emoji to add as a reaction. Can be Unicode emoji, custom emoji (<:name:id>), or standard emoji (:name:)" }, - "example": "[TOOL:reaction]😊[/TOOL] or [TOOL:reaction]:smile:[/TOOL]", - "usage": "Wrap emojis with [TOOL:reaction] markers" + "example": "That's awesome! [TOOL:reaction]👍[/TOOL]", + "usage": "Use [TOOL:reaction] for reactions: [TOOL:reaction]😊[/TOOL] or [TOOL:reaction]:smile:[/TOOL]" }, "create_embed": { @@ -53,12 +60,12 @@ TOOLS = { }, "example": """ [TOOL:embed] - Title Goes Here - This is the description content - It can span multiple lines + Poll Results + First place: X + Second place: Y [/TOOL] """, - "usage": "Wrap embed content with [TOOL:embed] markers" + "usage": "Use [TOOL:embed] for embeds, with title on first line and content on following lines" }, "create_thread": { @@ -67,36 +74,53 @@ TOOLS = { "name": "The name/topic for the thread", "message_id": "Optional ID of message to create thread from" }, - "example": "[TOOL:thread]Let's discuss X vs Y[/TOOL] or [TOOL:thread]This coaster is overrated[/TOOL]", - "usage": "Wrap thread topics with [TOOL:thread] markers" + "example": "[TOOL:thread]Let's discuss X vs Y[/TOOL]", + "usage": "Use [TOOL:thread] to create discussion threads: [TOOL:thread]Is this coaster overrated?[/TOOL]" } } # Tool Usage Guidelines USAGE_GUIDELINES = """ -Tools must be used with explicit markers in conversation: +Tools must be used with [TOOL:type] markers in natural language: 1. To mention a user: - Example: "Hey [TOOL:mention]@john[/TOOL], what do you think?" + [TOOL:mention]@username[/TOOL] + Examples: + - "Hey [TOOL:mention]@john[/TOOL], what's up?" + - "[TOOL:mention]@sarah[/TOOL] check this out!" 2. To add reactions: - Example: "That's a great point! [TOOL:reaction]👍[/TOOL]" + [TOOL:reaction]emoji[/TOOL] + Examples: + - "That's awesome! [TOOL:reaction]👍[/TOOL]" + - "LOL [TOOL:reaction]🤣[/TOOL]" + - "Nice! [TOOL:reaction]:heart:[/TOOL]" 3. To create embeds: + [TOOL:embed] + Title goes here + Content starts here + Can have multiple lines + [/TOOL] Example: [TOOL:embed] - Poll Results - First place: X - Second place: Y + Today's Poll + 1. First option + 2. Second option [/TOOL] 4. To create threads: + [TOOL:thread]thread topic[/TOOL] Examples: - - [TOOL:thread]Let's compare X vs Y[/TOOL] - - [TOOL:thread]Is this coaster overrated?[/TOOL] - - [TOOL:thread]Time for a safety discussion[/TOOL] + - "[TOOL:thread]Let's compare X vs Y[/TOOL]" + - "[TOOL:thread]Is this ride overrated?[/TOOL]" + - "[TOOL:thread]Safety discussion[/TOOL]" -Note: Always ensure tool markers are properly closed with their corresponding end tags. +Important Rules: +1. ALWAYS use [TOOL:type]...[/TOOL] format +2. Include tools naturally in your sentences +3. Make sure to close all tool tags properly +4. Never use any other format (no JSON, no <>) """ # Error Messages