openhands 0.0.0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of openhands might be problematic. Click here for more details.

Files changed (124) hide show
  1. openhands-1.0.1.dist-info/METADATA +52 -0
  2. openhands-1.0.1.dist-info/RECORD +31 -0
  3. {openhands-0.0.0.dist-info → openhands-1.0.1.dist-info}/WHEEL +1 -2
  4. openhands-1.0.1.dist-info/entry_points.txt +2 -0
  5. openhands_cli/__init__.py +8 -0
  6. openhands_cli/agent_chat.py +186 -0
  7. openhands_cli/argparsers/main_parser.py +56 -0
  8. openhands_cli/argparsers/serve_parser.py +31 -0
  9. openhands_cli/gui_launcher.py +220 -0
  10. openhands_cli/listeners/__init__.py +4 -0
  11. openhands_cli/listeners/loading_listener.py +63 -0
  12. openhands_cli/listeners/pause_listener.py +83 -0
  13. openhands_cli/llm_utils.py +57 -0
  14. openhands_cli/locations.py +13 -0
  15. openhands_cli/pt_style.py +30 -0
  16. openhands_cli/runner.py +178 -0
  17. openhands_cli/setup.py +116 -0
  18. openhands_cli/simple_main.py +59 -0
  19. openhands_cli/tui/__init__.py +5 -0
  20. openhands_cli/tui/settings/mcp_screen.py +217 -0
  21. openhands_cli/tui/settings/settings_screen.py +202 -0
  22. openhands_cli/tui/settings/store.py +93 -0
  23. openhands_cli/tui/status.py +109 -0
  24. openhands_cli/tui/tui.py +100 -0
  25. openhands_cli/tui/utils.py +14 -0
  26. openhands_cli/user_actions/__init__.py +17 -0
  27. openhands_cli/user_actions/agent_action.py +95 -0
  28. openhands_cli/user_actions/exit_session.py +18 -0
  29. openhands_cli/user_actions/settings_action.py +171 -0
  30. openhands_cli/user_actions/types.py +18 -0
  31. openhands_cli/user_actions/utils.py +199 -0
  32. openhands/__init__.py +0 -1
  33. openhands/sdk/__init__.py +0 -45
  34. openhands/sdk/agent/__init__.py +0 -8
  35. openhands/sdk/agent/agent/__init__.py +0 -6
  36. openhands/sdk/agent/agent/agent.py +0 -349
  37. openhands/sdk/agent/base.py +0 -103
  38. openhands/sdk/context/__init__.py +0 -28
  39. openhands/sdk/context/agent_context.py +0 -153
  40. openhands/sdk/context/condenser/__init__.py +0 -5
  41. openhands/sdk/context/condenser/condenser.py +0 -73
  42. openhands/sdk/context/condenser/no_op_condenser.py +0 -13
  43. openhands/sdk/context/manager.py +0 -5
  44. openhands/sdk/context/microagents/__init__.py +0 -26
  45. openhands/sdk/context/microagents/exceptions.py +0 -11
  46. openhands/sdk/context/microagents/microagent.py +0 -345
  47. openhands/sdk/context/microagents/types.py +0 -70
  48. openhands/sdk/context/utils/__init__.py +0 -8
  49. openhands/sdk/context/utils/prompt.py +0 -52
  50. openhands/sdk/context/view.py +0 -116
  51. openhands/sdk/conversation/__init__.py +0 -12
  52. openhands/sdk/conversation/conversation.py +0 -207
  53. openhands/sdk/conversation/state.py +0 -50
  54. openhands/sdk/conversation/types.py +0 -6
  55. openhands/sdk/conversation/visualizer.py +0 -300
  56. openhands/sdk/event/__init__.py +0 -27
  57. openhands/sdk/event/base.py +0 -148
  58. openhands/sdk/event/condenser.py +0 -49
  59. openhands/sdk/event/llm_convertible.py +0 -265
  60. openhands/sdk/event/types.py +0 -5
  61. openhands/sdk/event/user_action.py +0 -12
  62. openhands/sdk/event/utils.py +0 -30
  63. openhands/sdk/llm/__init__.py +0 -19
  64. openhands/sdk/llm/exceptions.py +0 -108
  65. openhands/sdk/llm/llm.py +0 -867
  66. openhands/sdk/llm/llm_registry.py +0 -116
  67. openhands/sdk/llm/message.py +0 -216
  68. openhands/sdk/llm/metadata.py +0 -34
  69. openhands/sdk/llm/utils/fn_call_converter.py +0 -1049
  70. openhands/sdk/llm/utils/metrics.py +0 -311
  71. openhands/sdk/llm/utils/model_features.py +0 -153
  72. openhands/sdk/llm/utils/retry_mixin.py +0 -122
  73. openhands/sdk/llm/utils/telemetry.py +0 -252
  74. openhands/sdk/logger.py +0 -167
  75. openhands/sdk/mcp/__init__.py +0 -20
  76. openhands/sdk/mcp/client.py +0 -113
  77. openhands/sdk/mcp/definition.py +0 -69
  78. openhands/sdk/mcp/tool.py +0 -104
  79. openhands/sdk/mcp/utils.py +0 -59
  80. openhands/sdk/tests/llm/test_llm.py +0 -447
  81. openhands/sdk/tests/llm/test_llm_fncall_converter.py +0 -691
  82. openhands/sdk/tests/llm/test_model_features.py +0 -221
  83. openhands/sdk/tool/__init__.py +0 -30
  84. openhands/sdk/tool/builtins/__init__.py +0 -34
  85. openhands/sdk/tool/builtins/finish.py +0 -57
  86. openhands/sdk/tool/builtins/think.py +0 -60
  87. openhands/sdk/tool/schema.py +0 -236
  88. openhands/sdk/tool/security_prompt.py +0 -5
  89. openhands/sdk/tool/tool.py +0 -142
  90. openhands/sdk/utils/__init__.py +0 -14
  91. openhands/sdk/utils/discriminated_union.py +0 -210
  92. openhands/sdk/utils/json.py +0 -48
  93. openhands/sdk/utils/truncate.py +0 -44
  94. openhands/tools/__init__.py +0 -44
  95. openhands/tools/execute_bash/__init__.py +0 -30
  96. openhands/tools/execute_bash/constants.py +0 -31
  97. openhands/tools/execute_bash/definition.py +0 -166
  98. openhands/tools/execute_bash/impl.py +0 -38
  99. openhands/tools/execute_bash/metadata.py +0 -101
  100. openhands/tools/execute_bash/terminal/__init__.py +0 -22
  101. openhands/tools/execute_bash/terminal/factory.py +0 -113
  102. openhands/tools/execute_bash/terminal/interface.py +0 -189
  103. openhands/tools/execute_bash/terminal/subprocess_terminal.py +0 -412
  104. openhands/tools/execute_bash/terminal/terminal_session.py +0 -492
  105. openhands/tools/execute_bash/terminal/tmux_terminal.py +0 -160
  106. openhands/tools/execute_bash/utils/command.py +0 -150
  107. openhands/tools/str_replace_editor/__init__.py +0 -17
  108. openhands/tools/str_replace_editor/definition.py +0 -158
  109. openhands/tools/str_replace_editor/editor.py +0 -683
  110. openhands/tools/str_replace_editor/exceptions.py +0 -41
  111. openhands/tools/str_replace_editor/impl.py +0 -66
  112. openhands/tools/str_replace_editor/utils/__init__.py +0 -0
  113. openhands/tools/str_replace_editor/utils/config.py +0 -2
  114. openhands/tools/str_replace_editor/utils/constants.py +0 -9
  115. openhands/tools/str_replace_editor/utils/encoding.py +0 -135
  116. openhands/tools/str_replace_editor/utils/file_cache.py +0 -154
  117. openhands/tools/str_replace_editor/utils/history.py +0 -122
  118. openhands/tools/str_replace_editor/utils/shell.py +0 -72
  119. openhands/tools/task_tracker/__init__.py +0 -16
  120. openhands/tools/task_tracker/definition.py +0 -336
  121. openhands/tools/utils/__init__.py +0 -1
  122. openhands-0.0.0.dist-info/METADATA +0 -3
  123. openhands-0.0.0.dist-info/RECORD +0 -94
  124. openhands-0.0.0.dist-info/top_level.txt +0 -1
@@ -1,1049 +0,0 @@
1
- """Convert function calling messages to non-function calling messages and vice versa.
2
-
3
- This will inject prompts so that models that doesn't support function calling
4
- can still be used with function calling agents.
5
-
6
- We follow format from: https://docs.litellm.ai/docs/completion/function_call
7
- """ # noqa: E501
8
-
9
- import copy
10
- import json
11
- import re
12
- import sys
13
- from typing import Iterable, Literal, NotRequired, TypedDict, cast
14
-
15
- from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
16
-
17
- from openhands.sdk.llm.exceptions import (
18
- FunctionCallConversionError,
19
- FunctionCallValidationError,
20
- )
21
-
22
-
23
- class CacheControl(TypedDict):
24
- type: Literal["ephemeral"]
25
-
26
-
27
- class TextPart(TypedDict):
28
- type: Literal["text"]
29
- text: str
30
- cache_control: NotRequired[CacheControl]
31
-
32
-
33
- Content = str | list[TextPart]
34
-
35
- EXECUTE_BASH_TOOL_NAME = "execute_bash"
36
- STR_REPLACE_EDITOR_TOOL_NAME = "str_replace_editor"
37
- BROWSER_TOOL_NAME = "browser"
38
- FINISH_TOOL_NAME = "finish"
39
- LLM_BASED_EDIT_TOOL_NAME = "edit_file"
40
- TASK_TRACKER_TOOL_NAME = "task_tracker"
41
-
42
- # Inspired by: https://docs.together.ai/docs/llama-3-function-calling#function-calling-w-llama-31-70b
43
- system_message_suffix_TEMPLATE = """
44
- You have access to the following functions:
45
-
46
- {description}
47
-
48
- If you choose to call a function ONLY reply in the following format with NO suffix:
49
-
50
- <function=example_function_name>
51
- <parameter=example_parameter_1>value_1</parameter>
52
- <parameter=example_parameter_2>
53
- This is the value for the second parameter
54
- that can span
55
- multiple lines
56
- </parameter>
57
- </function>
58
-
59
- <IMPORTANT>
60
- Reminder:
61
- - Function calls MUST follow the specified format, start with <function= and end with </function>
62
- - Required parameters MUST be specified
63
- - Only call one function at a time
64
- - You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after.
65
- - 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
66
- </IMPORTANT>
67
- """ # noqa: E501
68
-
69
- STOP_WORDS = ["</function"]
70
-
71
-
72
- def refine_prompt(prompt: str) -> str:
73
- if sys.platform == "win32":
74
- return prompt.replace("bash", "powershell")
75
- return prompt
76
-
77
-
78
- # NOTE: we need to make sure these examples are always in-sync with the tool
79
- # interface designed in openhands/agenthub/agent/function_calling.py
80
-
81
- # Example snippets for each tool
82
- TOOL_EXAMPLES = {
83
- "execute_bash": {
84
- "check_dir": """
85
- ASSISTANT: Sure! Let me first check the current directory:
86
- <function=execute_bash>
87
- <parameter=command>
88
- pwd && ls
89
- </parameter>
90
- </function>
91
-
92
- USER: EXECUTION RESULT of [execute_bash]:
93
- /workspace
94
- openhands@runtime:~/workspace$
95
- """, # noqa: E501
96
- "run_server": """
97
- ASSISTANT:
98
- Let me run the Python file for you:
99
- <function=execute_bash>
100
- <parameter=command>
101
- python3 app.py > server.log 2>&1 &
102
- </parameter>
103
- </function>
104
-
105
- USER: EXECUTION RESULT of [execute_bash]:
106
- [1] 121
107
- [1]+ Exit 1 python3 app.py > server.log 2>&1
108
-
109
- ASSISTANT:
110
- Looks like the server was running with PID 121 then crashed. Let me check the server log:
111
- <function=execute_bash>
112
- <parameter=command>
113
- cat server.log
114
- </parameter>
115
- </function>
116
-
117
- USER: EXECUTION RESULT of [execute_bash]:
118
- Traceback (most recent call last):
119
- File "/workspace/app.py", line 2, in <module>
120
- from flask import Flask
121
- ModuleNotFoundError: No module named 'flask'
122
-
123
- ASSISTANT:
124
- Looks like the server crashed because the `flask` module is not installed. Let me install the `flask` module for you:
125
- <function=execute_bash>
126
- <parameter=command>
127
- pip3 install flask
128
- </parameter>
129
- </function>
130
-
131
- USER: EXECUTION RESULT of [execute_bash]:
132
- Defaulting to user installation because normal site-packages is not writeable
133
- Collecting flask
134
- Using cached flask-3.0.3-py3-none-any.whl (101 kB)
135
- Collecting blinker>=1.6.2
136
- Using cached blinker-1.7.0-py3-none-any.whl (13 kB)
137
- Collecting Werkzeug>=3.0.0
138
- Using cached werkzeug-3.0.2-py3-none-any.whl (226 kB)
139
- Collecting click>=8.1.3
140
- Using cached click-8.1.7-py3-none-any.whl (97 kB)
141
- Collecting itsdangerous>=2.1.2
142
- Using cached itsdangerous-2.2.0-py3-none-any.whl (16 kB)
143
- Requirement already satisfied: Jinja2>=3.1.2 in /home/openhands/.local/lib/python3.10/site-packages (from flask) (3.1.3)
144
- Requirement already satisfied: MarkupSafe>=2.0 in /home/openhands/.local/lib/python3.10/site-packages (from Jinja2>=3.1.2->flask) (2.1.5)
145
- Installing collected packages: Werkzeug, itsdangerous, click, blinker, flask
146
- Successfully installed Werkzeug-3.0.2 blinker-1.7.0 click-8.1.7 flask-3.0.3 itsdangerous-2.2.0
147
-
148
- ASSISTANT:
149
- The `flask` module is installed successfully. Let me run the Python file again:
150
- <function=execute_bash>
151
- <parameter=command>
152
- python3 app.py > server.log 2>&1 &
153
- </parameter>
154
- </function>
155
-
156
- USER: EXECUTION RESULT of [execute_bash]:
157
- [1] 124
158
-
159
- ASSISTANT:
160
- Let me check the server log again:
161
- <function=execute_bash>
162
- <parameter=command>
163
- cat server.log
164
- </parameter>
165
- </function>
166
-
167
- USER: EXECUTION RESULT of [execute_bash]:
168
- * Serving Flask app 'app'
169
- * Debug mode: off
170
- WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
171
- * Running on http://127.0.0.1:5000
172
- Press CTRL+C to quit
173
-
174
- ASSISTANT:
175
- 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!
176
- """, # noqa: E501
177
- "kill_server": """
178
- USER: Now kill the server, make it display the numbers in a table format.
179
-
180
- ASSISTANT:
181
- Sure! Let me stop the server first:
182
- <function=execute_bash>
183
- <parameter=command>
184
- kill 124
185
- </parameter>
186
- </function>
187
-
188
- USER: EXECUTION RESULT of [execute_bash]:
189
- [1]+ Terminated python3 app.py > server.log 2>&1
190
- """, # noqa: E501
191
- "run_server_again": """
192
- ASSISTANT:
193
- Running the updated file:
194
- <function=execute_bash>
195
- <parameter=command>
196
- python3 app.py > server.log 2>&1 &
197
- </parameter>
198
- </function>
199
-
200
- USER: EXECUTION RESULT of [execute_bash]:
201
- [1] 126
202
-
203
- ASSISTANT:
204
- 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.
205
- """, # noqa: E501
206
- },
207
- "str_replace_editor": {
208
- "create_file": """
209
- ASSISTANT:
210
- There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
211
- <function=str_replace_editor>
212
- <parameter=command>create</parameter>
213
- <parameter=path>/workspace/app.py</parameter>
214
- <parameter=file_text>
215
- from flask import Flask
216
- app = Flask(__name__)
217
-
218
- @app.route('/')
219
- def index():
220
- numbers = list(range(1, 11))
221
- return str(numbers)
222
-
223
- if __name__ == '__main__':
224
- app.run(port=5000)
225
- </parameter>
226
- </function>
227
-
228
- USER: EXECUTION RESULT of [str_replace_editor]:
229
- File created successfully at: /workspace/app.py
230
- """, # noqa: E501
231
- "edit_file": """
232
- ASSISTANT:
233
- Now let me display the numbers in a table format:
234
- <function=str_replace_editor>
235
- <parameter=command>str_replace</parameter>
236
- <parameter=path>/workspace/app.py</parameter>
237
- <parameter=old_str>return str(numbers)</parameter>
238
- <parameter=new_str>return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'</parameter>
239
- </function>
240
-
241
- USER: EXECUTION RESULT of [str_replace_editor]:
242
- The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
243
- 3
244
- 4 @app.route('/')
245
- 5 def index():
246
- 6 numbers = list(range(1, 11))
247
- 7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
248
- 8
249
- 9 if __name__ == '__main__':
250
- 10 app.run(port=5000)
251
- Review the changes and make sure they are as expected. Edit the file again if necessary.
252
-
253
- """, # noqa: E501
254
- },
255
- "browser": {
256
- "view_page": """
257
- ASSISTANT:
258
- Let me check how the page looks in the browser:
259
- <function=browser>
260
- <parameter=code>
261
- goto('http://127.0.0.1:5000')
262
- noop(1000) # Wait for page to load
263
- </parameter>
264
- </function>
265
-
266
- USER: EXECUTION RESULT of [browser]:
267
- [Browser shows the numbers in a table format]
268
- """ # noqa: E501
269
- },
270
- "edit_file": {
271
- "create_file": """
272
- ASSISTANT: There is no `app.py` file in the current directory. Let me create a Python file `app.py`:
273
- <function=edit_file>
274
- <parameter=path>/workspace/app.py</parameter>
275
- <parameter=start>1</parameter>
276
- <parameter=end>-1</parameter>
277
- <parameter=content>
278
- from flask import Flask
279
- app = Flask(__name__)
280
-
281
- @app.route('/')
282
- def index():
283
- numbers = list(range(1, 11))
284
- return str(numbers)
285
-
286
- if __name__ == '__main__':
287
- app.run(port=5000)
288
- </parameter>
289
- </function>
290
-
291
- USER: EXECUTION RESULT of [edit_file]:
292
- File created successfully at: /workspace/app.py
293
- """, # noqa: E501
294
- "edit_file": """
295
- ASSISTANT:
296
- Now let me display the numbers in a table format:
297
- <function=edit_file>
298
- <parameter=path>/workspace/app.py</parameter>
299
- <parameter=start>6</parameter>
300
- <parameter=end>9</parameter>
301
- <parameter=content>
302
- numbers = list(range(1, 11))
303
- return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
304
- # ... existing code ...
305
- if __name__ == '__main__':
306
- </parameter>
307
- </function>
308
-
309
- USER: EXECUTION RESULT of [edit_file]:
310
- The file /workspace/app.py has been edited. Here's the result of running `cat -n` on a snippet of /workspace/app.py:
311
- 3
312
- 4 @app.route('/')
313
- 5 def index():
314
- 6 numbers = list(range(1, 11))
315
- 7 return '<table>' + ''.join([f'<tr><td>{i}</td></tr>' for i in numbers]) + '</table>'
316
- 8
317
- 9 if __name__ == '__main__':
318
- 10 app.run(port=5000)
319
- Review the changes and make sure they are as expected. Edit the file again if necessary.
320
- """, # noqa: E501
321
- },
322
- "finish": {
323
- "example": """
324
- ASSISTANT:
325
- 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!
326
- <function=finish>
327
- <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>
328
- </function>
329
- """ # noqa: E501
330
- },
331
- }
332
-
333
-
334
- def get_example_for_tools(tools: list[ChatCompletionToolParam]) -> str:
335
- """Generate an in-context learning example based on available tools."""
336
- available_tools = set()
337
- for tool in tools:
338
- if tool["type"] == "function":
339
- name = tool["function"]["name"]
340
- if name == EXECUTE_BASH_TOOL_NAME:
341
- available_tools.add("execute_bash")
342
- elif name == STR_REPLACE_EDITOR_TOOL_NAME:
343
- available_tools.add("str_replace_editor")
344
- elif name == BROWSER_TOOL_NAME:
345
- available_tools.add("browser")
346
- elif name == FINISH_TOOL_NAME:
347
- available_tools.add("finish")
348
- elif name == LLM_BASED_EDIT_TOOL_NAME:
349
- available_tools.add("edit_file")
350
-
351
- if not available_tools:
352
- return ""
353
-
354
- example = """Here's a running example of how to perform a task with the provided tools.
355
-
356
- --------------------- START OF EXAMPLE ---------------------
357
-
358
- USER: Create a list of numbers from 1 to 10, and display them in a web page at port 5000.
359
-
360
- """ # noqa: E501
361
-
362
- # Build example based on available tools
363
- if "execute_bash" in available_tools:
364
- example += TOOL_EXAMPLES["execute_bash"]["check_dir"]
365
-
366
- if "str_replace_editor" in available_tools:
367
- example += TOOL_EXAMPLES["str_replace_editor"]["create_file"]
368
- elif "edit_file" in available_tools:
369
- example += TOOL_EXAMPLES["edit_file"]["create_file"]
370
-
371
- if "execute_bash" in available_tools:
372
- example += TOOL_EXAMPLES["execute_bash"]["run_server"]
373
-
374
- if "browser" in available_tools:
375
- example += TOOL_EXAMPLES["browser"]["view_page"]
376
-
377
- if "execute_bash" in available_tools:
378
- example += TOOL_EXAMPLES["execute_bash"]["kill_server"]
379
-
380
- if "str_replace_editor" in available_tools:
381
- example += TOOL_EXAMPLES["str_replace_editor"]["edit_file"]
382
- elif "edit_file" in available_tools:
383
- example += TOOL_EXAMPLES["edit_file"]["edit_file"]
384
-
385
- if "execute_bash" in available_tools:
386
- example += TOOL_EXAMPLES["execute_bash"]["run_server_again"]
387
-
388
- if "finish" in available_tools:
389
- example += TOOL_EXAMPLES["finish"]["example"]
390
-
391
- example += """
392
- --------------------- END OF EXAMPLE ---------------------
393
-
394
- Do NOT assume the environment is the same as in the example above.
395
-
396
- --------------------- NEW TASK DESCRIPTION ---------------------
397
- """ # noqa: E501
398
- example = example.lstrip()
399
-
400
- return refine_prompt(example)
401
-
402
-
403
- IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools
404
-
405
- IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX = """
406
- --------------------- END OF NEW TASK DESCRIPTION ---------------------
407
-
408
- PLEASE follow the format strictly! PLEASE EMIT ONE AND ONLY ONE FUNCTION CALL PER MESSAGE.
409
- """ # noqa: E501
410
-
411
- # Regex patterns for function call parsing
412
- FN_REGEX_PATTERN = r"<function=([^>]+)>\n(.*?)</function>"
413
- FN_PARAM_REGEX_PATTERN = r"<parameter=([^>]+)>(.*?)</parameter>"
414
-
415
- # Add new regex pattern for tool execution results
416
- TOOL_RESULT_REGEX_PATTERN = r"EXECUTION RESULT of \[(.*?)\]:\n(.*)"
417
-
418
-
419
- def convert_tool_call_to_string(tool_call: dict) -> str:
420
- """Convert tool call to content in string format."""
421
- if "function" not in tool_call:
422
- raise FunctionCallConversionError("Tool call must contain 'function' key.")
423
- if "id" not in tool_call:
424
- raise FunctionCallConversionError("Tool call must contain 'id' key.")
425
- if "type" not in tool_call:
426
- raise FunctionCallConversionError("Tool call must contain 'type' key.")
427
- if tool_call["type"] != "function":
428
- raise FunctionCallConversionError("Tool call type must be 'function'.")
429
-
430
- ret = f"<function={tool_call['function']['name']}>\n"
431
- try:
432
- args = json.loads(tool_call["function"]["arguments"])
433
- except json.JSONDecodeError as e:
434
- raise FunctionCallConversionError(
435
- f"Failed to parse arguments as JSON. "
436
- f"Arguments: {tool_call['function']['arguments']}"
437
- ) from e
438
- for param_name, param_value in args.items():
439
- is_multiline = isinstance(param_value, str) and "\n" in param_value
440
- ret += f"<parameter={param_name}>"
441
- if is_multiline:
442
- ret += "\n"
443
- if isinstance(param_value, list) or isinstance(param_value, dict):
444
- ret += json.dumps(param_value)
445
- else:
446
- ret += f"{param_value}"
447
- if is_multiline:
448
- ret += "\n"
449
- ret += "</parameter>\n"
450
- ret += "</function>"
451
- return ret
452
-
453
-
454
- def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
455
- ret = ""
456
- for i, tool in enumerate(tools):
457
- assert tool["type"] == "function"
458
- fn = tool["function"]
459
- if i > 0:
460
- ret += "\n"
461
- ret += f"---- BEGIN FUNCTION #{i + 1}: {fn['name']} ----\n"
462
- if "description" in fn:
463
- ret += f"Description: {fn['description']}\n"
464
-
465
- if "parameters" in fn:
466
- ret += "Parameters:\n"
467
- properties = fn["parameters"].get("properties", {})
468
- required_params = set(fn["parameters"].get("required", []))
469
-
470
- for j, (param_name, param_info) in enumerate(properties.items()):
471
- # Indicate required/optional in parentheses with type
472
- is_required = param_name in required_params
473
- param_status = "required" if is_required else "optional"
474
- param_type = param_info.get("type", "string")
475
-
476
- # Get parameter description
477
- desc = param_info.get("description", "No description provided")
478
-
479
- # Handle enum values if present
480
- if "enum" in param_info:
481
- enum_values = ", ".join(f"`{v}`" for v in param_info["enum"])
482
- desc += f"\nAllowed values: [{enum_values}]"
483
-
484
- ret += (
485
- f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n"
486
- )
487
- else:
488
- ret += "No parameters are required for this function.\n"
489
-
490
- ret += f"---- END FUNCTION #{i + 1} ----\n"
491
- return ret
492
-
493
-
494
- def convert_fncall_messages_to_non_fncall_messages(
495
- messages: list[dict],
496
- tools: list[ChatCompletionToolParam],
497
- add_in_context_learning_example: bool = True,
498
- ) -> list[dict]:
499
- """Convert function calling messages to non-function calling messages."""
500
- messages = copy.deepcopy(messages)
501
-
502
- formatted_tools = convert_tools_to_description(tools)
503
- system_message_suffix = system_message_suffix_TEMPLATE.format(
504
- description=formatted_tools
505
- )
506
-
507
- converted_messages = []
508
- first_user_message_encountered = False
509
- for message in messages:
510
- role = message["role"]
511
- content: Content = message["content"]
512
-
513
- # 1. SYSTEM MESSAGES
514
- # append system prompt suffix to content
515
- if role == "system":
516
- if isinstance(content, str):
517
- content += system_message_suffix
518
- elif isinstance(content, list):
519
- if content and content[-1]["type"] == "text":
520
- content[-1]["text"] += system_message_suffix
521
- else:
522
- content.append({"type": "text", "text": system_message_suffix})
523
- else:
524
- raise FunctionCallConversionError(
525
- f"Unexpected content type {type(content)}. "
526
- f"Expected str or list. "
527
- f"Content: {content}"
528
- )
529
- converted_messages.append({"role": "system", "content": content})
530
-
531
- # 2. USER MESSAGES (no change)
532
- elif role == "user":
533
- # Add in-context learning example for the first user message
534
- if not first_user_message_encountered and add_in_context_learning_example:
535
- first_user_message_encountered = True
536
-
537
- # Generate example based on available tools
538
- example = IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)
539
-
540
- # Add example if we have any tools
541
- if example:
542
- # add in-context learning example
543
- if isinstance(content, str):
544
- content = example + content + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
545
- elif isinstance(content, list):
546
- if content and content[0]["type"] == "text":
547
- content[0]["text"] = (
548
- example
549
- + content[0]["text"]
550
- + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
551
- )
552
- else:
553
- content = (
554
- [
555
- cast(
556
- TextPart,
557
- {
558
- "type": "text",
559
- "text": example,
560
- },
561
- )
562
- ]
563
- + content
564
- + [
565
- cast(
566
- TextPart,
567
- {
568
- "type": "text",
569
- "text": IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX,
570
- },
571
- )
572
- ]
573
- )
574
- else:
575
- raise FunctionCallConversionError(
576
- f"Unexpected content type {type(content)}. "
577
- f"Expected str or list. "
578
- f"Content: {content}"
579
- )
580
- converted_messages.append(
581
- {
582
- "role": "user",
583
- "content": content,
584
- }
585
- )
586
-
587
- # 3. ASSISTANT MESSAGES
588
- # - 3.1 no change if no function call
589
- # - 3.2 change if function call
590
- elif role == "assistant":
591
- if "tool_calls" in message and message["tool_calls"] is not None:
592
- if len(message["tool_calls"]) != 1:
593
- raise FunctionCallConversionError(
594
- f"Expected exactly one tool call in the message. "
595
- f"More than one tool call is not supported. "
596
- f"But got {len(message['tool_calls'])} tool calls. "
597
- f"Content: {content}"
598
- )
599
- try:
600
- tool_content = convert_tool_call_to_string(message["tool_calls"][0])
601
- except FunctionCallConversionError as e:
602
- raise FunctionCallConversionError(
603
- f"Failed to convert tool call to string.\n"
604
- f"Current tool call: {message['tool_calls'][0]}.\n"
605
- f"Raw messages: {json.dumps(messages, indent=2)}"
606
- ) from e
607
- if isinstance(content, str):
608
- content += "\n\n" + tool_content
609
- content = content.lstrip()
610
- elif isinstance(content, list):
611
- if content and content[-1]["type"] == "text":
612
- content[-1]["text"] += "\n\n" + tool_content
613
- content[-1]["text"] = content[-1]["text"].lstrip()
614
- else:
615
- content.append({"type": "text", "text": tool_content})
616
- else:
617
- raise FunctionCallConversionError(
618
- f"Unexpected content type {type(content)}. "
619
- f"Expected str or list. Content: {content}"
620
- )
621
- converted_messages.append({"role": "assistant", "content": content})
622
-
623
- # 4. TOOL MESSAGES (tool outputs)
624
- elif role == "tool":
625
- # Convert tool result as user message
626
- tool_name = message.get("name", "function")
627
- prefix = f"EXECUTION RESULT of [{tool_name}]:\n"
628
- # and omit "tool_call_id" AND "name"
629
- if isinstance(content, str):
630
- content = prefix + content
631
- elif isinstance(content, list):
632
- if content and (
633
- first_text_content := next(
634
- (c for c in content if c["type"] == "text"), None
635
- )
636
- ):
637
- first_text_content["text"] = prefix + first_text_content["text"]
638
- else:
639
- content = [
640
- cast(TextPart, {"type": "text", "text": prefix})
641
- ] + content
642
-
643
- if "cache_control" in message:
644
- content[-1]["cache_control"] = cast(
645
- CacheControl, {"type": "ephemeral"}
646
- )
647
- else:
648
- raise FunctionCallConversionError(
649
- f"Unexpected content type {type(content)}. "
650
- f"Expected str or list. "
651
- f"Content: {content}"
652
- )
653
-
654
- converted_messages.append({"role": "user", "content": content})
655
- else:
656
- raise FunctionCallConversionError(
657
- f"Unexpected role {role}. Expected system, user, assistant or tool."
658
- )
659
- return converted_messages
660
-
661
-
662
- def _extract_and_validate_params(
663
- matching_tool: ChatCompletionToolParamFunctionChunk,
664
- param_matches: Iterable[re.Match],
665
- fn_name: str,
666
- ) -> dict:
667
- params = {}
668
- # Parse and validate parameters
669
- required_params = set()
670
- if "parameters" in matching_tool and "required" in matching_tool["parameters"]:
671
- required_params = set(matching_tool["parameters"].get("required", []))
672
-
673
- allowed_params = set()
674
- if "parameters" in matching_tool and "properties" in matching_tool["parameters"]:
675
- allowed_params = set(matching_tool["parameters"]["properties"].keys())
676
-
677
- param_name_to_type = {}
678
- if "parameters" in matching_tool and "properties" in matching_tool["parameters"]:
679
- param_name_to_type = {
680
- name: val.get("type", "string")
681
- for name, val in matching_tool["parameters"]["properties"].items()
682
- }
683
-
684
- # Collect parameters
685
- found_params = set()
686
- for param_match in param_matches:
687
- param_name = param_match.group(1)
688
- param_value = param_match.group(2)
689
-
690
- # Validate parameter is allowed
691
- if allowed_params and param_name not in allowed_params:
692
- raise FunctionCallValidationError(
693
- f"Parameter '{param_name}' is not allowed for function '{fn_name}'. "
694
- f"Allowed parameters: {allowed_params}"
695
- )
696
-
697
- # Validate and convert parameter type
698
- # supported: string, integer, array
699
- if param_name in param_name_to_type:
700
- if param_name_to_type[param_name] == "integer":
701
- try:
702
- param_value = int(param_value)
703
- except ValueError:
704
- raise FunctionCallValidationError(
705
- f"Parameter '{param_name}' is expected to be an integer."
706
- )
707
- elif param_name_to_type[param_name] == "array":
708
- try:
709
- param_value = json.loads(param_value)
710
- except json.JSONDecodeError:
711
- raise FunctionCallValidationError(
712
- f"Parameter '{param_name}' is expected to be an array."
713
- )
714
- else:
715
- # string
716
- pass
717
-
718
- # Enum check
719
- if (
720
- "parameters" in matching_tool
721
- and "enum" in matching_tool["parameters"]["properties"][param_name]
722
- ):
723
- if (
724
- param_value
725
- not in matching_tool["parameters"]["properties"][param_name]["enum"]
726
- ):
727
- raise FunctionCallValidationError(
728
- f"Parameter '{param_name}' is expected to be one of "
729
- f"{matching_tool['parameters']['properties'][param_name]['enum']}."
730
- )
731
-
732
- params[param_name] = param_value
733
- found_params.add(param_name)
734
-
735
- # Check all required parameters are present
736
- missing_params = required_params - found_params
737
- if missing_params:
738
- raise FunctionCallValidationError(
739
- f"Missing required parameters for function '{fn_name}': {missing_params}"
740
- )
741
- return params
742
-
743
-
744
- def _fix_stopword(content: str) -> str:
745
- """Fix the issue when some LLM would NOT return the stopword."""
746
- if "<function=" in content and content.count("<function=") == 1:
747
- if content.endswith("</"):
748
- content = content.rstrip() + "function>"
749
- else:
750
- content = content + "\n</function>"
751
- return content
752
-
753
-
754
- def _normalize_parameter_tags(fn_body: str) -> str:
755
- """Normalize malformed parameter tags to the canonical format.
756
-
757
- Some models occasionally emit malformed parameter tags like:
758
- <parameter=command=str_replace</parameter>
759
- instead of the correct:
760
- <parameter=command>str_replace</parameter>
761
-
762
- This function rewrites the malformed form into the correct one to allow
763
- downstream parsing to succeed.
764
- """
765
- # Replace '<parameter=name=value</parameter>'
766
- # with '<parameter=name>value</parameter>'
767
- return re.sub(
768
- r"<parameter=([a-zA-Z0-9_]+)=([^<]*)</parameter>",
769
- r"<parameter=\1>\2</parameter>",
770
- fn_body,
771
- )
772
-
773
-
774
- def convert_non_fncall_messages_to_fncall_messages(
775
- messages: list[dict],
776
- tools: list[ChatCompletionToolParam],
777
- ) -> list[dict]:
778
- """Convert non-function calling messages back to function calling messages."""
779
- messages = copy.deepcopy(messages)
780
- formatted_tools = convert_tools_to_description(tools)
781
- system_message_suffix = system_message_suffix_TEMPLATE.format(
782
- description=formatted_tools
783
- )
784
-
785
- converted_messages = []
786
- tool_call_counter = 1 # Counter for tool calls
787
-
788
- first_user_message_encountered = False
789
- for message in messages:
790
- role, content = message["role"], message["content"]
791
- content = content or "" # handle cases where content is None
792
- # For system messages, remove the added suffix
793
- if role == "system":
794
- if isinstance(content, str):
795
- # Remove the suffix if present
796
- content = content.split(system_message_suffix)[0]
797
- elif isinstance(content, list):
798
- if content and content[-1]["type"] == "text":
799
- # Remove the suffix from the last text item
800
- content[-1]["text"] = content[-1]["text"].split(
801
- system_message_suffix
802
- )[0]
803
- converted_messages.append({"role": "system", "content": content})
804
- # Skip user messages (no conversion needed)
805
- elif role == "user":
806
- # Check & replace in-context learning example
807
- if not first_user_message_encountered:
808
- first_user_message_encountered = True
809
- if isinstance(content, str):
810
- # Remove any existing example
811
- if content.startswith(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)):
812
- content = content.replace(
813
- IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools), "", 1
814
- )
815
- if content.endswith(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX):
816
- content = content.replace(
817
- IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, "", 1
818
- )
819
- elif isinstance(content, list):
820
- for item in content:
821
- if item["type"] == "text":
822
- # Remove any existing example
823
- example = IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)
824
- if item["text"].startswith(example):
825
- item["text"] = item["text"].replace(example, "", 1)
826
- if item["text"].endswith(
827
- IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
828
- ):
829
- item["text"] = item["text"].replace(
830
- IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, "", 1
831
- )
832
- else:
833
- raise FunctionCallConversionError(
834
- f"Unexpected content type {type(content)}. "
835
- f"Expected str or list. "
836
- f"Content: {content}"
837
- )
838
-
839
- # Check for tool execution result pattern
840
- if isinstance(content, str):
841
- tool_result_match = re.search(
842
- TOOL_RESULT_REGEX_PATTERN, content, re.DOTALL
843
- )
844
- elif isinstance(content, list):
845
- tool_result_match = next(
846
- (
847
- _match
848
- for item in content
849
- if item.get("type") == "text"
850
- and (
851
- _match := re.search(
852
- TOOL_RESULT_REGEX_PATTERN, item["text"], re.DOTALL
853
- )
854
- )
855
- ),
856
- None,
857
- )
858
- else:
859
- raise FunctionCallConversionError(
860
- f"Unexpected content type {type(content)}. "
861
- f"Expected str or list. "
862
- f"Content: {content}"
863
- )
864
-
865
- if tool_result_match:
866
- if isinstance(content, list):
867
- text_content_items = [
868
- item for item in content if item.get("type") == "text"
869
- ]
870
- if not text_content_items:
871
- raise FunctionCallConversionError(
872
- f"Could not find text content in message with tool result. "
873
- f"Content: {content}"
874
- )
875
- elif not isinstance(content, str):
876
- raise FunctionCallConversionError(
877
- f"Unexpected content type {type(content)}. "
878
- f"Expected str or list. "
879
- f"Content: {content}"
880
- )
881
-
882
- tool_name = tool_result_match.group(1)
883
- tool_result = tool_result_match.group(2).strip()
884
-
885
- # Convert to tool message format
886
- converted_messages.append(
887
- {
888
- "role": "tool",
889
- "name": tool_name,
890
- "content": [{"type": "text", "text": tool_result}]
891
- if isinstance(content, list)
892
- else tool_result,
893
- "tool_call_id": f"toolu_{tool_call_counter - 1:02d}",
894
- # Use last generated ID
895
- }
896
- )
897
- else:
898
- converted_messages.append({"role": "user", "content": content})
899
-
900
- # Handle assistant messages
901
- elif role == "assistant":
902
- if isinstance(content, str):
903
- content = _fix_stopword(content)
904
- fn_match = re.search(FN_REGEX_PATTERN, content, re.DOTALL)
905
- elif isinstance(content, list):
906
- if content and content[-1]["type"] == "text":
907
- content[-1]["text"] = _fix_stopword(content[-1]["text"])
908
- fn_match = re.search(
909
- FN_REGEX_PATTERN, content[-1]["text"], re.DOTALL
910
- )
911
- else:
912
- fn_match = None
913
- fn_match_exists = any(
914
- item.get("type") == "text"
915
- and re.search(FN_REGEX_PATTERN, item["text"], re.DOTALL)
916
- for item in content
917
- )
918
- if fn_match_exists and not fn_match:
919
- raise FunctionCallConversionError(
920
- f"Expecting function call in the LAST index of content list. "
921
- f"But got content={content}"
922
- )
923
- else:
924
- raise FunctionCallConversionError(
925
- f"Unexpected content type {type(content)}. "
926
- f"Expected str or list. "
927
- f"Content: {content}"
928
- )
929
-
930
- if fn_match:
931
- fn_name = fn_match.group(1)
932
- fn_body = _normalize_parameter_tags(fn_match.group(2))
933
- matching_tool: ChatCompletionToolParamFunctionChunk | None = next(
934
- (
935
- tool["function"]
936
- for tool in tools
937
- if tool["type"] == "function"
938
- and tool["function"]["name"] == fn_name
939
- ),
940
- None,
941
- )
942
- # Validate function exists in tools
943
- if not matching_tool:
944
- available_tools = [
945
- tool["function"]["name"]
946
- for tool in tools
947
- if tool["type"] == "function"
948
- ]
949
- raise FunctionCallValidationError(
950
- f"Function '{fn_name}' not found in available tools: "
951
- f"{available_tools}"
952
- )
953
-
954
- # Parse parameters
955
- param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL)
956
- params = _extract_and_validate_params(
957
- matching_tool, param_matches, fn_name
958
- )
959
-
960
- # Create tool call with unique ID
961
- tool_call_id = f"toolu_{tool_call_counter:02d}"
962
- tool_call = {
963
- "index": 1, # always 1 because we only support
964
- # **one tool call per message**
965
- "id": tool_call_id,
966
- "type": "function",
967
- "function": {"name": fn_name, "arguments": json.dumps(params)},
968
- }
969
- tool_call_counter += 1 # Increment counter
970
-
971
- # Remove the function call part from content
972
- if isinstance(content, list):
973
- assert content and content[-1]["type"] == "text"
974
- content[-1]["text"] = (
975
- content[-1]["text"].split("<function=")[0].strip()
976
- )
977
- elif isinstance(content, str):
978
- content = content.split("<function=")[0].strip()
979
- else:
980
- raise FunctionCallConversionError(
981
- f"Unexpected content type {type(content)}. "
982
- f"Expected str or list. "
983
- f"Content: {content}"
984
- )
985
-
986
- converted_messages.append(
987
- {"role": "assistant", "content": content, "tool_calls": [tool_call]}
988
- )
989
- else:
990
- # No function call, keep message as is
991
- converted_messages.append(message)
992
-
993
- else:
994
- raise FunctionCallConversionError(
995
- f"Unexpected role {role}. Expected system, user, or assistant "
996
- f"in non-function calling messages."
997
- )
998
- return converted_messages
999
-
1000
-
1001
- def convert_from_multiple_tool_calls_to_single_tool_call_messages(
1002
- messages: list[dict],
1003
- ignore_final_tool_result: bool = False,
1004
- ) -> list[dict]:
1005
- """Break one message with multiple tool calls into multiple messages."""
1006
- converted_messages = []
1007
-
1008
- pending_tool_calls: dict[str, dict] = {}
1009
- for message in messages:
1010
- role: str
1011
- content: Content
1012
- role, content = message["role"], message["content"]
1013
- if role == "assistant":
1014
- if message.get("tool_calls") and len(message["tool_calls"]) > 1:
1015
- # handle multiple tool calls by breaking them into multiple messages
1016
- for i, tool_call in enumerate(message["tool_calls"]):
1017
- pending_tool_calls[tool_call["id"]] = {
1018
- "role": "assistant",
1019
- "content": content if i == 0 else "",
1020
- "tool_calls": [tool_call],
1021
- }
1022
- else:
1023
- converted_messages.append(message)
1024
- elif role == "tool":
1025
- if message["tool_call_id"] in pending_tool_calls:
1026
- # remove the tool call from the pending list
1027
- _tool_call_message = pending_tool_calls.pop(message["tool_call_id"])
1028
- converted_messages.append(_tool_call_message)
1029
- # add the tool result
1030
- converted_messages.append(message)
1031
- else:
1032
- assert len(pending_tool_calls) == 0, (
1033
- f"Found pending tool calls but not found in pending list: "
1034
- f"{pending_tool_calls=}"
1035
- )
1036
- converted_messages.append(message)
1037
- else:
1038
- assert len(pending_tool_calls) == 0, (
1039
- f"Found pending tool calls but not expect to handle it "
1040
- f"with role {role}: "
1041
- f"{pending_tool_calls=}, {message=}"
1042
- )
1043
- converted_messages.append(message)
1044
-
1045
- if not ignore_final_tool_result and len(pending_tool_calls) > 0:
1046
- raise FunctionCallConversionError(
1047
- f"Found pending tool calls but no tool result: {pending_tool_calls=}"
1048
- )
1049
- return converted_messages