fn_call_converter.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. """Convert function calling messages to non-function calling messages and vice versa.
  2. This will inject prompts so that models that doesn't support function calling
  3. can still be used with function calling agents.
  4. We follow format from: https://docs.litellm.ai/docs/completion/function_call
  5. """
  6. import copy
  7. import json
  8. import re
  9. from typing import Iterable
  10. from litellm import ChatCompletionToolParam
  11. from openhands.core.exceptions import (
  12. FunctionCallConversionError,
  13. FunctionCallValidationError,
  14. )
  15. # Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b
  16. SYSTEM_PROMPT_SUFFIX_TEMPLATE = """
  17. You have access to the following functions:
  18. {description}
  19. If you choose to call a function ONLY reply in the following format with NO suffix:
  20. <function=example_function_name>
  21. <parameter=example_parameter_1>value_1</parameter>
  22. <parameter=example_parameter_2>
  23. This is the value for the second parameter
  24. that can span
  25. multiple lines
  26. </parameter>
  27. </function>
  28. <IMPORTANT>
  29. Reminder:
  30. - Function calls MUST follow the specified format, start with <function= and end with </function>
  31. - Required parameters MUST be specified
  32. - Only call one function at a time
  33. - You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after.
  34. - 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
  35. """
  36. STOP_WORDS = ['</function']
  37. # NOTE: we need to make sure this example is always in-sync with the tool interface designed in openhands/agenthub/codeact_agent/function_calling.py
  38. IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = """
  39. Here's a running example of how to perform a task with the provided tools.
  40. --------------------- START OF EXAMPLE ---------------------
  41. USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
  42. ASSISTANT: Sure! Let me first check the current directory:
  43. <function=execute_bash>
  44. <parameter=command>
  45. pwd && ls
  46. </parameter>
  47. </function>
  48. USER: EXECUTION RESULT of [execute_bash]:
  49. /workspace
  50. openhands@runtime:~/workspace$
  51. ASSISTANT: There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
  52. <function=str_replace_editor>
  53. <parameter=command>create</parameter>
  54. <parameter=path>/workspace/app.py</parameter>
  55. <parameter=file_text>
  56. from flask import Flask
  57. app = Flask(__name__)
  58. @app.route('/')
  59. def index():
  60. numbers = list(range(1, 11))
  61. return str(numbers)
  62. if __name__ == '__main__':
  63. app.run(port=5000)
  64. </parameter>
  65. </function>
  66. USER: EXECUTION RESULT of [str_replace_editor]:
  67. File created successfully at: /workspace/app.py
  68. 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:
  69. <function=execute_bash>
  70. <parameter=command>
  71. python3 app.py > server.log 2>&1 &
  72. </parameter>
  73. </function>
  74. USER: EXECUTION RESULT of [execute_bash]:
  75. [1] 121
  76. [1]+ Exit 1 python3 app.py > server.log 2>&1
  77. ASSISTANT: Looks like the server is running with PID 121 then crashed. Let me check the server log:
  78. <function=execute_bash>
  79. <parameter=command>
  80. cat server.log
  81. </parameter>
  82. </function>
  83. USER: EXECUTION RESULT of [execute_bash]:
  84. Traceback (most recent call last):
  85. File "/workspace/app.py", line 2, in <module>
  86. from flask import Flask
  87. ModuleNotFoundError: No module named 'flask'
  88. ASSISTANT: Looks like the server crashed because the `flask` module is not installed. Let me install the `flask` module for you:
  89. <function=execute_bash>
  90. <parameter=command>
  91. pip3 install flask
  92. </parameter>
  93. </function>
  94. USER: EXECUTION RESULT of [execute_bash]:
  95. Defaulting to user installation because normal site-packages is not writeable
  96. Collecting flask
  97. Using cached flask-3.0.3-py3-none-any.whl (101 kB)
  98. Collecting blinker>=1.6.2
  99. Using cached blinker-1.7.0-py3-none-any.whl (13 kB)
  100. Collecting Werkzeug>=3.0.0
  101. Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB)
  102. Collecting click>=8.1.3
  103. Using cached click-8.1.7-py3-none-any.whl (97 kB)
  104. Collecting itsdangerous>=2.1.2
  105. Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB)
  106. Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3)
  107. Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5)
  108. Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask
  109. Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0
  110. ASSISTANT: The `flask` module is installed successfully. Let me run the Python file again:
  111. <function=execute_bash>
  112. <parameter=command>
  113. python3 app.py > server.log 2>&1 &
  114. </parameter>
  115. </function>
  116. USER: EXECUTION RESULT of [execute_bash]:
  117. [1] 124
  118. ASSISTANT:
  119. Let me check the server log again:
  120. <function=execute_bash>
  121. <parameter=command>
  122. cat server.log
  123. </parameter>
  124. </function>
  125. USER: EXECUTION RESULT of [execute_bash]:
  126. * Serving Flask app 'app'
  127. * Debug mode: off
  128. WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
  129. * Running on http://127.0.0.1:5000
  130. Press CTRL+C to quit
  131. ASSISTANT:
  132. 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!
  133. USER: Now kill the server, make it display the numbers in a table format.
  134. ASSISTANT:
  135. Sure! Let me stop the server first:
  136. <function=execute_bash>
  137. <parameter=command>
  138. kill 124
  139. </parameter>
  140. </function>
  141. USER: EXECUTION RESULT of [execute_bash]:
  142. [1]+ Terminated python3 app.py > server.log 2>&1
  143. ASSISTANT:
  144. Now let me display the numbers in a table format:
  145. <function=str_replace_editor>
  146. <parameter=command>str_replace</parameter>
  147. <parameter=path>/workspace/app.py</parameter>
  148. <parameter=old_str>return str(numbers)</parameter>
  149. <parameter=new_str>return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'</parameter>
  150. </function>
  151. USER: EXECUTION RESULT of [str_replace_editor]:
  152. The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
  153. 3
  154. 4 @app.route('/')
  155. 5 def index():
  156. 6 numbers = list(range(1, 11))
  157. 7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
  158. 8
  159. 9 if __name__ == '__main__':
  160. 10 app.run(port=5000)
  161. Review the changes and make sure they are as expected. Edit the file again if necessary.
  162. ASSISTANT:
  163. Running the updated file:
  164. <function=execute_bash>
  165. <parameter=command>
  166. <parameter=command>
  167. python3 app.py > server.log 2>&1 &
  168. </parameter>
  169. </function>
  170. USER: EXECUTION RESULT of [execute_bash]:
  171. [1] 126
  172. ASSISTANT:
  173. 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!
  174. <function=finish>
  175. </function>
  176. --------------------- END OF EXAMPLE ---------------------
  177. Do NOT assume the environment is the same as in the example above.
  178. --------------------- NEW TASK DESCRIPTION ---------------------
  179. """.lstrip()
  180. IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """
  181. --------------------- END OF NEW TASK DESCRIPTION ---------------------
  182. PLEASE follow the format strictly! PLEASE EMIT ONE AND ONLY ONE FUNCTION CALL PER MESSAGE.
  183. """.lstrip()
  184. # Regex patterns for function call parsing
  185. FN_REGEX_PATTERN = r'<function=([^>]+)>\n(.*?)</function>'
  186. FN_PARAM_REGEX_PATTERN = r'<parameter=([^>]+)>(.*?)</parameter>'
  187. # Add new regex pattern for tool execution results
  188. TOOL_RESULT_REGEX_PATTERN = r'EXECUTION RESULT of \[(.*?)\]:\n(.*)'
  189. def convert_tool_call_to_string(tool_call: dict) -> str:
  190. """Convert tool call to content in string format."""
  191. if 'function' not in tool_call:
  192. raise FunctionCallConversionError("Tool call must contain 'function' key.")
  193. if 'id' not in tool_call:
  194. raise FunctionCallConversionError("Tool call must contain 'id' key.")
  195. if 'type' not in tool_call:
  196. raise FunctionCallConversionError("Tool call must contain 'type' key.")
  197. if tool_call['type'] != 'function':
  198. raise FunctionCallConversionError("Tool call type must be 'function'.")
  199. ret = f"<function={tool_call['function']['name']}>\n"
  200. try:
  201. args = json.loads(tool_call['function']['arguments'])
  202. except json.JSONDecodeError as e:
  203. raise FunctionCallConversionError(
  204. f"Failed to parse arguments as JSON. Arguments: {tool_call['function']['arguments']}"
  205. ) from e
  206. for param_name, param_value in args.items():
  207. is_multiline = isinstance(param_value, str) and '\n' in param_value
  208. ret += f'<parameter={param_name}>'
  209. if is_multiline:
  210. ret += '\n'
  211. ret += f'{param_value}'
  212. if is_multiline:
  213. ret += '\n'
  214. ret += '</parameter>\n'
  215. ret += '</function>'
  216. return ret
  217. def convert_tools_to_description(tools: list[dict]) -> str:
  218. ret = ''
  219. for i, tool in enumerate(tools):
  220. assert tool['type'] == 'function'
  221. fn = tool['function']
  222. if i > 0:
  223. ret += '\n'
  224. ret += f"---- BEGIN FUNCTION #{i+1}: {fn['name']} ----\n"
  225. ret += f"Description: {fn['description']}\n"
  226. if 'parameters' in fn:
  227. ret += 'Parameters:\n'
  228. properties = fn['parameters'].get('properties', {})
  229. required_params = set(fn['parameters'].get('required', []))
  230. for j, (param_name, param_info) in enumerate(properties.items()):
  231. # Indicate required/optional in parentheses with type
  232. is_required = param_name in required_params
  233. param_status = 'required' if is_required else 'optional'
  234. param_type = param_info.get('type', 'string')
  235. # Get parameter description
  236. desc = param_info.get('description', 'No description provided')
  237. # Handle enum values if present
  238. if 'enum' in param_info:
  239. enum_values = ', '.join(f'`{v}`' for v in param_info['enum'])
  240. desc += f'\nAllowed values: [{enum_values}]'
  241. ret += (
  242. f' ({j+1}) {param_name} ({param_type}, {param_status}): {desc}\n'
  243. )
  244. else:
  245. ret += 'No parameters are required for this function.\n'
  246. ret += f'---- END FUNCTION #{i+1} ----\n'
  247. return ret
  248. def convert_fncall_messages_to_non_fncall_messages(
  249. messages: list[dict],
  250. tools: list[ChatCompletionToolParam],
  251. add_in_context_learning_example: bool = True,
  252. ) -> list[dict]:
  253. """Convert function calling messages to non-function calling messages."""
  254. messages = copy.deepcopy(messages)
  255. formatted_tools = convert_tools_to_description(tools)
  256. system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format(
  257. description=formatted_tools
  258. )
  259. converted_messages = []
  260. first_user_message_encountered = False
  261. for message in messages:
  262. role = message['role']
  263. content = message['content']
  264. # 1. SYSTEM MESSAGES
  265. # append system prompt suffix to content
  266. if role == 'system':
  267. if isinstance(content, str):
  268. content += system_prompt_suffix
  269. elif isinstance(content, list):
  270. if content and content[-1]['type'] == 'text':
  271. content[-1]['text'] += system_prompt_suffix
  272. else:
  273. content.append({'type': 'text', 'text': system_prompt_suffix})
  274. else:
  275. raise FunctionCallConversionError(
  276. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  277. )
  278. converted_messages.append({'role': 'system', 'content': content})
  279. # 2. USER MESSAGES (no change)
  280. elif role == 'user':
  281. # Add in-context learning example for the first user message
  282. if not first_user_message_encountered and add_in_context_learning_example:
  283. first_user_message_encountered = True
  284. # Check tools
  285. if not (
  286. tools
  287. and len(tools) > 0
  288. and any(
  289. (
  290. tool['type'] == 'function'
  291. and tool['function']['name'] == 'execute_bash'
  292. and 'command'
  293. in tool['function']['parameters']['properties']
  294. )
  295. for tool in tools
  296. )
  297. and any(
  298. (
  299. tool['type'] == 'function'
  300. and tool['function']['name'] == 'str_replace_editor'
  301. and 'path' in tool['function']['parameters']['properties']
  302. and 'file_text'
  303. in tool['function']['parameters']['properties']
  304. and 'old_str'
  305. in tool['function']['parameters']['properties']
  306. and 'new_str'
  307. in tool['function']['parameters']['properties']
  308. )
  309. for tool in tools
  310. )
  311. ):
  312. raise FunctionCallConversionError(
  313. 'The currently provided tool set are NOT compatible with the in-context learning example for FnCall to Non-FnCall conversion. '
  314. 'Please update your tool set OR the in-context learning example in openhands/llm/fn_call_converter.py'
  315. )
  316. # add in-context learning example
  317. if isinstance(content, str):
  318. content = (
  319. IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
  320. + content
  321. + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
  322. )
  323. elif isinstance(content, list):
  324. if content and content[0]['type'] == 'text':
  325. content[0]['text'] = (
  326. IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
  327. + content[0]['text']
  328. + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
  329. )
  330. else:
  331. content = (
  332. [
  333. {
  334. 'type': 'text',
  335. 'text': IN_CONTEXT_LEARNING_EXAMPLE_PREFIX,
  336. }
  337. ]
  338. + content
  339. + [
  340. {
  341. 'type': 'text',
  342. 'text': IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX,
  343. }
  344. ]
  345. )
  346. else:
  347. raise FunctionCallConversionError(
  348. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  349. )
  350. converted_messages.append(
  351. {
  352. 'role': 'user',
  353. 'content': content,
  354. }
  355. )
  356. # 3. ASSISTANT MESSAGES
  357. # - 3.1 no change if no function call
  358. # - 3.2 change if function call
  359. elif role == 'assistant':
  360. if 'tool_calls' in message and message['tool_calls'] is not None:
  361. if len(message['tool_calls']) != 1:
  362. raise FunctionCallConversionError(
  363. 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}'
  364. )
  365. try:
  366. tool_content = convert_tool_call_to_string(message['tool_calls'][0])
  367. except FunctionCallConversionError as e:
  368. raise FunctionCallConversionError(
  369. f'Failed to convert tool call to string.\nCurrent tool call: {message["tool_calls"][0]}.\nRaw messages: {json.dumps(messages, indent=2)}'
  370. ) from e
  371. if isinstance(content, str):
  372. content += '\n\n' + tool_content
  373. content = content.lstrip()
  374. elif isinstance(content, list):
  375. if content and content[-1]['type'] == 'text':
  376. content[-1]['text'] += '\n\n' + tool_content
  377. content[-1]['text'] = content[-1]['text'].lstrip()
  378. else:
  379. content.append({'type': 'text', 'text': tool_content})
  380. else:
  381. raise FunctionCallConversionError(
  382. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  383. )
  384. converted_messages.append({'role': 'assistant', 'content': content})
  385. # 4. TOOL MESSAGES (tool outputs)
  386. elif role == 'tool':
  387. # Convert tool result as user message
  388. tool_name = message.get('name', 'function')
  389. prefix = f'EXECUTION RESULT of [{tool_name}]:\n'
  390. # and omit "tool_call_id" AND "name"
  391. if isinstance(content, str):
  392. content = prefix + content
  393. elif isinstance(content, list):
  394. if content and content[-1]['type'] == 'text':
  395. content[-1]['text'] = prefix + content[-1]['text']
  396. else:
  397. content = [{'type': 'text', 'text': prefix}] + content
  398. else:
  399. raise FunctionCallConversionError(
  400. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  401. )
  402. converted_messages.append({'role': 'user', 'content': content})
  403. else:
  404. raise FunctionCallConversionError(
  405. f'Unexpected role {role}. Expected system, user, assistant or tool.'
  406. )
  407. return converted_messages
  408. def _extract_and_validate_params(
  409. matching_tool: dict, param_matches: Iterable[re.Match], fn_name: str
  410. ) -> dict:
  411. params = {}
  412. # Parse and validate parameters
  413. required_params = set()
  414. if 'parameters' in matching_tool and 'required' in matching_tool['parameters']:
  415. required_params = set(matching_tool['parameters'].get('required', []))
  416. allowed_params = set()
  417. if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']:
  418. allowed_params = set(matching_tool['parameters']['properties'].keys())
  419. param_name_to_type = {}
  420. if 'parameters' in matching_tool and 'properties' in matching_tool['parameters']:
  421. param_name_to_type = {
  422. name: val.get('type', 'string')
  423. for name, val in matching_tool['parameters']['properties'].items()
  424. }
  425. # Collect parameters
  426. found_params = set()
  427. for param_match in param_matches:
  428. param_name = param_match.group(1)
  429. param_value = param_match.group(2).strip()
  430. # Validate parameter is allowed
  431. if allowed_params and param_name not in allowed_params:
  432. raise FunctionCallValidationError(
  433. f"Parameter '{param_name}' is not allowed for function '{fn_name}'. "
  434. f'Allowed parameters: {allowed_params}'
  435. )
  436. # Validate and convert parameter type
  437. # supported: string, integer, array
  438. if param_name in param_name_to_type:
  439. if param_name_to_type[param_name] == 'integer':
  440. try:
  441. param_value = int(param_value)
  442. except ValueError:
  443. raise FunctionCallValidationError(
  444. f"Parameter '{param_name}' is expected to be an integer."
  445. )
  446. elif param_name_to_type[param_name] == 'array':
  447. try:
  448. param_value = json.loads(param_value)
  449. except json.JSONDecodeError:
  450. raise FunctionCallValidationError(
  451. f"Parameter '{param_name}' is expected to be an array."
  452. )
  453. else:
  454. # string
  455. pass
  456. # Enum check
  457. if 'enum' in matching_tool['parameters']['properties'][param_name]:
  458. if (
  459. param_value
  460. not in matching_tool['parameters']['properties'][param_name]['enum']
  461. ):
  462. raise FunctionCallValidationError(
  463. f"Parameter '{param_name}' is expected to be one of {matching_tool['parameters']['properties'][param_name]['enum']}."
  464. )
  465. params[param_name] = param_value
  466. found_params.add(param_name)
  467. # Check all required parameters are present
  468. missing_params = required_params - found_params
  469. if missing_params:
  470. raise FunctionCallValidationError(
  471. f"Missing required parameters for function '{fn_name}': {missing_params}"
  472. )
  473. return params
  474. def _fix_stopword(content: str) -> str:
  475. """Fix the issue when some LLM would NOT return the stopword."""
  476. if '<function=' in content and content.count('<function=') == 1:
  477. if content.endswith('</'):
  478. content = content.rstrip() + 'function>'
  479. else:
  480. content = content + '\n</function>'
  481. return content
  482. def convert_non_fncall_messages_to_fncall_messages(
  483. messages: list[dict],
  484. tools: list[ChatCompletionToolParam],
  485. ) -> list[dict]:
  486. """Convert non-function calling messages back to function calling messages."""
  487. messages = copy.deepcopy(messages)
  488. formatted_tools = convert_tools_to_description(tools)
  489. system_prompt_suffix = SYSTEM_PROMPT_SUFFIX_TEMPLATE.format(
  490. description=formatted_tools
  491. )
  492. converted_messages = []
  493. tool_call_counter = 1 # Counter for tool calls
  494. first_user_message_encountered = False
  495. for message in messages:
  496. role, content = message['role'], message['content']
  497. content = content or '' # handle cases where content is None
  498. # For system messages, remove the added suffix
  499. if role == 'system':
  500. if isinstance(content, str):
  501. # Remove the suffix if present
  502. content = content.split(system_prompt_suffix)[0]
  503. elif isinstance(content, list):
  504. if content and content[-1]['type'] == 'text':
  505. # Remove the suffix from the last text item
  506. content[-1]['text'] = content[-1]['text'].split(
  507. system_prompt_suffix
  508. )[0]
  509. converted_messages.append({'role': 'system', 'content': content})
  510. # Skip user messages (no conversion needed)
  511. elif role == 'user':
  512. # Check & replace in-context learning example
  513. if not first_user_message_encountered:
  514. first_user_message_encountered = True
  515. if isinstance(content, str):
  516. content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, '')
  517. content = content.replace(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, '')
  518. elif isinstance(content, list):
  519. for item in content:
  520. if item['type'] == 'text':
  521. item['text'] = item['text'].replace(
  522. IN_CONTEXT_LEARNING_EXAMPLE_PREFIX, ''
  523. )
  524. item['text'] = item['text'].replace(
  525. IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, ''
  526. )
  527. else:
  528. raise FunctionCallConversionError(
  529. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  530. )
  531. # Check for tool execution result pattern
  532. if isinstance(content, str):
  533. tool_result_match = re.search(
  534. TOOL_RESULT_REGEX_PATTERN, content, re.DOTALL
  535. )
  536. elif isinstance(content, list):
  537. tool_result_match = next(
  538. (
  539. _match
  540. for item in content
  541. if item.get('type') == 'text'
  542. and (
  543. _match := re.search(
  544. TOOL_RESULT_REGEX_PATTERN, item['text'], re.DOTALL
  545. )
  546. )
  547. ),
  548. None,
  549. )
  550. else:
  551. raise FunctionCallConversionError(
  552. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  553. )
  554. if tool_result_match:
  555. if not (
  556. isinstance(content, str)
  557. or (
  558. isinstance(content, list)
  559. and len(content) == 1
  560. and content[0].get('type') == 'text'
  561. )
  562. ):
  563. raise FunctionCallConversionError(
  564. f'Expected str or list with one text item when tool result is present in the message. Content: {content}'
  565. )
  566. tool_name = tool_result_match.group(1)
  567. tool_result = tool_result_match.group(2).strip()
  568. # Convert to tool message format
  569. converted_messages.append(
  570. {
  571. 'role': 'tool',
  572. 'name': tool_name,
  573. 'content': [{'type': 'text', 'text': tool_result}]
  574. if isinstance(content, list)
  575. else tool_result,
  576. 'tool_call_id': f'toolu_{tool_call_counter-1:02d}', # Use last generated ID
  577. }
  578. )
  579. else:
  580. converted_messages.append({'role': 'user', 'content': content})
  581. # Handle assistant messages
  582. elif role == 'assistant':
  583. if isinstance(content, str):
  584. content = _fix_stopword(content)
  585. fn_match = re.search(FN_REGEX_PATTERN, content, re.DOTALL)
  586. elif isinstance(content, list):
  587. if content and content[-1]['type'] == 'text':
  588. content[-1]['text'] = _fix_stopword(content[-1]['text'])
  589. fn_match = re.search(
  590. FN_REGEX_PATTERN, content[-1]['text'], re.DOTALL
  591. )
  592. else:
  593. fn_match = None
  594. fn_match_exists = any(
  595. item.get('type') == 'text'
  596. and re.search(FN_REGEX_PATTERN, item['text'], re.DOTALL)
  597. for item in content
  598. )
  599. if fn_match_exists and not fn_match:
  600. raise FunctionCallConversionError(
  601. f'Expecting function call in the LAST index of content list. But got content={content}'
  602. )
  603. else:
  604. raise FunctionCallConversionError(
  605. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  606. )
  607. if fn_match:
  608. fn_name = fn_match.group(1)
  609. fn_body = fn_match.group(2)
  610. matching_tool = next(
  611. (
  612. tool['function']
  613. for tool in tools
  614. if tool['type'] == 'function'
  615. and tool['function']['name'] == fn_name
  616. ),
  617. None,
  618. )
  619. # Validate function exists in tools
  620. if not matching_tool:
  621. raise FunctionCallValidationError(
  622. f"Function '{fn_name}' not found in available tools: {[tool['function']['name'] for tool in tools if tool['type'] == 'function']}"
  623. )
  624. # Parse parameters
  625. param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL)
  626. params = _extract_and_validate_params(
  627. matching_tool, param_matches, fn_name
  628. )
  629. # Create tool call with unique ID
  630. tool_call_id = f'toolu_{tool_call_counter:02d}'
  631. tool_call = {
  632. 'index': 1, # always 1 because we only support **one tool call per message**
  633. 'id': tool_call_id,
  634. 'type': 'function',
  635. 'function': {'name': fn_name, 'arguments': json.dumps(params)},
  636. }
  637. tool_call_counter += 1 # Increment counter
  638. # Remove the function call part from content
  639. if isinstance(content, list):
  640. assert content and content[-1]['type'] == 'text'
  641. content[-1]['text'] = (
  642. content[-1]['text'].split('<function=')[0].strip()
  643. )
  644. elif isinstance(content, str):
  645. content = content.split('<function=')[0].strip()
  646. else:
  647. raise FunctionCallConversionError(
  648. f'Unexpected content type {type(content)}. Expected str or list. Content: {content}'
  649. )
  650. converted_messages.append(
  651. {'role': 'assistant', 'content': content, 'tool_calls': [tool_call]}
  652. )
  653. else:
  654. # No function call, keep message as is
  655. converted_messages.append(message)
  656. else:
  657. raise FunctionCallConversionError(
  658. f'Unexpected role {role}. Expected system, user, or assistant in non-function calling messages.'
  659. )
  660. return converted_messages
  661. def convert_from_multiple_tool_calls_to_single_tool_call_messages(
  662. messages: list[dict],
  663. ignore_final_tool_result: bool = False,
  664. ) -> list[dict]:
  665. """Break one message with multiple tool calls into multiple messages."""
  666. converted_messages = []
  667. pending_tool_calls: dict[str, dict] = {}
  668. for message in messages:
  669. role, content = message['role'], message['content']
  670. if role == 'assistant':
  671. if message.get('tool_calls') and len(message['tool_calls']) > 1:
  672. # handle multiple tool calls by breaking them into multiple messages
  673. for i, tool_call in enumerate(message['tool_calls']):
  674. pending_tool_calls[tool_call['id']] = {
  675. 'role': 'assistant',
  676. 'content': content if i == 0 else '',
  677. 'tool_calls': [tool_call],
  678. }
  679. else:
  680. converted_messages.append(message)
  681. elif role == 'tool':
  682. if message['tool_call_id'] in pending_tool_calls:
  683. # remove the tool call from the pending list
  684. _tool_call_message = pending_tool_calls.pop(message['tool_call_id'])
  685. converted_messages.append(_tool_call_message)
  686. # add the tool result
  687. converted_messages.append(message)
  688. else:
  689. assert (
  690. len(pending_tool_calls) == 0
  691. ), f'Found pending tool calls but not found in pending list: {pending_tool_calls=}'
  692. converted_messages.append(message)
  693. else:
  694. assert (
  695. len(pending_tool_calls) == 0
  696. ), f'Found pending tool calls but not expect to handle it with role {role}: {pending_tool_calls=}, {message=}'
  697. converted_messages.append(message)
  698. if not ignore_final_tool_result and len(pending_tool_calls) > 0:
  699. raise FunctionCallConversionError(
  700. f'Found pending tool calls but no tool result: {pending_tool_calls=}'
  701. )
  702. return converted_messages