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