camel-ai 0.2.73a4__py3-none-any.whl → 0.2.80a2__py3-none-any.whl

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