| """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
|
| from typing import Iterable
|
|
|
| from litellm import ChatCompletionToolParam
|
|
|
| from openhands.core.exceptions import (
|
| FunctionCallConversionError,
|
| FunctionCallValidationError,
|
| )
|
|
|
|
|
| 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
|
| """
|
|
|
| STOP_WORDS = ['</function']
|
|
|
|
|
| IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = """
|
| 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.
|
|
|
| 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$
|
|
|
| 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
|
|
|
| ASSISTANT: I have created a Python file `app.py` that will display a list of numbers from 1 to 10 when you run it. 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 is 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!
|
|
|
| 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
|
|
|
| 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.
|
|
|
| ASSISTANT:
|
| Running the updated file:
|
| <function=execute_bash>
|
| <parameter=command>
|
| <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. Let me know if you have any further requests!
|
| <function=finish>
|
| </function>
|
|
|
| --------------------- END OF EXAMPLE ---------------------
|
|
|
| Do NOT assume the environment is the same as in the example above.
|
|
|
| --------------------- NEW TASK DESCRIPTION ---------------------
|
| """.lstrip()
|
|
|
| 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.
|
| """
|
|
|
|
|
| FN_REGEX_PATTERN = r'<function=([^>]+)>\n(.*?)</function>'
|
| FN_PARAM_REGEX_PATTERN = r'<parameter=([^>]+)>(.*?)</parameter>'
|
|
|
|
|
| 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()):
|
|
|
| is_required = param_name in required_params
|
| param_status = 'required' if is_required else 'optional'
|
| param_type = param_info.get('type', 'string')
|
|
|
|
|
| desc = param_info.get('description', 'No description provided')
|
|
|
|
|
| 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']
|
|
|
|
|
|
|
| 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})
|
|
|
|
|
| elif role == 'user':
|
|
|
| if not first_user_message_encountered and add_in_context_learning_example:
|
| first_user_message_encountered = True
|
|
|
| if not (
|
| tools
|
| and len(tools) > 0
|
| and any(
|
| (
|
| tool['type'] == 'function'
|
| and tool['function']['name'] == 'execute_bash'
|
| and 'command'
|
| in tool['function']['parameters']['properties']
|
| )
|
| for tool in tools
|
| )
|
| and any(
|
| (
|
| tool['type'] == 'function'
|
| and tool['function']['name'] == 'str_replace_editor'
|
| and 'path' in tool['function']['parameters']['properties']
|
| and 'file_text'
|
| in tool['function']['parameters']['properties']
|
| and 'old_str'
|
| in tool['function']['parameters']['properties']
|
| and 'new_str'
|
| in tool['function']['parameters']['properties']
|
| )
|
| for tool in tools
|
| )
|
| ):
|
| raise FunctionCallConversionError(
|
| 'The currently provided tool set are NOT compatible with the in-context learning example for FnCall to Non-FnCall conversion. '
|
| 'Please update your tool set OR the in-context learning example in openhands/llm/fn_call_converter.py'
|
| )
|
|
|
|
|
| if isinstance(content, str):
|
| content = (
|
| IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
|
| + content
|
| + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
|
| )
|
| elif isinstance(content, list):
|
| if content and content[0]['type'] == 'text':
|
| content[0]['text'] = (
|
| IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
|
| + content[0]['text']
|
| + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
|
| )
|
| else:
|
| content = (
|
| [
|
| {
|
| 'type': 'text',
|
| 'text': IN_CONTEXT_LEARNING_EXAMPLE_PREFIX,
|
| }
|
| ]
|
| + 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,
|
| }
|
| )
|
|
|
|
|
|
|
|
|
| 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})
|
|
|
|
|
| elif role == 'tool':
|
|
|
| tool_name = message.get('name', 'function')
|
| prefix = f'EXECUTION RESULT of [{tool_name}]:\n'
|
|
|
| if isinstance(content, str):
|
| content = prefix + content
|
| elif isinstance(content, list):
|
| if content and content[-1]['type'] == 'text':
|
| content[-1]['text'] = prefix + content[-1]['text']
|
| else:
|
| content = [{'type': 'text', 'text': prefix}] + content
|
| else:
|
| raise FunctionCallConversionError(
|
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
|
| )
|
| 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 = {}
|
|
|
| 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()
|
| }
|
|
|
|
|
| found_params = set()
|
| for param_match in param_matches:
|
| param_name = param_match.group(1)
|
| param_value = param_match.group(2).strip()
|
|
|
|
|
| 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}'
|
| )
|
|
|
|
|
|
|
| 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:
|
|
|
| pass
|
|
|
|
|
| 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)
|
|
|
|
|
| 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
|
|
|
| first_user_message_encountered = False
|
| for message in messages:
|
| role, content = message['role'], message['content']
|
| content = content or ''
|
|
|
| if role == 'system':
|
| if isinstance(content, str):
|
|
|
| content = content.split(system_prompt_suffix)[0]
|
| elif isinstance(content, list):
|
| if content and content[-1]['type'] == 'text':
|
|
|
| content[-1]['text'] = content[-1]['text'].split(
|
| system_prompt_suffix
|
| )[0]
|
| converted_messages.append({'role': 'system', 'content': content})
|
|
|
| elif role == 'user':
|
|
|
| if not first_user_message_encountered:
|
| first_user_message_encountered = True
|
| if isinstance(content, str):
|
| content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, '')
|
| content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '')
|
| elif isinstance(content, list):
|
| for item in content:
|
| if item['type'] == 'text':
|
| item['text'] = item['text'].replace(
|
| IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, ''
|
| )
|
| item['text'] = item['text'].replace(
|
| IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, ''
|
| )
|
| else:
|
| raise FunctionCallConversionError(
|
| f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
|
| )
|
|
|
|
|
| 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()
|
|
|
|
|
| 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}',
|
| }
|
| )
|
| else:
|
| converted_messages.append({'role': 'user', 'content': content})
|
|
|
|
|
| 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,
|
| )
|
|
|
| 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']}"
|
| )
|
|
|
|
|
| param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL)
|
| params = _extract_and_validate_params(
|
| matching_tool, param_matches, fn_name
|
| )
|
|
|
|
|
| tool_call_id = f'toolu_{tool_call_counter:02d}'
|
| tool_call = {
|
| 'index': 1,
|
| 'id': tool_call_id,
|
| 'type': 'function',
|
| 'function': {'name': fn_name, 'arguments': json.dumps(params)},
|
| }
|
| tool_call_counter += 1
|
|
|
|
|
| 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:
|
|
|
| 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:
|
|
|
| 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:
|
|
|
| _tool_call_message = pending_tool_calls.pop(message['tool_call_id'])
|
| converted_messages.append(_tool_call_message)
|
|
|
| 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
|
|
|