kweaver-dolphin 0.1.0__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.
- DolphinLanguageSDK/__init__.py +58 -0
- dolphin/__init__.py +62 -0
- dolphin/cli/__init__.py +20 -0
- dolphin/cli/args/__init__.py +9 -0
- dolphin/cli/args/parser.py +567 -0
- dolphin/cli/builtin_agents/__init__.py +22 -0
- dolphin/cli/commands/__init__.py +4 -0
- dolphin/cli/interrupt/__init__.py +8 -0
- dolphin/cli/interrupt/handler.py +205 -0
- dolphin/cli/interrupt/keyboard.py +82 -0
- dolphin/cli/main.py +49 -0
- dolphin/cli/multimodal/__init__.py +34 -0
- dolphin/cli/multimodal/clipboard.py +327 -0
- dolphin/cli/multimodal/handler.py +249 -0
- dolphin/cli/multimodal/image_processor.py +214 -0
- dolphin/cli/multimodal/input_parser.py +149 -0
- dolphin/cli/runner/__init__.py +8 -0
- dolphin/cli/runner/runner.py +989 -0
- dolphin/cli/ui/__init__.py +10 -0
- dolphin/cli/ui/console.py +2795 -0
- dolphin/cli/ui/input.py +340 -0
- dolphin/cli/ui/layout.py +425 -0
- dolphin/cli/ui/stream_renderer.py +302 -0
- dolphin/cli/utils/__init__.py +8 -0
- dolphin/cli/utils/helpers.py +135 -0
- dolphin/cli/utils/version.py +49 -0
- dolphin/core/__init__.py +107 -0
- dolphin/core/agent/__init__.py +10 -0
- dolphin/core/agent/agent_state.py +69 -0
- dolphin/core/agent/base_agent.py +970 -0
- dolphin/core/code_block/__init__.py +0 -0
- dolphin/core/code_block/agent_init_block.py +0 -0
- dolphin/core/code_block/assign_block.py +98 -0
- dolphin/core/code_block/basic_code_block.py +1865 -0
- dolphin/core/code_block/explore_block.py +1327 -0
- dolphin/core/code_block/explore_block_v2.py +712 -0
- dolphin/core/code_block/explore_strategy.py +672 -0
- dolphin/core/code_block/judge_block.py +220 -0
- dolphin/core/code_block/prompt_block.py +32 -0
- dolphin/core/code_block/skill_call_deduplicator.py +291 -0
- dolphin/core/code_block/tool_block.py +129 -0
- dolphin/core/common/__init__.py +17 -0
- dolphin/core/common/constants.py +176 -0
- dolphin/core/common/enums.py +1173 -0
- dolphin/core/common/exceptions.py +133 -0
- dolphin/core/common/multimodal.py +539 -0
- dolphin/core/common/object_type.py +165 -0
- dolphin/core/common/output_format.py +432 -0
- dolphin/core/common/types.py +36 -0
- dolphin/core/config/__init__.py +16 -0
- dolphin/core/config/global_config.py +1289 -0
- dolphin/core/config/ontology_config.py +133 -0
- dolphin/core/context/__init__.py +12 -0
- dolphin/core/context/context.py +1580 -0
- dolphin/core/context/context_manager.py +161 -0
- dolphin/core/context/var_output.py +82 -0
- dolphin/core/context/variable_pool.py +356 -0
- dolphin/core/context_engineer/__init__.py +41 -0
- dolphin/core/context_engineer/config/__init__.py +5 -0
- dolphin/core/context_engineer/config/settings.py +402 -0
- dolphin/core/context_engineer/core/__init__.py +7 -0
- dolphin/core/context_engineer/core/budget_manager.py +327 -0
- dolphin/core/context_engineer/core/context_assembler.py +583 -0
- dolphin/core/context_engineer/core/context_manager.py +637 -0
- dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
- dolphin/core/context_engineer/example/incremental_example.py +267 -0
- dolphin/core/context_engineer/example/traditional_example.py +334 -0
- dolphin/core/context_engineer/services/__init__.py +5 -0
- dolphin/core/context_engineer/services/compressor.py +399 -0
- dolphin/core/context_engineer/utils/__init__.py +6 -0
- dolphin/core/context_engineer/utils/context_utils.py +441 -0
- dolphin/core/context_engineer/utils/message_formatter.py +270 -0
- dolphin/core/context_engineer/utils/token_utils.py +139 -0
- dolphin/core/coroutine/__init__.py +15 -0
- dolphin/core/coroutine/context_snapshot.py +154 -0
- dolphin/core/coroutine/context_snapshot_profile.py +922 -0
- dolphin/core/coroutine/context_snapshot_store.py +268 -0
- dolphin/core/coroutine/execution_frame.py +145 -0
- dolphin/core/coroutine/execution_state_registry.py +161 -0
- dolphin/core/coroutine/resume_handle.py +101 -0
- dolphin/core/coroutine/step_result.py +101 -0
- dolphin/core/executor/__init__.py +18 -0
- dolphin/core/executor/debug_controller.py +630 -0
- dolphin/core/executor/dolphin_executor.py +1063 -0
- dolphin/core/executor/executor.py +624 -0
- dolphin/core/flags/__init__.py +27 -0
- dolphin/core/flags/definitions.py +49 -0
- dolphin/core/flags/manager.py +113 -0
- dolphin/core/hook/__init__.py +95 -0
- dolphin/core/hook/expression_evaluator.py +499 -0
- dolphin/core/hook/hook_dispatcher.py +380 -0
- dolphin/core/hook/hook_types.py +248 -0
- dolphin/core/hook/isolated_variable_pool.py +284 -0
- dolphin/core/interfaces.py +53 -0
- dolphin/core/llm/__init__.py +0 -0
- dolphin/core/llm/llm.py +495 -0
- dolphin/core/llm/llm_call.py +100 -0
- dolphin/core/llm/llm_client.py +1285 -0
- dolphin/core/llm/message_sanitizer.py +120 -0
- dolphin/core/logging/__init__.py +20 -0
- dolphin/core/logging/logger.py +526 -0
- dolphin/core/message/__init__.py +8 -0
- dolphin/core/message/compressor.py +749 -0
- dolphin/core/parser/__init__.py +8 -0
- dolphin/core/parser/parser.py +405 -0
- dolphin/core/runtime/__init__.py +10 -0
- dolphin/core/runtime/runtime_graph.py +926 -0
- dolphin/core/runtime/runtime_instance.py +446 -0
- dolphin/core/skill/__init__.py +14 -0
- dolphin/core/skill/context_retention.py +157 -0
- dolphin/core/skill/skill_function.py +686 -0
- dolphin/core/skill/skill_matcher.py +282 -0
- dolphin/core/skill/skillkit.py +700 -0
- dolphin/core/skill/skillset.py +72 -0
- dolphin/core/trajectory/__init__.py +10 -0
- dolphin/core/trajectory/recorder.py +189 -0
- dolphin/core/trajectory/trajectory.py +522 -0
- dolphin/core/utils/__init__.py +9 -0
- dolphin/core/utils/cache_kv.py +212 -0
- dolphin/core/utils/tools.py +340 -0
- dolphin/lib/__init__.py +93 -0
- dolphin/lib/debug/__init__.py +8 -0
- dolphin/lib/debug/visualizer.py +409 -0
- dolphin/lib/memory/__init__.py +28 -0
- dolphin/lib/memory/async_processor.py +220 -0
- dolphin/lib/memory/llm_calls.py +195 -0
- dolphin/lib/memory/manager.py +78 -0
- dolphin/lib/memory/sandbox.py +46 -0
- dolphin/lib/memory/storage.py +245 -0
- dolphin/lib/memory/utils.py +51 -0
- dolphin/lib/ontology/__init__.py +12 -0
- dolphin/lib/ontology/basic/__init__.py +0 -0
- dolphin/lib/ontology/basic/base.py +102 -0
- dolphin/lib/ontology/basic/concept.py +130 -0
- dolphin/lib/ontology/basic/object.py +11 -0
- dolphin/lib/ontology/basic/relation.py +63 -0
- dolphin/lib/ontology/datasource/__init__.py +27 -0
- dolphin/lib/ontology/datasource/datasource.py +66 -0
- dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
- dolphin/lib/ontology/datasource/sql.py +845 -0
- dolphin/lib/ontology/mapping.py +177 -0
- dolphin/lib/ontology/ontology.py +733 -0
- dolphin/lib/ontology/ontology_context.py +16 -0
- dolphin/lib/ontology/ontology_manager.py +107 -0
- dolphin/lib/skill_results/__init__.py +31 -0
- dolphin/lib/skill_results/cache_backend.py +559 -0
- dolphin/lib/skill_results/result_processor.py +181 -0
- dolphin/lib/skill_results/result_reference.py +179 -0
- dolphin/lib/skill_results/skillkit_hook.py +324 -0
- dolphin/lib/skill_results/strategies.py +328 -0
- dolphin/lib/skill_results/strategy_registry.py +150 -0
- dolphin/lib/skillkits/__init__.py +44 -0
- dolphin/lib/skillkits/agent_skillkit.py +155 -0
- dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
- dolphin/lib/skillkits/env_skillkit.py +250 -0
- dolphin/lib/skillkits/mcp_adapter.py +616 -0
- dolphin/lib/skillkits/mcp_skillkit.py +771 -0
- dolphin/lib/skillkits/memory_skillkit.py +650 -0
- dolphin/lib/skillkits/noop_skillkit.py +31 -0
- dolphin/lib/skillkits/ontology_skillkit.py +89 -0
- dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
- dolphin/lib/skillkits/resource/__init__.py +52 -0
- dolphin/lib/skillkits/resource/models/__init__.py +6 -0
- dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
- dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
- dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
- dolphin/lib/skillkits/resource/skill_cache.py +215 -0
- dolphin/lib/skillkits/resource/skill_loader.py +395 -0
- dolphin/lib/skillkits/resource/skill_validator.py +406 -0
- dolphin/lib/skillkits/resource_skillkit.py +11 -0
- dolphin/lib/skillkits/search_skillkit.py +163 -0
- dolphin/lib/skillkits/sql_skillkit.py +274 -0
- dolphin/lib/skillkits/system_skillkit.py +509 -0
- dolphin/lib/skillkits/vm_skillkit.py +65 -0
- dolphin/lib/utils/__init__.py +9 -0
- dolphin/lib/utils/data_process.py +207 -0
- dolphin/lib/utils/handle_progress.py +178 -0
- dolphin/lib/utils/security.py +139 -0
- dolphin/lib/utils/text_retrieval.py +462 -0
- dolphin/lib/vm/__init__.py +11 -0
- dolphin/lib/vm/env_executor.py +895 -0
- dolphin/lib/vm/python_session_manager.py +453 -0
- dolphin/lib/vm/vm.py +610 -0
- dolphin/sdk/__init__.py +60 -0
- dolphin/sdk/agent/__init__.py +12 -0
- dolphin/sdk/agent/agent_factory.py +236 -0
- dolphin/sdk/agent/dolphin_agent.py +1106 -0
- dolphin/sdk/api/__init__.py +4 -0
- dolphin/sdk/runtime/__init__.py +8 -0
- dolphin/sdk/runtime/env.py +363 -0
- dolphin/sdk/skill/__init__.py +10 -0
- dolphin/sdk/skill/global_skills.py +706 -0
- dolphin/sdk/skill/traditional_toolkit.py +260 -0
- kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
- kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
- kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
- kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
- kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
- kweaver_dolphin-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Environment Executor Module
|
|
4
|
+
|
|
5
|
+
This module defines the abstract interface for environment executors
|
|
6
|
+
and provides concrete implementations for different execution environments.
|
|
7
|
+
|
|
8
|
+
Executors are responsible for executing Python and Bash commands in
|
|
9
|
+
specific environments (local, VM, Docker, etc.)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from abc import ABC, abstractmethod
|
|
13
|
+
from typing import Any, Dict, Optional, Tuple
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import random
|
|
18
|
+
import string
|
|
19
|
+
import shlex
|
|
20
|
+
import threading
|
|
21
|
+
import uuid
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import Enum
|
|
25
|
+
|
|
26
|
+
from dolphin.core.logging.logger import get_logger
|
|
27
|
+
|
|
28
|
+
logger = get_logger("env_executor")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandStatus(Enum):
|
|
32
|
+
"""Status of an async command."""
|
|
33
|
+
RUNNING = "running"
|
|
34
|
+
COMPLETED = "completed"
|
|
35
|
+
FAILED = "failed"
|
|
36
|
+
TIMEOUT = "timeout"
|
|
37
|
+
CANCELLED = "cancelled"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AsyncCommand:
|
|
42
|
+
"""Represents an async command execution."""
|
|
43
|
+
command_id: str
|
|
44
|
+
command: str
|
|
45
|
+
cwd: str
|
|
46
|
+
process: subprocess.Popen
|
|
47
|
+
start_time: float
|
|
48
|
+
output_buffer: str = ""
|
|
49
|
+
error_buffer: str = ""
|
|
50
|
+
status: CommandStatus = CommandStatus.RUNNING
|
|
51
|
+
return_code: Optional[int] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AsyncCommandManager:
|
|
55
|
+
"""Manager for async command execution and monitoring.
|
|
56
|
+
|
|
57
|
+
This allows commands to be started and monitored incrementally,
|
|
58
|
+
giving LLMs the ability to decide whether to continue waiting.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_instance = None
|
|
62
|
+
_lock = threading.Lock()
|
|
63
|
+
|
|
64
|
+
def __new__(cls):
|
|
65
|
+
if cls._instance is None:
|
|
66
|
+
with cls._lock:
|
|
67
|
+
if cls._instance is None:
|
|
68
|
+
cls._instance = super().__new__(cls)
|
|
69
|
+
cls._instance._commands: Dict[str, AsyncCommand] = {}
|
|
70
|
+
cls._instance._output_lock = threading.Lock()
|
|
71
|
+
cls._instance._status_lock = threading.Lock()
|
|
72
|
+
return cls._instance
|
|
73
|
+
|
|
74
|
+
def start_command(
|
|
75
|
+
self,
|
|
76
|
+
command: str,
|
|
77
|
+
cwd: str,
|
|
78
|
+
env: Optional[Dict[str, str]] = None
|
|
79
|
+
) -> str:
|
|
80
|
+
"""Start a command asynchronously.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
command: Command to execute
|
|
84
|
+
cwd: Working directory
|
|
85
|
+
env: Environment variables
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Command ID for tracking
|
|
89
|
+
"""
|
|
90
|
+
command_id = str(uuid.uuid4())[:8]
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
process = subprocess.Popen(
|
|
94
|
+
command,
|
|
95
|
+
shell=True,
|
|
96
|
+
cwd=cwd,
|
|
97
|
+
stdout=subprocess.PIPE,
|
|
98
|
+
stderr=subprocess.PIPE,
|
|
99
|
+
stdin=subprocess.DEVNULL,
|
|
100
|
+
env=env,
|
|
101
|
+
text=True,
|
|
102
|
+
bufsize=1, # Line buffered
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
async_cmd = AsyncCommand(
|
|
106
|
+
command_id=command_id,
|
|
107
|
+
command=command,
|
|
108
|
+
cwd=cwd,
|
|
109
|
+
process=process,
|
|
110
|
+
start_time=time.time(),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self._commands[command_id] = async_cmd
|
|
114
|
+
logger.debug(f"[{command_id}] Command started: {command[:80]}...")
|
|
115
|
+
|
|
116
|
+
# Start output collection threads
|
|
117
|
+
# unique thread for stdout
|
|
118
|
+
threading.Thread(
|
|
119
|
+
target=self._collect_stream,
|
|
120
|
+
args=(command_id, 'stdout'),
|
|
121
|
+
daemon=True
|
|
122
|
+
).start()
|
|
123
|
+
logger.debug(f"[{command_id}] stdout collector thread started")
|
|
124
|
+
|
|
125
|
+
# unique thread for stderr
|
|
126
|
+
threading.Thread(
|
|
127
|
+
target=self._collect_stream,
|
|
128
|
+
args=(command_id, 'stderr'),
|
|
129
|
+
daemon=True
|
|
130
|
+
).start()
|
|
131
|
+
logger.debug(f"[{command_id}] stderr collector thread started")
|
|
132
|
+
|
|
133
|
+
return command_id
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error(f"Failed to start async command: {e}")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
def _collect_stream(self, command_id: str, stream_name: str):
|
|
140
|
+
"""Collect output from a specific stream."""
|
|
141
|
+
cmd = self._commands.get(command_id)
|
|
142
|
+
if not cmd:
|
|
143
|
+
logger.debug(f"[{command_id}] {stream_name} collector: command not found")
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
stream = cmd.process.stdout if stream_name == 'stdout' else cmd.process.stderr
|
|
148
|
+
if not stream:
|
|
149
|
+
logger.debug(f"[{command_id}] {stream_name} collector: stream is None")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
logger.debug(f"[{command_id}] {stream_name} collector: starting to read")
|
|
153
|
+
line_count = 0
|
|
154
|
+
# Read stream line by line
|
|
155
|
+
for line in iter(stream.readline, ''):
|
|
156
|
+
if not line:
|
|
157
|
+
break
|
|
158
|
+
line_count += 1
|
|
159
|
+
with self._output_lock:
|
|
160
|
+
if stream_name == 'stdout':
|
|
161
|
+
cmd.output_buffer += line
|
|
162
|
+
else:
|
|
163
|
+
cmd.error_buffer += line
|
|
164
|
+
|
|
165
|
+
logger.debug(f"[{command_id}] {stream_name} collector: finished, read {line_count} lines")
|
|
166
|
+
# Stream ended, try to update status non-blockingly
|
|
167
|
+
self._update_status(cmd)
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"Error collecting {stream_name} for {command_id}: {e}")
|
|
171
|
+
|
|
172
|
+
def _update_status(self, cmd: AsyncCommand):
|
|
173
|
+
"""Update command status non-blockingly."""
|
|
174
|
+
with self._status_lock:
|
|
175
|
+
if cmd.status in (
|
|
176
|
+
CommandStatus.COMPLETED,
|
|
177
|
+
CommandStatus.FAILED,
|
|
178
|
+
CommandStatus.CANCELLED,
|
|
179
|
+
):
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
return_code = cmd.process.poll()
|
|
183
|
+
if return_code is not None:
|
|
184
|
+
cmd.return_code = return_code
|
|
185
|
+
cmd.status = (
|
|
186
|
+
CommandStatus.COMPLETED if return_code == 0 else CommandStatus.FAILED
|
|
187
|
+
)
|
|
188
|
+
logger.debug(
|
|
189
|
+
f"[{cmd.command_id}] Status updated: {cmd.status.value}, return_code={return_code}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def wait_command(
|
|
193
|
+
self,
|
|
194
|
+
command_id: str,
|
|
195
|
+
timeout: float = 60
|
|
196
|
+
) -> Tuple[CommandStatus, str, Optional[int]]:
|
|
197
|
+
"""Wait for a command to complete or timeout.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
command_id: Command ID to wait for
|
|
201
|
+
timeout: Maximum seconds to wait
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Tuple of (status, output, return_code)
|
|
205
|
+
"""
|
|
206
|
+
cmd = self._commands.get(command_id)
|
|
207
|
+
if not cmd:
|
|
208
|
+
return CommandStatus.FAILED, f"Command {command_id} not found", None
|
|
209
|
+
|
|
210
|
+
# Check status first
|
|
211
|
+
self._update_status(cmd)
|
|
212
|
+
if cmd.status in (CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED):
|
|
213
|
+
with self._output_lock:
|
|
214
|
+
output = cmd.output_buffer
|
|
215
|
+
if cmd.error_buffer:
|
|
216
|
+
if cmd.return_code != 0:
|
|
217
|
+
output += f"\nSTDERR:\n{cmd.error_buffer}"
|
|
218
|
+
else:
|
|
219
|
+
output += f"\n{cmd.error_buffer}"
|
|
220
|
+
return cmd.status, output, cmd.return_code
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
logger.debug(f"[{command_id}] Waiting with timeout={timeout}s...")
|
|
224
|
+
# Wait with timeout - this is safe now as no other thread holds the lock
|
|
225
|
+
cmd.process.wait(timeout=timeout)
|
|
226
|
+
|
|
227
|
+
logger.debug(f"[{command_id}] Wait completed, process exited")
|
|
228
|
+
# Command completed
|
|
229
|
+
self._update_status(cmd)
|
|
230
|
+
with self._output_lock:
|
|
231
|
+
output = cmd.output_buffer
|
|
232
|
+
if cmd.error_buffer:
|
|
233
|
+
if cmd.return_code != 0:
|
|
234
|
+
output += f"\nSTDERR:\n{cmd.error_buffer}"
|
|
235
|
+
else:
|
|
236
|
+
output += f"\n{cmd.error_buffer}"
|
|
237
|
+
|
|
238
|
+
logger.debug(f"[{command_id}] Returning status={cmd.status.value}, output_len={len(output)}")
|
|
239
|
+
return cmd.status, output, cmd.return_code
|
|
240
|
+
|
|
241
|
+
except subprocess.TimeoutExpired:
|
|
242
|
+
# Still running
|
|
243
|
+
logger.debug(f"[{command_id}] Timeout expired after {timeout}s, command still running")
|
|
244
|
+
with self._status_lock:
|
|
245
|
+
cmd.status = CommandStatus.TIMEOUT
|
|
246
|
+
|
|
247
|
+
with self._output_lock:
|
|
248
|
+
partial_output = cmd.output_buffer
|
|
249
|
+
|
|
250
|
+
logger.debug(f"[{command_id}] Returning TIMEOUT with partial_output_len={len(partial_output)}")
|
|
251
|
+
return CommandStatus.TIMEOUT, partial_output, None
|
|
252
|
+
|
|
253
|
+
def cancel_command(self, command_id: str) -> bool:
|
|
254
|
+
"""Cancel a running command.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
command_id: Command ID to cancel
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
True if cancelled successfully
|
|
261
|
+
"""
|
|
262
|
+
cmd = self._commands.get(command_id)
|
|
263
|
+
if not cmd:
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
self._update_status(cmd)
|
|
267
|
+
if cmd.status in (CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED):
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
cmd.process.terminate()
|
|
272
|
+
try:
|
|
273
|
+
cmd.process.wait(timeout=5)
|
|
274
|
+
except subprocess.TimeoutExpired:
|
|
275
|
+
cmd.process.kill()
|
|
276
|
+
cmd.process.wait(timeout=1)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
with self._status_lock:
|
|
281
|
+
cmd.status = CommandStatus.CANCELLED
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
def get_command_info(self, command_id: str) -> Optional[Dict[str, Any]]:
|
|
285
|
+
"""Get information about a command.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
command_id: Command ID
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Command info dict or None
|
|
292
|
+
"""
|
|
293
|
+
cmd = self._commands.get(command_id)
|
|
294
|
+
if not cmd:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
self._update_status(cmd)
|
|
298
|
+
|
|
299
|
+
elapsed = time.time() - cmd.start_time
|
|
300
|
+
with self._output_lock:
|
|
301
|
+
output_size = len(cmd.output_buffer)
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
"command_id": cmd.command_id,
|
|
305
|
+
"command": cmd.command[:100] + "..." if len(cmd.command) > 100 else cmd.command,
|
|
306
|
+
"status": cmd.status.value,
|
|
307
|
+
"elapsed_seconds": round(elapsed, 1),
|
|
308
|
+
"output_size": output_size,
|
|
309
|
+
"return_code": cmd.return_code,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
def cleanup_old_commands(self, max_age_seconds: float = 3600):
|
|
313
|
+
"""Remove old completed/failed commands."""
|
|
314
|
+
now = time.time()
|
|
315
|
+
to_remove = []
|
|
316
|
+
|
|
317
|
+
for cmd_id, cmd in self._commands.items():
|
|
318
|
+
if cmd.status in (CommandStatus.COMPLETED, CommandStatus.FAILED, CommandStatus.CANCELLED):
|
|
319
|
+
if now - cmd.start_time > max_age_seconds:
|
|
320
|
+
to_remove.append(cmd_id)
|
|
321
|
+
|
|
322
|
+
for cmd_id in to_remove:
|
|
323
|
+
del self._commands[cmd_id]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class EnvExecutor(ABC):
|
|
327
|
+
"""
|
|
328
|
+
Abstract base class for environment executors.
|
|
329
|
+
|
|
330
|
+
An executor is responsible for executing commands (Python, Bash)
|
|
331
|
+
in a specific environment. Different implementations handle
|
|
332
|
+
different execution contexts (local machine, remote VM, Docker, etc.)
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
@abstractmethod
|
|
336
|
+
def exec_python(self, code: str, varDict: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
|
337
|
+
"""Execute Python code in the environment.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
code: Python code to execute
|
|
341
|
+
varDict: Optional dictionary of variables to inject
|
|
342
|
+
**kwargs: Additional execution parameters
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Execution result as string
|
|
346
|
+
"""
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
@abstractmethod
|
|
350
|
+
def exec_bash(self, command: str, **kwargs) -> str:
|
|
351
|
+
"""Execute a Bash command in the environment.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
command: Bash command to execute
|
|
355
|
+
**kwargs: Additional execution parameters
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Execution result as string
|
|
359
|
+
"""
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
@abstractmethod
|
|
364
|
+
def env_type(self) -> str:
|
|
365
|
+
"""Get the environment type identifier.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
str: Environment type (e.g., 'local', 'vm')
|
|
369
|
+
"""
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
@abstractmethod
|
|
373
|
+
def is_connected(self) -> bool:
|
|
374
|
+
"""Check if the executor is connected/ready.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
True if ready to execute, False otherwise
|
|
378
|
+
"""
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
def _preprocess_code(self, code: str, lang: str) -> str:
|
|
382
|
+
"""Remove markdown code block wrappers if present.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
code: Code that may be wrapped in markdown
|
|
386
|
+
lang: Language identifier (python, bash)
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
Clean code without markdown wrappers
|
|
390
|
+
"""
|
|
391
|
+
start_flag = f"```{lang}"
|
|
392
|
+
end_flag = "```"
|
|
393
|
+
|
|
394
|
+
idx_start = code.find(start_flag)
|
|
395
|
+
if idx_start == -1:
|
|
396
|
+
return code
|
|
397
|
+
|
|
398
|
+
idx_end = code.find(end_flag, idx_start + len(start_flag))
|
|
399
|
+
if idx_end == -1:
|
|
400
|
+
return code[idx_start + len(start_flag):]
|
|
401
|
+
|
|
402
|
+
return code[idx_start + len(start_flag):idx_end]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class LocalExecutor(EnvExecutor):
|
|
406
|
+
"""
|
|
407
|
+
Executor for running commands on the local machine.
|
|
408
|
+
|
|
409
|
+
This executor runs commands directly on the machine where
|
|
410
|
+
dolphin is running. Useful for:
|
|
411
|
+
- Local development servers
|
|
412
|
+
- Node.js/npm operations
|
|
413
|
+
- Browser automation
|
|
414
|
+
- Local file operations
|
|
415
|
+
"""
|
|
416
|
+
|
|
417
|
+
def __init__(self, working_dir: Optional[str] = None, timeout: int = 60):
|
|
418
|
+
"""
|
|
419
|
+
Initialize the local executor.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
working_dir: Default working directory for commands
|
|
423
|
+
timeout: Default command timeout in seconds (default 60 seconds)
|
|
424
|
+
Commands that exceed this timeout will return with a command_id
|
|
425
|
+
that can be used to continue waiting via wait_command().
|
|
426
|
+
"""
|
|
427
|
+
self.working_dir = working_dir or os.getcwd()
|
|
428
|
+
self.default_timeout = timeout
|
|
429
|
+
self._session_namespace: Dict[str, Any] = {}
|
|
430
|
+
self._async_manager = AsyncCommandManager()
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def env_type(self) -> str:
|
|
434
|
+
return "local"
|
|
435
|
+
|
|
436
|
+
def is_connected(self) -> bool:
|
|
437
|
+
"""Local executor is always connected."""
|
|
438
|
+
return True
|
|
439
|
+
|
|
440
|
+
def exec_bash(self, command: str, cwd: Optional[str] = None, **kwargs) -> str:
|
|
441
|
+
"""Execute a Bash command locally.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
command: Bash command to execute
|
|
445
|
+
cwd: Working directory (overrides default)
|
|
446
|
+
timeout: Command timeout in seconds (default 60).
|
|
447
|
+
If command exceeds timeout, returns with a command_id for continuation.
|
|
448
|
+
background: If True, run command in background (for long-running servers).
|
|
449
|
+
Also auto-detects trailing '&' in command.
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Command output (stdout and stderr), or:
|
|
453
|
+
- Startup message for background commands
|
|
454
|
+
- Timeout message with command_id for long-running commands
|
|
455
|
+
"""
|
|
456
|
+
work_dir = cwd or self.working_dir
|
|
457
|
+
command = self._preprocess_code(command, "bash")
|
|
458
|
+
background = kwargs.get("background", False)
|
|
459
|
+
timeout = kwargs.get("timeout", self.default_timeout)
|
|
460
|
+
|
|
461
|
+
# Validate timeout
|
|
462
|
+
if timeout is not None:
|
|
463
|
+
timeout = max(1, min(timeout, 3600)) # Clamp between 1s and 1 hour
|
|
464
|
+
|
|
465
|
+
# Auto-detect trailing & as background execution request
|
|
466
|
+
if not background and command.rstrip().endswith('&'):
|
|
467
|
+
background = True
|
|
468
|
+
logger.info("Detected trailing '&' in command, switching to background execution")
|
|
469
|
+
|
|
470
|
+
logger.debug(f"Executing local bash in {work_dir}: {command[:100]}... (timeout={timeout}s)")
|
|
471
|
+
|
|
472
|
+
# Handle background execution for long-running processes
|
|
473
|
+
if background:
|
|
474
|
+
return self._exec_bash_background(command, work_dir)
|
|
475
|
+
|
|
476
|
+
# Use async execution for better timeout handling
|
|
477
|
+
try:
|
|
478
|
+
command_id = self._async_manager.start_command(
|
|
479
|
+
command,
|
|
480
|
+
work_dir,
|
|
481
|
+
self._get_enhanced_env()
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
status, output, return_code = self._async_manager.wait_command(
|
|
485
|
+
command_id,
|
|
486
|
+
timeout=timeout
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if status == CommandStatus.TIMEOUT:
|
|
490
|
+
# Command still running - provide command_id for continuation
|
|
491
|
+
elapsed = timeout
|
|
492
|
+
partial_info = f"\nPartial output:\n{output[:2000]}" if output else ""
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
f"⏳ Command still running after {elapsed} seconds.\n"
|
|
496
|
+
f"Command ID: {command_id}\n"
|
|
497
|
+
f"{partial_info}\n\n"
|
|
498
|
+
f"To continue waiting, call _bash with: command_id=\"{command_id}\"\n"
|
|
499
|
+
f"To cancel, call _bash with: command_id=\"{command_id}\", cancel=True"
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
elif status in (CommandStatus.COMPLETED, CommandStatus.FAILED):
|
|
503
|
+
if return_code != 0:
|
|
504
|
+
output = f"Command exited with code {return_code}\n{output}"
|
|
505
|
+
return output.strip() if output else "(no output)"
|
|
506
|
+
|
|
507
|
+
else:
|
|
508
|
+
return f"Command ended with status: {status.value}\n{output}"
|
|
509
|
+
|
|
510
|
+
except Exception as e:
|
|
511
|
+
logger.error(f"Error executing local bash: {e}")
|
|
512
|
+
return f"Error executing command: {str(e)}"
|
|
513
|
+
|
|
514
|
+
def wait_command(self, command_id: str, timeout: int = 60) -> str:
|
|
515
|
+
"""Continue waiting for a previously started command.
|
|
516
|
+
|
|
517
|
+
Use this when a command times out and you want to wait longer.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
command_id: The command ID from a previous timeout message
|
|
521
|
+
timeout: Additional seconds to wait (default 60)
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
Command output if completed, or timeout message if still running
|
|
525
|
+
"""
|
|
526
|
+
timeout = max(1, min(timeout, 3600))
|
|
527
|
+
|
|
528
|
+
cmd_info = self._async_manager.get_command_info(command_id)
|
|
529
|
+
if not cmd_info:
|
|
530
|
+
return f"Error: Command {command_id} not found. It may have already completed or been cleaned up."
|
|
531
|
+
|
|
532
|
+
if cmd_info["status"] in ("completed", "failed", "cancelled"):
|
|
533
|
+
# Command already finished, get the result
|
|
534
|
+
status, output, return_code = self._async_manager.wait_command(command_id, timeout=0)
|
|
535
|
+
if return_code is not None and return_code != 0:
|
|
536
|
+
output = f"Command exited with code {return_code}\n{output}"
|
|
537
|
+
return output.strip() if output else "(no output)"
|
|
538
|
+
|
|
539
|
+
# Continue waiting
|
|
540
|
+
status, output, return_code = self._async_manager.wait_command(command_id, timeout=timeout)
|
|
541
|
+
|
|
542
|
+
if status == CommandStatus.TIMEOUT:
|
|
543
|
+
elapsed = cmd_info["elapsed_seconds"] + timeout
|
|
544
|
+
partial_info = f"\nPartial output:\n{output[:2000]}" if output else ""
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
f"⏳ Command still running after {elapsed:.0f} seconds total.\n"
|
|
548
|
+
f"Command ID: {command_id}\n"
|
|
549
|
+
f"{partial_info}\n\n"
|
|
550
|
+
f"To continue waiting, call _bash with: command_id=\"{command_id}\"\n"
|
|
551
|
+
f"To cancel, call _bash with: command_id=\"{command_id}\", cancel=True"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
elif status in (CommandStatus.COMPLETED, CommandStatus.FAILED):
|
|
555
|
+
if return_code is not None and return_code != 0:
|
|
556
|
+
output = f"Command exited with code {return_code}\n{output}"
|
|
557
|
+
return output.strip() if output else "(no output)"
|
|
558
|
+
|
|
559
|
+
else:
|
|
560
|
+
return f"Command ended with status: {status.value}\n{output}"
|
|
561
|
+
|
|
562
|
+
def cancel_command(self, command_id: str) -> str:
|
|
563
|
+
"""Cancel a running command.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
command_id: The command ID to cancel
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
Cancellation status message
|
|
570
|
+
"""
|
|
571
|
+
cmd_info = self._async_manager.get_command_info(command_id)
|
|
572
|
+
if not cmd_info:
|
|
573
|
+
return f"Error: Command {command_id} not found."
|
|
574
|
+
|
|
575
|
+
if cmd_info["status"] != "running" and cmd_info["status"] != "timeout":
|
|
576
|
+
return f"Command {command_id} is already {cmd_info['status']}."
|
|
577
|
+
|
|
578
|
+
success = self._async_manager.cancel_command(command_id)
|
|
579
|
+
if success:
|
|
580
|
+
return f"Command {command_id} has been cancelled."
|
|
581
|
+
else:
|
|
582
|
+
return f"Failed to cancel command {command_id}."
|
|
583
|
+
|
|
584
|
+
def _exec_bash_background(self, command: str, work_dir: str) -> str:
|
|
585
|
+
"""Execute a command in the background, detached from the parent process.
|
|
586
|
+
|
|
587
|
+
Used for long-running processes like development servers.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
command: Bash command to execute
|
|
591
|
+
work_dir: Working directory
|
|
592
|
+
|
|
593
|
+
Returns:
|
|
594
|
+
Status message with PID
|
|
595
|
+
"""
|
|
596
|
+
import time
|
|
597
|
+
|
|
598
|
+
try:
|
|
599
|
+
# Strip trailing & if present (we add our own)
|
|
600
|
+
command = command.rstrip().rstrip('&').rstrip()
|
|
601
|
+
|
|
602
|
+
# Use nohup and redirect all output to detach properly
|
|
603
|
+
# Create a log file for the background process output
|
|
604
|
+
log_file = os.path.join(work_dir, ".background_process.log")
|
|
605
|
+
|
|
606
|
+
# Wrap in bash -c to handle complex commands (like cd && ./script)
|
|
607
|
+
safe_command = shlex.quote(command)
|
|
608
|
+
bg_command = f"nohup bash -c {safe_command} > {log_file} 2>&1 & echo $!"
|
|
609
|
+
|
|
610
|
+
result = subprocess.run(
|
|
611
|
+
bg_command,
|
|
612
|
+
shell=True,
|
|
613
|
+
cwd=work_dir,
|
|
614
|
+
capture_output=True,
|
|
615
|
+
text=True,
|
|
616
|
+
timeout=120, # Longer timeout for commands that do setup (npm install etc.)
|
|
617
|
+
env=self._get_enhanced_env()
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
pid = result.stdout.strip()
|
|
621
|
+
|
|
622
|
+
# Give the process a moment to start
|
|
623
|
+
time.sleep(2)
|
|
624
|
+
|
|
625
|
+
# Check if process is still running
|
|
626
|
+
try:
|
|
627
|
+
check_result = subprocess.run(
|
|
628
|
+
f"ps -p {pid} > /dev/null 2>&1 && echo 'running' || echo 'stopped'",
|
|
629
|
+
shell=True,
|
|
630
|
+
capture_output=True,
|
|
631
|
+
text=True,
|
|
632
|
+
timeout=5
|
|
633
|
+
)
|
|
634
|
+
status = check_result.stdout.strip()
|
|
635
|
+
|
|
636
|
+
if status == "running":
|
|
637
|
+
# Try to read initial log output
|
|
638
|
+
try:
|
|
639
|
+
with open(log_file, 'r') as f:
|
|
640
|
+
initial_output = f.read()[:500] # First 500 chars
|
|
641
|
+
if initial_output:
|
|
642
|
+
return f"Background process started (PID: {pid})\nInitial output:\n{initial_output}"
|
|
643
|
+
except Exception:
|
|
644
|
+
pass
|
|
645
|
+
return f"Background process started successfully (PID: {pid})\nLog file: {log_file}"
|
|
646
|
+
else:
|
|
647
|
+
# Process failed, read log for error
|
|
648
|
+
try:
|
|
649
|
+
with open(log_file, 'r') as f:
|
|
650
|
+
error_log = f.read()
|
|
651
|
+
return f"Background process failed to start (PID: {pid})\nError log:\n{error_log}"
|
|
652
|
+
except Exception:
|
|
653
|
+
return f"Background process failed to start (PID: {pid})"
|
|
654
|
+
|
|
655
|
+
except Exception as e:
|
|
656
|
+
return f"Background process started (PID: {pid}), but status check failed: {e}"
|
|
657
|
+
|
|
658
|
+
except subprocess.TimeoutExpired:
|
|
659
|
+
return "Error: Failed to start background process (timeout getting PID)"
|
|
660
|
+
except Exception as e:
|
|
661
|
+
logger.error(f"Error starting background process: {e}")
|
|
662
|
+
return f"Error starting background process: {str(e)}"
|
|
663
|
+
|
|
664
|
+
def exec_python(self, code: str, varDict: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
|
665
|
+
"""Execute Python code locally.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
code: Python code to execute
|
|
669
|
+
varDict: Variables to inject into namespace
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Execution result
|
|
673
|
+
"""
|
|
674
|
+
code = self._preprocess_code(code, "python")
|
|
675
|
+
cwd = kwargs.get("cwd", self.working_dir)
|
|
676
|
+
|
|
677
|
+
logger.debug(f"Executing local Python in {cwd}")
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
old_cwd = os.getcwd()
|
|
681
|
+
os.chdir(cwd)
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
# Create execution namespace
|
|
685
|
+
namespace = {
|
|
686
|
+
'__builtins__': __builtins__,
|
|
687
|
+
'__name__': '__main__',
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
# Preload common data-science aliases if available.
|
|
691
|
+
# This reduces "NameError: name 'np' is not defined" noise in typical analysis workflows.
|
|
692
|
+
try:
|
|
693
|
+
import numpy as np # type: ignore
|
|
694
|
+
namespace.setdefault("np", np)
|
|
695
|
+
except ImportError:
|
|
696
|
+
pass
|
|
697
|
+
try:
|
|
698
|
+
import pandas as pd # type: ignore
|
|
699
|
+
namespace.setdefault("pd", pd)
|
|
700
|
+
except ImportError:
|
|
701
|
+
pass
|
|
702
|
+
|
|
703
|
+
# Restore previous session state
|
|
704
|
+
namespace.update(self._session_namespace)
|
|
705
|
+
|
|
706
|
+
# Inject provided variables
|
|
707
|
+
if varDict:
|
|
708
|
+
namespace.update(varDict)
|
|
709
|
+
|
|
710
|
+
# Capture stdout
|
|
711
|
+
import io
|
|
712
|
+
import sys
|
|
713
|
+
old_stdout = sys.stdout
|
|
714
|
+
old_stderr = sys.stderr
|
|
715
|
+
sys.stdout = captured_stdout = io.StringIO()
|
|
716
|
+
sys.stderr = captured_stderr = io.StringIO()
|
|
717
|
+
|
|
718
|
+
try:
|
|
719
|
+
# Execute the code
|
|
720
|
+
exec(code, namespace)
|
|
721
|
+
|
|
722
|
+
# Get output
|
|
723
|
+
stdout_val = captured_stdout.getvalue()
|
|
724
|
+
stderr_val = captured_stderr.getvalue()
|
|
725
|
+
|
|
726
|
+
output = stdout_val
|
|
727
|
+
if stderr_val:
|
|
728
|
+
output += f"\n{stderr_val}"
|
|
729
|
+
|
|
730
|
+
# Check for return_value
|
|
731
|
+
if 'return_value' in namespace:
|
|
732
|
+
output += f"\nReturn value: {namespace['return_value']}"
|
|
733
|
+
|
|
734
|
+
# Save session state (filter non-pickleable)
|
|
735
|
+
for key, value in list(namespace.items()):
|
|
736
|
+
if not key.startswith('_'):
|
|
737
|
+
try:
|
|
738
|
+
import pickle
|
|
739
|
+
pickle.dumps(value)
|
|
740
|
+
self._session_namespace[key] = value
|
|
741
|
+
except Exception:
|
|
742
|
+
pass
|
|
743
|
+
|
|
744
|
+
return output.strip() if output else "(no output)"
|
|
745
|
+
|
|
746
|
+
finally:
|
|
747
|
+
sys.stdout = old_stdout
|
|
748
|
+
sys.stderr = old_stderr
|
|
749
|
+
|
|
750
|
+
finally:
|
|
751
|
+
os.chdir(old_cwd)
|
|
752
|
+
|
|
753
|
+
except Exception as e:
|
|
754
|
+
import traceback
|
|
755
|
+
logger.error(f"Error executing local Python: {e}")
|
|
756
|
+
return f"Error: {str(e)}\n{traceback.format_exc()}"
|
|
757
|
+
|
|
758
|
+
def _get_enhanced_env(self) -> dict:
|
|
759
|
+
"""Get environment with NVM/Node.js paths added."""
|
|
760
|
+
env = os.environ.copy()
|
|
761
|
+
|
|
762
|
+
# Add NVM paths if available
|
|
763
|
+
nvm_dir = os.path.expanduser("~/.nvm")
|
|
764
|
+
if os.path.exists(nvm_dir):
|
|
765
|
+
versions_dir = os.path.join(nvm_dir, "versions", "node")
|
|
766
|
+
if os.path.exists(versions_dir):
|
|
767
|
+
try:
|
|
768
|
+
versions = sorted(os.listdir(versions_dir), reverse=True)
|
|
769
|
+
if versions:
|
|
770
|
+
node_bin = os.path.join(versions_dir, versions[0], "bin")
|
|
771
|
+
if os.path.exists(node_bin):
|
|
772
|
+
env["PATH"] = f"{node_bin}:{env.get('PATH', '')}"
|
|
773
|
+
except OSError:
|
|
774
|
+
pass
|
|
775
|
+
|
|
776
|
+
return env
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
class VMExecutor(EnvExecutor):
|
|
780
|
+
"""
|
|
781
|
+
Executor for running commands on a remote VM via SSH.
|
|
782
|
+
|
|
783
|
+
This executor connects to a remote virtual machine and
|
|
784
|
+
executes commands there. Useful for:
|
|
785
|
+
- Isolated execution environments
|
|
786
|
+
- Remote server operations
|
|
787
|
+
- Sandboxed code execution
|
|
788
|
+
"""
|
|
789
|
+
|
|
790
|
+
def __init__(self, vm):
|
|
791
|
+
"""
|
|
792
|
+
Initialize the VM executor.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
vm: VM instance (VMSSH or similar)
|
|
796
|
+
"""
|
|
797
|
+
self.vm = vm
|
|
798
|
+
self._session_manager = None
|
|
799
|
+
|
|
800
|
+
def set_session_manager(self, session_manager):
|
|
801
|
+
"""Set the Python session manager for state persistence."""
|
|
802
|
+
self._session_manager = session_manager
|
|
803
|
+
|
|
804
|
+
@property
|
|
805
|
+
def env_type(self) -> str:
|
|
806
|
+
return "vm"
|
|
807
|
+
|
|
808
|
+
def is_connected(self) -> bool:
|
|
809
|
+
"""Check if VM is connected."""
|
|
810
|
+
if self.vm is None:
|
|
811
|
+
return False
|
|
812
|
+
return getattr(self.vm, 'connected', False)
|
|
813
|
+
|
|
814
|
+
def exec_bash(self, command: str, **kwargs) -> str:
|
|
815
|
+
"""Execute a Bash command on the remote VM.
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
command: Bash command to execute
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
Command output
|
|
822
|
+
"""
|
|
823
|
+
if self.vm is None:
|
|
824
|
+
raise RuntimeError("VM is not configured")
|
|
825
|
+
return self.vm.execBash(command)
|
|
826
|
+
|
|
827
|
+
def exec_python(self, code: str, varDict: Optional[Dict[str, Any]] = None, **kwargs) -> str:
|
|
828
|
+
"""Execute Python code on the remote VM.
|
|
829
|
+
|
|
830
|
+
Args:
|
|
831
|
+
code: Python code to execute
|
|
832
|
+
varDict: Variables to inject
|
|
833
|
+
|
|
834
|
+
Returns:
|
|
835
|
+
Execution result
|
|
836
|
+
"""
|
|
837
|
+
if self.vm is None:
|
|
838
|
+
raise RuntimeError("VM is not configured")
|
|
839
|
+
|
|
840
|
+
# Handle session management
|
|
841
|
+
session_id = kwargs.get("session_id")
|
|
842
|
+
if session_id and self._session_manager:
|
|
843
|
+
kwargs["session_id"] = session_id
|
|
844
|
+
kwargs["session_manager"] = self._session_manager
|
|
845
|
+
|
|
846
|
+
return self.vm.execPython(code, varDict, **kwargs)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def create_executor(config) -> EnvExecutor:
|
|
850
|
+
"""
|
|
851
|
+
Factory function to create the appropriate executor based on config.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
config: GlobalConfig or similar configuration object
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
EnvExecutor instance
|
|
858
|
+
|
|
859
|
+
Note:
|
|
860
|
+
- Default is LocalExecutor (local execution)
|
|
861
|
+
- Use env.type: 'vm' to explicitly use VMExecutor
|
|
862
|
+
- Having vm: config alone does NOT switch to VMExecutor
|
|
863
|
+
"""
|
|
864
|
+
# Check for explicit env config
|
|
865
|
+
env_config = getattr(config, 'env_config', None)
|
|
866
|
+
|
|
867
|
+
if env_config is not None:
|
|
868
|
+
env_type = getattr(env_config, 'type', 'local')
|
|
869
|
+
else:
|
|
870
|
+
# Default to local execution
|
|
871
|
+
# Note: Just having vm_config does NOT automatically use VM
|
|
872
|
+
# User must explicitly set env.type: 'vm' to use VMExecutor
|
|
873
|
+
env_type = 'local'
|
|
874
|
+
|
|
875
|
+
if env_type == 'local':
|
|
876
|
+
working_dir = getattr(env_config, 'working_dir', None) if env_config else None
|
|
877
|
+
return LocalExecutor(working_dir=working_dir)
|
|
878
|
+
|
|
879
|
+
elif env_type == 'vm':
|
|
880
|
+
from dolphin.lib.vm.vm import VMFactory
|
|
881
|
+
vm_config = getattr(config, 'vm_config', None)
|
|
882
|
+
if vm_config is None:
|
|
883
|
+
raise ValueError("VM configuration is required for 'vm' environment type")
|
|
884
|
+
|
|
885
|
+
vm = VMFactory.createVM(vm_config)
|
|
886
|
+
executor = VMExecutor(vm)
|
|
887
|
+
|
|
888
|
+
# Set up session manager
|
|
889
|
+
from dolphin.lib.vm.python_session_manager import PythonSessionManager
|
|
890
|
+
executor.set_session_manager(PythonSessionManager())
|
|
891
|
+
|
|
892
|
+
return executor
|
|
893
|
+
|
|
894
|
+
else:
|
|
895
|
+
raise ValueError(f"Unknown environment type: {env_type}")
|