camel-ai 0.2.75a6__py3-none-any.whl → 0.2.76__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.

Files changed (97) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +1001 -205
  3. camel/agents/mcp_agent.py +30 -27
  4. camel/configs/__init__.py +6 -0
  5. camel/configs/amd_config.py +70 -0
  6. camel/configs/cometapi_config.py +104 -0
  7. camel/data_collectors/alpaca_collector.py +15 -6
  8. camel/environments/tic_tac_toe.py +1 -1
  9. camel/interpreters/__init__.py +2 -0
  10. camel/interpreters/docker/Dockerfile +3 -12
  11. camel/interpreters/microsandbox_interpreter.py +395 -0
  12. camel/loaders/__init__.py +11 -2
  13. camel/loaders/chunkr_reader.py +9 -0
  14. camel/memories/__init__.py +2 -1
  15. camel/memories/agent_memories.py +3 -1
  16. camel/memories/blocks/chat_history_block.py +21 -3
  17. camel/memories/records.py +88 -8
  18. camel/messages/base.py +127 -34
  19. camel/models/__init__.py +4 -0
  20. camel/models/amd_model.py +101 -0
  21. camel/models/azure_openai_model.py +0 -6
  22. camel/models/base_model.py +30 -0
  23. camel/models/cometapi_model.py +83 -0
  24. camel/models/model_factory.py +4 -0
  25. camel/models/openai_compatible_model.py +0 -6
  26. camel/models/openai_model.py +0 -6
  27. camel/models/zhipuai_model.py +61 -2
  28. camel/parsers/__init__.py +18 -0
  29. camel/parsers/mcp_tool_call_parser.py +176 -0
  30. camel/retrievers/auto_retriever.py +1 -0
  31. camel/runtimes/daytona_runtime.py +11 -12
  32. camel/societies/workforce/prompts.py +131 -50
  33. camel/societies/workforce/single_agent_worker.py +434 -49
  34. camel/societies/workforce/structured_output_handler.py +30 -18
  35. camel/societies/workforce/task_channel.py +43 -0
  36. camel/societies/workforce/utils.py +105 -12
  37. camel/societies/workforce/workforce.py +1322 -311
  38. camel/societies/workforce/workforce_logger.py +24 -5
  39. camel/storages/key_value_storages/json.py +15 -2
  40. camel/storages/object_storages/google_cloud.py +1 -1
  41. camel/storages/vectordb_storages/oceanbase.py +10 -11
  42. camel/storages/vectordb_storages/tidb.py +8 -6
  43. camel/tasks/task.py +4 -3
  44. camel/toolkits/__init__.py +18 -5
  45. camel/toolkits/aci_toolkit.py +45 -0
  46. camel/toolkits/code_execution.py +28 -1
  47. camel/toolkits/context_summarizer_toolkit.py +684 -0
  48. camel/toolkits/dingtalk.py +1135 -0
  49. camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
  50. camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
  51. camel/toolkits/function_tool.py +6 -1
  52. camel/toolkits/google_drive_mcp_toolkit.py +12 -31
  53. camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
  54. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +79 -2
  55. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
  56. camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
  57. camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
  58. camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
  59. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
  60. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
  61. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
  62. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  63. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  64. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  65. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
  66. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
  67. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +405 -131
  68. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
  69. camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
  70. camel/toolkits/markitdown_toolkit.py +27 -1
  71. camel/toolkits/mcp_toolkit.py +348 -348
  72. camel/toolkits/message_integration.py +3 -0
  73. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  74. camel/toolkits/note_taking_toolkit.py +18 -8
  75. camel/toolkits/notion_mcp_toolkit.py +16 -26
  76. camel/toolkits/origene_mcp_toolkit.py +8 -49
  77. camel/toolkits/playwright_mcp_toolkit.py +12 -31
  78. camel/toolkits/resend_toolkit.py +168 -0
  79. camel/toolkits/slack_toolkit.py +50 -1
  80. camel/toolkits/terminal_toolkit/__init__.py +18 -0
  81. camel/toolkits/terminal_toolkit/terminal_toolkit.py +924 -0
  82. camel/toolkits/terminal_toolkit/utils.py +532 -0
  83. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  84. camel/toolkits/video_analysis_toolkit.py +17 -11
  85. camel/toolkits/wechat_official_toolkit.py +483 -0
  86. camel/types/enums.py +124 -1
  87. camel/types/unified_model_type.py +5 -0
  88. camel/utils/commons.py +17 -0
  89. camel/utils/context_utils.py +804 -0
  90. camel/utils/mcp.py +136 -2
  91. camel/utils/token_counting.py +25 -17
  92. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/METADATA +158 -59
  93. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/RECORD +95 -76
  94. camel/loaders/pandas_reader.py +0 -368
  95. camel/toolkits/terminal_toolkit.py +0 -1788
  96. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/WHEEL +0 -0
  97. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/licenses/LICENSE +0 -0
@@ -1,1788 +0,0 @@
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 atexit
16
- import os
17
- import platform
18
- import queue
19
- import shutil
20
- import signal
21
- import subprocess
22
- import sys
23
- import threading
24
- import venv
25
- from queue import Queue
26
- from typing import Any, Dict, List, Optional, Tuple
27
-
28
- from camel.logger import get_logger
29
- from camel.toolkits.base import BaseToolkit
30
- from camel.toolkits.function_tool import FunctionTool
31
- from camel.utils import MCPServer
32
-
33
- logger = get_logger(__name__)
34
-
35
-
36
- @MCPServer()
37
- class TerminalToolkit(BaseToolkit):
38
- r"""A toolkit for terminal operations across multiple operating systems.
39
-
40
- This toolkit provides a set of functions for terminal operations such as
41
- searching for files by name or content, executing shell commands, and
42
- managing terminal sessions.
43
-
44
- Args:
45
- timeout (Optional[float]): The timeout for terminal operations.
46
- (default: :obj:`20.0`)
47
- shell_sessions (Optional[Dict[str, Any]]): A dictionary to store
48
- shell session information. If :obj:`None`, an empty dictionary
49
- will be used. (default: :obj:`None`)
50
- working_directory (Optional[str]): The working directory for
51
- operations. If not provided, it will be determined by the
52
- `CAMEL_WORKDIR` environment variable (if set). If the
53
- environment variable is not set, it defaults to `./workspace`. All
54
- execution and write operations will be restricted to this
55
- directory. Read operations can access paths outside this
56
- directory. (default: :obj:`None`)
57
- need_terminal (bool): Whether to create a terminal interface.
58
- (default: :obj:`True`)
59
- use_shell_mode (bool): Whether to use shell mode for command
60
- execution. (default: :obj:`True`)
61
- clone_current_env (bool): Whether to clone the current Python
62
- environment. (default: :obj:`False`)
63
- safe_mode (bool): Whether to enable safe mode to restrict
64
- operations. (default: :obj:`True`)
65
- interactive (bool): Whether to use interactive mode for shell commands,
66
- connecting them to the terminal's standard input. This is useful
67
- for commands that require user input, like `ssh`. Interactive mode
68
- is only supported on macOS and Linux. (default: :obj:`False`)
69
-
70
- Note:
71
- Most functions are compatible with Unix-based systems (macOS, Linux).
72
- For Windows compatibility, additional implementation details are
73
- needed.
74
- """
75
-
76
- def __init__(
77
- self,
78
- timeout: Optional[float] = 20.0,
79
- shell_sessions: Optional[Dict[str, Any]] = None,
80
- working_directory: Optional[str] = None,
81
- need_terminal: bool = True,
82
- use_shell_mode: bool = True,
83
- clone_current_env: bool = False,
84
- safe_mode: bool = True,
85
- interactive: bool = False,
86
- ):
87
- # Store timeout before calling super().__init__
88
- self._timeout = timeout
89
- super().__init__(timeout=timeout)
90
- self.shell_sessions = shell_sessions or {}
91
- self.os_type = platform.system()
92
- self.output_queue: Queue[str] = Queue()
93
- self.agent_queue: Queue[str] = Queue()
94
- self.terminal_ready = threading.Event()
95
- self.gui_thread = None
96
- self.safe_mode = safe_mode
97
- self._file_initialized = False
98
- self.cloned_env_path = None
99
- self.use_shell_mode = use_shell_mode
100
- self._human_takeover_active = False
101
- self.interactive = interactive
102
-
103
- self.python_executable = sys.executable
104
- self.is_macos = platform.system() == 'Darwin'
105
- self.initial_env_path: Optional[str] = None
106
- self.initial_env_prepared = False
107
- self.uv_path: Optional[str] = None
108
-
109
- atexit.register(self.__del__)
110
-
111
- if working_directory:
112
- self.working_dir = os.path.abspath(working_directory)
113
- else:
114
- camel_workdir = os.environ.get("CAMEL_WORKDIR")
115
- if camel_workdir:
116
- self.working_dir = os.path.abspath(camel_workdir)
117
- else:
118
- self.working_dir = os.path.abspath("./workspace")
119
-
120
- if not os.path.exists(self.working_dir):
121
- os.makedirs(self.working_dir, exist_ok=True)
122
- self._update_terminal_output(
123
- f"Working directory set to: {self.working_dir}\n"
124
- )
125
- if self.safe_mode:
126
- self._update_terminal_output(
127
- "Safe mode enabled: Write operations can only "
128
- "be performed within the working directory\n"
129
- )
130
-
131
- if clone_current_env:
132
- self.cloned_env_path = os.path.join(self.working_dir, ".venv")
133
- self._clone_current_environment()
134
- else:
135
- self.cloned_env_path = None
136
- self._prepare_initial_environment()
137
-
138
- if need_terminal:
139
- if self.is_macos:
140
- # macOS uses non-GUI mode
141
- logger.info("Detected macOS environment, using non-GUI mode")
142
- self._setup_file_output()
143
- self.terminal_ready.set()
144
- else:
145
- # Other platforms use normal GUI
146
- self.gui_thread = threading.Thread(
147
- target=self._create_terminal, daemon=True
148
- )
149
- self.gui_thread.start()
150
- self.terminal_ready.wait(timeout=5)
151
-
152
- def _setup_file_output(self):
153
- r"""Set up file output to replace GUI, using a fixed file to simulate
154
- terminal.
155
- """
156
-
157
- self.log_file = os.path.join(os.getcwd(), "camel_terminal.txt")
158
-
159
- # Inform the user
160
- logger.info(f"Terminal output will be redirected to: {self.log_file}")
161
-
162
- def file_update(output: str):
163
- import sys
164
-
165
- try:
166
- # For macOS/Linux file-based mode, also write to stdout
167
- # to provide real-time feedback in the user's terminal.
168
- sys.stdout.write(output)
169
- sys.stdout.flush()
170
-
171
- # Initialize file on first write
172
- if not self._file_initialized:
173
- with open(self.log_file, "w") as f:
174
- f.write("CAMEL Terminal Session\n")
175
- f.write("=" * 50 + "\n")
176
- f.write(f"Working Directory: {os.getcwd()}\n")
177
- f.write("=" * 50 + "\n\n")
178
- self._file_initialized = True
179
-
180
- # Directly append to the end of the file
181
- with open(self.log_file, "a") as f:
182
- f.write(output)
183
- # Ensure the agent also receives the output
184
- self.agent_queue.put(output)
185
- except Exception as e:
186
- logger.error(f"Failed to write to terminal: {e}")
187
-
188
- # Replace the update method
189
- self._update_terminal_output = file_update
190
-
191
- def _clone_current_environment(self):
192
- r"""Create a new Python virtual environment."""
193
- try:
194
- if self.cloned_env_path is None:
195
- self._update_terminal_output(
196
- "Error: No environment path specified\n"
197
- )
198
- return
199
-
200
- if os.path.exists(self.cloned_env_path):
201
- self._update_terminal_output(
202
- f"Using existing environment: {self.cloned_env_path}\n"
203
- )
204
- return
205
-
206
- self._update_terminal_output(
207
- f"Creating new Python environment at: {self.cloned_env_path}\n"
208
- )
209
-
210
- # Try to use uv if available
211
- if self._ensure_uv_available():
212
- # Use uv to create environment with current Python version
213
- uv_command = self.uv_path if self.uv_path else "uv"
214
-
215
- # Get current Python version
216
- current_version = (
217
- f"{sys.version_info.major}.{sys.version_info.minor}"
218
- )
219
-
220
- subprocess.run(
221
- [
222
- uv_command,
223
- "venv",
224
- "--python",
225
- current_version,
226
- self.cloned_env_path,
227
- ],
228
- check=True,
229
- capture_output=True,
230
- cwd=self.working_dir,
231
- timeout=300,
232
- )
233
-
234
- # Get the python path from the new environment
235
- if self.os_type == 'Windows':
236
- python_path = os.path.join(
237
- self.cloned_env_path, "Scripts", "python.exe"
238
- )
239
- else:
240
- python_path = os.path.join(
241
- self.cloned_env_path, "bin", "python"
242
- )
243
-
244
- # Install pip and setuptools using uv
245
- subprocess.run(
246
- [
247
- uv_command,
248
- "pip",
249
- "install",
250
- "--python",
251
- python_path,
252
- "pip",
253
- "setuptools",
254
- "wheel",
255
- ],
256
- check=True,
257
- capture_output=True,
258
- cwd=self.working_dir,
259
- timeout=300,
260
- )
261
-
262
- self._update_terminal_output(
263
- "[UV] Cloned Python environment created successfully!\n"
264
- )
265
-
266
- else:
267
- # Fallback to standard venv
268
- self._update_terminal_output(
269
- "Falling back to standard venv for cloning environment\n"
270
- )
271
-
272
- # Create virtual environment with pip. On macOS, use
273
- # symlinks=False to avoid dyld library loading issues
274
- venv.create(
275
- self.cloned_env_path, with_pip=True, symlinks=False
276
- )
277
-
278
- # Ensure pip is properly available by upgrading it
279
- if self.os_type == 'Windows':
280
- python_path = os.path.join(
281
- self.cloned_env_path, "Scripts", "python.exe"
282
- )
283
- else:
284
- python_path = os.path.join(
285
- self.cloned_env_path, "bin", "python"
286
- )
287
-
288
- # Verify python executable exists
289
- if os.path.exists(python_path):
290
- # Use python -m pip to ensure pip is available
291
- subprocess.run(
292
- [
293
- python_path,
294
- "-m",
295
- "pip",
296
- "install",
297
- "--upgrade",
298
- "pip",
299
- ],
300
- check=True,
301
- capture_output=True,
302
- cwd=self.working_dir,
303
- timeout=60,
304
- )
305
- self._update_terminal_output(
306
- "New Python environment created successfully "
307
- "with pip!\n"
308
- )
309
- else:
310
- self._update_terminal_output(
311
- f"Warning: Python executable not found "
312
- f"at {python_path}\n"
313
- )
314
-
315
- except subprocess.CalledProcessError as e:
316
- error_msg = e.stderr.decode() if e.stderr else str(e)
317
- self._update_terminal_output(
318
- f"Failed to upgrade pip in cloned environment: {error_msg}\n"
319
- )
320
- logger.error(f"Failed to upgrade pip: {error_msg}")
321
- except subprocess.TimeoutExpired:
322
- self._update_terminal_output(
323
- "Pip upgrade timed out, but environment may still be usable\n"
324
- )
325
- except Exception as e:
326
- self._update_terminal_output(
327
- f"Failed to create environment: {e!s}\n"
328
- )
329
- logger.error(f"Failed to create environment: {e}")
330
-
331
- def _is_uv_environment(self) -> bool:
332
- r"""Detect whether the current Python runtime is managed by uv."""
333
- return (
334
- "UV_CACHE_DIR" in os.environ
335
- or "uv" in sys.executable
336
- or shutil.which("uv") is not None
337
- )
338
-
339
- def _ensure_uv_available(self) -> bool:
340
- r"""Ensure uv is available, installing it if necessary.
341
-
342
- Returns:
343
- bool: True if uv is available (either already installed or
344
- successfully installed), False otherwise.
345
- """
346
- # Check if uv is already available
347
- existing_uv = shutil.which("uv")
348
- if existing_uv is not None:
349
- self.uv_path = existing_uv
350
- self._update_terminal_output(
351
- f"uv is already available at: {self.uv_path}\n"
352
- )
353
- return True
354
-
355
- try:
356
- self._update_terminal_output("uv not found, installing...\n")
357
-
358
- # Install uv using the official installer script
359
- if self.os_type in ['Darwin', 'Linux']:
360
- # Use curl to download and execute the installer
361
- install_cmd = "curl -LsSf https://astral.sh/uv/install.sh | sh"
362
- result = subprocess.run(
363
- install_cmd,
364
- shell=True,
365
- capture_output=True,
366
- text=True,
367
- timeout=60,
368
- )
369
-
370
- if result.returncode != 0:
371
- self._update_terminal_output(
372
- f"Failed to install uv: {result.stderr}\n"
373
- )
374
- return False
375
-
376
- # Check if uv was installed in the expected location
377
- home = os.path.expanduser("~")
378
- uv_bin_path = os.path.join(home, ".cargo", "bin")
379
- uv_executable = os.path.join(uv_bin_path, "uv")
380
-
381
- if os.path.exists(uv_executable):
382
- # Store the full path to uv instead of modifying PATH
383
- self.uv_path = uv_executable
384
- self._update_terminal_output(
385
- f"uv installed successfully at: {self.uv_path}\n"
386
- )
387
- return True
388
-
389
- elif self.os_type == 'Windows':
390
- # Use PowerShell to install uv on Windows
391
- install_cmd = (
392
- "powershell -ExecutionPolicy Bypass -c "
393
- "\"irm https://astral.sh/uv/install.ps1 | iex\""
394
- )
395
- result = subprocess.run(
396
- install_cmd,
397
- shell=True,
398
- capture_output=True,
399
- text=True,
400
- timeout=60,
401
- )
402
-
403
- if result.returncode != 0:
404
- self._update_terminal_output(
405
- f"Failed to install uv: {result.stderr}\n"
406
- )
407
- return False
408
-
409
- # Check if uv was installed in the expected location on Windows
410
- home = os.path.expanduser("~")
411
- uv_bin_path = os.path.join(home, ".cargo", "bin")
412
- uv_executable = os.path.join(uv_bin_path, "uv.exe")
413
-
414
- if os.path.exists(uv_executable):
415
- # Store the full path to uv instead of modifying PATH
416
- self.uv_path = uv_executable
417
- self._update_terminal_output(
418
- f"uv installed successfully at: {self.uv_path}\n"
419
- )
420
- return True
421
-
422
- self._update_terminal_output("Failed to verify uv installation\n")
423
- return False
424
-
425
- except Exception as e:
426
- self._update_terminal_output(f"Error installing uv: {e!s}\n")
427
- logger.error(f"Failed to install uv: {e}")
428
- return False
429
-
430
- def _prepare_initial_environment(self):
431
- r"""Prepare initial environment with Python 3.10, pip, and other
432
- essential tools.
433
- """
434
- try:
435
- self.initial_env_path = os.path.join(
436
- self.working_dir, ".initial_env"
437
- )
438
-
439
- if os.path.exists(self.initial_env_path):
440
- self._update_terminal_output(
441
- f"Using existing initial environment"
442
- f": {self.initial_env_path}\n"
443
- )
444
- self.initial_env_prepared = True
445
- return
446
-
447
- self._update_terminal_output(
448
- f"Preparing initial environment at: {self.initial_env_path}\n"
449
- )
450
-
451
- # Create the initial environment directory
452
- os.makedirs(self.initial_env_path, exist_ok=True)
453
-
454
- # Try to ensure uv is available and use it preferentially
455
- if self._ensure_uv_available():
456
- self._setup_initial_env_with_uv()
457
- else:
458
- # Fallback to venv if uv installation failed
459
- self._update_terminal_output(
460
- "Falling back to standard venv for environment setup\n"
461
- )
462
- self._setup_initial_env_with_venv()
463
-
464
- self.initial_env_prepared = True
465
- self._update_terminal_output(
466
- "Initial environment prepared successfully!\n"
467
- )
468
-
469
- except Exception as e:
470
- self._update_terminal_output(
471
- f"Failed to prepare initial environment: {e!s}\n"
472
- )
473
- logger.error(f"Failed to prepare initial environment: {e}")
474
-
475
- def _setup_initial_env_with_uv(self):
476
- r"""Set up initial environment using uv."""
477
- if self.initial_env_path is None:
478
- raise Exception("Initial environment path not set")
479
-
480
- try:
481
- # Use the stored uv path if available, otherwise fall back to "uv"
482
- uv_command = self.uv_path if self.uv_path else "uv"
483
-
484
- # Create virtual environment with Python 3.10 using uv
485
- subprocess.run(
486
- [
487
- uv_command,
488
- "venv",
489
- "--python",
490
- "3.10",
491
- self.initial_env_path,
492
- ],
493
- check=True,
494
- capture_output=True,
495
- cwd=self.working_dir,
496
- timeout=300,
497
- )
498
-
499
- # Get the python path from the new environment
500
- if self.os_type == 'Windows':
501
- python_path = os.path.join(
502
- self.initial_env_path, "Scripts", "python.exe"
503
- )
504
- else:
505
- python_path = os.path.join(
506
- self.initial_env_path, "bin", "python"
507
- )
508
-
509
- # Install essential packages using uv
510
- essential_packages = [
511
- "pip",
512
- "setuptools",
513
- "wheel",
514
- "pyautogui",
515
- "plotly",
516
- "ffmpeg",
517
- ]
518
- subprocess.run(
519
- [
520
- uv_command,
521
- "pip",
522
- "install",
523
- "--python",
524
- python_path,
525
- *essential_packages,
526
- ],
527
- check=True,
528
- capture_output=True,
529
- cwd=self.working_dir,
530
- timeout=300,
531
- )
532
-
533
- # Check if Node.js is available (but don't install it)
534
- self._check_nodejs_availability()
535
-
536
- self._update_terminal_output(
537
- "[UV] Initial environment created with Python 3.10 "
538
- "and essential packages\n"
539
- )
540
-
541
- except subprocess.CalledProcessError as e:
542
- error_msg = e.stderr.decode() if e.stderr else str(e)
543
- raise Exception(f"UV setup failed: {error_msg}")
544
- except subprocess.TimeoutExpired:
545
- raise Exception("UV setup timed out after 5 minutes")
546
-
547
- def _setup_initial_env_with_venv(self):
548
- r"""Set up initial environment using standard venv."""
549
- if self.initial_env_path is None:
550
- raise Exception("Initial environment path not set")
551
-
552
- try:
553
- # Create virtual environment with system Python
554
- # On macOS, use symlinks=False to avoid dyld library loading issues
555
- venv.create(
556
- self.initial_env_path,
557
- with_pip=True,
558
- system_site_packages=False,
559
- symlinks=False,
560
- )
561
-
562
- # Get pip path
563
- if self.os_type == 'Windows':
564
- pip_path = os.path.join(
565
- self.initial_env_path, "Scripts", "pip.exe"
566
- )
567
- else:
568
- pip_path = os.path.join(self.initial_env_path, "bin", "pip")
569
-
570
- # Upgrade pip and install essential packages
571
- essential_packages = [
572
- "pip",
573
- "setuptools",
574
- "wheel",
575
- "pyautogui",
576
- "plotly",
577
- "ffmpeg",
578
- ]
579
- subprocess.run(
580
- [pip_path, "install", "--upgrade", *essential_packages],
581
- check=True,
582
- capture_output=True,
583
- cwd=self.working_dir,
584
- timeout=300,
585
- )
586
-
587
- # Check if Node.js is available (but don't install it)
588
- self._check_nodejs_availability()
589
-
590
- self._update_terminal_output(
591
- "Initial environment created with system Python and "
592
- "essential packages\n"
593
- )
594
-
595
- except subprocess.CalledProcessError as e:
596
- error_msg = e.stderr.decode() if e.stderr else str(e)
597
- raise Exception(f"Venv setup failed: {error_msg}")
598
- except subprocess.TimeoutExpired:
599
- raise Exception("Venv setup timed out after 5 minutes")
600
-
601
- def _check_nodejs_availability(self):
602
- r"""Check if Node.js is available without modifying the system."""
603
- try:
604
- # Check if Node.js is already available in the system
605
- node_result = subprocess.run(
606
- ["node", "--version"],
607
- check=False,
608
- capture_output=True,
609
- timeout=10,
610
- )
611
-
612
- npm_result = subprocess.run(
613
- ["npm", "--version"],
614
- check=False,
615
- capture_output=True,
616
- timeout=10,
617
- )
618
-
619
- if node_result.returncode == 0 and npm_result.returncode == 0:
620
- node_version = node_result.stdout.decode().strip()
621
- npm_version = npm_result.stdout.decode().strip()
622
- self._update_terminal_output(
623
- f"Node.js {node_version} and npm {npm_version} "
624
- "are available\n"
625
- )
626
- else:
627
- self._update_terminal_output(
628
- "Note: Node.js not found. If needed, please install it "
629
- "manually.\n"
630
- )
631
-
632
- except Exception as e:
633
- self._update_terminal_output(
634
- f"Note: Could not check Node.js availability - {e}.\n"
635
- )
636
- logger.warning(f"Failed to check Node.js: {e}")
637
-
638
- def _create_terminal(self):
639
- r"""Create a terminal GUI. If GUI creation fails, fallback
640
- to file output."""
641
-
642
- try:
643
- import tkinter as tk
644
- from tkinter import scrolledtext
645
-
646
- def update_terminal():
647
- try:
648
- while True:
649
- output = self.output_queue.get_nowait()
650
- if isinstance(output, bytes):
651
- output = output.decode('utf-8', errors='replace')
652
- self.terminal.insert(tk.END, output)
653
- self.terminal.see(tk.END)
654
- except queue.Empty:
655
- if hasattr(self, 'root') and self.root:
656
- self.root.after(100, update_terminal)
657
-
658
- self.root = tk.Tk()
659
- self.root.title(f"{self.os_type} Terminal")
660
-
661
- self.root.geometry("800x600")
662
- self.root.minsize(400, 300)
663
-
664
- self.terminal = scrolledtext.ScrolledText(
665
- self.root,
666
- wrap=tk.WORD,
667
- bg='black',
668
- fg='white',
669
- font=('Consolas', 10),
670
- insertbackground='white', # Cursor color
671
- )
672
- self.terminal.pack(fill=tk.BOTH, expand=True)
673
-
674
- # Set the handling for closing the window
675
- def on_closing():
676
- self.root.quit()
677
- self.root.destroy()
678
- self.root = None
679
-
680
- self.root.protocol("WM_DELETE_WINDOW", on_closing)
681
-
682
- # Start updating
683
- update_terminal()
684
-
685
- # Mark the terminal as ready
686
- self.terminal_ready.set()
687
-
688
- # Start the main loop
689
- self.root.mainloop()
690
-
691
- except Exception as e:
692
- logger.warning(
693
- f"Failed to create GUI terminal: {e}, "
694
- f"falling back to file output mode"
695
- )
696
- # Fallback to file output mode when GUI creation fails
697
- self._setup_file_output()
698
- self.terminal_ready.set()
699
-
700
- def _update_terminal_output(self, output: str):
701
- r"""Update terminal output and send to agent.
702
-
703
- Args:
704
- output (str): The output to be sent to the agent
705
- """
706
- try:
707
- # If it is macOS or if we have a log_file (fallback mode),
708
- # write to file
709
- if self.is_macos or hasattr(self, 'log_file'):
710
- if hasattr(self, 'log_file'):
711
- with open(self.log_file, "a") as f:
712
- f.write(output)
713
- # Ensure the agent also receives the output
714
- self.agent_queue.put(output)
715
- return
716
-
717
- # For other cases, try to update the GUI (if it exists)
718
- if hasattr(self, 'root') and self.root:
719
- self.output_queue.put(output)
720
-
721
- # Always send to agent queue
722
- self.agent_queue.put(output)
723
-
724
- except Exception as e:
725
- logger.error(f"Failed to update terminal output: {e}")
726
-
727
- def _is_path_within_working_dir(self, path: str) -> bool:
728
- r"""Check if the path is within the working directory.
729
-
730
- Args:
731
- path (str): The path to check
732
-
733
- Returns:
734
- bool: Returns True if the path is within the working directory,
735
- otherwise returns False
736
- """
737
- abs_path = os.path.abspath(path)
738
- return abs_path.startswith(self.working_dir)
739
-
740
- def _enforce_working_dir_for_execution(self, path: str) -> Optional[str]:
741
- r"""Enforce working directory restrictions, return error message
742
- if execution path is not within the working directory.
743
-
744
- Args:
745
- path (str): The path to be used for executing operations
746
-
747
- Returns:
748
- Optional[str]: Returns error message if the path is not within
749
- the working directory, otherwise returns None
750
- """
751
- if not self._is_path_within_working_dir(path):
752
- return (
753
- f"Operation restriction: Execution path {path} must "
754
- f"be within working directory {self.working_dir}"
755
- )
756
- return None
757
-
758
- def _copy_external_file_to_workdir(
759
- self, external_file: str
760
- ) -> Optional[str]:
761
- r"""Copy external file to working directory.
762
-
763
- Args:
764
- external_file (str): The path of the external file
765
-
766
- Returns:
767
- Optional[str]: New path after copying to the working directory,
768
- returns None on failure
769
- """
770
- try:
771
- import shutil
772
-
773
- filename = os.path.basename(external_file)
774
- new_path = os.path.join(self.working_dir, filename)
775
- shutil.copy2(external_file, new_path)
776
- return new_path
777
- except Exception as e:
778
- logger.error(f"Failed to copy file: {e}")
779
- return None
780
-
781
- def _sanitize_command(self, command: str, exec_dir: str) -> Tuple:
782
- r"""Check and modify command to ensure safety.
783
-
784
- Args:
785
- command (str): The command to check
786
- exec_dir (str): The directory to execute the command in
787
-
788
- Returns:
789
- Tuple: (is safe, modified command or error message)
790
- """
791
- if not self.safe_mode:
792
- return True, command
793
-
794
- if not command or command.strip() == "":
795
- return False, "Empty command"
796
-
797
- # Use shlex for safer command parsing
798
- import shlex
799
-
800
- try:
801
- parts = shlex.split(command)
802
- except ValueError as e:
803
- # Handle malformed commands (e.g., unbalanced quotes)
804
- return False, f"Invalid command format: {e}"
805
-
806
- if not parts:
807
- return False, "Empty command"
808
-
809
- # Get base command
810
- base_cmd = parts[0].lower()
811
-
812
- # Handle special commands
813
- if base_cmd in ['cd', 'chdir']:
814
- # Check if cd command attempts to leave the working directory
815
- if len(parts) > 1:
816
- target_dir = parts[1].strip('"\'')
817
- if (
818
- target_dir.startswith('/')
819
- or target_dir.startswith('\\')
820
- or ':' in target_dir
821
- ):
822
- # Absolute path
823
- abs_path = os.path.abspath(target_dir)
824
- else:
825
- # Relative path
826
- abs_path = os.path.abspath(
827
- os.path.join(exec_dir, target_dir)
828
- )
829
-
830
- if not self._is_path_within_working_dir(abs_path):
831
- return False, (
832
- f"Safety restriction: Cannot change to directory "
833
- f"outside of working directory {self.working_dir}"
834
- )
835
-
836
- # Check file operation commands
837
- elif base_cmd in [
838
- 'rm',
839
- 'del',
840
- 'rmdir',
841
- 'rd',
842
- 'deltree',
843
- 'erase',
844
- 'unlink',
845
- 'shred',
846
- 'srm',
847
- 'wipe',
848
- 'remove',
849
- ]:
850
- # Check targets of delete commands
851
- for _, part in enumerate(parts[1:], 1):
852
- if part.startswith('-') or part.startswith(
853
- '/'
854
- ): # Skip options
855
- continue
856
-
857
- target = part.strip('"\'')
858
- if (
859
- target.startswith('/')
860
- or target.startswith('\\')
861
- or ':' in target
862
- ):
863
- # Absolute path
864
- abs_path = os.path.abspath(target)
865
- else:
866
- # Relative path
867
- abs_path = os.path.abspath(os.path.join(exec_dir, target))
868
-
869
- if not self._is_path_within_working_dir(abs_path):
870
- return False, (
871
- f"Safety restriction: Cannot delete files outside "
872
- f"of working directory {self.working_dir}"
873
- )
874
-
875
- # Check write/modify commands
876
- elif base_cmd in [
877
- 'touch',
878
- 'mkdir',
879
- 'md',
880
- 'echo',
881
- 'cat',
882
- 'cp',
883
- 'copy',
884
- 'mv',
885
- 'move',
886
- 'rename',
887
- 'ren',
888
- 'write',
889
- 'output',
890
- ]:
891
- # Check for redirection symbols
892
- full_cmd = command.lower()
893
- if '>' in full_cmd:
894
- # Find the file path after redirection
895
- redirect_parts = command.split('>')
896
- if len(redirect_parts) > 1:
897
- output_file = (
898
- redirect_parts[1].strip().split()[0].strip('"\'')
899
- )
900
- if (
901
- output_file.startswith('/')
902
- or output_file.startswith('\\')
903
- or ':' in output_file
904
- ):
905
- # Absolute path
906
- abs_path = os.path.abspath(output_file)
907
- else:
908
- # Relative path
909
- abs_path = os.path.abspath(
910
- os.path.join(exec_dir, output_file)
911
- )
912
-
913
- if not self._is_path_within_working_dir(abs_path):
914
- return False, (
915
- f"Safety restriction: Cannot write to file "
916
- f"outside of working directory {self.working_dir}"
917
- )
918
-
919
- # For cp/mv commands, check target paths
920
- if base_cmd in ['cp', 'copy', 'mv', 'move']:
921
- # Simple handling, assuming the last parameter is the target
922
- if len(parts) > 2:
923
- target = parts[-1].strip('"\'')
924
- if (
925
- target.startswith('/')
926
- or target.startswith('\\')
927
- or ':' in target
928
- ):
929
- # Absolute path
930
- abs_path = os.path.abspath(target)
931
- else:
932
- # Relative path
933
- abs_path = os.path.abspath(
934
- os.path.join(exec_dir, target)
935
- )
936
-
937
- if not self._is_path_within_working_dir(abs_path):
938
- return False, (
939
- f"Safety restriction: Cannot write to file "
940
- f"outside of working directory {self.working_dir}"
941
- )
942
-
943
- # Check dangerous commands
944
- elif base_cmd in [
945
- 'sudo',
946
- 'su',
947
- 'chmod',
948
- 'chown',
949
- 'chgrp',
950
- 'passwd',
951
- 'mkfs',
952
- 'fdisk',
953
- 'dd',
954
- 'shutdown',
955
- 'reboot',
956
- 'halt',
957
- 'poweroff',
958
- 'init',
959
- ]:
960
- return False, (
961
- f"Safety restriction: Command '{base_cmd}' may affect system "
962
- f"security and is prohibited"
963
- )
964
-
965
- # Check network commands
966
- elif base_cmd in ['ssh', 'telnet', 'ftp', 'sftp', 'nc', 'netcat']:
967
- return False, (
968
- f"Safety restriction: Network command '{base_cmd}' "
969
- f"is prohibited"
970
- )
971
-
972
- # Add copy functionality - copy from external to working directory
973
- elif base_cmd == 'safecopy':
974
- # Custom command: safecopy <source file> <target file>
975
- if len(parts) != 3:
976
- return False, "Usage: safecopy <source file> <target file>"
977
-
978
- source = parts[1].strip('\'"')
979
- target = parts[2].strip('\'"')
980
-
981
- # Check if source file exists
982
- if not os.path.exists(source):
983
- return False, f"Source file does not exist: {source}"
984
-
985
- # Ensure target is within working directory
986
- if (
987
- target.startswith('/')
988
- or target.startswith('\\')
989
- or ':' in target
990
- ):
991
- # Absolute path
992
- abs_target = os.path.abspath(target)
993
- else:
994
- # Relative path
995
- abs_target = os.path.abspath(os.path.join(exec_dir, target))
996
-
997
- if not self._is_path_within_working_dir(abs_target):
998
- return False, (
999
- f"Safety restriction: Target file must be within "
1000
- f"working directory {self.working_dir}"
1001
- )
1002
-
1003
- # Replace with safe copy command
1004
- if self.os_type == 'Windows':
1005
- return True, f"copy \"{source}\" \"{abs_target}\""
1006
- else:
1007
- return True, f"cp \"{source}\" \"{abs_target}\""
1008
-
1009
- return True, command
1010
-
1011
- def shell_exec(self, id: str, command: str) -> str:
1012
- r"""Executes a shell command in a specified session.
1013
-
1014
- This function creates and manages shell sessions to execute commands,
1015
- simulating a real terminal. The behavior depends on the toolkit's
1016
- interactive mode setting. Each session is identified by a unique ID.
1017
- If a session with the given ID does not exist, it will be created.
1018
-
1019
- Args:
1020
- id (str): A unique identifier for the shell session. This is used
1021
- to manage multiple concurrent shell processes.
1022
- command (str): The shell command to be executed.
1023
-
1024
- Returns:
1025
- str: The standard output and standard error from the command. If an
1026
- error occurs during execution, a descriptive error message is
1027
- returned.
1028
-
1029
- Note:
1030
- When the toolkit is initialized with interactive mode, commands may
1031
- block if they require input. In safe mode, some commands that are
1032
- considered dangerous are restricted.
1033
- """
1034
- error_msg = self._enforce_working_dir_for_execution(self.working_dir)
1035
- if error_msg:
1036
- return error_msg
1037
-
1038
- if self.safe_mode:
1039
- is_safe, sanitized_command = self._sanitize_command(
1040
- command, self.working_dir
1041
- )
1042
- if not is_safe:
1043
- return f"Command rejected: {sanitized_command}"
1044
- command = sanitized_command
1045
-
1046
- if id not in self.shell_sessions:
1047
- self.shell_sessions[id] = {
1048
- "process": None,
1049
- "output": "",
1050
- "running": False,
1051
- }
1052
-
1053
- try:
1054
- self._update_terminal_output(f"\n$ {command}\n")
1055
-
1056
- if command.startswith('python') or command.startswith('pip'):
1057
- python_path = None
1058
- pip_path = None
1059
-
1060
- # Try cloned environment first
1061
- if self.cloned_env_path and os.path.exists(
1062
- self.cloned_env_path
1063
- ):
1064
- if self.os_type == 'Windows':
1065
- base_path = os.path.join(
1066
- self.cloned_env_path, "Scripts"
1067
- )
1068
- python_candidate = os.path.join(
1069
- base_path, "python.exe"
1070
- )
1071
- pip_candidate = os.path.join(base_path, "pip.exe")
1072
- else:
1073
- base_path = os.path.join(self.cloned_env_path, "bin")
1074
- python_candidate = os.path.join(base_path, "python")
1075
- pip_candidate = os.path.join(base_path, "pip")
1076
-
1077
- # Verify the executables exist
1078
- if os.path.exists(python_candidate):
1079
- python_path = python_candidate
1080
- # For pip, use python -m pip if pip executable doesn't
1081
- # exist
1082
- if os.path.exists(pip_candidate):
1083
- pip_path = pip_candidate
1084
- else:
1085
- pip_path = f'"{python_path}" -m pip'
1086
-
1087
- # Try initial environment if cloned environment failed
1088
- if (
1089
- python_path is None
1090
- and self.initial_env_prepared
1091
- and self.initial_env_path
1092
- and os.path.exists(self.initial_env_path)
1093
- ):
1094
- if self.os_type == 'Windows':
1095
- base_path = os.path.join(
1096
- self.initial_env_path, "Scripts"
1097
- )
1098
- python_candidate = os.path.join(
1099
- base_path, "python.exe"
1100
- )
1101
- pip_candidate = os.path.join(base_path, "pip.exe")
1102
- else:
1103
- base_path = os.path.join(self.initial_env_path, "bin")
1104
- python_candidate = os.path.join(base_path, "python")
1105
- pip_candidate = os.path.join(base_path, "pip")
1106
-
1107
- # Verify the executables exist
1108
- if os.path.exists(python_candidate):
1109
- python_path = python_candidate
1110
- # For pip, use python -m pip if pip executable doesn't
1111
- # exist
1112
- if os.path.exists(pip_candidate):
1113
- pip_path = pip_candidate
1114
- else:
1115
- pip_path = f'"{python_path}" -m pip'
1116
-
1117
- # Fall back to system Python
1118
- if python_path is None:
1119
- python_path = self.python_executable
1120
- pip_path = f'"{python_path}" -m pip'
1121
-
1122
- # Ensure we have valid paths before replacement
1123
- if python_path and command.startswith('python'):
1124
- command = command.replace('python', f'"{python_path}"', 1)
1125
- elif pip_path and command.startswith('pip'):
1126
- command = command.replace('pip', pip_path, 1)
1127
-
1128
- if not self.interactive:
1129
- # Use preexec_fn to create a new process group on Unix systems
1130
- preexec_fn = None
1131
- if self.os_type in ['Darwin', 'Linux']:
1132
- preexec_fn = os.setsid
1133
-
1134
- proc = subprocess.Popen(
1135
- command,
1136
- shell=True,
1137
- cwd=self.working_dir,
1138
- stdout=subprocess.PIPE,
1139
- stderr=subprocess.PIPE,
1140
- stdin=subprocess.PIPE,
1141
- text=True,
1142
- bufsize=1,
1143
- universal_newlines=True,
1144
- env=os.environ.copy(),
1145
- preexec_fn=preexec_fn,
1146
- errors='replace', # Handle non-UTF-8 characters
1147
- )
1148
-
1149
- self.shell_sessions[id]["process"] = proc
1150
- self.shell_sessions[id]["running"] = True
1151
- try:
1152
- # Use the instance timeout if available
1153
- stdout, stderr = proc.communicate(timeout=self.timeout)
1154
- output = stdout or ""
1155
- if stderr:
1156
- output += f"\nStderr Output:\n{stderr}"
1157
- self.shell_sessions[id]["output"] = output
1158
- self.shell_sessions[id]["running"] = False
1159
- self._update_terminal_output(output + "\n")
1160
- return output
1161
- except subprocess.TimeoutExpired:
1162
- # Kill the entire process group on Unix systems
1163
- if self.os_type in ['Darwin', 'Linux']:
1164
- try:
1165
- os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
1166
- # Give it a short time to terminate
1167
- stdout, stderr = proc.communicate(timeout=1)
1168
- except subprocess.TimeoutExpired:
1169
- # Force kill the process group
1170
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
1171
- stdout, stderr = proc.communicate()
1172
- except ProcessLookupError:
1173
- # Process already dead
1174
- stdout, stderr = proc.communicate()
1175
- else:
1176
- # Windows fallback
1177
- proc.terminate()
1178
- try:
1179
- stdout, stderr = proc.communicate(timeout=1)
1180
- except subprocess.TimeoutExpired:
1181
- proc.kill()
1182
- stdout, stderr = proc.communicate()
1183
-
1184
- output = stdout or ""
1185
- if stderr:
1186
- output += f"\nStderr Output:\n{stderr}"
1187
- error_msg = (
1188
- f"\nCommand timed out after {self.timeout} seconds"
1189
- )
1190
- output += error_msg
1191
- self.shell_sessions[id]["output"] = output
1192
- self.shell_sessions[id]["running"] = False
1193
- self._update_terminal_output(output + "\n")
1194
- return output
1195
-
1196
- # Interactive mode with real-time streaming via PTY
1197
- if self.os_type not in ['Darwin', 'Linux']:
1198
- return (
1199
- "Interactive mode is not supported on "
1200
- f"{self.os_type} due to PTY limitations."
1201
- )
1202
-
1203
- import pty
1204
- import select
1205
- import sys
1206
- import termios
1207
- import tty
1208
-
1209
- # Fork a new process with a PTY
1210
- pid, master_fd = pty.fork()
1211
-
1212
- if pid == 0: # Child process
1213
- # Execute the command in the child process
1214
- try:
1215
- import shlex
1216
-
1217
- parts = shlex.split(command)
1218
- if not parts:
1219
- logger.error("Error: Empty command")
1220
- os._exit(1)
1221
-
1222
- os.chdir(self.working_dir)
1223
- os.execvp(parts[0], parts)
1224
- except (ValueError, IndexError, OSError) as e:
1225
- logger.error(f"Command execution error: {e}")
1226
- os._exit(127)
1227
- except Exception as e:
1228
- logger.error(f"Unexpected error: {e}")
1229
- os._exit(1)
1230
-
1231
- # Parent process
1232
- self.shell_sessions[id]["process_id"] = pid
1233
- self.shell_sessions[id]["running"] = True
1234
- output_lines: List[str] = []
1235
- original_settings = termios.tcgetattr(sys.stdin)
1236
-
1237
- try:
1238
- tty.setraw(sys.stdin.fileno())
1239
-
1240
- while True:
1241
- # Check if the child process has exited
1242
- try:
1243
- wait_pid, status = os.waitpid(pid, os.WNOHANG)
1244
- if wait_pid == pid:
1245
- self.shell_sessions[id]["running"] = False
1246
- break
1247
- except OSError:
1248
- # Process already reaped
1249
- self.shell_sessions[id]["running"] = False
1250
- break
1251
-
1252
- # Use select to wait for I/O on stdin or master PTY
1253
- r, _, _ = select.select(
1254
- [sys.stdin, master_fd], [], [], 0.1
1255
- )
1256
-
1257
- if master_fd in r:
1258
- try:
1259
- data = os.read(master_fd, 1024)
1260
- if not data:
1261
- break
1262
- decoded_data = data.decode(
1263
- 'utf-8', errors='replace'
1264
- )
1265
- # Echo to user's terminal and log
1266
- self._update_terminal_output(decoded_data)
1267
- output_lines.append(decoded_data)
1268
- except OSError:
1269
- break # PTY has been closed
1270
-
1271
- if sys.stdin in r:
1272
- try:
1273
- user_input = os.read(sys.stdin.fileno(), 1024)
1274
- if not user_input:
1275
- break
1276
- os.write(master_fd, user_input)
1277
- except OSError:
1278
- break
1279
-
1280
- finally:
1281
- if original_settings is not None:
1282
- termios.tcsetattr(
1283
- sys.stdin, termios.TCSADRAIN, original_settings
1284
- )
1285
- if master_fd:
1286
- os.close(master_fd)
1287
-
1288
- final_output = "".join(output_lines)
1289
- self.shell_sessions[id]["output"] = final_output
1290
- return final_output
1291
-
1292
- except Exception as e:
1293
- error_msg = f"Command execution error: {e!s}"
1294
- logger.error(error_msg)
1295
- self._update_terminal_output(f"\nError: {error_msg}\n")
1296
- import traceback
1297
-
1298
- detailed_error = traceback.format_exc()
1299
- return (
1300
- f"Error: {error_msg}\n\n"
1301
- f"Detailed information: {detailed_error}"
1302
- )
1303
-
1304
- # Mark shell_exec to skip automatic timeout wrapping
1305
- shell_exec._manual_timeout = True # type: ignore[attr-defined]
1306
-
1307
- def shell_view(self, id: str) -> str:
1308
- r"""View the full output history of a specified shell session.
1309
-
1310
- Retrieves the accumulated output (both stdout and stderr) generated by
1311
- commands in the specified session since its creation. This is useful
1312
- for checking the complete history of a session, especially after a
1313
- command has finished execution.
1314
-
1315
- Args:
1316
- id (str): The unique identifier of the shell session to view.
1317
-
1318
- Returns:
1319
- str: The complete output history of the shell session. Returns an
1320
- error message if the session is not found.
1321
- """
1322
- if id not in self.shell_sessions:
1323
- return f"Shell session not found: {id}"
1324
-
1325
- session = self.shell_sessions[id]
1326
-
1327
- try:
1328
- # Check process status
1329
- if session["process"].poll() is not None:
1330
- session["running"] = False
1331
-
1332
- # Collect all new output from agent queue
1333
- new_output = ""
1334
- try:
1335
- while True:
1336
- output = self.agent_queue.get_nowait()
1337
- new_output += output
1338
- session["output"] += output
1339
- except queue.Empty:
1340
- pass
1341
-
1342
- return new_output or session["output"]
1343
-
1344
- except Exception as e:
1345
- error_msg = f"Error reading terminal output: {e}"
1346
- self._update_terminal_output(f"\nError: {error_msg}\n")
1347
- logger.error(error_msg)
1348
- return f"Error: {e!s}"
1349
-
1350
- def shell_wait(self, id: str, seconds: Optional[int] = None) -> str:
1351
- r"""Wait for a command to finish in a specified shell session.
1352
-
1353
- Blocks execution and waits for the running process in a shell session
1354
- to complete. This is useful for ensuring a long-running command has
1355
- finished before proceeding.
1356
-
1357
- Args:
1358
- id (str): The unique identifier of the target shell session.
1359
- seconds (Optional[int], optional): The maximum time to wait, in
1360
- seconds. If `None`, it waits indefinitely.
1361
- (default: :obj:`None`)
1362
-
1363
- Returns:
1364
- str: A message indicating that the process has completed, including
1365
- the final output. If the process times out, it returns a
1366
- timeout message.
1367
- """
1368
- if id not in self.shell_sessions:
1369
- return f"Shell session not found: {id}"
1370
-
1371
- session = self.shell_sessions[id]
1372
- process = session.get("process")
1373
-
1374
- if process is None:
1375
- return f"No active process in session '{id}'"
1376
-
1377
- if not session["running"] or process.poll() is not None:
1378
- return f"Process in session '{id}' is not running"
1379
-
1380
- try:
1381
- if hasattr(process, 'communicate'):
1382
- # Use communicate with timeout
1383
- stdout, stderr = process.communicate(timeout=seconds)
1384
-
1385
- if stdout:
1386
- stdout_str = (
1387
- stdout.decode('utf-8')
1388
- if isinstance(stdout, bytes)
1389
- else stdout
1390
- )
1391
- session["output"] += stdout_str
1392
- if stderr:
1393
- stderr_str = (
1394
- stderr.decode('utf-8')
1395
- if isinstance(stderr, bytes)
1396
- else stderr
1397
- )
1398
- if stderr_str:
1399
- session["output"] += f"\nStderr Output:\n{stderr_str}"
1400
-
1401
- session["running"] = False
1402
- return (
1403
- f"Process completed in session '{id}'. "
1404
- f"Output: {session['output']}"
1405
- )
1406
- else:
1407
- return (
1408
- f"Process already completed in session '{id}'. "
1409
- f"Output: {session['output']}"
1410
- )
1411
-
1412
- except subprocess.TimeoutExpired:
1413
- return (
1414
- f"Process in session '{id}' is still running "
1415
- f"after {seconds} seconds"
1416
- )
1417
- except Exception as e:
1418
- logger.error(f"Error waiting for process: {e}")
1419
- return f"Error waiting for process: {e!s}"
1420
-
1421
- def shell_write_to_process(
1422
- self, id: str, input: str, press_enter: bool
1423
- ) -> str:
1424
- r"""Write input to a running process in a specified shell session.
1425
-
1426
- Sends a string of text to the standard input of a running process.
1427
- This is useful for interacting with commands that require input. This
1428
- function cannot be used with a command that was started in
1429
- interactive mode.
1430
-
1431
- Args:
1432
- id (str): The unique identifier of the target shell session.
1433
- input (str): The text to write to the process's stdin.
1434
- press_enter (bool): If `True`, a newline character (`\n`) is
1435
- appended to the input, simulating pressing the Enter key.
1436
-
1437
- Returns:
1438
- str: A status message indicating whether the input was sent, or an
1439
- error message if the operation fails.
1440
- """
1441
- if id not in self.shell_sessions:
1442
- return f"Shell session not found: {id}"
1443
-
1444
- session = self.shell_sessions[id]
1445
- process = session.get("process")
1446
-
1447
- if process is None:
1448
- return f"No active process in session '{id}'"
1449
-
1450
- if not session["running"] or process.poll() is not None:
1451
- return f"Process in session '{id}' is not running"
1452
-
1453
- try:
1454
- if not process.stdin or process.stdin.closed:
1455
- return (
1456
- f"Cannot write to process in session '{id}': "
1457
- f"stdin is closed"
1458
- )
1459
-
1460
- if press_enter:
1461
- input = input + "\n"
1462
-
1463
- # Write to stdin - handle encoding
1464
- if hasattr(process.stdin, 'write'):
1465
- if isinstance(input, str):
1466
- process.stdin.write(input)
1467
- else:
1468
- process.stdin.write(
1469
- input.decode('utf-8', errors='replace')
1470
- )
1471
- else:
1472
- # Fallback for byte mode
1473
- process.stdin.write(input.encode('utf-8', errors='replace'))
1474
- process.stdin.flush()
1475
-
1476
- return f"Input sent to process in session '{id}'"
1477
- except Exception as e:
1478
- logger.error(f"Error writing to process: {e}")
1479
- return f"Error writing to process: {e!s}"
1480
-
1481
- def shell_kill_process(self, id: str) -> str:
1482
- r"""Terminate a running process in a specified shell session.
1483
-
1484
- Forcibly stops a command that is currently running in a shell session.
1485
- This is useful for ending processes that are stuck, running too long,
1486
- or need to be cancelled.
1487
-
1488
- Args:
1489
- id (str): The unique identifier of the shell session containing the
1490
- process to be terminated.
1491
-
1492
- Returns:
1493
- str: A status message indicating that the process has been
1494
- terminated, or an error message if the operation fails.
1495
- """
1496
- if id not in self.shell_sessions:
1497
- return f"Shell session not found: {id}"
1498
-
1499
- session = self.shell_sessions[id]
1500
- process = session.get("process")
1501
-
1502
- if process is None:
1503
- return f"No active process in session '{id}'"
1504
-
1505
- if not session["running"] or process.poll() is not None:
1506
- return f"Process in session '{id}' is not running"
1507
-
1508
- try:
1509
- # Clean up process resources before termination
1510
- if process.stdin and not process.stdin.closed:
1511
- process.stdin.close()
1512
-
1513
- process.terminate()
1514
- try:
1515
- process.wait(timeout=5)
1516
- except subprocess.TimeoutExpired:
1517
- logger.warning(
1518
- f"Process in session '{id}' did not terminate gracefully"
1519
- f", forcing kill"
1520
- )
1521
- process.kill()
1522
-
1523
- session["running"] = False
1524
- return f"Process in session '{id}' has been terminated"
1525
- except Exception as e:
1526
- logger.error(f"Error killing process: {e}")
1527
- return f"Error killing process: {e!s}"
1528
-
1529
- def ask_user_for_help(self, id: str) -> str:
1530
- r"""Pause the agent and ask a human for help with a command.
1531
-
1532
- This function should be used when the agent is stuck and requires
1533
- manual intervention, such as solving a CAPTCHA or debugging a complex
1534
- issue. It pauses the agent's execution and allows a human to take
1535
- control of a specified shell session. The human can execute one
1536
- command to resolve the issue, and then control is returned to the
1537
- agent.
1538
-
1539
- Args:
1540
- id (str): The identifier of the shell session for the human to
1541
- interact with. If the session does not exist, it will be
1542
- created.
1543
-
1544
- Returns:
1545
- str: A status message indicating that the human has finished,
1546
- including the number of commands executed. If the takeover
1547
- times out or fails, an error message is returned.
1548
- """
1549
- # Input validation
1550
- if not id or not isinstance(id, str):
1551
- return "Error: Invalid session ID provided"
1552
-
1553
- # Prevent concurrent human takeovers
1554
- if (
1555
- hasattr(self, '_human_takeover_active')
1556
- and self._human_takeover_active
1557
- ):
1558
- return "Error: Human takeover already in progress"
1559
-
1560
- try:
1561
- self._human_takeover_active = True
1562
-
1563
- # Ensure the session exists so that the human can reuse it
1564
- if id not in self.shell_sessions:
1565
- self.shell_sessions[id] = {
1566
- "process": None,
1567
- "output": "",
1568
- "running": False,
1569
- }
1570
-
1571
- command_count = 0
1572
- error_occurred = False
1573
-
1574
- # Create clear banner message for user
1575
- takeover_banner = (
1576
- f"\n{'='*60}\n"
1577
- f"🤖 CAMEL Agent needs human help! Session: {id}\n"
1578
- f"📂 Working directory: {self.working_dir}\n"
1579
- f"{'='*60}\n"
1580
- f"💡 Type commands or '/exit' to return control to agent.\n"
1581
- f"{'='*60}\n"
1582
- )
1583
-
1584
- # Print once to console for immediate visibility
1585
- print(takeover_banner, flush=True)
1586
- # Log for terminal output tracking
1587
- self._update_terminal_output(takeover_banner)
1588
-
1589
- # Helper flag + event for coordination
1590
- done_event = threading.Event()
1591
-
1592
- def _human_loop() -> None:
1593
- r"""Blocking loop that forwards human input to shell_exec."""
1594
- nonlocal command_count, error_occurred
1595
- try:
1596
- while True:
1597
- try:
1598
- # Clear, descriptive prompt for user input
1599
- user_cmd = input(f"🧑‍💻 [{id}]> ")
1600
- if (
1601
- user_cmd.strip()
1602
- ): # Only count non-empty commands
1603
- command_count += 1
1604
- except EOFError:
1605
- # e.g. Ctrl_D / stdin closed, treat as exit.
1606
- break
1607
- except (KeyboardInterrupt, Exception) as e:
1608
- logger.warning(
1609
- f"Input error during human takeover: {e}"
1610
- )
1611
- error_occurred = True
1612
- break
1613
-
1614
- if user_cmd.strip() in {"/exit", "exit", "quit"}:
1615
- break
1616
-
1617
- try:
1618
- exec_result = self.shell_exec(id, user_cmd)
1619
- # Show the result immediately to the user
1620
- if exec_result.strip():
1621
- print(exec_result)
1622
- logger.info(
1623
- f"Human command executed: {user_cmd[:50]}..."
1624
- )
1625
- # Auto-exit after successful command
1626
- break
1627
- except Exception as e:
1628
- error_msg = f"Error executing command: {e}"
1629
- logger.error(f"Error executing human command: {e}")
1630
- print(error_msg) # Show error to user immediately
1631
- self._update_terminal_output(f"{error_msg}\n")
1632
- error_occurred = True
1633
-
1634
- except Exception as e:
1635
- logger.error(f"Unexpected error in human loop: {e}")
1636
- error_occurred = True
1637
- finally:
1638
- # Notify completion clearly
1639
- finish_msg = (
1640
- f"\n{'='*60}\n"
1641
- f"✅ Human assistance completed! "
1642
- f"Commands: {command_count}\n"
1643
- f"🤖 Returning control to CAMEL agent...\n"
1644
- f"{'='*60}\n"
1645
- )
1646
- print(finish_msg, flush=True)
1647
- self._update_terminal_output(finish_msg)
1648
- done_event.set()
1649
-
1650
- # Start interactive thread (non-daemon for proper cleanup)
1651
- thread = threading.Thread(target=_human_loop, daemon=False)
1652
- thread.start()
1653
-
1654
- # Block until human signals completion with timeout
1655
- if done_event.wait(timeout=600): # 10 minutes timeout
1656
- thread.join(timeout=10) # Give thread time to cleanup
1657
-
1658
- # Generate detailed status message
1659
- status = "completed successfully"
1660
- if error_occurred:
1661
- status = "completed with some errors"
1662
-
1663
- result_msg = (
1664
- f"Human assistance {status} for session '{id}'. "
1665
- f"Total commands executed: {command_count}. "
1666
- f"Working directory: {self.working_dir}"
1667
- )
1668
- logger.info(result_msg)
1669
- return result_msg
1670
- else:
1671
- timeout_msg = (
1672
- f"Human takeover for session '{id}' timed out after 10 "
1673
- "minutes"
1674
- )
1675
- logger.warning(timeout_msg)
1676
- return timeout_msg
1677
-
1678
- except Exception as e:
1679
- error_msg = f"Error during human takeover for session '{id}': {e}"
1680
- logger.error(error_msg)
1681
- # Notify user of the error clearly
1682
- error_banner = (
1683
- f"\n{'='*60}\n"
1684
- f"❌ Error in human takeover! Session: {id}\n"
1685
- f"❗ {e}\n"
1686
- f"{'='*60}\n"
1687
- )
1688
- print(error_banner, flush=True)
1689
- return error_msg
1690
- finally:
1691
- # Always reset the flag
1692
- self._human_takeover_active = False
1693
-
1694
- def __del__(self):
1695
- r"""Clean up resources when the object is being destroyed.
1696
- Terminates all running processes and closes any open file handles.
1697
- """
1698
- # Log that cleanup is starting
1699
- logger.info("TerminalToolkit cleanup initiated")
1700
-
1701
- # Clean up all processes in shell sessions
1702
- if hasattr(self, 'shell_sessions'):
1703
- for session_id, session in self.shell_sessions.items():
1704
- process = session.get("process")
1705
- if process is not None and session.get("running", False):
1706
- try:
1707
- logger.info(
1708
- f"Terminating process in session '{session_id}'"
1709
- )
1710
-
1711
- # Close process input/output streams if open
1712
- if (
1713
- hasattr(process, 'stdin')
1714
- and process.stdin
1715
- and not process.stdin.closed
1716
- ):
1717
- process.stdin.close()
1718
-
1719
- # Terminate the process
1720
- process.terminate()
1721
- try:
1722
- # Give the process a short time to terminate
1723
- # gracefully
1724
- process.wait(timeout=3)
1725
- except subprocess.TimeoutExpired:
1726
- # Force kill if the process doesn't terminate
1727
- # gracefully
1728
- logger.warning(
1729
- f"Process in session '{session_id}' did not "
1730
- f"terminate gracefully, forcing kill"
1731
- )
1732
- process.kill()
1733
-
1734
- # Mark the session as not running
1735
- session["running"] = False
1736
-
1737
- except Exception as e:
1738
- logger.error(
1739
- f"Error cleaning up process in session "
1740
- f"'{session_id}': {e}"
1741
- )
1742
-
1743
- # Clean up file output if it exists
1744
- if hasattr(self, 'log_file') and self.is_macos:
1745
- try:
1746
- logger.info(f"Final terminal log saved to: {self.log_file}")
1747
- except Exception as e:
1748
- logger.error(f"Error logging file information: {e}")
1749
-
1750
- # Clean up initial environment if it exists
1751
- if hasattr(self, 'initial_env_path') and self.initial_env_path:
1752
- try:
1753
- if os.path.exists(self.initial_env_path):
1754
- shutil.rmtree(self.initial_env_path)
1755
- logger.info(
1756
- f"Cleaned up initial environment: "
1757
- f"{self.initial_env_path}"
1758
- )
1759
- except Exception as e:
1760
- logger.error(f"Error cleaning up initial environment: {e}")
1761
-
1762
- # Clean up GUI resources if they exist
1763
- if hasattr(self, 'root') and self.root:
1764
- try:
1765
- logger.info("Closing terminal GUI")
1766
- self.root.quit()
1767
- self.root.destroy()
1768
- except Exception as e:
1769
- logger.error(f"Error closing terminal GUI: {e}")
1770
-
1771
- logger.info("TerminalToolkit cleanup completed")
1772
-
1773
- def get_tools(self) -> List[FunctionTool]:
1774
- r"""Returns a list of FunctionTool objects representing the functions
1775
- in the toolkit.
1776
-
1777
- Returns:
1778
- List[FunctionTool]: A list of FunctionTool objects representing the
1779
- functions in the toolkit.
1780
- """
1781
- return [
1782
- FunctionTool(self.shell_exec),
1783
- FunctionTool(self.shell_view),
1784
- FunctionTool(self.shell_wait),
1785
- FunctionTool(self.shell_write_to_process),
1786
- FunctionTool(self.shell_kill_process),
1787
- FunctionTool(self.ask_user_for_help),
1788
- ]