test_llm_fncall_converter.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. """Test for FunctionCallingConverter."""
  2. import copy
  3. import json
  4. import pytest
  5. from litellm import ChatCompletionToolParam
  6. from openhands.llm.fn_call_converter import (
  7. IN_CONTEXT_LEARNING_EXAMPLE_PREFIX,
  8. IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX,
  9. FunctionCallConversionError,
  10. convert_fncall_messages_to_non_fncall_messages,
  11. convert_from_multiple_tool_calls_to_single_tool_call_messages,
  12. convert_non_fncall_messages_to_fncall_messages,
  13. convert_tool_call_to_string,
  14. convert_tools_to_description,
  15. )
  16. FNCALL_TOOLS: list[ChatCompletionToolParam] = [
  17. {
  18. 'type': 'function',
  19. 'function': {
  20. 'name': 'execute_bash',
  21. 'description': 'Execute a bash command in the terminal.\n* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.\n* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.\n* Timeout: If a command execution result says "Command timed out. Sending SIGINT to the process", the assistant should retry running the command in the background.\n',
  22. 'parameters': {
  23. 'type': 'object',
  24. 'properties': {
  25. 'command': {
  26. 'type': 'string',
  27. 'description': 'The bash command to execute. Can be empty to view additional logs when previous exit code is `-1`. Can be `ctrl+c` to interrupt the currently running process.',
  28. }
  29. },
  30. 'required': ['command'],
  31. },
  32. },
  33. },
  34. {
  35. 'type': 'function',
  36. 'function': {
  37. 'name': 'finish',
  38. 'description': 'Finish the interaction when the task is complete OR if the assistant cannot proceed further with the task.',
  39. },
  40. },
  41. {
  42. 'type': 'function',
  43. 'function': {
  44. 'name': 'str_replace_editor',
  45. 'description': 'Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n* The `undo_edit` command will revert the last edit made to the file at `path`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`\n',
  46. 'parameters': {
  47. 'type': 'object',
  48. 'properties': {
  49. 'command': {
  50. 'description': 'The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.',
  51. 'enum': [
  52. 'view',
  53. 'create',
  54. 'str_replace',
  55. 'insert',
  56. 'undo_edit',
  57. ],
  58. 'type': 'string',
  59. },
  60. 'path': {
  61. 'description': 'Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.',
  62. 'type': 'string',
  63. },
  64. 'file_text': {
  65. 'description': 'Required parameter of `create` command, with the content of the file to be created.',
  66. 'type': 'string',
  67. },
  68. 'old_str': {
  69. 'description': 'Required parameter of `str_replace` command containing the string in `path` to replace.',
  70. 'type': 'string',
  71. },
  72. 'new_str': {
  73. 'description': 'Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.',
  74. 'type': 'string',
  75. },
  76. 'insert_line': {
  77. 'description': 'Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.',
  78. 'type': 'integer',
  79. },
  80. 'view_range': {
  81. 'description': 'Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.',
  82. 'items': {'type': 'integer'},
  83. 'type': 'array',
  84. },
  85. },
  86. 'required': ['command', 'path'],
  87. },
  88. },
  89. },
  90. ]
  91. def test_convert_tools_to_description():
  92. formatted_tools = convert_tools_to_description(FNCALL_TOOLS)
  93. print(formatted_tools)
  94. assert (
  95. formatted_tools.strip()
  96. == """---- BEGIN FUNCTION #1: execute_bash ----
  97. Description: Execute a bash command in the terminal.
  98. * Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
  99. * Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.
  100. * Timeout: If a command execution result says "Command timed out. Sending SIGINT to the process", the assistant should retry running the command in the background.
  101. Parameters:
  102. (1) command (string, required): The bash command to execute. Can be empty to view additional logs when previous exit code is `-1`. Can be `ctrl+c` to interrupt the currently running process.
  103. ---- END FUNCTION #1 ----
  104. ---- BEGIN FUNCTION #2: finish ----
  105. Description: Finish the interaction when the task is complete OR if the assistant cannot proceed further with the task.
  106. No parameters are required for this function.
  107. ---- END FUNCTION #2 ----
  108. ---- BEGIN FUNCTION #3: str_replace_editor ----
  109. Description: Custom editing tool for viewing, creating and editing files
  110. * State is persistent across command calls and discussions with the user
  111. * If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
  112. * The `create` command cannot be used if the specified `path` already exists as a file
  113. * If a `command` generates a long output, it will be truncated and marked with `<response clipped>`
  114. * The `undo_edit` command will revert the last edit made to the file at `path`
  115. Notes for using the `str_replace` command:
  116. * The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!
  117. * If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique
  118. * The `new_str` parameter should contain the edited lines that should replace the `old_str`
  119. Parameters:
  120. (1) command (string, required): The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
  121. Allowed values: [`view`, `create`, `str_replace`, `insert`, `undo_edit`]
  122. (2) path (string, required): Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.
  123. (3) file_text (string, optional): Required parameter of `create` command, with the content of the file to be created.
  124. (4) old_str (string, optional): Required parameter of `str_replace` command containing the string in `path` to replace.
  125. (5) new_str (string, optional): Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.
  126. (6) insert_line (integer, optional): Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.
  127. (7) view_range (array, optional): Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.
  128. ---- END FUNCTION #3 ----""".strip()
  129. )
  130. FNCALL_MESSAGES = [
  131. {
  132. 'content': [
  133. {
  134. 'type': 'text',
  135. 'text': "You are a helpful assistant that can interact with a computer to solve tasks.\n<IMPORTANT>\n* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.\n</IMPORTANT>\n\n",
  136. 'cache_control': {'type': 'ephemeral'},
  137. }
  138. ],
  139. 'role': 'system',
  140. },
  141. {
  142. 'content': [
  143. {
  144. 'type': 'text',
  145. 'text': "<uploaded_files>\n/workspace/astropy__astropy__5.1\n</uploaded_files>\nI've uploaded a python code repository in the directory astropy__astropy__5.1. LONG DESCRIPTION:\n\n",
  146. }
  147. ],
  148. 'role': 'user',
  149. },
  150. {
  151. 'content': [
  152. {
  153. 'type': 'text',
  154. 'text': "I'll help you implement the necessary changes to meet the requirements. Let's follow the steps:\n\n1. First, let's explore the repository structure:",
  155. }
  156. ],
  157. 'role': 'assistant',
  158. 'tool_calls': [
  159. {
  160. 'index': 1,
  161. 'function': {
  162. 'arguments': '{"command": "ls -la /workspace/astropy__astropy__5.1"}',
  163. 'name': 'execute_bash',
  164. },
  165. 'id': 'toolu_01',
  166. 'type': 'function',
  167. }
  168. ],
  169. },
  170. {
  171. 'content': [
  172. {
  173. 'type': 'text',
  174. 'text': 'ls -la /workspace/astropy__astropy__5.1\r\nls: /workspace/astropy__astropy__5.1: Bad file descriptor\r\nlrwxrwxrwx 1 root root 8 Oct 28 21:58 /workspace/astropy__astropy__5.1 -> /testbed[Python Interpreter: /opt/miniconda3/envs/testbed/bin/python]\nroot@openhands-workspace:/workspace/astropy__astropy__5.1 # \n[Command finished with exit code 0]',
  175. }
  176. ],
  177. 'role': 'tool',
  178. 'tool_call_id': 'toolu_01',
  179. 'name': 'execute_bash',
  180. },
  181. {
  182. 'content': [
  183. {
  184. 'type': 'text',
  185. 'text': "I see there's a symlink. Let's explore the actual directory:",
  186. }
  187. ],
  188. 'role': 'assistant',
  189. 'tool_calls': [
  190. {
  191. 'index': 1,
  192. 'function': {
  193. 'arguments': '{"command": "ls -la /testbed"}',
  194. 'name': 'execute_bash',
  195. },
  196. 'id': 'toolu_02',
  197. 'type': 'function',
  198. }
  199. ],
  200. },
  201. {
  202. 'content': [
  203. {
  204. 'type': 'text',
  205. 'text': 'SOME OBSERVATION',
  206. }
  207. ],
  208. 'role': 'tool',
  209. 'tool_call_id': 'toolu_02',
  210. 'name': 'execute_bash',
  211. },
  212. {
  213. 'content': [
  214. {
  215. 'type': 'text',
  216. 'text': "Let's look at the source code file mentioned in the PR description:",
  217. }
  218. ],
  219. 'role': 'assistant',
  220. 'tool_calls': [
  221. {
  222. 'index': 1,
  223. 'function': {
  224. 'arguments': '{"command": "view", "path": "/testbed/astropy/io/fits/card.py"}',
  225. 'name': 'str_replace_editor',
  226. },
  227. 'id': 'toolu_03',
  228. 'type': 'function',
  229. }
  230. ],
  231. },
  232. {
  233. 'content': [
  234. {
  235. 'type': 'text',
  236. 'text': "Here's the result of running `cat -n` on /testbed/astropy/io/fits/card.py:\n 1\t# Licensed under a 3-clause BSD style license - see PYFITS.rst...VERY LONG TEXT",
  237. }
  238. ],
  239. 'role': 'tool',
  240. 'tool_call_id': 'toolu_03',
  241. 'name': 'str_replace_editor',
  242. },
  243. ]
  244. NON_FNCALL_MESSAGES = [
  245. {
  246. 'role': 'system',
  247. 'content': [
  248. {
  249. 'type': 'text',
  250. 'text': 'You are a helpful assistant that can interact with a computer to solve tasks.\n<IMPORTANT>\n* If user provides a path, you should NOT assume it\'s relative to the current working directory. Instead, you should explore the file system to find the file before working on it.\n</IMPORTANT>\n\n\nYou have access to the following functions:\n\n---- BEGIN FUNCTION #1: execute_bash ----\nDescription: Execute a bash command in the terminal.\n* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.\n* Interactive: If a bash command returns exit code `-1`, this means the process is not yet finished. The assistant must then send a second call to terminal with an empty `command` (which will retrieve any additional logs), or it can send additional text (set `command` to the text) to STDIN of the running process, or it can send command=`ctrl+c` to interrupt the process.\n* Timeout: If a command execution result says "Command timed out. Sending SIGINT to the process", the assistant should retry running the command in the background.\n\nParameters:\n (1) command (string, required): The bash command to execute. Can be empty to view additional logs when previous exit code is `-1`. Can be `ctrl+c` to interrupt the currently running process.\n---- END FUNCTION #1 ----\n\n---- BEGIN FUNCTION #2: finish ----\nDescription: Finish the interaction when the task is complete OR if the assistant cannot proceed further with the task.\nNo parameters are required for this function.\n---- END FUNCTION #2 ----\n\n---- BEGIN FUNCTION #3: str_replace_editor ----\nDescription: Custom editing tool for viewing, creating and editing files\n* State is persistent across command calls and discussions with the user\n* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep\n* The `create` command cannot be used if the specified `path` already exists as a file\n* If a `command` generates a long output, it will be truncated and marked with `<response clipped>`\n* The `undo_edit` command will revert the last edit made to the file at `path`\n\nNotes for using the `str_replace` command:\n* The `old_str` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n* If the `old_str` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `old_str` to make it unique\n* The `new_str` parameter should contain the edited lines that should replace the `old_str`\n\nParameters:\n (1) command (string, required): The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.\nAllowed values: [`view`, `create`, `str_replace`, `insert`, `undo_edit`]\n (2) path (string, required): Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.\n (3) file_text (string, optional): Required parameter of `create` command, with the content of the file to be created.\n (4) old_str (string, optional): Required parameter of `str_replace` command containing the string in `path` to replace.\n (5) new_str (string, optional): Optional parameter of `str_replace` command containing the new string (if not given, no string will be added). Required parameter of `insert` command containing the string to insert.\n (6) insert_line (integer, optional): Required parameter of `insert` command. The `new_str` will be inserted AFTER the line `insert_line` of `path`.\n (7) view_range (array, optional): Optional parameter of `view` command when `path` points to a file. If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file.\n---- END FUNCTION #3 ----\n\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<function=example_function_name>\n<parameter=example_parameter_1>value_1</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format, start with <function= and end with </function>\n- Required parameters MUST be specified\n- Only call one function at a time\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after.\n- 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\n',
  251. 'cache_control': {'type': 'ephemeral'},
  252. }
  253. ],
  254. },
  255. {
  256. 'content': [
  257. {
  258. 'type': 'text',
  259. 'text': IN_CONTEXT_LEARNING_EXAMPLE_PREFIX
  260. + "<uploaded_files>\n/workspace/astropy__astropy__5.1\n</uploaded_files>\nI've uploaded a python code repository in the directory astropy__astropy__5.1. LONG DESCRIPTION:\n\n"
  261. + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX,
  262. }
  263. ],
  264. 'role': 'user',
  265. },
  266. {
  267. 'role': 'assistant',
  268. 'content': [
  269. {
  270. 'type': 'text',
  271. 'text': "I'll help you implement the necessary changes to meet the requirements. Let's follow the steps:\n\n1. First, let's explore the repository structure:\n\n<function=execute_bash>\n<parameter=command>ls -la /workspace/astropy__astropy__5.1</parameter>\n</function>",
  272. }
  273. ],
  274. },
  275. {
  276. 'role': 'user',
  277. 'content': [
  278. {
  279. 'type': 'text',
  280. 'text': 'EXECUTION RESULT of [execute_bash]:\nls -la /workspace/astropy__astropy__5.1\r\nls: /workspace/astropy__astropy__5.1: Bad file descriptor\r\nlrwxrwxrwx 1 root root 8 Oct 28 21:58 /workspace/astropy__astropy__5.1 -> /testbed[Python Interpreter: /opt/miniconda3/envs/testbed/bin/python]\nroot@openhands-workspace:/workspace/astropy__astropy__5.1 # \n[Command finished with exit code 0]',
  281. }
  282. ],
  283. },
  284. {
  285. 'role': 'assistant',
  286. 'content': [
  287. {
  288. 'type': 'text',
  289. 'text': "I see there's a symlink. Let's explore the actual directory:\n\n<function=execute_bash>\n<parameter=command>ls -la /testbed</parameter>\n</function>",
  290. }
  291. ],
  292. },
  293. {
  294. 'role': 'user',
  295. 'content': [
  296. {
  297. 'type': 'text',
  298. 'text': 'EXECUTION RESULT of [execute_bash]:\nSOME OBSERVATION',
  299. }
  300. ],
  301. },
  302. {
  303. 'role': 'assistant',
  304. 'content': [
  305. {
  306. 'type': 'text',
  307. 'text': "Let's look at the source code file mentioned in the PR description:\n\n<function=str_replace_editor>\n<parameter=command>view</parameter>\n<parameter=path>/testbed/astropy/io/fits/card.py</parameter>\n</function>",
  308. }
  309. ],
  310. },
  311. {
  312. 'role': 'user',
  313. 'content': [
  314. {
  315. 'type': 'text',
  316. 'text': "EXECUTION RESULT of [str_replace_editor]:\nHere's the result of running `cat -n` on /testbed/astropy/io/fits/card.py:\n 1\t# Licensed under a 3-clause BSD style license - see PYFITS.rst...VERY LONG TEXT",
  317. }
  318. ],
  319. },
  320. ]
  321. FNCALL_RESPONSE_MESSAGE = {
  322. 'content': [
  323. {
  324. 'type': 'text',
  325. 'text': 'Let me search for the `_format_float` method mentioned in the PR description:',
  326. }
  327. ],
  328. 'role': 'assistant',
  329. 'tool_calls': [
  330. {
  331. 'index': 1,
  332. 'function': {
  333. 'arguments': '{"command": "grep -n \\"_format_float\\" /testbed/astropy/io/fits/card.py"}',
  334. 'name': 'execute_bash',
  335. },
  336. 'id': 'toolu_04',
  337. 'type': 'function',
  338. }
  339. ],
  340. }
  341. NON_FNCALL_RESPONSE_MESSAGE = {
  342. 'content': [
  343. {
  344. 'type': 'text',
  345. 'text': 'Let me search for the `_format_float` method mentioned in the PR description:\n\n<function=execute_bash>\n<parameter=command>grep -n "_format_float" /testbed/astropy/io/fits/card.py</parameter>\n</function>',
  346. }
  347. ],
  348. 'role': 'assistant',
  349. }
  350. @pytest.mark.parametrize(
  351. 'tool_calls, expected',
  352. [
  353. # Original test case
  354. (
  355. FNCALL_RESPONSE_MESSAGE['tool_calls'],
  356. """<function=execute_bash>
  357. <parameter=command>grep -n "_format_float" /testbed/astropy/io/fits/card.py</parameter>
  358. </function>""",
  359. ),
  360. # Test case with multiple parameters
  361. (
  362. [
  363. {
  364. 'index': 1,
  365. 'function': {
  366. 'arguments': '{"command": "view", "path": "/test/file.py", "view_range": [1, 10]}',
  367. 'name': 'str_replace_editor',
  368. },
  369. 'id': 'test_id',
  370. 'type': 'function',
  371. }
  372. ],
  373. """<function=str_replace_editor>
  374. <parameter=command>view</parameter>
  375. <parameter=path>/test/file.py</parameter>
  376. <parameter=view_range>[1, 10]</parameter>
  377. </function>""",
  378. ),
  379. ],
  380. )
  381. def test_convert_tool_call_to_string(tool_calls, expected):
  382. assert len(tool_calls) == 1
  383. converted = convert_tool_call_to_string(tool_calls[0])
  384. print(converted)
  385. assert converted == expected
  386. def test_convert_fncall_messages_to_non_fncall_messages():
  387. converted_non_fncall = convert_fncall_messages_to_non_fncall_messages(
  388. FNCALL_MESSAGES, FNCALL_TOOLS
  389. )
  390. assert converted_non_fncall == NON_FNCALL_MESSAGES
  391. def test_convert_non_fncall_messages_to_fncall_messages():
  392. converted = convert_non_fncall_messages_to_fncall_messages(
  393. NON_FNCALL_MESSAGES, FNCALL_TOOLS
  394. )
  395. print(json.dumps(converted, indent=2))
  396. assert converted == FNCALL_MESSAGES
  397. def test_two_way_conversion_nonfn_to_fn_to_nonfn():
  398. non_fncall_copy = copy.deepcopy(NON_FNCALL_MESSAGES)
  399. converted_fncall = convert_non_fncall_messages_to_fncall_messages(
  400. NON_FNCALL_MESSAGES, FNCALL_TOOLS
  401. )
  402. assert (
  403. non_fncall_copy == NON_FNCALL_MESSAGES
  404. ) # make sure original messages are not modified
  405. assert converted_fncall == FNCALL_MESSAGES
  406. fncall_copy = copy.deepcopy(FNCALL_MESSAGES)
  407. converted_non_fncall = convert_fncall_messages_to_non_fncall_messages(
  408. FNCALL_MESSAGES, FNCALL_TOOLS
  409. )
  410. assert (
  411. fncall_copy == FNCALL_MESSAGES
  412. ) # make sure original messages are not modified
  413. assert converted_non_fncall == NON_FNCALL_MESSAGES
  414. def test_two_way_conversion_fn_to_nonfn_to_fn():
  415. fncall_copy = copy.deepcopy(FNCALL_MESSAGES)
  416. converted_non_fncall = convert_fncall_messages_to_non_fncall_messages(
  417. FNCALL_MESSAGES, FNCALL_TOOLS
  418. )
  419. assert (
  420. fncall_copy == FNCALL_MESSAGES
  421. ) # make sure original messages are not modified
  422. assert converted_non_fncall == NON_FNCALL_MESSAGES
  423. non_fncall_copy = copy.deepcopy(NON_FNCALL_MESSAGES)
  424. converted_fncall = convert_non_fncall_messages_to_fncall_messages(
  425. NON_FNCALL_MESSAGES, FNCALL_TOOLS
  426. )
  427. assert (
  428. non_fncall_copy == NON_FNCALL_MESSAGES
  429. ) # make sure original messages are not modified
  430. assert converted_fncall == FNCALL_MESSAGES
  431. def test_infer_fncall_on_noncall_model():
  432. messages_for_llm_inference = convert_fncall_messages_to_non_fncall_messages(
  433. FNCALL_MESSAGES, FNCALL_TOOLS
  434. )
  435. assert messages_for_llm_inference == NON_FNCALL_MESSAGES
  436. # Mock LLM inference
  437. response_message_from_llm_inference = NON_FNCALL_RESPONSE_MESSAGE
  438. # Convert back to fncall messages to hand back to the agent
  439. # so agent is model-agnostic
  440. all_nonfncall_messages = NON_FNCALL_MESSAGES + [response_message_from_llm_inference]
  441. converted_fncall_messages = convert_non_fncall_messages_to_fncall_messages(
  442. all_nonfncall_messages, FNCALL_TOOLS
  443. )
  444. assert converted_fncall_messages == FNCALL_MESSAGES + [FNCALL_RESPONSE_MESSAGE]
  445. assert converted_fncall_messages[-1] == FNCALL_RESPONSE_MESSAGE
  446. def test_convert_from_multiple_tool_calls_to_single_tool_call_messages():
  447. # Test case with multiple tool calls in one message
  448. input_messages = [
  449. {
  450. 'role': 'assistant',
  451. 'content': 'Let me help you with that.',
  452. 'tool_calls': [
  453. {
  454. 'id': 'call1',
  455. 'type': 'function',
  456. 'function': {'name': 'func1', 'arguments': '{}'},
  457. },
  458. {
  459. 'id': 'call2',
  460. 'type': 'function',
  461. 'function': {'name': 'func2', 'arguments': '{}'},
  462. },
  463. ],
  464. },
  465. {
  466. 'role': 'tool',
  467. 'tool_call_id': 'call1',
  468. 'content': 'Result 1',
  469. 'name': 'func1',
  470. },
  471. {
  472. 'role': 'tool',
  473. 'tool_call_id': 'call2',
  474. 'content': 'Result 2',
  475. 'name': 'func2',
  476. },
  477. {
  478. 'role': 'assistant',
  479. 'content': 'Test again',
  480. 'tool_calls': [
  481. {
  482. 'id': 'call3',
  483. 'type': 'function',
  484. 'function': {'name': 'func3', 'arguments': '{}'},
  485. },
  486. {
  487. 'id': 'call4',
  488. 'type': 'function',
  489. 'function': {'name': 'func4', 'arguments': '{}'},
  490. },
  491. ],
  492. },
  493. {
  494. 'role': 'tool',
  495. 'tool_call_id': 'call3',
  496. 'content': 'Result 3',
  497. 'name': 'func3',
  498. },
  499. {
  500. 'role': 'tool',
  501. 'tool_call_id': 'call4',
  502. 'content': 'Result 4',
  503. 'name': 'func4',
  504. },
  505. ]
  506. expected_output = [
  507. {
  508. 'role': 'assistant',
  509. 'content': 'Let me help you with that.',
  510. 'tool_calls': [
  511. {
  512. 'id': 'call1',
  513. 'type': 'function',
  514. 'function': {'name': 'func1', 'arguments': '{}'},
  515. }
  516. ],
  517. },
  518. {
  519. 'role': 'tool',
  520. 'tool_call_id': 'call1',
  521. 'content': 'Result 1',
  522. 'name': 'func1',
  523. },
  524. {
  525. 'role': 'assistant',
  526. 'content': '',
  527. 'tool_calls': [
  528. {
  529. 'id': 'call2',
  530. 'type': 'function',
  531. 'function': {'name': 'func2', 'arguments': '{}'},
  532. }
  533. ],
  534. },
  535. {
  536. 'role': 'tool',
  537. 'tool_call_id': 'call2',
  538. 'content': 'Result 2',
  539. 'name': 'func2',
  540. },
  541. {
  542. 'role': 'assistant',
  543. 'content': 'Test again',
  544. 'tool_calls': [
  545. {
  546. 'id': 'call3',
  547. 'type': 'function',
  548. 'function': {'name': 'func3', 'arguments': '{}'},
  549. }
  550. ],
  551. },
  552. {
  553. 'role': 'tool',
  554. 'tool_call_id': 'call3',
  555. 'content': 'Result 3',
  556. 'name': 'func3',
  557. },
  558. {
  559. 'role': 'assistant',
  560. 'content': '',
  561. 'tool_calls': [
  562. {
  563. 'id': 'call4',
  564. 'type': 'function',
  565. 'function': {'name': 'func4', 'arguments': '{}'},
  566. }
  567. ],
  568. },
  569. {
  570. 'role': 'tool',
  571. 'tool_call_id': 'call4',
  572. 'content': 'Result 4',
  573. 'name': 'func4',
  574. },
  575. ]
  576. result = convert_from_multiple_tool_calls_to_single_tool_call_messages(
  577. input_messages
  578. )
  579. assert result == expected_output
  580. def test_convert_from_multiple_tool_calls_to_single_tool_call_messages_incomplete():
  581. # Test case with multiple tool calls in one message
  582. input_messages = [
  583. {
  584. 'role': 'assistant',
  585. 'content': 'Let me help you with that.',
  586. 'tool_calls': [
  587. {
  588. 'id': 'call1',
  589. 'type': 'function',
  590. 'function': {'name': 'func1', 'arguments': '{}'},
  591. },
  592. {
  593. 'id': 'call2',
  594. 'type': 'function',
  595. 'function': {'name': 'func2', 'arguments': '{}'},
  596. },
  597. ],
  598. },
  599. {
  600. 'role': 'tool',
  601. 'tool_call_id': 'call1',
  602. 'content': 'Result 1',
  603. 'name': 'func1',
  604. },
  605. ]
  606. with pytest.raises(FunctionCallConversionError):
  607. convert_from_multiple_tool_calls_to_single_tool_call_messages(input_messages)
  608. def test_convert_from_multiple_tool_calls_no_changes_needed():
  609. # Test case where no conversion is needed (single tool call)
  610. input_messages = [
  611. {
  612. 'role': 'assistant',
  613. 'content': 'Let me help you with that.',
  614. 'tool_calls': [
  615. {
  616. 'id': 'call1',
  617. 'type': 'function',
  618. 'function': {'name': 'func1', 'arguments': '{}'},
  619. }
  620. ],
  621. },
  622. {
  623. 'role': 'tool',
  624. 'tool_call_id': 'call1',
  625. 'content': 'Result 1',
  626. 'name': 'func1',
  627. },
  628. ]
  629. result = convert_from_multiple_tool_calls_to_single_tool_call_messages(
  630. input_messages
  631. )
  632. assert result == input_messages
  633. def test_convert_from_multiple_tool_calls_no_tool_calls():
  634. # Test case with no tool calls
  635. input_messages = [
  636. {'role': 'user', 'content': 'Hello'},
  637. {'role': 'assistant', 'content': 'Hi there!'},
  638. ]
  639. result = convert_from_multiple_tool_calls_to_single_tool_call_messages(
  640. input_messages
  641. )
  642. assert result == input_messages