camel-ai 0.2.23__py3-none-any.whl → 0.2.24__py3-none-any.whl

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

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

@@ -0,0 +1,251 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+ import inspect
15
+ from contextlib import AsyncExitStack, asynccontextmanager
16
+ from typing import (
17
+ TYPE_CHECKING,
18
+ Any,
19
+ Callable,
20
+ Dict,
21
+ List,
22
+ Optional,
23
+ Set,
24
+ Union,
25
+ )
26
+ from urllib.parse import urlparse
27
+
28
+ if TYPE_CHECKING:
29
+ from mcp import ListToolsResult, Tool
30
+
31
+ from camel.toolkits import BaseToolkit, FunctionTool
32
+
33
+
34
+ class MCPToolkit(BaseToolkit):
35
+ r"""MCPToolkit provides an abstraction layer to interact with external
36
+ tools using the Model Context Protocol (MCP). It supports two modes of
37
+ connection:
38
+
39
+ 1. stdio mode: Connects via standard input/output streams for local
40
+ command-line interactions.
41
+
42
+ 2. SSE mode (HTTP Server-Sent Events): Connects via HTTP for persistent,
43
+ event-based interactions.
44
+
45
+ Attributes:
46
+ command_or_url (str): URL for SSE mode or command executable for stdio
47
+ mode. (default: :obj:`'None'`)
48
+ args (List[str]): List of command-line arguments if stdio mode is used.
49
+ (default: :obj:`'None'`)
50
+ env (Dict[str, str]): Environment variables for the stdio mode command.
51
+ (default: :obj:`'None'`)
52
+ timeout (Optional[float]): Connection timeout. (default: :obj:`'None'`)
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ command_or_url: str,
58
+ args: Optional[List[str]] = None,
59
+ env: Optional[Dict[str, str]] = None,
60
+ timeout: Optional[float] = None,
61
+ ):
62
+ from mcp import Tool
63
+ from mcp.client.session import ClientSession
64
+
65
+ super().__init__(timeout=timeout)
66
+
67
+ self.command_or_url = command_or_url
68
+ self.args = args or []
69
+ self.env = env or {}
70
+
71
+ self._mcp_tools: List[Tool] = []
72
+ self._session: Optional['ClientSession'] = None
73
+ self._exit_stack = AsyncExitStack()
74
+ self._is_connected = False
75
+
76
+ @asynccontextmanager
77
+ async def connection(self):
78
+ r"""Async context manager for establishing and managing the connection
79
+ with the MCP server. Automatically selects SSE or stdio mode based
80
+ on the provided `command_or_url`.
81
+
82
+ Yields:
83
+ MCPToolkit: Instance with active connection ready for tool
84
+ interaction.
85
+ """
86
+ from mcp.client.session import ClientSession
87
+ from mcp.client.sse import sse_client
88
+ from mcp.client.stdio import StdioServerParameters, stdio_client
89
+
90
+ try:
91
+ if urlparse(self.command_or_url).scheme in ("http", "https"):
92
+ (
93
+ read_stream,
94
+ write_stream,
95
+ ) = await self._exit_stack.enter_async_context(
96
+ sse_client(self.command_or_url)
97
+ )
98
+ else:
99
+ server_parameters = StdioServerParameters(
100
+ command=self.command_or_url, args=self.args, env=self.env
101
+ )
102
+ (
103
+ read_stream,
104
+ write_stream,
105
+ ) = await self._exit_stack.enter_async_context(
106
+ stdio_client(server_parameters)
107
+ )
108
+
109
+ self._session = await self._exit_stack.enter_async_context(
110
+ ClientSession(read_stream, write_stream)
111
+ )
112
+ await self._session.initialize()
113
+ list_tools_result = await self.list_mcp_tools()
114
+ self._mcp_tools = list_tools_result.tools
115
+ self._is_connected = True
116
+ yield self
117
+
118
+ finally:
119
+ self._is_connected = False
120
+ await self._exit_stack.aclose()
121
+ self._session = None
122
+
123
+ async def list_mcp_tools(self) -> Union[str, "ListToolsResult"]:
124
+ r"""Retrieves the list of available tools from the connected MCP
125
+ server.
126
+
127
+ Returns:
128
+ ListToolsResult: Result containing available MCP tools.
129
+ """
130
+ if not self._session:
131
+ return "MCP Client is not connected. Call `connection()` first."
132
+ try:
133
+ return await self._session.list_tools()
134
+ except Exception as e:
135
+ return f"Failed to list MCP tools: {e!s}"
136
+
137
+ def generate_function_from_mcp_tool(self, mcp_tool: "Tool") -> Callable:
138
+ r"""Dynamically generates a Python callable function corresponding to
139
+ a given MCP tool.
140
+
141
+ Args:
142
+ mcp_tool (Tool): The MCP tool definition received from the MCP
143
+ server.
144
+
145
+ Returns:
146
+ Callable: A dynamically created async Python function that wraps
147
+ the MCP tool.
148
+ """
149
+ func_name = mcp_tool.name
150
+ func_desc = mcp_tool.description or "No description provided."
151
+ parameters_schema = mcp_tool.inputSchema.get("properties", {})
152
+ required_params = mcp_tool.inputSchema.get("required", [])
153
+
154
+ type_map = {
155
+ "string": str,
156
+ "integer": int,
157
+ "number": float,
158
+ "boolean": bool,
159
+ "array": list,
160
+ "object": dict,
161
+ }
162
+ annotations = {} # used to type hints
163
+ defaults: Dict[str, Any] = {} # store default values
164
+
165
+ func_params = []
166
+ for param_name, param_schema in parameters_schema.items():
167
+ param_type = param_schema.get("type", "Any")
168
+ param_type = type_map.get(param_type, Any)
169
+
170
+ annotations[param_name] = param_type
171
+ if param_name not in required_params:
172
+ defaults[param_name] = None
173
+
174
+ func_params.append(param_name)
175
+
176
+ async def dynamic_function(**kwargs):
177
+ r"""Auto-generated function for MCP Tool interaction.
178
+
179
+ Args:
180
+ kwargs: Keyword arguments corresponding to MCP tool parameters.
181
+
182
+ Returns:
183
+ str: The textual result returned by the MCP tool.
184
+ """
185
+ from mcp.types import CallToolResult
186
+
187
+ missing_params: Set[str] = set(required_params) - set(
188
+ kwargs.keys()
189
+ )
190
+ if missing_params:
191
+ raise ValueError(
192
+ f"Missing required parameters: {missing_params}"
193
+ )
194
+
195
+ result: CallToolResult = await self._session.call_tool(
196
+ func_name, kwargs
197
+ )
198
+
199
+ if not result.content:
200
+ return "No data available for this request."
201
+
202
+ # Handle different content types
203
+ content = result.content[0]
204
+ if content.type == "text":
205
+ return content.text
206
+ elif content.type == "image":
207
+ # Return image URL or data URI if available
208
+ if hasattr(content, "url") and content.url:
209
+ return f"Image available at: {content.url}"
210
+ return "Image content received (data URI not shown)"
211
+ elif content.type == "embedded_resource":
212
+ # Return resource information if available
213
+ if hasattr(content, "name") and content.name:
214
+ return f"Embedded resource: {content.name}"
215
+ return "Embedded resource received"
216
+ else:
217
+ msg = f"Received content of type '{content.type}'"
218
+ return f"{msg} which is not fully supported yet."
219
+
220
+ dynamic_function.__name__ = func_name
221
+ dynamic_function.__doc__ = func_desc
222
+ dynamic_function.__annotations__ = annotations
223
+
224
+ sig = inspect.Signature(
225
+ parameters=[
226
+ inspect.Parameter(
227
+ name=param,
228
+ kind=inspect.Parameter.KEYWORD_ONLY,
229
+ default=defaults.get(param, inspect.Parameter.empty),
230
+ annotation=annotations[param],
231
+ )
232
+ for param in func_params
233
+ ]
234
+ )
235
+ dynamic_function.__signature__ = sig # type: ignore[attr-defined]
236
+
237
+ return dynamic_function
238
+
239
+ def get_tools(self) -> List[FunctionTool]:
240
+ r"""Returns a list of FunctionTool objects representing the
241
+ functions in the toolkit. Each function is dynamically generated
242
+ based on the MCP tool definitions received from the server.
243
+
244
+ Returns:
245
+ List[FunctionTool]: A list of FunctionTool objects
246
+ representing the functions in the toolkit.
247
+ """
248
+ return [
249
+ FunctionTool(self.generate_function_from_mcp_tool(mcp_tool))
250
+ for mcp_tool in self._mcp_tools
251
+ ]
@@ -0,0 +1,421 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+
15
+ import os
16
+ import subprocess
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ from camel.logger import get_logger
20
+ from camel.toolkits.base import BaseToolkit
21
+ from camel.toolkits.function_tool import FunctionTool
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class TerminalToolkit(BaseToolkit):
27
+ r"""A toolkit for terminal operations across multiple operating systems.
28
+
29
+ This toolkit provides a set of functions for terminal operations such as
30
+ searching for files by name or content, executing shell commands, and
31
+ managing terminal sessions.
32
+
33
+ Args:
34
+ timeout (Optional[float]): The timeout for terminal operations.
35
+ shell_sessions (Optional[Dict[str, Any]]): A dictionary to store
36
+ shell session information. If None, an empty dictionary will be
37
+ used.
38
+
39
+ Note:
40
+ Most functions are compatible with Unix-based systems (macOS, Linux).
41
+ For Windows compatibility, additional implementation details are
42
+ needed.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ timeout: Optional[float] = None,
48
+ shell_sessions: Optional[Dict[str, Any]] = None,
49
+ ):
50
+ import platform
51
+
52
+ super().__init__(timeout=timeout)
53
+ self.shell_sessions = shell_sessions or {}
54
+ self.os_type = (
55
+ platform.system()
56
+ ) # 'Windows', 'Darwin' (macOS), 'Linux'
57
+
58
+ def file_find_in_content(
59
+ self, file: str, regex: str, sudo: bool = False
60
+ ) -> str:
61
+ r"""Search for matching text within file content.
62
+
63
+ Args:
64
+ file (str): Absolute path of the file to search within.
65
+ regex (str): Regular expression pattern to match.
66
+ sudo (bool, optional): Whether to use sudo privileges. Defaults to
67
+ False. Note: Using sudo requires the process to have
68
+ appropriate permissions.
69
+
70
+ Returns:
71
+ str: Matching content found in the file.
72
+ """
73
+ if not os.path.exists(file):
74
+ return f"File not found: {file}"
75
+
76
+ if not os.path.isfile(file):
77
+ return f"The path provided is not a file: {file}"
78
+
79
+ command = []
80
+ if sudo:
81
+ command.extend(["sudo"])
82
+
83
+ if self.os_type in ['Darwin', 'Linux']: # macOS or Linux
84
+ command.extend(["grep", "-E", regex, file])
85
+ else: # Windows
86
+ # For Windows, we could use PowerShell or findstr
87
+ command.extend(["findstr", "/R", regex, file])
88
+
89
+ try:
90
+ result = subprocess.run(
91
+ command, check=False, capture_output=True, text=True
92
+ )
93
+ return result.stdout.strip()
94
+ except subprocess.SubprocessError as e:
95
+ logger.error(f"Error searching in file content: {e}")
96
+ return f"Error: {e!s}"
97
+
98
+ def file_find_by_name(self, path: str, glob: str) -> str:
99
+ r"""Find files by name pattern in specified directory.
100
+
101
+ Args:
102
+ path (str): Absolute path of directory to search.
103
+ glob (str): Filename pattern using glob syntax wildcards.
104
+
105
+ Returns:
106
+ str: List of files matching the pattern.
107
+ """
108
+ if not os.path.exists(path):
109
+ return f"Directory not found: {path}"
110
+
111
+ if not os.path.isdir(path):
112
+ return f"The path provided is not a directory: {path}"
113
+
114
+ command = []
115
+ if self.os_type in ['Darwin', 'Linux']: # macOS or Linux
116
+ command.extend(["find", path, "-name", glob])
117
+ else: # Windows
118
+ # For Windows, we use dir command with /s for recursive search
119
+ # and /b for bare format
120
+
121
+ pattern = glob
122
+ command.extend(["dir", "/s", "/b", os.path.join(path, pattern)])
123
+
124
+ try:
125
+ result = subprocess.run(
126
+ command, check=False, capture_output=True, text=True
127
+ )
128
+ return result.stdout.strip()
129
+ except subprocess.SubprocessError as e:
130
+ logger.error(f"Error finding files by name: {e}")
131
+ return f"Error: {e!s}"
132
+
133
+ def shell_exec(self, id: str, exec_dir: str, command: str) -> str:
134
+ r"""Execute commands in a specified shell session.
135
+
136
+ Args:
137
+ id (str): Unique identifier of the target shell session.
138
+ exec_dir (str): Working directory for command execution (must use
139
+ absolute path).
140
+ command (str): Shell command to execute.
141
+
142
+ Returns:
143
+ str: Output of the command execution or error message.
144
+ """
145
+ if not os.path.isabs(exec_dir):
146
+ return f"exec_dir must be an absolute path: {exec_dir}"
147
+
148
+ if not os.path.exists(exec_dir):
149
+ return f"Directory not found: {exec_dir}"
150
+
151
+ # If the session doesn't exist, create a new one
152
+ if id not in self.shell_sessions:
153
+ self.shell_sessions[id] = {
154
+ "process": None,
155
+ "output": "",
156
+ "running": False,
157
+ }
158
+
159
+ try:
160
+ # Execute the command in the specified directory
161
+ process = subprocess.Popen(
162
+ command,
163
+ shell=True,
164
+ cwd=exec_dir,
165
+ stdout=subprocess.PIPE,
166
+ stderr=subprocess.PIPE,
167
+ stdin=subprocess.PIPE,
168
+ text=False,
169
+ )
170
+
171
+ # Store the process and mark as running
172
+ self.shell_sessions[id]["process"] = process
173
+ self.shell_sessions[id]["running"] = True
174
+ self.shell_sessions[id]["output"] = ""
175
+
176
+ # Get initial output (non-blocking)
177
+ stdout, stderr = "", ""
178
+ try:
179
+ if process.stdout:
180
+ stdout = process.stdout.read().decode('utf-8')
181
+ if process.stderr:
182
+ stderr = process.stderr.read().decode('utf-8')
183
+ except Exception as e:
184
+ logger.error(f"Error reading initial output: {e}")
185
+ return f"Error: {e!s}"
186
+
187
+ output = stdout
188
+ if stderr:
189
+ output += f"\nErrors:\n{stderr}"
190
+
191
+ self.shell_sessions[id]["output"] = output
192
+ return (
193
+ f"Command started in session '{id}'. Initial output: {output}"
194
+ )
195
+
196
+ except subprocess.SubprocessError as e:
197
+ self.shell_sessions[id]["running"] = False
198
+ error_msg = f"Error executing command: {e}"
199
+ self.shell_sessions[id]["output"] = error_msg
200
+ logger.error(error_msg)
201
+ return error_msg
202
+
203
+ def shell_view(self, id: str) -> str:
204
+ r"""View the content of a specified shell session.
205
+
206
+ Args:
207
+ id (str): Unique identifier of the target shell session.
208
+
209
+ Returns:
210
+ str: Current output content of the shell session.
211
+ """
212
+ if id not in self.shell_sessions:
213
+ return f"Shell session not found: {id}"
214
+
215
+ session = self.shell_sessions[id]
216
+ process = session.get("process")
217
+
218
+ if process is None:
219
+ return f"No active process in session '{id}'"
220
+
221
+ # Try to get any new output
222
+ if session["running"] and process.poll() is None:
223
+ try:
224
+ # Non-blocking read from stdout/stderr
225
+ stdout_data, stderr_data = "", ""
226
+ if process.stdout and process.stdout.readable():
227
+ stdout_data = process.stdout.read1().decode('utf-8')
228
+ if process.stderr and process.stderr.readable():
229
+ stderr_data = process.stderr.read1().decode('utf-8')
230
+
231
+ if stdout_data:
232
+ session["output"] += stdout_data
233
+ if stderr_data:
234
+ session["output"] += f"\nErrors:\n{stderr_data}"
235
+ except Exception as e:
236
+ logger.error(f"Error getting process output: {e}")
237
+ return f"Error: {e!s}"
238
+
239
+ # Check if the process has completed
240
+ if process.poll() is not None and session["running"]:
241
+ try:
242
+ # Get remaining output if any
243
+ stdout_data, stderr_data = "", ""
244
+ if process.stdout and process.stdout.readable():
245
+ stdout_data = process.stdout.read().decode('utf-8')
246
+ if process.stderr and process.stderr.readable():
247
+ stderr_data = process.stderr.read().decode('utf-8')
248
+
249
+ if stdout_data:
250
+ session["output"] += stdout_data
251
+ if stderr_data:
252
+ session["output"] += f"\nErrors:\n{stderr_data}"
253
+ except Exception as e:
254
+ logger.error(f"Error getting final process output: {e}")
255
+ return f"Error: {e!s}"
256
+ finally:
257
+ session["running"] = False
258
+
259
+ return session["output"]
260
+
261
+ def shell_wait(self, id: str, seconds: Optional[int] = None) -> str:
262
+ r"""Wait for the running process in a specified shell session to
263
+ return.
264
+
265
+ Args:
266
+ id (str): Unique identifier of the target shell session.
267
+ seconds (Optional[int], optional): Wait duration in seconds.
268
+ If None, wait indefinitely. Defaults to None.
269
+
270
+ Returns:
271
+ str: Final output content after waiting.
272
+ """
273
+ if id not in self.shell_sessions:
274
+ return f"Shell session not found: {id}"
275
+
276
+ session = self.shell_sessions[id]
277
+ process = session.get("process")
278
+
279
+ if process is None:
280
+ return f"No active process in session '{id}'"
281
+
282
+ if not session["running"]:
283
+ return f"Process in session '{id}' is not running"
284
+
285
+ try:
286
+ # Use communicate with timeout
287
+ stdout, stderr = process.communicate(timeout=seconds)
288
+
289
+ if stdout:
290
+ stdout_str = (
291
+ stdout.decode('utf-8')
292
+ if isinstance(stdout, bytes)
293
+ else stdout
294
+ )
295
+ session["output"] += stdout_str
296
+ if stderr:
297
+ stderr_str = (
298
+ stderr.decode('utf-8')
299
+ if isinstance(stderr, bytes)
300
+ else stderr
301
+ )
302
+ session["output"] += f"\nErrors:\n{stderr_str}"
303
+
304
+ session["running"] = False
305
+ return (
306
+ f"Process completed in session '{id}'. "
307
+ f"Output: {session['output']}"
308
+ )
309
+
310
+ except subprocess.TimeoutExpired:
311
+ return (
312
+ f"Process in session '{id}' is still running "
313
+ f"after {seconds} seconds"
314
+ )
315
+ except Exception as e:
316
+ logger.error(f"Error waiting for process: {e}")
317
+ return f"Error waiting for process: {e!s}"
318
+
319
+ def shell_write_to_process(
320
+ self, id: str, input: str, press_enter: bool
321
+ ) -> str:
322
+ r"""Write input to a running process in a specified shell session.
323
+
324
+ Args:
325
+ id (str): Unique identifier of the target shell session.
326
+ input (str): Input content to write to the process.
327
+ press_enter (bool): Whether to press Enter key after input.
328
+
329
+ Returns:
330
+ str: Status message indicating whether the input was sent.
331
+ """
332
+ if id not in self.shell_sessions:
333
+ return f"Shell session not found: {id}"
334
+
335
+ session = self.shell_sessions[id]
336
+ process = session.get("process")
337
+
338
+ if process is None:
339
+ return f"No active process in session '{id}'"
340
+
341
+ if not session["running"] or process.poll() is not None:
342
+ return f"Process in session '{id}' is not running"
343
+
344
+ try:
345
+ if not process.stdin or process.stdin.closed:
346
+ return (
347
+ f"Cannot write to process in session '{id}': "
348
+ f"stdin is closed"
349
+ )
350
+
351
+ if press_enter:
352
+ input = input + "\n"
353
+
354
+ # Write bytes to stdin
355
+ process.stdin.write(input.encode('utf-8'))
356
+ process.stdin.flush()
357
+
358
+ return f"Input sent to process in session '{id}'"
359
+ except Exception as e:
360
+ logger.error(f"Error writing to process: {e}")
361
+ return f"Error writing to process: {e!s}"
362
+
363
+ def shell_kill_process(self, id: str) -> str:
364
+ r"""Terminate a running process in a specified shell session.
365
+
366
+ Args:
367
+ id (str): Unique identifier of the target shell session.
368
+
369
+ Returns:
370
+ str: Status message indicating whether the process was terminated.
371
+ """
372
+ if id not in self.shell_sessions:
373
+ return f"Shell session not found: {id}"
374
+
375
+ session = self.shell_sessions[id]
376
+ process = session.get("process")
377
+
378
+ if process is None:
379
+ return f"No active process in session '{id}'"
380
+
381
+ if not session["running"] or process.poll() is not None:
382
+ return f"Process in session '{id}' is not running"
383
+
384
+ try:
385
+ # Clean up process resources before termination
386
+ if process.stdin and not process.stdin.closed:
387
+ process.stdin.close()
388
+
389
+ process.terminate()
390
+ try:
391
+ process.wait(timeout=5)
392
+ except subprocess.TimeoutExpired:
393
+ logger.warning(
394
+ f"Process in session '{id}' did not terminate gracefully"
395
+ f", forcing kill"
396
+ )
397
+ process.kill()
398
+
399
+ session["running"] = False
400
+ return f"Process in session '{id}' has been terminated"
401
+ except Exception as e:
402
+ logger.error(f"Error killing process: {e}")
403
+ return f"Error killing process: {e!s}"
404
+
405
+ def get_tools(self) -> List[FunctionTool]:
406
+ r"""Returns a list of FunctionTool objects representing the functions
407
+ in the toolkit.
408
+
409
+ Returns:
410
+ List[FunctionTool]: A list of FunctionTool objects representing the
411
+ functions in the toolkit.
412
+ """
413
+ return [
414
+ FunctionTool(self.file_find_in_content),
415
+ FunctionTool(self.file_find_by_name),
416
+ FunctionTool(self.shell_exec),
417
+ FunctionTool(self.shell_view),
418
+ FunctionTool(self.shell_wait),
419
+ FunctionTool(self.shell_write_to_process),
420
+ FunctionTool(self.shell_kill_process),
421
+ ]
@@ -157,16 +157,15 @@ def _parse_json_output(text: str) -> Dict[str, Any]:
157
157
  if triple_quotes_match:
158
158
  text = triple_quotes_match.group(1).strip()
159
159
 
160
- text = text.replace("`", '"')
161
-
162
160
  try:
163
161
  return json.loads(text)
164
162
  except json.JSONDecodeError:
165
163
  try:
166
- fixed_text = re.sub(r'`([^`]*)`', r'"\1"', text)
164
+ fixed_text = re.sub(
165
+ r'`([^`]*?)`(?=\s*[:,\[\]{}]|$)', r'"\1"', text
166
+ )
167
167
  return json.loads(fixed_text)
168
168
  except json.JSONDecodeError:
169
- # Try to extract key fields
170
169
  result = {}
171
170
  try:
172
171
  bool_pattern = r'"(\w+)"\s*:\s*(true|false)'
@@ -986,7 +985,7 @@ Here are an example of the output:
986
985
  {{
987
986
  "observation": [IMAGE_DESCRIPTION],
988
987
  "reasoning": [YOUR_REASONING],
989
- "action_code": `fill_input_id([ID], [TEXT])`
988
+ "action_code": "fill_input_id([ID], [TEXT])"
990
989
  }}
991
990
 
992
991
  Here are some tips for you: