Enhance tool response handling and parsing; update tool markers configuration and usage guidelines

This commit is contained in:
pacnpal
2025-02-24 22:57:45 -05:00
parent 57eff11433
commit 4f94600375
4 changed files with 239 additions and 224 deletions

View File

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

View File

@@ -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 {}

View File

@@ -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?:[a-zA-Z0-9_]+:\d+>|:[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

View File

@@ -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": "<tool_call>",
"end": "</tool_call>",
"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