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.
- camel/__init__.py +1 -1
- camel/agents/_utils.py +38 -0
- camel/agents/chat_agent.py +2217 -519
- camel/agents/mcp_agent.py +30 -27
- camel/configs/__init__.py +15 -0
- camel/configs/aihubmix_config.py +88 -0
- camel/configs/amd_config.py +70 -0
- camel/configs/cometapi_config.py +104 -0
- camel/configs/minimax_config.py +93 -0
- camel/configs/nebius_config.py +103 -0
- camel/data_collectors/alpaca_collector.py +15 -6
- camel/datasets/base_generator.py +39 -10
- camel/environments/single_step.py +28 -3
- camel/environments/tic_tac_toe.py +1 -1
- camel/interpreters/__init__.py +2 -0
- camel/interpreters/docker/Dockerfile +3 -12
- camel/interpreters/e2b_interpreter.py +34 -1
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/loaders/__init__.py +11 -2
- camel/loaders/chunkr_reader.py +9 -0
- camel/memories/agent_memories.py +48 -4
- camel/memories/base.py +26 -0
- camel/memories/blocks/chat_history_block.py +122 -4
- camel/memories/context_creators/score_based.py +25 -384
- camel/memories/records.py +88 -8
- camel/messages/base.py +153 -34
- camel/models/__init__.py +10 -0
- camel/models/aihubmix_model.py +83 -0
- camel/models/aiml_model.py +1 -16
- camel/models/amd_model.py +101 -0
- camel/models/anthropic_model.py +6 -19
- camel/models/aws_bedrock_model.py +2 -33
- camel/models/azure_openai_model.py +114 -89
- camel/models/base_audio_model.py +3 -1
- camel/models/base_model.py +32 -14
- camel/models/cohere_model.py +1 -16
- camel/models/cometapi_model.py +83 -0
- camel/models/crynux_model.py +1 -16
- camel/models/deepseek_model.py +1 -16
- camel/models/fish_audio_model.py +6 -0
- camel/models/gemini_model.py +36 -18
- camel/models/groq_model.py +1 -17
- camel/models/internlm_model.py +1 -16
- camel/models/litellm_model.py +1 -16
- camel/models/lmstudio_model.py +1 -17
- camel/models/minimax_model.py +83 -0
- camel/models/mistral_model.py +1 -16
- camel/models/model_factory.py +27 -1
- camel/models/modelscope_model.py +1 -16
- camel/models/moonshot_model.py +105 -24
- camel/models/nebius_model.py +83 -0
- camel/models/nemotron_model.py +0 -5
- camel/models/netmind_model.py +1 -16
- camel/models/novita_model.py +1 -16
- camel/models/nvidia_model.py +1 -16
- camel/models/ollama_model.py +4 -19
- camel/models/openai_compatible_model.py +62 -41
- camel/models/openai_model.py +62 -57
- camel/models/openrouter_model.py +1 -17
- camel/models/ppio_model.py +1 -16
- camel/models/qianfan_model.py +1 -16
- camel/models/qwen_model.py +1 -16
- camel/models/reka_model.py +1 -16
- camel/models/samba_model.py +34 -47
- camel/models/sglang_model.py +64 -31
- camel/models/siliconflow_model.py +1 -16
- camel/models/stub_model.py +0 -4
- camel/models/togetherai_model.py +1 -16
- camel/models/vllm_model.py +1 -16
- camel/models/volcano_model.py +0 -17
- camel/models/watsonx_model.py +1 -16
- camel/models/yi_model.py +1 -16
- camel/models/zhipuai_model.py +60 -16
- camel/parsers/__init__.py +18 -0
- camel/parsers/mcp_tool_call_parser.py +176 -0
- camel/retrievers/auto_retriever.py +1 -0
- camel/runtimes/daytona_runtime.py +11 -12
- camel/societies/__init__.py +2 -0
- camel/societies/workforce/__init__.py +2 -0
- camel/societies/workforce/events.py +122 -0
- camel/societies/workforce/prompts.py +146 -66
- camel/societies/workforce/role_playing_worker.py +15 -11
- camel/societies/workforce/single_agent_worker.py +302 -65
- camel/societies/workforce/structured_output_handler.py +30 -18
- camel/societies/workforce/task_channel.py +163 -27
- camel/societies/workforce/utils.py +107 -13
- camel/societies/workforce/workflow_memory_manager.py +772 -0
- camel/societies/workforce/workforce.py +1949 -579
- camel/societies/workforce/workforce_callback.py +74 -0
- camel/societies/workforce/workforce_logger.py +168 -145
- camel/societies/workforce/workforce_metrics.py +33 -0
- camel/storages/key_value_storages/json.py +15 -2
- camel/storages/key_value_storages/mem0_cloud.py +48 -47
- camel/storages/object_storages/google_cloud.py +1 -1
- camel/storages/vectordb_storages/oceanbase.py +13 -13
- camel/storages/vectordb_storages/qdrant.py +3 -3
- camel/storages/vectordb_storages/tidb.py +8 -6
- camel/tasks/task.py +4 -3
- camel/toolkits/__init__.py +20 -7
- camel/toolkits/aci_toolkit.py +45 -0
- camel/toolkits/base.py +6 -4
- camel/toolkits/code_execution.py +28 -1
- camel/toolkits/context_summarizer_toolkit.py +684 -0
- camel/toolkits/dappier_toolkit.py +5 -1
- camel/toolkits/dingtalk.py +1135 -0
- camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
- camel/toolkits/excel_toolkit.py +1 -1
- camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +430 -36
- camel/toolkits/function_tool.py +13 -3
- camel/toolkits/github_toolkit.py +104 -17
- camel/toolkits/gmail_toolkit.py +1839 -0
- camel/toolkits/google_calendar_toolkit.py +38 -4
- camel/toolkits/google_drive_mcp_toolkit.py +12 -31
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +15 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +77 -8
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +884 -88
- camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
- camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +959 -89
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +9 -2
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +281 -213
- camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +23 -3
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +72 -7
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +582 -132
- camel/toolkits/hybrid_browser_toolkit_py/actions.py +158 -0
- camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +55 -8
- camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +43 -0
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +321 -8
- camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +10 -4
- camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +45 -4
- camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +151 -53
- camel/toolkits/klavis_toolkit.py +5 -1
- camel/toolkits/markitdown_toolkit.py +27 -1
- camel/toolkits/math_toolkit.py +64 -10
- camel/toolkits/mcp_toolkit.py +366 -71
- camel/toolkits/memory_toolkit.py +5 -1
- camel/toolkits/message_integration.py +18 -13
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/note_taking_toolkit.py +19 -10
- camel/toolkits/notion_mcp_toolkit.py +16 -26
- camel/toolkits/openbb_toolkit.py +5 -1
- camel/toolkits/origene_mcp_toolkit.py +8 -49
- camel/toolkits/playwright_mcp_toolkit.py +12 -31
- camel/toolkits/resend_toolkit.py +168 -0
- camel/toolkits/search_toolkit.py +264 -91
- camel/toolkits/slack_toolkit.py +64 -10
- camel/toolkits/terminal_toolkit/__init__.py +18 -0
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +957 -0
- camel/toolkits/terminal_toolkit/utils.py +532 -0
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +17 -11
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/toolkits/zapier_toolkit.py +5 -1
- camel/types/__init__.py +2 -2
- camel/types/enums.py +274 -7
- camel/types/openai_types.py +2 -2
- camel/types/unified_model_type.py +15 -0
- camel/utils/commons.py +36 -5
- camel/utils/constants.py +3 -0
- camel/utils/context_utils.py +1003 -0
- camel/utils/mcp.py +138 -4
- camel/utils/token_counting.py +43 -20
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/METADATA +223 -83
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/RECORD +170 -141
- camel/loaders/pandas_reader.py +0 -368
- camel/toolkits/openai_agent_toolkit.py +0 -135
- camel/toolkits/terminal_toolkit.py +0 -1550
- {camel_ai-0.2.73a4.dist-info → camel_ai-0.2.80a2.dist-info}/WHEEL +0 -0
- {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
|
-
]
|