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.
Files changed (199) hide show
  1. DolphinLanguageSDK/__init__.py +58 -0
  2. dolphin/__init__.py +62 -0
  3. dolphin/cli/__init__.py +20 -0
  4. dolphin/cli/args/__init__.py +9 -0
  5. dolphin/cli/args/parser.py +567 -0
  6. dolphin/cli/builtin_agents/__init__.py +22 -0
  7. dolphin/cli/commands/__init__.py +4 -0
  8. dolphin/cli/interrupt/__init__.py +8 -0
  9. dolphin/cli/interrupt/handler.py +205 -0
  10. dolphin/cli/interrupt/keyboard.py +82 -0
  11. dolphin/cli/main.py +49 -0
  12. dolphin/cli/multimodal/__init__.py +34 -0
  13. dolphin/cli/multimodal/clipboard.py +327 -0
  14. dolphin/cli/multimodal/handler.py +249 -0
  15. dolphin/cli/multimodal/image_processor.py +214 -0
  16. dolphin/cli/multimodal/input_parser.py +149 -0
  17. dolphin/cli/runner/__init__.py +8 -0
  18. dolphin/cli/runner/runner.py +989 -0
  19. dolphin/cli/ui/__init__.py +10 -0
  20. dolphin/cli/ui/console.py +2795 -0
  21. dolphin/cli/ui/input.py +340 -0
  22. dolphin/cli/ui/layout.py +425 -0
  23. dolphin/cli/ui/stream_renderer.py +302 -0
  24. dolphin/cli/utils/__init__.py +8 -0
  25. dolphin/cli/utils/helpers.py +135 -0
  26. dolphin/cli/utils/version.py +49 -0
  27. dolphin/core/__init__.py +107 -0
  28. dolphin/core/agent/__init__.py +10 -0
  29. dolphin/core/agent/agent_state.py +69 -0
  30. dolphin/core/agent/base_agent.py +970 -0
  31. dolphin/core/code_block/__init__.py +0 -0
  32. dolphin/core/code_block/agent_init_block.py +0 -0
  33. dolphin/core/code_block/assign_block.py +98 -0
  34. dolphin/core/code_block/basic_code_block.py +1865 -0
  35. dolphin/core/code_block/explore_block.py +1327 -0
  36. dolphin/core/code_block/explore_block_v2.py +712 -0
  37. dolphin/core/code_block/explore_strategy.py +672 -0
  38. dolphin/core/code_block/judge_block.py +220 -0
  39. dolphin/core/code_block/prompt_block.py +32 -0
  40. dolphin/core/code_block/skill_call_deduplicator.py +291 -0
  41. dolphin/core/code_block/tool_block.py +129 -0
  42. dolphin/core/common/__init__.py +17 -0
  43. dolphin/core/common/constants.py +176 -0
  44. dolphin/core/common/enums.py +1173 -0
  45. dolphin/core/common/exceptions.py +133 -0
  46. dolphin/core/common/multimodal.py +539 -0
  47. dolphin/core/common/object_type.py +165 -0
  48. dolphin/core/common/output_format.py +432 -0
  49. dolphin/core/common/types.py +36 -0
  50. dolphin/core/config/__init__.py +16 -0
  51. dolphin/core/config/global_config.py +1289 -0
  52. dolphin/core/config/ontology_config.py +133 -0
  53. dolphin/core/context/__init__.py +12 -0
  54. dolphin/core/context/context.py +1580 -0
  55. dolphin/core/context/context_manager.py +161 -0
  56. dolphin/core/context/var_output.py +82 -0
  57. dolphin/core/context/variable_pool.py +356 -0
  58. dolphin/core/context_engineer/__init__.py +41 -0
  59. dolphin/core/context_engineer/config/__init__.py +5 -0
  60. dolphin/core/context_engineer/config/settings.py +402 -0
  61. dolphin/core/context_engineer/core/__init__.py +7 -0
  62. dolphin/core/context_engineer/core/budget_manager.py +327 -0
  63. dolphin/core/context_engineer/core/context_assembler.py +583 -0
  64. dolphin/core/context_engineer/core/context_manager.py +637 -0
  65. dolphin/core/context_engineer/core/tokenizer_service.py +260 -0
  66. dolphin/core/context_engineer/example/incremental_example.py +267 -0
  67. dolphin/core/context_engineer/example/traditional_example.py +334 -0
  68. dolphin/core/context_engineer/services/__init__.py +5 -0
  69. dolphin/core/context_engineer/services/compressor.py +399 -0
  70. dolphin/core/context_engineer/utils/__init__.py +6 -0
  71. dolphin/core/context_engineer/utils/context_utils.py +441 -0
  72. dolphin/core/context_engineer/utils/message_formatter.py +270 -0
  73. dolphin/core/context_engineer/utils/token_utils.py +139 -0
  74. dolphin/core/coroutine/__init__.py +15 -0
  75. dolphin/core/coroutine/context_snapshot.py +154 -0
  76. dolphin/core/coroutine/context_snapshot_profile.py +922 -0
  77. dolphin/core/coroutine/context_snapshot_store.py +268 -0
  78. dolphin/core/coroutine/execution_frame.py +145 -0
  79. dolphin/core/coroutine/execution_state_registry.py +161 -0
  80. dolphin/core/coroutine/resume_handle.py +101 -0
  81. dolphin/core/coroutine/step_result.py +101 -0
  82. dolphin/core/executor/__init__.py +18 -0
  83. dolphin/core/executor/debug_controller.py +630 -0
  84. dolphin/core/executor/dolphin_executor.py +1063 -0
  85. dolphin/core/executor/executor.py +624 -0
  86. dolphin/core/flags/__init__.py +27 -0
  87. dolphin/core/flags/definitions.py +49 -0
  88. dolphin/core/flags/manager.py +113 -0
  89. dolphin/core/hook/__init__.py +95 -0
  90. dolphin/core/hook/expression_evaluator.py +499 -0
  91. dolphin/core/hook/hook_dispatcher.py +380 -0
  92. dolphin/core/hook/hook_types.py +248 -0
  93. dolphin/core/hook/isolated_variable_pool.py +284 -0
  94. dolphin/core/interfaces.py +53 -0
  95. dolphin/core/llm/__init__.py +0 -0
  96. dolphin/core/llm/llm.py +495 -0
  97. dolphin/core/llm/llm_call.py +100 -0
  98. dolphin/core/llm/llm_client.py +1285 -0
  99. dolphin/core/llm/message_sanitizer.py +120 -0
  100. dolphin/core/logging/__init__.py +20 -0
  101. dolphin/core/logging/logger.py +526 -0
  102. dolphin/core/message/__init__.py +8 -0
  103. dolphin/core/message/compressor.py +749 -0
  104. dolphin/core/parser/__init__.py +8 -0
  105. dolphin/core/parser/parser.py +405 -0
  106. dolphin/core/runtime/__init__.py +10 -0
  107. dolphin/core/runtime/runtime_graph.py +926 -0
  108. dolphin/core/runtime/runtime_instance.py +446 -0
  109. dolphin/core/skill/__init__.py +14 -0
  110. dolphin/core/skill/context_retention.py +157 -0
  111. dolphin/core/skill/skill_function.py +686 -0
  112. dolphin/core/skill/skill_matcher.py +282 -0
  113. dolphin/core/skill/skillkit.py +700 -0
  114. dolphin/core/skill/skillset.py +72 -0
  115. dolphin/core/trajectory/__init__.py +10 -0
  116. dolphin/core/trajectory/recorder.py +189 -0
  117. dolphin/core/trajectory/trajectory.py +522 -0
  118. dolphin/core/utils/__init__.py +9 -0
  119. dolphin/core/utils/cache_kv.py +212 -0
  120. dolphin/core/utils/tools.py +340 -0
  121. dolphin/lib/__init__.py +93 -0
  122. dolphin/lib/debug/__init__.py +8 -0
  123. dolphin/lib/debug/visualizer.py +409 -0
  124. dolphin/lib/memory/__init__.py +28 -0
  125. dolphin/lib/memory/async_processor.py +220 -0
  126. dolphin/lib/memory/llm_calls.py +195 -0
  127. dolphin/lib/memory/manager.py +78 -0
  128. dolphin/lib/memory/sandbox.py +46 -0
  129. dolphin/lib/memory/storage.py +245 -0
  130. dolphin/lib/memory/utils.py +51 -0
  131. dolphin/lib/ontology/__init__.py +12 -0
  132. dolphin/lib/ontology/basic/__init__.py +0 -0
  133. dolphin/lib/ontology/basic/base.py +102 -0
  134. dolphin/lib/ontology/basic/concept.py +130 -0
  135. dolphin/lib/ontology/basic/object.py +11 -0
  136. dolphin/lib/ontology/basic/relation.py +63 -0
  137. dolphin/lib/ontology/datasource/__init__.py +27 -0
  138. dolphin/lib/ontology/datasource/datasource.py +66 -0
  139. dolphin/lib/ontology/datasource/oracle_datasource.py +338 -0
  140. dolphin/lib/ontology/datasource/sql.py +845 -0
  141. dolphin/lib/ontology/mapping.py +177 -0
  142. dolphin/lib/ontology/ontology.py +733 -0
  143. dolphin/lib/ontology/ontology_context.py +16 -0
  144. dolphin/lib/ontology/ontology_manager.py +107 -0
  145. dolphin/lib/skill_results/__init__.py +31 -0
  146. dolphin/lib/skill_results/cache_backend.py +559 -0
  147. dolphin/lib/skill_results/result_processor.py +181 -0
  148. dolphin/lib/skill_results/result_reference.py +179 -0
  149. dolphin/lib/skill_results/skillkit_hook.py +324 -0
  150. dolphin/lib/skill_results/strategies.py +328 -0
  151. dolphin/lib/skill_results/strategy_registry.py +150 -0
  152. dolphin/lib/skillkits/__init__.py +44 -0
  153. dolphin/lib/skillkits/agent_skillkit.py +155 -0
  154. dolphin/lib/skillkits/cognitive_skillkit.py +82 -0
  155. dolphin/lib/skillkits/env_skillkit.py +250 -0
  156. dolphin/lib/skillkits/mcp_adapter.py +616 -0
  157. dolphin/lib/skillkits/mcp_skillkit.py +771 -0
  158. dolphin/lib/skillkits/memory_skillkit.py +650 -0
  159. dolphin/lib/skillkits/noop_skillkit.py +31 -0
  160. dolphin/lib/skillkits/ontology_skillkit.py +89 -0
  161. dolphin/lib/skillkits/plan_act_skillkit.py +452 -0
  162. dolphin/lib/skillkits/resource/__init__.py +52 -0
  163. dolphin/lib/skillkits/resource/models/__init__.py +6 -0
  164. dolphin/lib/skillkits/resource/models/skill_config.py +109 -0
  165. dolphin/lib/skillkits/resource/models/skill_meta.py +127 -0
  166. dolphin/lib/skillkits/resource/resource_skillkit.py +393 -0
  167. dolphin/lib/skillkits/resource/skill_cache.py +215 -0
  168. dolphin/lib/skillkits/resource/skill_loader.py +395 -0
  169. dolphin/lib/skillkits/resource/skill_validator.py +406 -0
  170. dolphin/lib/skillkits/resource_skillkit.py +11 -0
  171. dolphin/lib/skillkits/search_skillkit.py +163 -0
  172. dolphin/lib/skillkits/sql_skillkit.py +274 -0
  173. dolphin/lib/skillkits/system_skillkit.py +509 -0
  174. dolphin/lib/skillkits/vm_skillkit.py +65 -0
  175. dolphin/lib/utils/__init__.py +9 -0
  176. dolphin/lib/utils/data_process.py +207 -0
  177. dolphin/lib/utils/handle_progress.py +178 -0
  178. dolphin/lib/utils/security.py +139 -0
  179. dolphin/lib/utils/text_retrieval.py +462 -0
  180. dolphin/lib/vm/__init__.py +11 -0
  181. dolphin/lib/vm/env_executor.py +895 -0
  182. dolphin/lib/vm/python_session_manager.py +453 -0
  183. dolphin/lib/vm/vm.py +610 -0
  184. dolphin/sdk/__init__.py +60 -0
  185. dolphin/sdk/agent/__init__.py +12 -0
  186. dolphin/sdk/agent/agent_factory.py +236 -0
  187. dolphin/sdk/agent/dolphin_agent.py +1106 -0
  188. dolphin/sdk/api/__init__.py +4 -0
  189. dolphin/sdk/runtime/__init__.py +8 -0
  190. dolphin/sdk/runtime/env.py +363 -0
  191. dolphin/sdk/skill/__init__.py +10 -0
  192. dolphin/sdk/skill/global_skills.py +706 -0
  193. dolphin/sdk/skill/traditional_toolkit.py +260 -0
  194. kweaver_dolphin-0.1.0.dist-info/METADATA +521 -0
  195. kweaver_dolphin-0.1.0.dist-info/RECORD +199 -0
  196. kweaver_dolphin-0.1.0.dist-info/WHEEL +5 -0
  197. kweaver_dolphin-0.1.0.dist-info/entry_points.txt +27 -0
  198. kweaver_dolphin-0.1.0.dist-info/licenses/LICENSE.txt +201 -0
  199. 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}")