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