openhands-sdk 1.7.3__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 (180) hide show
  1. openhands/sdk/__init__.py +111 -0
  2. openhands/sdk/agent/__init__.py +8 -0
  3. openhands/sdk/agent/agent.py +650 -0
  4. openhands/sdk/agent/base.py +457 -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 +2 -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 +228 -0
  20. openhands/sdk/context/__init__.py +28 -0
  21. openhands/sdk/context/agent_context.py +264 -0
  22. openhands/sdk/context/condenser/__init__.py +18 -0
  23. openhands/sdk/context/condenser/base.py +100 -0
  24. openhands/sdk/context/condenser/llm_summarizing_condenser.py +248 -0
  25. openhands/sdk/context/condenser/no_op_condenser.py +14 -0
  26. openhands/sdk/context/condenser/pipeline_condenser.py +56 -0
  27. openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +59 -0
  28. openhands/sdk/context/condenser/utils.py +149 -0
  29. openhands/sdk/context/prompts/__init__.py +6 -0
  30. openhands/sdk/context/prompts/prompt.py +114 -0
  31. openhands/sdk/context/prompts/templates/ask_agent_template.j2 +11 -0
  32. openhands/sdk/context/prompts/templates/skill_knowledge_info.j2 +8 -0
  33. openhands/sdk/context/prompts/templates/system_message_suffix.j2 +32 -0
  34. openhands/sdk/context/skills/__init__.py +28 -0
  35. openhands/sdk/context/skills/exceptions.py +11 -0
  36. openhands/sdk/context/skills/skill.py +720 -0
  37. openhands/sdk/context/skills/trigger.py +36 -0
  38. openhands/sdk/context/skills/types.py +48 -0
  39. openhands/sdk/context/view.py +503 -0
  40. openhands/sdk/conversation/__init__.py +40 -0
  41. openhands/sdk/conversation/base.py +281 -0
  42. openhands/sdk/conversation/conversation.py +152 -0
  43. openhands/sdk/conversation/conversation_stats.py +85 -0
  44. openhands/sdk/conversation/event_store.py +157 -0
  45. openhands/sdk/conversation/events_list_base.py +17 -0
  46. openhands/sdk/conversation/exceptions.py +50 -0
  47. openhands/sdk/conversation/fifo_lock.py +133 -0
  48. openhands/sdk/conversation/impl/__init__.py +5 -0
  49. openhands/sdk/conversation/impl/local_conversation.py +665 -0
  50. openhands/sdk/conversation/impl/remote_conversation.py +956 -0
  51. openhands/sdk/conversation/persistence_const.py +9 -0
  52. openhands/sdk/conversation/response_utils.py +41 -0
  53. openhands/sdk/conversation/secret_registry.py +126 -0
  54. openhands/sdk/conversation/serialization_diff.py +0 -0
  55. openhands/sdk/conversation/state.py +392 -0
  56. openhands/sdk/conversation/stuck_detector.py +311 -0
  57. openhands/sdk/conversation/title_utils.py +191 -0
  58. openhands/sdk/conversation/types.py +45 -0
  59. openhands/sdk/conversation/visualizer/__init__.py +12 -0
  60. openhands/sdk/conversation/visualizer/base.py +67 -0
  61. openhands/sdk/conversation/visualizer/default.py +373 -0
  62. openhands/sdk/critic/__init__.py +15 -0
  63. openhands/sdk/critic/base.py +38 -0
  64. openhands/sdk/critic/impl/__init__.py +12 -0
  65. openhands/sdk/critic/impl/agent_finished.py +83 -0
  66. openhands/sdk/critic/impl/empty_patch.py +49 -0
  67. openhands/sdk/critic/impl/pass_critic.py +42 -0
  68. openhands/sdk/event/__init__.py +42 -0
  69. openhands/sdk/event/base.py +149 -0
  70. openhands/sdk/event/condenser.py +82 -0
  71. openhands/sdk/event/conversation_error.py +25 -0
  72. openhands/sdk/event/conversation_state.py +104 -0
  73. openhands/sdk/event/llm_completion_log.py +39 -0
  74. openhands/sdk/event/llm_convertible/__init__.py +20 -0
  75. openhands/sdk/event/llm_convertible/action.py +139 -0
  76. openhands/sdk/event/llm_convertible/message.py +142 -0
  77. openhands/sdk/event/llm_convertible/observation.py +141 -0
  78. openhands/sdk/event/llm_convertible/system.py +61 -0
  79. openhands/sdk/event/token.py +16 -0
  80. openhands/sdk/event/types.py +11 -0
  81. openhands/sdk/event/user_action.py +21 -0
  82. openhands/sdk/git/exceptions.py +43 -0
  83. openhands/sdk/git/git_changes.py +249 -0
  84. openhands/sdk/git/git_diff.py +129 -0
  85. openhands/sdk/git/models.py +21 -0
  86. openhands/sdk/git/utils.py +189 -0
  87. openhands/sdk/hooks/__init__.py +30 -0
  88. openhands/sdk/hooks/config.py +180 -0
  89. openhands/sdk/hooks/conversation_hooks.py +227 -0
  90. openhands/sdk/hooks/executor.py +155 -0
  91. openhands/sdk/hooks/manager.py +170 -0
  92. openhands/sdk/hooks/types.py +40 -0
  93. openhands/sdk/io/__init__.py +6 -0
  94. openhands/sdk/io/base.py +48 -0
  95. openhands/sdk/io/cache.py +85 -0
  96. openhands/sdk/io/local.py +119 -0
  97. openhands/sdk/io/memory.py +54 -0
  98. openhands/sdk/llm/__init__.py +45 -0
  99. openhands/sdk/llm/exceptions/__init__.py +45 -0
  100. openhands/sdk/llm/exceptions/classifier.py +50 -0
  101. openhands/sdk/llm/exceptions/mapping.py +54 -0
  102. openhands/sdk/llm/exceptions/types.py +101 -0
  103. openhands/sdk/llm/llm.py +1140 -0
  104. openhands/sdk/llm/llm_registry.py +122 -0
  105. openhands/sdk/llm/llm_response.py +59 -0
  106. openhands/sdk/llm/message.py +656 -0
  107. openhands/sdk/llm/mixins/fn_call_converter.py +1288 -0
  108. openhands/sdk/llm/mixins/non_native_fc.py +97 -0
  109. openhands/sdk/llm/options/__init__.py +1 -0
  110. openhands/sdk/llm/options/chat_options.py +93 -0
  111. openhands/sdk/llm/options/common.py +19 -0
  112. openhands/sdk/llm/options/responses_options.py +67 -0
  113. openhands/sdk/llm/router/__init__.py +10 -0
  114. openhands/sdk/llm/router/base.py +117 -0
  115. openhands/sdk/llm/router/impl/multimodal.py +76 -0
  116. openhands/sdk/llm/router/impl/random.py +22 -0
  117. openhands/sdk/llm/streaming.py +9 -0
  118. openhands/sdk/llm/utils/metrics.py +312 -0
  119. openhands/sdk/llm/utils/model_features.py +192 -0
  120. openhands/sdk/llm/utils/model_info.py +90 -0
  121. openhands/sdk/llm/utils/model_prompt_spec.py +98 -0
  122. openhands/sdk/llm/utils/retry_mixin.py +128 -0
  123. openhands/sdk/llm/utils/telemetry.py +362 -0
  124. openhands/sdk/llm/utils/unverified_models.py +156 -0
  125. openhands/sdk/llm/utils/verified_models.py +65 -0
  126. openhands/sdk/logger/__init__.py +22 -0
  127. openhands/sdk/logger/logger.py +195 -0
  128. openhands/sdk/logger/rolling.py +113 -0
  129. openhands/sdk/mcp/__init__.py +24 -0
  130. openhands/sdk/mcp/client.py +76 -0
  131. openhands/sdk/mcp/definition.py +106 -0
  132. openhands/sdk/mcp/exceptions.py +19 -0
  133. openhands/sdk/mcp/tool.py +270 -0
  134. openhands/sdk/mcp/utils.py +83 -0
  135. openhands/sdk/observability/__init__.py +4 -0
  136. openhands/sdk/observability/laminar.py +166 -0
  137. openhands/sdk/observability/utils.py +20 -0
  138. openhands/sdk/py.typed +0 -0
  139. openhands/sdk/secret/__init__.py +19 -0
  140. openhands/sdk/secret/secrets.py +92 -0
  141. openhands/sdk/security/__init__.py +6 -0
  142. openhands/sdk/security/analyzer.py +111 -0
  143. openhands/sdk/security/confirmation_policy.py +61 -0
  144. openhands/sdk/security/llm_analyzer.py +29 -0
  145. openhands/sdk/security/risk.py +100 -0
  146. openhands/sdk/tool/__init__.py +34 -0
  147. openhands/sdk/tool/builtins/__init__.py +34 -0
  148. openhands/sdk/tool/builtins/finish.py +106 -0
  149. openhands/sdk/tool/builtins/think.py +117 -0
  150. openhands/sdk/tool/registry.py +184 -0
  151. openhands/sdk/tool/schema.py +286 -0
  152. openhands/sdk/tool/spec.py +39 -0
  153. openhands/sdk/tool/tool.py +481 -0
  154. openhands/sdk/utils/__init__.py +22 -0
  155. openhands/sdk/utils/async_executor.py +115 -0
  156. openhands/sdk/utils/async_utils.py +39 -0
  157. openhands/sdk/utils/cipher.py +68 -0
  158. openhands/sdk/utils/command.py +90 -0
  159. openhands/sdk/utils/deprecation.py +166 -0
  160. openhands/sdk/utils/github.py +44 -0
  161. openhands/sdk/utils/json.py +48 -0
  162. openhands/sdk/utils/models.py +570 -0
  163. openhands/sdk/utils/paging.py +63 -0
  164. openhands/sdk/utils/pydantic_diff.py +85 -0
  165. openhands/sdk/utils/pydantic_secrets.py +64 -0
  166. openhands/sdk/utils/truncate.py +117 -0
  167. openhands/sdk/utils/visualize.py +58 -0
  168. openhands/sdk/workspace/__init__.py +17 -0
  169. openhands/sdk/workspace/base.py +158 -0
  170. openhands/sdk/workspace/local.py +189 -0
  171. openhands/sdk/workspace/models.py +35 -0
  172. openhands/sdk/workspace/remote/__init__.py +8 -0
  173. openhands/sdk/workspace/remote/async_remote_workspace.py +149 -0
  174. openhands/sdk/workspace/remote/base.py +164 -0
  175. openhands/sdk/workspace/remote/remote_workspace_mixin.py +323 -0
  176. openhands/sdk/workspace/workspace.py +49 -0
  177. openhands_sdk-1.7.3.dist-info/METADATA +17 -0
  178. openhands_sdk-1.7.3.dist-info/RECORD +180 -0
  179. openhands_sdk-1.7.3.dist-info/WHEEL +5 -0
  180. openhands_sdk-1.7.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1288 @@
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
+ # Note: newline after function name is optional for compatibility with various models
454
+ FN_REGEX_PATTERN = r"<function=([^>]+)>\n?(.*?)</function>"
455
+ FN_PARAM_REGEX_PATTERN = r"<parameter=([^>]+)>(.*?)</parameter>"
456
+
457
+ # Add new regex pattern for tool execution results
458
+ TOOL_RESULT_REGEX_PATTERN = r"EXECUTION RESULT of \[(.*?)\]:\n(.*)"
459
+
460
+
461
+ def convert_tool_call_to_string(tool_call: dict) -> str:
462
+ """Convert tool call to content in string format."""
463
+ if "function" not in tool_call:
464
+ raise FunctionCallConversionError("Tool call must contain 'function' key.")
465
+ if "id" not in tool_call:
466
+ raise FunctionCallConversionError("Tool call must contain 'id' key.")
467
+ if "type" not in tool_call:
468
+ raise FunctionCallConversionError("Tool call must contain 'type' key.")
469
+ if tool_call["type"] != "function":
470
+ raise FunctionCallConversionError("Tool call type must be 'function'.")
471
+
472
+ ret = f"<function={tool_call['function']['name']}>\n"
473
+ try:
474
+ args = json.loads(tool_call["function"]["arguments"])
475
+ except json.JSONDecodeError as e:
476
+ raise FunctionCallConversionError(
477
+ f"Failed to parse arguments as JSON. "
478
+ f"Arguments: {tool_call['function']['arguments']}"
479
+ ) from e
480
+ for param_name, param_value in args.items():
481
+ is_multiline = isinstance(param_value, str) and "\n" in param_value
482
+ ret += f"<parameter={param_name}>"
483
+ if is_multiline:
484
+ ret += "\n"
485
+ if isinstance(param_value, list) or isinstance(param_value, dict):
486
+ ret += json.dumps(param_value)
487
+ else:
488
+ ret += f"{param_value}"
489
+ if is_multiline:
490
+ ret += "\n"
491
+ ret += "</parameter>\n"
492
+ ret += "</function>"
493
+ return ret
494
+
495
+
496
+ def _summarize_schema_type(schema: object | None) -> str:
497
+ """
498
+ Capture array, union, enum, and nested type info.
499
+ """
500
+ if not isinstance(schema, dict):
501
+ return "unknown" if schema is None else str(schema)
502
+
503
+ for key in SCHEMA_UNION_KEYS:
504
+ if key in schema:
505
+ return " or ".join(_summarize_schema_type(option) for option in schema[key])
506
+
507
+ schema_type = schema.get("type")
508
+ if isinstance(schema_type, list):
509
+ return " or ".join(str(t) for t in schema_type)
510
+ if schema_type == "array":
511
+ items = schema.get("items")
512
+ if isinstance(items, list):
513
+ item_types = ", ".join(_summarize_schema_type(item) for item in items)
514
+ return f"array[{item_types}]"
515
+ if isinstance(items, dict):
516
+ return f"array[{_summarize_schema_type(items)}]"
517
+ return "array"
518
+ if schema_type:
519
+ return str(schema_type)
520
+ if "enum" in schema:
521
+ return "enum"
522
+ return "unknown"
523
+
524
+
525
+ def _indent(indent: int) -> str:
526
+ return " " * indent
527
+
528
+
529
+ def _nested_indent(indent: int, levels: int = 1) -> int:
530
+ return indent + SCHEMA_INDENT_STEP * levels
531
+
532
+
533
+ def _get_description(schema: dict[str, object] | None) -> str:
534
+ """
535
+ Extract description from schema, or return placeholder if missing.
536
+ """
537
+ if not isinstance(schema, dict):
538
+ return MISSING_DESCRIPTION_PLACEHOLDER
539
+ description = schema.get("description")
540
+ if isinstance(description, str) and description.strip():
541
+ return description
542
+ return MISSING_DESCRIPTION_PLACEHOLDER
543
+
544
+
545
+ def _format_union_details(schema: dict[str, object], indent: int) -> list[str] | None:
546
+ for key in SCHEMA_UNION_KEYS:
547
+ options = schema.get(key)
548
+ if not isinstance(options, list):
549
+ continue
550
+ lines = [f"{_indent(indent)}{key} options:"]
551
+ for option in options:
552
+ option_type = _summarize_schema_type(option)
553
+ option_line = f"{_indent(_nested_indent(indent))}- {option_type}"
554
+ option_line += (
555
+ f": {_get_description(option if isinstance(option, dict) else None)}"
556
+ )
557
+ lines.append(option_line)
558
+ lines.extend(_format_schema_detail(option, _nested_indent(indent, 2)))
559
+ return lines
560
+ return None
561
+
562
+
563
+ def _format_array_details(schema: dict[str, object], indent: int) -> list[str]:
564
+ lines = [f"{_indent(indent)}Array items:"]
565
+ items = schema.get("items")
566
+ if isinstance(items, list):
567
+ for index, item_schema in enumerate(items):
568
+ item_type = _summarize_schema_type(item_schema)
569
+ lines.append(
570
+ f"{_indent(_nested_indent(indent))}- index {index}: {item_type}"
571
+ )
572
+ lines.extend(_format_schema_detail(item_schema, _nested_indent(indent, 2)))
573
+ elif isinstance(items, dict):
574
+ lines.append(
575
+ f"{_indent(_nested_indent(indent))}Type: {_summarize_schema_type(items)}"
576
+ )
577
+ lines.extend(_format_schema_detail(items, _nested_indent(indent, 2)))
578
+ else:
579
+ lines.append(f"{_indent(_nested_indent(indent))}Type: unknown")
580
+ return lines
581
+
582
+
583
+ def _format_additional_properties(
584
+ additional_props: object | None, indent: int
585
+ ) -> list[str]:
586
+ if isinstance(additional_props, dict):
587
+ line = (
588
+ f"{_indent(indent)}Additional properties allowed: "
589
+ f"{_summarize_schema_type(additional_props)}"
590
+ )
591
+ lines = [line]
592
+ lines.extend(_format_schema_detail(additional_props, _nested_indent(indent)))
593
+ return lines
594
+ if additional_props is True:
595
+ return [f"{_indent(indent)}Additional properties allowed."]
596
+ if additional_props is False:
597
+ return [f"{_indent(indent)}Additional properties not allowed."]
598
+ return []
599
+
600
+
601
+ def _format_object_details(schema: dict[str, Any], indent: int) -> list[str]:
602
+ lines: list[str] = []
603
+ properties = schema.get("properties", {})
604
+ required = set(schema.get("required", []))
605
+ if isinstance(properties, dict) and properties:
606
+ lines.append(f"{_indent(indent)}Object properties:")
607
+ for name, prop in properties.items():
608
+ prop_type = _summarize_schema_type(prop)
609
+ required_flag = "required" if name in required else "optional"
610
+ prop_desc = _get_description(prop if isinstance(prop, dict) else None)
611
+ lines.append(
612
+ f"{_indent(_nested_indent(indent))}- {name} ({prop_type},"
613
+ f" {required_flag}): {prop_desc}"
614
+ )
615
+ lines.extend(_format_schema_detail(prop, _nested_indent(indent, 2)))
616
+ lines.extend(
617
+ _format_additional_properties(schema.get("additionalProperties"), indent)
618
+ )
619
+ return lines
620
+
621
+
622
+ def _format_schema_detail(schema: object | None, indent: int = 4) -> list[str]:
623
+ """Recursively describe arrays, objects, unions, and additional properties."""
624
+ if not isinstance(schema, dict):
625
+ return []
626
+
627
+ union_lines = _format_union_details(schema, indent)
628
+ if union_lines is not None:
629
+ return union_lines
630
+
631
+ schema_type = schema.get("type")
632
+ if isinstance(schema_type, list):
633
+ allowed_types = ", ".join(str(t) for t in schema_type)
634
+ return [f"{_indent(indent)}Allowed types: {allowed_types}"]
635
+
636
+ if schema_type == "array":
637
+ return _format_array_details(schema, indent)
638
+
639
+ if schema_type == "object":
640
+ return _format_object_details(schema, indent)
641
+
642
+ return []
643
+
644
+
645
+ def convert_tools_to_description(tools: list[ChatCompletionToolParam]) -> str:
646
+ ret = ""
647
+ for i, tool in enumerate(tools):
648
+ assert tool["type"] == "function"
649
+ fn = tool["function"]
650
+ if i > 0:
651
+ ret += "\n"
652
+ ret += f"---- BEGIN FUNCTION #{i + 1}: {fn['name']} ----\n"
653
+ if "description" in fn:
654
+ ret += f"Description: {fn['description']}\n"
655
+
656
+ if "parameters" in fn:
657
+ ret += "Parameters:\n"
658
+ properties = fn["parameters"].get("properties", {})
659
+ required_params = set(fn["parameters"].get("required", []))
660
+
661
+ for j, (param_name, param_info) in enumerate(properties.items()):
662
+ is_required = param_name in required_params
663
+ param_status = "required" if is_required else "optional"
664
+ param_type = _summarize_schema_type(param_info)
665
+
666
+ desc = _get_description(
667
+ param_info if isinstance(param_info, dict) else None
668
+ )
669
+
670
+ if "enum" in param_info:
671
+ enum_values = ", ".join(f"`{v}`" for v in param_info["enum"])
672
+ desc += f"\nAllowed values: [{enum_values}]"
673
+
674
+ ret += (
675
+ f" ({j + 1}) {param_name} ({param_type}, {param_status}): {desc}\n"
676
+ )
677
+
678
+ detail_lines = _format_schema_detail(param_info, indent=6)
679
+ if detail_lines:
680
+ ret += "\n".join(detail_lines) + "\n"
681
+
682
+ else:
683
+ ret += "No parameters are required for this function.\n"
684
+
685
+ ret += f"---- END FUNCTION #{i + 1} ----\n"
686
+ return ret
687
+
688
+
689
+ def convert_fncall_messages_to_non_fncall_messages(
690
+ messages: list[dict],
691
+ tools: list[ChatCompletionToolParam],
692
+ add_in_context_learning_example: bool = True,
693
+ ) -> list[dict]:
694
+ """Convert function calling messages to non-function calling messages."""
695
+ messages = copy.deepcopy(messages)
696
+
697
+ formatted_tools = convert_tools_to_description(tools)
698
+ system_message_suffix = system_message_suffix_TEMPLATE.format(
699
+ description=formatted_tools
700
+ )
701
+
702
+ converted_messages = []
703
+ first_user_message_encountered = False
704
+ for message in messages:
705
+ role = message["role"]
706
+ content: Content = message.get("content") or ""
707
+
708
+ # 1. SYSTEM MESSAGES
709
+ # append system prompt suffix to content
710
+ if role == "system":
711
+ if isinstance(content, str):
712
+ content += system_message_suffix
713
+ elif isinstance(content, list):
714
+ if content and content[-1]["type"] == "text":
715
+ content[-1]["text"] += system_message_suffix
716
+ else:
717
+ content.append({"type": "text", "text": system_message_suffix})
718
+ else:
719
+ raise FunctionCallConversionError(
720
+ f"Unexpected content type {type(content)}. "
721
+ f"Expected str or list. "
722
+ f"Content: {content}"
723
+ )
724
+ converted_messages.append({"role": "system", "content": content})
725
+
726
+ # 2. USER MESSAGES (no change)
727
+ elif role == "user":
728
+ # Add in-context learning example for the first user message
729
+ if not first_user_message_encountered and add_in_context_learning_example:
730
+ first_user_message_encountered = True
731
+
732
+ # Generate example based on available tools
733
+ example = IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)
734
+
735
+ # Add example if we have any tools
736
+ if example:
737
+ # add in-context learning example
738
+ if isinstance(content, str):
739
+ content = example + content + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
740
+ elif isinstance(content, list):
741
+ if content and content[0]["type"] == "text":
742
+ content[0]["text"] = (
743
+ example
744
+ + content[0]["text"]
745
+ + IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
746
+ )
747
+ else:
748
+ content = (
749
+ [
750
+ cast(
751
+ TextPart,
752
+ {
753
+ "type": "text",
754
+ "text": example,
755
+ },
756
+ )
757
+ ]
758
+ + content
759
+ + [
760
+ cast(
761
+ TextPart,
762
+ {
763
+ "type": "text",
764
+ "text": IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX,
765
+ },
766
+ )
767
+ ]
768
+ )
769
+ else:
770
+ raise FunctionCallConversionError(
771
+ f"Unexpected content type {type(content)}. "
772
+ f"Expected str or list. "
773
+ f"Content: {content}"
774
+ )
775
+ converted_messages.append(
776
+ {
777
+ "role": "user",
778
+ "content": content,
779
+ }
780
+ )
781
+
782
+ # 3. ASSISTANT MESSAGES
783
+ # - 3.1 no change if no function call
784
+ # - 3.2 change if function call
785
+ elif role == "assistant":
786
+ if "tool_calls" in message and message["tool_calls"] is not None:
787
+ if len(message["tool_calls"]) != 1:
788
+ raise FunctionCallConversionError(
789
+ f"Expected exactly one tool call in the message. "
790
+ f"More than one tool call is not supported. "
791
+ f"But got {len(message['tool_calls'])} tool calls. "
792
+ f"Content: {content}"
793
+ )
794
+ try:
795
+ tool_content = convert_tool_call_to_string(message["tool_calls"][0])
796
+ except FunctionCallConversionError as e:
797
+ raise FunctionCallConversionError(
798
+ f"Failed to convert tool call to string.\n"
799
+ f"Current tool call: {message['tool_calls'][0]}.\n"
800
+ f"Raw messages: {json.dumps(messages, indent=2)}"
801
+ ) from e
802
+ if isinstance(content, str):
803
+ content += "\n\n" + tool_content
804
+ content = content.lstrip()
805
+ elif isinstance(content, list):
806
+ if content and content[-1]["type"] == "text":
807
+ content[-1]["text"] += "\n\n" + tool_content
808
+ content[-1]["text"] = content[-1]["text"].lstrip()
809
+ else:
810
+ content.append({"type": "text", "text": tool_content})
811
+ else:
812
+ raise FunctionCallConversionError(
813
+ f"Unexpected content type {type(content)}. "
814
+ f"Expected str or list. Content: {content}"
815
+ )
816
+ converted_messages.append({"role": "assistant", "content": content})
817
+
818
+ # 4. TOOL MESSAGES (tool outputs)
819
+ elif role == "tool":
820
+ # Convert tool result as user message
821
+ tool_name = message.get("name", "function")
822
+ prefix = f"EXECUTION RESULT of [{tool_name}]:\n"
823
+ # and omit "tool_call_id" AND "name"
824
+ if isinstance(content, str):
825
+ content = prefix + content
826
+ elif isinstance(content, list):
827
+ if content and (
828
+ first_text_content := next(
829
+ (c for c in content if c["type"] == "text"), None
830
+ )
831
+ ):
832
+ first_text_content["text"] = prefix + first_text_content["text"]
833
+ else:
834
+ content = [
835
+ cast(TextPart, {"type": "text", "text": prefix})
836
+ ] + content
837
+
838
+ if "cache_control" in message:
839
+ content[-1]["cache_control"] = cast(
840
+ CacheControl, {"type": "ephemeral"}
841
+ )
842
+ else:
843
+ raise FunctionCallConversionError(
844
+ f"Unexpected content type {type(content)}. "
845
+ f"Expected str or list. "
846
+ f"Content: {content}"
847
+ )
848
+
849
+ converted_messages.append({"role": "user", "content": content})
850
+ else:
851
+ raise FunctionCallConversionError(
852
+ f"Unexpected role {role}. Expected system, user, assistant or tool."
853
+ )
854
+ return converted_messages
855
+
856
+
857
+ def _extract_and_validate_params(
858
+ matching_tool: ChatCompletionToolParamFunctionChunk,
859
+ param_matches: Iterable[re.Match],
860
+ fn_name: str,
861
+ ) -> dict:
862
+ params = {}
863
+ # Parse and validate parameters
864
+ required_params = set()
865
+ if "parameters" in matching_tool and "required" in matching_tool["parameters"]:
866
+ required_params = set(matching_tool["parameters"].get("required", []))
867
+
868
+ allowed_params = set()
869
+ if "parameters" in matching_tool and "properties" in matching_tool["parameters"]:
870
+ allowed_params = set(matching_tool["parameters"]["properties"].keys())
871
+
872
+ param_name_to_type = {}
873
+ if "parameters" in matching_tool and "properties" in matching_tool["parameters"]:
874
+ param_name_to_type = {
875
+ name: val.get("type", "string")
876
+ for name, val in matching_tool["parameters"]["properties"].items()
877
+ }
878
+
879
+ # Collect parameters
880
+ found_params = set()
881
+ for param_match in param_matches:
882
+ param_name = param_match.group(1)
883
+ param_value = param_match.group(2)
884
+ # Normalize whitespace: some models add extra newlines around values
885
+ if isinstance(param_value, str):
886
+ param_value = param_value.strip()
887
+
888
+ # Validate parameter is allowed
889
+ if allowed_params and param_name not in allowed_params:
890
+ raise FunctionCallValidationError(
891
+ f"Parameter '{param_name}' is not allowed for function '{fn_name}'. "
892
+ f"Allowed parameters: {allowed_params}"
893
+ )
894
+
895
+ # Validate and convert parameter type
896
+ # supported: string, integer, array
897
+ if param_name in param_name_to_type:
898
+ if param_name_to_type[param_name] == "integer":
899
+ try:
900
+ param_value = int(param_value)
901
+ except ValueError:
902
+ raise FunctionCallValidationError(
903
+ f"Parameter '{param_name}' is expected to be an integer."
904
+ )
905
+ elif param_name_to_type[param_name] == "array":
906
+ try:
907
+ param_value = json.loads(param_value)
908
+ except json.JSONDecodeError:
909
+ raise FunctionCallValidationError(
910
+ f"Parameter '{param_name}' is expected to be an array."
911
+ )
912
+ else:
913
+ # string
914
+ pass
915
+
916
+ # Enum check
917
+ if (
918
+ "parameters" in matching_tool
919
+ and "enum" in matching_tool["parameters"]["properties"][param_name]
920
+ ):
921
+ if (
922
+ param_value
923
+ not in matching_tool["parameters"]["properties"][param_name]["enum"]
924
+ ):
925
+ raise FunctionCallValidationError(
926
+ f"Parameter '{param_name}' is expected to be one of "
927
+ f"{matching_tool['parameters']['properties'][param_name]['enum']}."
928
+ )
929
+
930
+ params[param_name] = param_value
931
+ found_params.add(param_name)
932
+
933
+ # Check all required parameters are present
934
+ # Note: security_risk is excluded here because its validation happens later
935
+ # in Agent._extract_security_risk(), which has context about whether a security
936
+ # analyzer is configured. This allows weaker models to omit it when no analyzer
937
+ # is active, while still enforcing it for stronger models with LLMSecurityAnalyzer.
938
+ missing_params = required_params - found_params - {"security_risk"}
939
+ if missing_params:
940
+ raise FunctionCallValidationError(
941
+ f"Missing required parameters for function '{fn_name}': {missing_params}"
942
+ )
943
+ return params
944
+
945
+
946
+ def _preprocess_model_output(content: str) -> str:
947
+ """Clean up model-specific formatting before parsing function calls.
948
+
949
+ Removes wrapper tags that some models (like Nemotron) emit around function calls:
950
+ - </think> before the function call
951
+ - <tool_call>...</tool_call> around the function call
952
+
953
+ Only strips tags at boundaries, not inside parameter values.
954
+ """
955
+ # Strip </think> when it appears before <function= (Nemotron reasoning end)
956
+ content = re.sub(r"</think>\s*(?=<function=)", "", content)
957
+ # Strip <tool_call> when it appears right before <function=
958
+ content = re.sub(r"<tool_call>\s*(?=<function=)", "", content)
959
+ # Strip </tool_call> when it appears right after </function>
960
+ content = re.sub(r"(?<=</function>)\s*</tool_call>", "", content)
961
+ return content
962
+
963
+
964
+ def _fix_stopword(content: str) -> str:
965
+ """Fix the issue when some LLM would NOT return the stopword."""
966
+ content = _preprocess_model_output(content)
967
+ if "<function=" in content and content.count("<function=") == 1:
968
+ if content.endswith("</"):
969
+ content = content.rstrip() + "function>"
970
+ elif not content.rstrip().endswith("</function>"):
971
+ content = content + "\n</function>"
972
+ return content
973
+
974
+
975
+ def _normalize_parameter_tags(fn_body: str) -> str:
976
+ """Normalize malformed parameter tags to the canonical format.
977
+
978
+ Some models occasionally emit malformed parameter tags like:
979
+ <parameter=command=str_replace</parameter>
980
+ instead of the correct:
981
+ <parameter=command>str_replace</parameter>
982
+
983
+ This function rewrites the malformed form into the correct one to allow
984
+ downstream parsing to succeed.
985
+ """
986
+ # Replace '<parameter=name=value</parameter>'
987
+ # with '<parameter=name>value</parameter>'
988
+ return re.sub(
989
+ r"<parameter=([a-zA-Z0-9_]+)=([^<]*)</parameter>",
990
+ r"<parameter=\1>\2</parameter>",
991
+ fn_body,
992
+ )
993
+
994
+
995
+ def convert_non_fncall_messages_to_fncall_messages(
996
+ messages: list[dict],
997
+ tools: list[ChatCompletionToolParam],
998
+ ) -> list[dict]:
999
+ """Convert non-function calling messages back to function calling messages."""
1000
+ messages = copy.deepcopy(messages)
1001
+ formatted_tools = convert_tools_to_description(tools)
1002
+ system_message_suffix = system_message_suffix_TEMPLATE.format(
1003
+ description=formatted_tools
1004
+ )
1005
+
1006
+ converted_messages = []
1007
+ tool_call_counter = 1 # Counter for tool calls
1008
+
1009
+ first_user_message_encountered = False
1010
+ for message in messages:
1011
+ role = message["role"]
1012
+ content = message.get("content") or ""
1013
+ # For system messages, remove the added suffix
1014
+ if role == "system":
1015
+ if isinstance(content, str):
1016
+ # Remove the suffix if present
1017
+ content = content.split(system_message_suffix)[0]
1018
+ elif isinstance(content, list):
1019
+ if content and content[-1]["type"] == "text":
1020
+ # Remove the suffix from the last text item
1021
+ content[-1]["text"] = content[-1]["text"].split(
1022
+ system_message_suffix
1023
+ )[0]
1024
+ converted_messages.append({"role": "system", "content": content})
1025
+ # Skip user messages (no conversion needed)
1026
+ elif role == "user":
1027
+ # Check & replace in-context learning example
1028
+ if not first_user_message_encountered:
1029
+ first_user_message_encountered = True
1030
+ if isinstance(content, str):
1031
+ # Remove any existing example
1032
+ if content.startswith(IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)):
1033
+ content = content.replace(
1034
+ IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools), "", 1
1035
+ )
1036
+ if content.endswith(IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX):
1037
+ content = content.replace(
1038
+ IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, "", 1
1039
+ )
1040
+ elif isinstance(content, list):
1041
+ for item in content:
1042
+ if item["type"] == "text":
1043
+ # Remove any existing example
1044
+ example = IN_CONTEXT_LEARNING_EXAMPLE_PREFIX(tools)
1045
+ if item["text"].startswith(example):
1046
+ item["text"] = item["text"].replace(example, "", 1)
1047
+ if item["text"].endswith(
1048
+ IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX
1049
+ ):
1050
+ item["text"] = item["text"].replace(
1051
+ IN_CONTEXT_LEARNING_EXAMPLE_SUFFIX, "", 1
1052
+ )
1053
+ else:
1054
+ raise FunctionCallConversionError(
1055
+ f"Unexpected content type {type(content)}. "
1056
+ f"Expected str or list. "
1057
+ f"Content: {content}"
1058
+ )
1059
+
1060
+ # Check for tool execution result pattern
1061
+ if isinstance(content, str):
1062
+ tool_result_match = re.search(
1063
+ TOOL_RESULT_REGEX_PATTERN, content, re.DOTALL
1064
+ )
1065
+ elif isinstance(content, list):
1066
+ tool_result_match = next(
1067
+ (
1068
+ _match
1069
+ for item in content
1070
+ if item.get("type") == "text"
1071
+ and (
1072
+ _match := re.search(
1073
+ TOOL_RESULT_REGEX_PATTERN, item["text"], re.DOTALL
1074
+ )
1075
+ )
1076
+ ),
1077
+ None,
1078
+ )
1079
+ else:
1080
+ raise FunctionCallConversionError(
1081
+ f"Unexpected content type {type(content)}. "
1082
+ f"Expected str or list. "
1083
+ f"Content: {content}"
1084
+ )
1085
+
1086
+ if tool_result_match:
1087
+ if isinstance(content, list):
1088
+ text_content_items = [
1089
+ item for item in content if item.get("type") == "text"
1090
+ ]
1091
+ if not text_content_items:
1092
+ raise FunctionCallConversionError(
1093
+ f"Could not find text content in message with tool result. "
1094
+ f"Content: {content}"
1095
+ )
1096
+ elif not isinstance(content, str):
1097
+ raise FunctionCallConversionError(
1098
+ f"Unexpected content type {type(content)}. "
1099
+ f"Expected str or list. "
1100
+ f"Content: {content}"
1101
+ )
1102
+
1103
+ tool_name = tool_result_match.group(1)
1104
+ tool_result = tool_result_match.group(2).strip()
1105
+
1106
+ # Convert to tool message format
1107
+ converted_messages.append(
1108
+ {
1109
+ "role": "tool",
1110
+ "name": tool_name,
1111
+ "content": [{"type": "text", "text": tool_result}]
1112
+ if isinstance(content, list)
1113
+ else tool_result,
1114
+ "tool_call_id": f"toolu_{tool_call_counter - 1:02d}",
1115
+ # Use last generated ID
1116
+ }
1117
+ )
1118
+ else:
1119
+ converted_messages.append({"role": "user", "content": content})
1120
+
1121
+ # Handle assistant messages
1122
+ elif role == "assistant":
1123
+ if isinstance(content, str):
1124
+ content = _fix_stopword(content)
1125
+ fn_match = re.search(FN_REGEX_PATTERN, content, re.DOTALL)
1126
+ elif isinstance(content, list):
1127
+ if content and content[-1]["type"] == "text":
1128
+ content[-1]["text"] = _fix_stopword(content[-1]["text"])
1129
+ fn_match = re.search(
1130
+ FN_REGEX_PATTERN, content[-1]["text"], re.DOTALL
1131
+ )
1132
+ else:
1133
+ fn_match = None
1134
+ fn_match_exists = any(
1135
+ item.get("type") == "text"
1136
+ and re.search(FN_REGEX_PATTERN, item["text"], re.DOTALL)
1137
+ for item in content
1138
+ )
1139
+ if fn_match_exists and not fn_match:
1140
+ raise FunctionCallConversionError(
1141
+ f"Expecting function call in the LAST index of content list. "
1142
+ f"But got content={content}"
1143
+ )
1144
+ else:
1145
+ raise FunctionCallConversionError(
1146
+ f"Unexpected content type {type(content)}. "
1147
+ f"Expected str or list. "
1148
+ f"Content: {content}"
1149
+ )
1150
+
1151
+ if fn_match:
1152
+ fn_name = fn_match.group(1)
1153
+ fn_body = _normalize_parameter_tags(fn_match.group(2))
1154
+
1155
+ def _find_tool(
1156
+ name: str,
1157
+ ) -> ChatCompletionToolParamFunctionChunk | None:
1158
+ return next(
1159
+ (
1160
+ tool["function"]
1161
+ for tool in tools
1162
+ if tool["type"] == "function"
1163
+ and tool["function"]["name"] == name
1164
+ ),
1165
+ None,
1166
+ )
1167
+
1168
+ matching_tool = _find_tool(fn_name)
1169
+ # Try aliases if tool not found (some models use legacy names)
1170
+ if not matching_tool:
1171
+ TOOL_NAME_ALIASES = {
1172
+ "str_replace_editor": "file_editor",
1173
+ "bash": "terminal",
1174
+ "execute_bash": "terminal",
1175
+ "str_replace": "file_editor",
1176
+ }
1177
+ if fn_name in TOOL_NAME_ALIASES:
1178
+ fn_name = TOOL_NAME_ALIASES[fn_name]
1179
+ matching_tool = _find_tool(fn_name)
1180
+ # Validate function exists in tools
1181
+ if not matching_tool:
1182
+ available_tools = [
1183
+ tool["function"]["name"]
1184
+ for tool in tools
1185
+ if tool["type"] == "function"
1186
+ ]
1187
+ raise FunctionCallValidationError(
1188
+ f"Function '{fn_name}' not found in available tools: "
1189
+ f"{available_tools}"
1190
+ )
1191
+
1192
+ # Parse parameters
1193
+ param_matches = re.finditer(FN_PARAM_REGEX_PATTERN, fn_body, re.DOTALL)
1194
+ params = _extract_and_validate_params(
1195
+ matching_tool, param_matches, fn_name
1196
+ )
1197
+
1198
+ # Create tool call with unique ID
1199
+ tool_call_id = f"toolu_{tool_call_counter:02d}"
1200
+ tool_call = {
1201
+ "index": 1, # always 1 because we only support
1202
+ # **one tool call per message**
1203
+ "id": tool_call_id,
1204
+ "type": "function",
1205
+ "function": {"name": fn_name, "arguments": json.dumps(params)},
1206
+ }
1207
+ tool_call_counter += 1 # Increment counter
1208
+
1209
+ # Remove the function call part from content
1210
+ if isinstance(content, list):
1211
+ assert content and content[-1]["type"] == "text"
1212
+ content[-1]["text"] = (
1213
+ content[-1]["text"].split("<function=")[0].strip()
1214
+ )
1215
+ elif isinstance(content, str):
1216
+ content = content.split("<function=")[0].strip()
1217
+ else:
1218
+ raise FunctionCallConversionError(
1219
+ f"Unexpected content type {type(content)}. "
1220
+ f"Expected str or list. "
1221
+ f"Content: {content}"
1222
+ )
1223
+
1224
+ converted_messages.append(
1225
+ {"role": "assistant", "content": content, "tool_calls": [tool_call]}
1226
+ )
1227
+ else:
1228
+ # No function call, keep message as is
1229
+ converted_messages.append(message)
1230
+
1231
+ else:
1232
+ raise FunctionCallConversionError(
1233
+ f"Unexpected role {role}. Expected system, user, or assistant "
1234
+ f"in non-function calling messages."
1235
+ )
1236
+ return converted_messages
1237
+
1238
+
1239
+ def convert_from_multiple_tool_calls_to_single_tool_call_messages(
1240
+ messages: list[dict],
1241
+ ignore_final_tool_result: bool = False,
1242
+ ) -> list[dict]:
1243
+ """Break one message with multiple tool calls into multiple messages."""
1244
+ converted_messages = []
1245
+
1246
+ pending_tool_calls: dict[str, dict] = {}
1247
+ for message in messages:
1248
+ role: str
1249
+ content: Content
1250
+ role = message["role"]
1251
+ content = message.get("content") or ""
1252
+ if role == "assistant":
1253
+ if message.get("tool_calls") and len(message["tool_calls"]) > 1:
1254
+ # handle multiple tool calls by breaking them into multiple messages
1255
+ for i, tool_call in enumerate(message["tool_calls"]):
1256
+ pending_tool_calls[tool_call["id"]] = {
1257
+ "role": "assistant",
1258
+ "content": content if i == 0 else "",
1259
+ "tool_calls": [tool_call],
1260
+ }
1261
+ else:
1262
+ converted_messages.append(message)
1263
+ elif role == "tool":
1264
+ if message["tool_call_id"] in pending_tool_calls:
1265
+ # remove the tool call from the pending list
1266
+ _tool_call_message = pending_tool_calls.pop(message["tool_call_id"])
1267
+ converted_messages.append(_tool_call_message)
1268
+ # add the tool result
1269
+ converted_messages.append(message)
1270
+ else:
1271
+ assert len(pending_tool_calls) == 0, (
1272
+ f"Found pending tool calls but not found in pending list: "
1273
+ f"{pending_tool_calls=}"
1274
+ )
1275
+ converted_messages.append(message)
1276
+ else:
1277
+ assert len(pending_tool_calls) == 0, (
1278
+ f"Found pending tool calls but not expect to handle it "
1279
+ f"with role {role}: "
1280
+ f"{pending_tool_calls=}, {message=}"
1281
+ )
1282
+ converted_messages.append(message)
1283
+
1284
+ if not ignore_final_tool_result and len(pending_tool_calls) > 0:
1285
+ raise FunctionCallConversionError(
1286
+ f"Found pending tool calls but no tool result: {pending_tool_calls=}"
1287
+ )
1288
+ return converted_messages