Spaces:
Build error
Build error
| """Convert function calling messages to non-function calling messages and vice versa. | |
| This will inject prompts so that models that doesn't support function calling | |
| can still be used with function calling agents. | |
| We follow format from: https://docs.litellm.ai/docs/completion/function_call | |
| """ | |
| import copy | |
| import json | |
| import re | |
| import sys | |
| from typing import Iterable | |
| from litellm import ChatCompletionToolParam | |
| from openhands.core.exceptions import ( | |
| FunctionCallConversionError, | |
| FunctionCallValidationError, | |
| ) | |
| from openhands.llm.tool_names import ( | |
| BROWSER_TOOL_NAME, | |
| EXECUTE_BASH_TOOL_NAME, | |
| FINISH_TOOL_NAME, | |
| STR_REPLACE_EDITOR_TOOL_NAME, | |
| ) | |
| # Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b | |
| SYSTEM_PROMPT_SUFFIX_TEMPLATE = """ | |
| You have access to the following functions: | |
| {description} | |
| If you choose to call a function ONLY reply in the following format with NO suffix: | |
| <function=example_function_name> | |
| <parameter=example_parameter_1>value_1</parameter> | |
| <parameter=example_parameter_2> | |
| This is the value for the second parameter | |
| that can span | |
| multiple lines | |
| </parameter> | |
| </function> | |
| <IMPORTANT> | |
| Reminder: | |
| - Function calls MUST follow the specified format, start with <function= and end with </function> | |
| - Required parameters MUST be specified | |
| - Only call one function at a time | |
| - You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after. | |
| - If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls | |
| </IMPORTANT> | |
| """ | |
| STOP_WORDS = ['</function'] | |
| def refine_prompt(prompt: str) -> str: | |
| if sys.platform == 'win32': | |
| return prompt.replace('bash', 'powershell') | |
| return prompt | |
| # NOTE: we need to make sure these examples are always in-sync with the tool interface designed in openhands/agenthub/codeact_agent/function_calling.py | |
| # Example snippets for each tool | |
| TOOL_EXAMPLES = { | |
| 'execute_bash': { | |
| 'check_dir': """ | |
| ASSISTANT: Sure! Let me first check the current directory: | |
| <function=execute_bash> | |
| <parameter=command> | |
| pwd && ls | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| /workspace | |
| openhands@runtime:~/workspace$ | |
| """, | |
| 'run_server': """ | |
| ASSISTANT: | |
| Let me run the Python file for you: | |
| <function=execute_bash> | |
| <parameter=command> | |
| python3 app.py > server.log 2>&1 & | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| [1] 121 | |
| [1]+ Exit 1 python3 app.py > server.log 2>&1 | |
| ASSISTANT: | |
| Looks like the server was running with PID 121 then crashed. Let me check the server log: | |
| <function=execute_bash> | |
| <parameter=command> | |
| cat server.log | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| Traceback (most recent call last): | |
| File "/workspace/app.py", line 2, in <module> | |
| from flask import Flask | |
| ModuleNotFoundError: No module named 'flask' | |
| ASSISTANT: | |
| Looks like the server crashed because the `flask` module is not installed. Let me install the `flask` module for you: | |
| <function=execute_bash> | |
| <parameter=command> | |
| pip3 install flask | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| Defaulting to user installation because normal site-packages is not writeable | |
| Collecting flask | |
| Using cached flask-3.0.3-py3-none-any.whl (101 kB) | |
| Collecting blinker>=1.6.2 | |
| Using cached blinker-1.7.0-py3-none-any.whl (13 kB) | |
| Collecting Werkzeug>=3.0.0 | |
| Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB) | |
| Collecting click>=8.1.3 | |
| Using cached click-8.1.7-py3-none-any.whl (97 kB) | |
| Collecting itsdangerous>=2.1.2 | |
| Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB) | |
| Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3) | |
| Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5) | |
| Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask | |
| Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0 | |
| ASSISTANT: | |
| The `flask` module is installed successfully. Let me run the Python file again: | |
| <function=execute_bash> | |
| <parameter=command> | |
| python3 app.py > server.log 2>&1 & | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| [1] 124 | |
| ASSISTANT: | |
| Let me check the server log again: | |
| <function=execute_bash> | |
| <parameter=command> | |
| cat server.log | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| * Serving Flask app 'app' | |
| * Debug mode: off | |
| WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead. | |
| * Running on http://127.0.0.1:5000 | |
| Press CTRL+C to quit | |
| ASSISTANT: | |
| The server is running on port 5000 with PID 124. You can access the list of numbers by visiting http://127.0.0.1:5000. If you have any further questions, feel free to ask! | |
| """, | |
| 'kill_server': """ | |
| USER: Now kill the server, make it display the numbers in a table format. | |
| ASSISTANT: | |
| Sure! Let me stop the server first: | |
| <function=execute_bash> | |
| <parameter=command> | |
| kill 124 | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| [1]+ Terminated python3 app.py > server.log 2>&1 | |
| """, | |
| 'run_server_again': """ | |
| ASSISTANT: | |
| Running the updated file: | |
| <function=execute_bash> | |
| <parameter=command> | |
| python3 app.py > server.log 2>&1 & | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [execute_bash]: | |
| [1] 126 | |
| ASSISTANT: | |
| The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. | |
| """, | |
| }, | |
| 'str_replace_editor': { | |
| 'create_file': """ | |
| ASSISTANT: | |
| There is no `app.py` file in the current directory. Let me create a Python file `app.py`: | |
| <function=str_replace_editor> | |
| <parameter=command>create</parameter> | |
| <parameter=path>/workspace/app.py</parameter> | |
| <parameter=file_text> | |
| from flask import Flask | |
| app = Flask(__name__) | |
| @app.route('/') | |
| def index(): | |
| numbers = list(range(1, 11)) | |
| return str(numbers) | |
| if __name__ == '__main__': | |
| app.run(port=5000) | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [str_replace_editor]: | |
| File created successfully at: /workspace/app.py | |
| """, | |
| 'edit_file': """ | |
| ASSISTANT: | |
| Now let me display the numbers in a table format: | |
| <function=str_replace_editor> | |
| <parameter=command>str_replace</parameter> | |
| <parameter=path>/workspace/app.py</parameter> | |
| <parameter=old_str>return str(numbers)</parameter> | |
| <parameter=new_str>return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'</parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [str_replace_editor]: | |
| The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py: | |
| 3 | |
| 4 @app.route('/') | |
| 5 def index(): | |
| 6 numbers = list(range(1, 11)) | |
| 7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>' | |
| 8 | |
| 9 if __name__ == '__main__': | |
| 10 app.run(port=5000) | |
| Review the changes and make sure they are as expected. Edit the file again if necessary. | |
| """, | |
| }, | |
| 'browser': { | |
| 'view_page': """ | |
| ASSISTANT: | |
| Let me check how the page looks in the browser: | |
| <function=browser> | |
| <parameter=code> | |
| goto('http://127.0.0.1:5000') | |
| noop(1000) # Wait for page to load | |
| </parameter> | |
| </function> | |
| USER: EXECUTION RESULT of [browser]: | |
| [Browser shows the numbers in a table format] | |
| """ | |
| }, | |
| 'finish': { | |
| 'task_completed': """ | |
| ASSISTANT: | |
| The server is running on port 5000 with PID 126. You can access the list of numbers in a table format by visiting http://127.0.0.1:5000. Let me know if you have any further requests! | |
| <function=finish> | |
| <parameter=message>The task has been completed. The web server is running and displaying numbers 1-10 in a table format at http://127.0.0.1:5000.</parameter> | |
| <parameter=task_completed>true</parameter> | |
| </function> | |
| """ | |
| }, | |
| } | |
| def get_example_for_tools(tools: list[dict]) -> str: | |
| """Generate an in-context learning example based on available tools.""" | |
| available_tools = set() | |
| for tool in tools: | |
| if tool['type'] == 'function': | |
| name = tool['function']['name'] | |
| if name == EXECUTE_BASH_TOOL_NAME: | |
| available_tools.add('execute_bash') | |
| elif name == STR_REPLACE_EDITOR_TOOL_NAME: | |
| available_tools.add('str_replace_editor') | |
| elif name == BROWSER_TOOL_NAME: | |
| available_tools.add('browser') | |
| elif name == FINISH_TOOL_NAME: | |
| available_tools.add('finish') | |
| if not available_tools: | |
| return '' | |
| example = """Here's a running example of how to perform a task with the provided tools. | |
| --------------------- START OF EXAMPLE --------------------- | |
| USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000. | |
| """ | |
| # Build example based on available tools | |
| if 'execute_bash' in available_tools: | |
| example += TOOL_EXAMPLES['execute_bash']['check_dir'] | |
| if 'str_replace_editor' in available_tools: | |
| example += TOOL_EXAMPLES['str_replace_editor']['create_file'] | |
| if 'execute_bash' in available_tools: | |
| example += TOOL_EXAMPLES['execute_bash']['run_server'] | |
| if 'browser' in available_tools: | |
| example += TOOL_EXAMPLES['browser']['view_page'] | |
| if 'execute_bash' in available_tools: | |
| example += TOOL_EXAMPLES['execute_bash']['kill_server'] | |
| if 'str_replace_editor' in available_tools: | |
| example += TOOL_EXAMPLES['str_replace_editor']['edit_file'] | |
| if 'execute_bash' in available_tools: | |
| example += TOOL_EXAMPLES['execute_bash']['run_server_again'] | |
| if 'finish' in available_tools: | |
| example += TOOL_EXAMPLES['finish']['task_completed'] | |
| example += """ | |
| --------------------- END OF EXAMPLE --------------------- | |
| Do NOT assume the environment is the same as in the example above. | |
| --------------------- NEW TASK DESCRIPTION --------------------- | |
| """ | |
| example = example.lstrip() | |
| return example | |
| IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools | |
| IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """ | |
| --------------------- END OF NEW TASK DESCRIPTION --------------------- | |
| PLEASE follow the format strictly! PLEASE EMIT ONE AND ONLY ONE FUNCTION CALL PER MESSAGE. | |
| """ | |
| # Regex patterns for function call parsing | |
| FN_REGEX_PATTERN = r'<function=([^>]+)>\n(.*?)</function>' | |
| FN_PARAM_REGEX_PATTERN = r'<parameter=([^>]+)>(.*?)</parameter>' | |
| # Add new regex pattern for tool execution results | |
| TOOL_RESULT_REGEX_PATTERN = r'EXECUTION RESULT of \[(.*?)\]:\n(.*)' | |
| def convert_tool_call_to_string(tool_call: dict) -> str: | |
| """Convert tool call to content in string format.""" | |
| if 'function' not in tool_call: | |
| raise FunctionCallConversionError("Tool call must contain 'function' key.") | |
| if 'id' not in tool_call: | |
| raise FunctionCallConversionError("Tool call must contain 'id' key.") | |
| if 'type' not in tool_call: | |
| raise FunctionCallConversionError("Tool call must contain 'type' key.") | |
| if tool_call['type'] != 'function': | |
| raise FunctionCallConversionError("Tool call type must be 'function'.") | |
| ret = f'<function={tool_call["function"]["name"]}>\n' | |
| try: | |
| args = json.loads(tool_call['function']['arguments']) | |
| except json.JSONDecodeError as e: | |
| raise FunctionCallConversionError( | |
| f'Failed to parse arguments as JSON. Arguments: {tool_call["function"]["arguments"]}' | |
| ) from e | |
| for param_name, param_value in args.items(): | |
| is_multiline = isinstance(param_value, str) and '\n' in param_value | |
| ret += f'<parameter={param_name}>' | |
| if is_multiline: | |
| ret += '\n' | |
| ret += f'{param_value}' | |
| if is_multiline: | |
| ret += '\n' | |
| ret += '</parameter>\n' | |
| ret += '</function>' | |
| return ret | |
| def convert_tools_to_description(tools: list[dict]) -> str: | |
| ret = '' | |
| for i, tool in enumerate(tools): | |
| assert tool['type'] == 'function' | |
| fn = tool['function'] | |
| if i > 0: | |
| ret += '\n' | |
| ret += f'---- BEGIN FUNCTION #{i + 1}: {fn["name"]} ----\n' | |
| ret += f'Description: {fn["description"]}\n' | |
| if 'parameters' in fn: | |
| ret += 'Parameters:\n' | |
| properties = fn['parameters'].get('properties', {}) | |
| required_params = set(fn['parameters'].get('required', [])) | |
| for j, (param_name, param_info) in enumerate(properties.items()): | |
| # Indicate required/optional in parentheses with type | |
| is_required = param_name in required_params | |
| param_status = 'required' if is_required else 'optional' | |
| param_type = param_info.get('type', 'string') | |
| # Get parameter description | |
| desc = param_info.get('description', 'No description provided') | |
| # Handle enum values if present | |
| if 'enum' in param_info: | |
| enum_values = ', '.join(f'`{v}`' for v in param_info['enum']) | |
| desc += f'\nAllowed values: [{enum_values}]' | |
| ret += ( | |
| f' ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n' | |
| ) | |
| else: | |
| ret += 'No parameters are required for this function.\n' | |
| ret += f'---- END FUNCTION #{i + 1} ----\n' | |
| return ret | |
| def convert_fncall_messages_to_non_fncall_messages( | |
| messages: list[dict], | |
| tools: list[ChatCompletionToolParam], | |
| add_in_context_learning_example: bool = True, | |
| ) -> list[dict]: | |
| """Convert function calling messages to non-function calling messages.""" | |
| messages = copy.deepcopy(messages) | |
| formatted_tools = convert_tools_to_description(tools) | |
| system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format( | |
| description=formatted_tools | |
| ) | |
| converted_messages = [] | |
| first_user_message_encountered = False | |
| for message in messages: | |
| role = message['role'] | |
| content = message['content'] | |
| # 1. SYSTEM MESSAGES | |
| # append system prompt suffix to content | |
| if role == 'system': | |
| if isinstance(content, str): | |
| content += system_prompt_suffix | |
| elif isinstance(content, list): | |
| if content and content[-1]['type'] == 'text': | |
| content[-1]['text'] += system_prompt_suffix | |
| else: | |
| content.append({'type': 'text', 'text': system_prompt_suffix}) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| converted_messages.append({'role': 'system', 'content': content}) | |
| # 2. USER MESSAGES (no change) | |
| elif role == 'user': | |
| # Add in-context learning example for the first user message | |
| if not first_user_message_encountered and add_in_context_learning_example: | |
| first_user_message_encountered = True | |
| # Generate example based on available tools | |
| example = IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools) | |
| # Add example if we have any tools | |
| if example: | |
| # add in-context learning example | |
| if isinstance(content, str): | |
| content = example + content + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX | |
| elif isinstance(content, list): | |
| if content and content[0]['type'] == 'text': | |
| content[0]['text'] = ( | |
| example | |
| + content[0]['text'] | |
| + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX | |
| ) | |
| else: | |
| content = ( | |
| [ | |
| { | |
| 'type': 'text', | |
| 'text': example, | |
| } | |
| ] | |
| + content | |
| + [ | |
| { | |
| 'type': 'text', | |
| 'text': IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, | |
| } | |
| ] | |
| ) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| converted_messages.append( | |
| { | |
| 'role': 'user', | |
| 'content': content, | |
| } | |
| ) | |
| # 3. ASSISTANT MESSAGES | |
| # - 3.1 no change if no function call | |
| # - 3.2 change if function call | |
| elif role == 'assistant': | |
| if 'tool_calls' in message and message['tool_calls'] is not None: | |
| if len(message['tool_calls']) != 1: | |
| raise FunctionCallConversionError( | |
| f'Expected exactly one tool call in the message. More than one tool call is not supported. But got {len(message["tool_calls"])} tool calls. Content: {content}' | |
| ) | |
| try: | |
| tool_content = convert_tool_call_to_string(message['tool_calls'][0]) | |
| except FunctionCallConversionError as e: | |
| raise FunctionCallConversionError( | |
| f'Failed to convert tool call to string.\nCurrent tool call: {message["tool_calls"][0]}.\nRaw messages: {json.dumps(messages, indent=2)}' | |
| ) from e | |
| if isinstance(content, str): | |
| content += '\n\n' + tool_content | |
| content = content.lstrip() | |
| elif isinstance(content, list): | |
| if content and content[-1]['type'] == 'text': | |
| content[-1]['text'] += '\n\n' + tool_content | |
| content[-1]['text'] = content[-1]['text'].lstrip() | |
| else: | |
| content.append({'type': 'text', 'text': tool_content}) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| converted_messages.append({'role': 'assistant', 'content': content}) | |
| # 4. TOOL MESSAGES (tool outputs) | |
| elif role == 'tool': | |
| # Convert tool result as user message | |
| tool_name = message.get('name', 'function') | |
| prefix = f'EXECUTION RESULT of [{tool_name}]:\n' | |
| # and omit "tool_call_id" AND "name" | |
| if isinstance(content, str): | |
| content = prefix + content | |
| elif isinstance(content, list): | |
| if content and ( | |
| first_text_content := next( | |
| (c for c in content if c['type'] == 'text'), None | |
| ) | |
| ): | |
| first_text_content['text'] = prefix + first_text_content['text'] | |
| else: | |
| content = [{'type': 'text', 'text': prefix}] + content | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| if 'cache_control' in message: | |
| content[-1]['cache_control'] = {'type': 'ephemeral'} | |
| converted_messages.append({'role': 'user', 'content': content}) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected role {role}. Expected system, user, assistant or tool.' | |
| ) | |
| return converted_messages | |
| def _extract_and_validate_params( | |
| matching_tool: dict, param_matches: Iterable[re.Match], fn_name: str | |
| ) -> dict: | |
| params = {} | |
| # Parse and validate parameters | |
| required_params = set() | |
| if 'parameters' in matching_tool and 'required' in matching_tool['parameters']: | |
| required_params = set(matching_tool['parameters'].get('required', [])) | |
| allowed_params = set() | |
| if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']: | |
| allowed_params = set(matching_tool['parameters']['properties'].keys()) | |
| param_name_to_type = {} | |
| if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']: | |
| param_name_to_type = { | |
| name: val.get('type', 'string') | |
| for name, val in matching_tool['parameters']['properties'].items() | |
| } | |
| # Collect parameters | |
| found_params = set() | |
| for param_match in param_matches: | |
| param_name = param_match.group(1) | |
| param_value = param_match.group(2) | |
| # Validate parameter is allowed | |
| if allowed_params and param_name not in allowed_params: | |
| raise FunctionCallValidationError( | |
| f"Parameter '{param_name}' is not allowed for function '{fn_name}'. " | |
| f'Allowed parameters: {allowed_params}' | |
| ) | |
| # Validate and convert parameter type | |
| # supported: string, integer, array | |
| if param_name in param_name_to_type: | |
| if param_name_to_type[param_name] == 'integer': | |
| try: | |
| param_value = int(param_value) | |
| except ValueError: | |
| raise FunctionCallValidationError( | |
| f"Parameter '{param_name}' is expected to be an integer." | |
| ) | |
| elif param_name_to_type[param_name] == 'array': | |
| try: | |
| param_value = json.loads(param_value) | |
| except json.JSONDecodeError: | |
| raise FunctionCallValidationError( | |
| f"Parameter '{param_name}' is expected to be an array." | |
| ) | |
| else: | |
| # string | |
| pass | |
| # Enum check | |
| if 'enum' in matching_tool['parameters']['properties'][param_name]: | |
| if ( | |
| param_value | |
| not in matching_tool['parameters']['properties'][param_name]['enum'] | |
| ): | |
| raise FunctionCallValidationError( | |
| f"Parameter '{param_name}' is expected to be one of {matching_tool['parameters']['properties'][param_name]['enum']}." | |
| ) | |
| params[param_name] = param_value | |
| found_params.add(param_name) | |
| # Check all required parameters are present | |
| missing_params = required_params - found_params | |
| if missing_params: | |
| raise FunctionCallValidationError( | |
| f"Missing required parameters for function '{fn_name}': {missing_params}" | |
| ) | |
| return params | |
| def _fix_stopword(content: str) -> str: | |
| """Fix the issue when some LLM would NOT return the stopword.""" | |
| if '<function=' in content and content.count('<function=') == 1: | |
| if content.endswith('</'): | |
| content = content.rstrip() + 'function>' | |
| else: | |
| content = content + '\n</function>' | |
| return content | |
| def convert_non_fncall_messages_to_fncall_messages( | |
| messages: list[dict], | |
| tools: list[ChatCompletionToolParam], | |
| ) -> list[dict]: | |
| """Convert non-function calling messages back to function calling messages.""" | |
| messages = copy.deepcopy(messages) | |
| formatted_tools = convert_tools_to_description(tools) | |
| system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format( | |
| description=formatted_tools | |
| ) | |
| converted_messages = [] | |
| tool_call_counter = 1 # Counter for tool calls | |
| first_user_message_encountered = False | |
| for message in messages: | |
| role, content = message['role'], message['content'] | |
| content = content or '' # handle cases where content is None | |
| # For system messages, remove the added suffix | |
| if role == 'system': | |
| if isinstance(content, str): | |
| # Remove the suffix if present | |
| content = content.split(system_prompt_suffix)[0] | |
| elif isinstance(content, list): | |
| if content and content[-1]['type'] == 'text': | |
| # Remove the suffix from the last text item | |
| content[-1]['text'] = content[-1]['text'].split( | |
| system_prompt_suffix | |
| )[0] | |
| converted_messages.append({'role': 'system', 'content': content}) | |
| # Skip user messages (no conversion needed) | |
| elif role == 'user': | |
| # Check & replace in-context learning example | |
| if not first_user_message_encountered: | |
| first_user_message_encountered = True | |
| if isinstance(content, str): | |
| # Remove any existing example | |
| if content.startswith(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)): | |
| content = content.replace( | |
| IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools), '', 1 | |
| ) | |
| if content.endswith(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX): | |
| content = content.replace( | |
| IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '', 1 | |
| ) | |
| elif isinstance(content, list): | |
| for item in content: | |
| if item['type'] == 'text': | |
| # Remove any existing example | |
| example = IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools) | |
| if item['text'].startswith(example): | |
| item['text'] = item['text'].replace(example, '', 1) | |
| if item['text'].endswith( | |
| IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX | |
| ): | |
| item['text'] = item['text'].replace( | |
| IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '', 1 | |
| ) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| # Check for tool execution result pattern | |
| if isinstance(content, str): | |
| tool_result_match = re.search( | |
| TOOL_RESULT_REGEX_PATTERN, content, re.DOTALL | |
| ) | |
| elif isinstance(content, list): | |
| tool_result_match = next( | |
| ( | |
| _match | |
| for item in content | |
| if item.get('type') == 'text' | |
| and ( | |
| _match := re.search( | |
| TOOL_RESULT_REGEX_PATTERN, item['text'], re.DOTALL | |
| ) | |
| ) | |
| ), | |
| None, | |
| ) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| if tool_result_match: | |
| if not ( | |
| isinstance(content, str) | |
| or ( | |
| isinstance(content, list) | |
| and len(content) == 1 | |
| and content[0].get('type') == 'text' | |
| ) | |
| ): | |
| raise FunctionCallConversionError( | |
| f'Expected str or list with one text item when tool result is present in the message. Content: {content}' | |
| ) | |
| tool_name = tool_result_match.group(1) | |
| tool_result = tool_result_match.group(2).strip() | |
| # Convert to tool message format | |
| converted_messages.append( | |
| { | |
| 'role': 'tool', | |
| 'name': tool_name, | |
| 'content': [{'type': 'text', 'text': tool_result}] | |
| if isinstance(content, list) | |
| else tool_result, | |
| 'tool_call_id': f'toolu_{tool_call_counter - 1:02d}', # Use last generated ID | |
| } | |
| ) | |
| else: | |
| converted_messages.append({'role': 'user', 'content': content}) | |
| # Handle assistant messages | |
| elif role == 'assistant': | |
| if isinstance(content, str): | |
| content = _fix_stopword(content) | |
| fn_match = re.search(FN_REGEX_PATTERN, content, re.DOTALL) | |
| elif isinstance(content, list): | |
| if content and content[-1]['type'] == 'text': | |
| content[-1]['text'] = _fix_stopword(content[-1]['text']) | |
| fn_match = re.search( | |
| FN_REGEX_PATTERN, content[-1]['text'], re.DOTALL | |
| ) | |
| else: | |
| fn_match = None | |
| fn_match_exists = any( | |
| item.get('type') == 'text' | |
| and re.search(FN_REGEX_PATTERN, item['text'], re.DOTALL) | |
| for item in content | |
| ) | |
| if fn_match_exists and not fn_match: | |
| raise FunctionCallConversionError( | |
| f'Expecting function call in the LAST index of content list. But got content={content}' | |
| ) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| if fn_match: | |
| fn_name = fn_match.group(1) | |
| fn_body = fn_match.group(2) | |
| matching_tool = next( | |
| ( | |
| tool['function'] | |
| for tool in tools | |
| if tool['type'] == 'function' | |
| and tool['function']['name'] == fn_name | |
| ), | |
| None, | |
| ) | |
| # Validate function exists in tools | |
| if not matching_tool: | |
| raise FunctionCallValidationError( | |
| f"Function '{fn_name}' not found in available tools: {[tool['function']['name'] for tool in tools if tool['type'] == 'function']}" | |
| ) | |
| # Parse parameters | |
| param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL) | |
| params = _extract_and_validate_params( | |
| matching_tool, param_matches, fn_name | |
| ) | |
| # Create tool call with unique ID | |
| tool_call_id = f'toolu_{tool_call_counter:02d}' | |
| tool_call = { | |
| 'index': 1, # always 1 because we only support **one tool call per message** | |
| 'id': tool_call_id, | |
| 'type': 'function', | |
| 'function': {'name': fn_name, 'arguments': json.dumps(params)}, | |
| } | |
| tool_call_counter += 1 # Increment counter | |
| # Remove the function call part from content | |
| if isinstance(content, list): | |
| assert content and content[-1]['type'] == 'text' | |
| content[-1]['text'] = ( | |
| content[-1]['text'].split('<function=')[0].strip() | |
| ) | |
| elif isinstance(content, str): | |
| content = content.split('<function=')[0].strip() | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}' | |
| ) | |
| converted_messages.append( | |
| {'role': 'assistant', 'content': content, 'tool_calls': [tool_call]} | |
| ) | |
| else: | |
| # No function call, keep message as is | |
| converted_messages.append(message) | |
| else: | |
| raise FunctionCallConversionError( | |
| f'Unexpected role {role}. Expected system, user, or assistant in non-function calling messages.' | |
| ) | |
| return converted_messages | |
| def convert_from_multiple_tool_calls_to_single_tool_call_messages( | |
| messages: list[dict], | |
| ignore_final_tool_result: bool = False, | |
| ) -> list[dict]: | |
| """Break one message with multiple tool calls into multiple messages.""" | |
| converted_messages = [] | |
| pending_tool_calls: dict[str, dict] = {} | |
| for message in messages: | |
| role, content = message['role'], message['content'] | |
| if role == 'assistant': | |
| if message.get('tool_calls') and len(message['tool_calls']) > 1: | |
| # handle multiple tool calls by breaking them into multiple messages | |
| for i, tool_call in enumerate(message['tool_calls']): | |
| pending_tool_calls[tool_call['id']] = { | |
| 'role': 'assistant', | |
| 'content': content if i == 0 else '', | |
| 'tool_calls': [tool_call], | |
| } | |
| else: | |
| converted_messages.append(message) | |
| elif role == 'tool': | |
| if message['tool_call_id'] in pending_tool_calls: | |
| # remove the tool call from the pending list | |
| _tool_call_message = pending_tool_calls.pop(message['tool_call_id']) | |
| converted_messages.append(_tool_call_message) | |
| # add the tool result | |
| converted_messages.append(message) | |
| else: | |
| assert len(pending_tool_calls) == 0, ( | |
| f'Found pending tool calls but not found in pending list: {pending_tool_calls=}' | |
| ) | |
| converted_messages.append(message) | |
| else: | |
| assert len(pending_tool_calls) == 0, ( | |
| f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}' | |
| ) | |
| converted_messages.append(message) | |
| if not ignore_final_tool_result and len(pending_tool_calls) > 0: | |
| raise FunctionCallConversionError( | |
| f'Found pending tool calls but no tool result: {pending_tool_calls=}' | |
| ) | |
| return converted_messages | |