kolega-code 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 (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,868 @@
1
+ """Terminal manager implementation for sandbox environments."""
2
+
3
+ import uuid
4
+ import asyncio
5
+ import re
6
+ import os
7
+ from typing import Any, Dict, Optional, Callable, Awaitable
8
+ from datetime import datetime, timezone
9
+
10
+ from ..services.base import TerminalManager
11
+ from kolega_code.events import AgentEvent
12
+
13
+
14
+ class SandboxTerminalManager(TerminalManager):
15
+ """Terminal manager that operates within a sandbox."""
16
+
17
+ def __init__(self, sandbox: Any, workspace_id: str, thread_id: str, connection_manager: Any = None):
18
+ """
19
+ Initialize sandbox terminal manager.
20
+
21
+ Args:
22
+ sandbox: The sandbox instance (e.g., E2B Sandbox)
23
+ workspace_id: ID of the workspace
24
+ thread_id: ID of the thread
25
+ connection_manager: Connection manager for broadcasting events (optional)
26
+ """
27
+ self.sandbox = sandbox
28
+ self.workspace_id = workspace_id
29
+ self.thread_id = thread_id
30
+ self.connection_manager = connection_manager
31
+ self.terminals: Dict[str, Dict[str, Any]] = {}
32
+ self.outputs: Dict[str, list] = {}
33
+ self._default_terminal_id: Optional[str] = None
34
+
35
+ # Track commands and their status (for interface parity)
36
+ self.command_history: Dict[str, Dict[str, Any]] = {}
37
+ self.command_counter = 0
38
+
39
+ def set_connection_manager(self, connection_manager: Any) -> None:
40
+ """
41
+ Set the connection manager for streaming terminal output.
42
+ This allows setting it after creation when it becomes available.
43
+
44
+ Args:
45
+ connection_manager: Connection manager for broadcasting events
46
+ """
47
+ self.connection_manager = connection_manager
48
+
49
+ async def _ensure_default_terminal(self) -> str:
50
+ """Ensure a default terminal exists and return its ID."""
51
+ if self._default_terminal_id is None or self._default_terminal_id not in self.terminals:
52
+ self._default_terminal_id = await self.launch_terminal()
53
+ return self._default_terminal_id
54
+
55
+ async def get_last_command(self, terminal_id: str) -> str:
56
+ """Get the last command sent to a terminal."""
57
+ if terminal_id not in self.terminals:
58
+ raise KeyError(f"Terminal {terminal_id} not found")
59
+
60
+ terminal_info = self.terminals[terminal_id]
61
+ return terminal_info.get("last_command", "")
62
+
63
+ async def get_last_command_purpose(self, terminal_id: str) -> str:
64
+ """Get the purpose of the last command sent to a terminal."""
65
+ if terminal_id not in self.terminals:
66
+ raise KeyError(f"Terminal {terminal_id} not found")
67
+
68
+ terminal_info = self.terminals[terminal_id]
69
+ return terminal_info.get("last_command_purpose", "")
70
+
71
+ def _handle_cd_command(self, command: str, current_dir: str, terminal_info: Dict[str, Any]) -> None:
72
+ """
73
+ Check if command is a cd command and update terminal's working directory if so.
74
+
75
+ Args:
76
+ command: The command that was executed
77
+ current_dir: The current working directory
78
+ terminal_info: Terminal info dict to update
79
+ """
80
+ # Check if this is a cd command
81
+ # Match cd followed by path, stopping at ; or && or ||
82
+ cd_match = re.match(r"^\s*cd\s+([^;&|]+)", command.strip())
83
+ if not cd_match:
84
+ return
85
+
86
+ new_dir = cd_match.group(1).strip()
87
+
88
+ # Remove quotes if present
89
+ if (new_dir.startswith('"') and new_dir.endswith('"')) or (new_dir.startswith("'") and new_dir.endswith("'")):
90
+ new_dir = new_dir[1:-1]
91
+
92
+ # Handle relative and absolute paths
93
+ if new_dir.startswith("/"):
94
+ # Absolute path
95
+ new_working_dir = new_dir
96
+ elif new_dir == "..":
97
+ # Parent directory
98
+ new_working_dir = os.path.dirname(current_dir.rstrip("/"))
99
+ if not new_working_dir:
100
+ new_working_dir = "/"
101
+ elif new_dir == ".":
102
+ # Current directory (no change)
103
+ new_working_dir = current_dir
104
+ elif new_dir == "~":
105
+ # Home directory
106
+ new_working_dir = "/home/user"
107
+ else:
108
+ # Relative path
109
+ new_working_dir = os.path.join(current_dir, new_dir)
110
+
111
+ # Normalize the path (handle double slashes)
112
+ new_working_dir = os.path.normpath(new_working_dir)
113
+ # Ensure single leading slash for absolute paths
114
+ if new_working_dir.startswith("//"):
115
+ new_working_dir = new_working_dir[1:]
116
+
117
+ # Update the terminal's stored working directory
118
+ terminal_info["cwd"] = new_working_dir
119
+
120
+ async def _create_output_handler(self, terminal_id: str, output_type: str) -> Callable[[str], Awaitable[None]]:
121
+ """
122
+ Create an async output handler for streaming.
123
+
124
+ Args:
125
+ terminal_id: ID of the terminal
126
+ output_type: Type of output ('stdout' or 'stderr')
127
+
128
+ Returns:
129
+ Async callback function for handling output
130
+ """
131
+
132
+ async def handler(data: str) -> None:
133
+ # Store output
134
+ self.outputs[terminal_id].append(
135
+ {"type": output_type, "data": data, "timestamp": datetime.now(timezone.utc)}
136
+ )
137
+
138
+ # Broadcast output immediately for streaming
139
+ if self.connection_manager:
140
+ try:
141
+ terminal_output_event = AgentEvent(
142
+ event_type="terminal_output",
143
+ sender="agent",
144
+ content={
145
+ "output": data,
146
+ "terminal_id": terminal_id,
147
+ "thread_id": self.thread_id,
148
+ },
149
+ )
150
+ await self.connection_manager.broadcast_event(
151
+ terminal_output_event, self.workspace_id, self.thread_id
152
+ )
153
+ except Exception:
154
+ # Don't let broadcast errors affect command execution
155
+ pass
156
+
157
+ return handler
158
+
159
+ async def run_command(self, command: str, cwd: Optional[str] = None, timeout: Optional[int] = None) -> str:
160
+ """
161
+ Run a command directly (convenience method for utilities).
162
+
163
+ Args:
164
+ command: Command to execute
165
+ cwd: Optional working directory (defaults to /home/user/workspace)
166
+ timeout: Optional timeout in seconds (0 for no timeout, None for default 60s)
167
+
168
+ Returns:
169
+ Command output as string
170
+ """
171
+ working_dir = cwd if cwd is not None else "/home/user/workspace"
172
+
173
+ # Convert Path objects to strings for E2B compatibility
174
+ if hasattr(working_dir, "__fspath__"):
175
+ working_dir = str(working_dir)
176
+
177
+ # Ensure the working directory exists (E2B specific fix)
178
+ if working_dir != "/home/user":
179
+ try:
180
+ # Try to create the directory if it doesn't exist
181
+ await self.sandbox.commands.run(f"test -d {working_dir} || mkdir -p {working_dir}")
182
+ except Exception:
183
+ # If we can't create it, fall back to /home/user
184
+ working_dir = "/home/user"
185
+
186
+ try:
187
+ # Determine timeout settings
188
+ sandbox_timeout = timeout if timeout is not None else 60 # Default to 60s for backward compatibility
189
+
190
+ # For utility commands, we don't need streaming
191
+ if sandbox_timeout == 0:
192
+ # No timeout - let it run indefinitely
193
+ result = await self.sandbox.commands.run(command, cwd=working_dir, timeout=0)
194
+ else:
195
+ # Use timeout with buffer for asyncio
196
+ result = await asyncio.wait_for(
197
+ self.sandbox.commands.run(command, cwd=working_dir, timeout=sandbox_timeout),
198
+ timeout=sandbox_timeout + 5, # Give 5 seconds more than the sandbox timeout
199
+ )
200
+
201
+ # Return the combined output (stdout + stderr)
202
+ output = ""
203
+ if result.stdout:
204
+ output += result.stdout
205
+ if result.stderr:
206
+ if output:
207
+ output += "\n"
208
+ output += result.stderr
209
+
210
+ return output
211
+
212
+ except asyncio.TimeoutError:
213
+ return f"Command execution timed out after {sandbox_timeout + 5} seconds"
214
+ except Exception as e:
215
+ return f"Command failed: {str(e)}"
216
+
217
+ async def launch_terminal(self, terminal_id: Optional[str] = None, **terminal_kwargs) -> str:
218
+ """
219
+ Launch a new terminal session.
220
+
221
+ Args:
222
+ terminal_id: Optional ID for the terminal. If not provided, generates UUID.
223
+ **terminal_kwargs: Additional terminal options:
224
+ - cwd: Working directory (default: /home/user/workspace)
225
+ - env: Environment variables (default: {})
226
+
227
+ Returns:
228
+ Terminal ID
229
+ """
230
+ if terminal_id is None:
231
+ terminal_id = str(uuid.uuid4())
232
+
233
+ # Extract terminal options
234
+ cwd = terminal_kwargs.get("cwd", "/home/user/workspace")
235
+ env = terminal_kwargs.get("env", {})
236
+
237
+ # Convert Path objects to strings for E2B compatibility
238
+ if hasattr(cwd, "__fspath__"): # Check if it's a Path-like object
239
+ cwd = str(cwd)
240
+
241
+ # Ensure the directory exists (try to create if it doesn't)
242
+ try:
243
+ # Check if directory exists
244
+ await self.sandbox.commands.run(f"test -d {cwd}")
245
+ except Exception:
246
+ # Directory doesn't exist, try to create it
247
+ try:
248
+ await self.sandbox.commands.run(f"mkdir -p {cwd}")
249
+ # Directory now exists (either already existed or was created)
250
+ except Exception as e:
251
+ # If we can't create the directory, fall back to /home/user
252
+ print(f"Warning: Could not ensure directory {cwd} exists: {e}")
253
+ cwd = "/home/user"
254
+
255
+ self.terminals[terminal_id] = {
256
+ "created_at": datetime.now(timezone.utc),
257
+ "cwd": cwd,
258
+ "env": env,
259
+ "process": None,
260
+ "last_command": "",
261
+ "last_command_purpose": "",
262
+ "active_commands": {}, # Track commands for this terminal
263
+ }
264
+ self.outputs[terminal_id] = []
265
+
266
+ return terminal_id
267
+
268
+ async def send_command(
269
+ self, terminal_id: str, command: str, purpose: Optional[str] = None, timeout: Optional[int] = None
270
+ ) -> bool:
271
+ """
272
+ Send a command to a terminal.
273
+
274
+ Args:
275
+ terminal_id: ID of the terminal
276
+ command: Command to execute
277
+ purpose: Optional description of command purpose
278
+ timeout: Optional timeout in seconds (0 or None for no timeout)
279
+
280
+ Returns:
281
+ True if command was sent successfully
282
+
283
+ Raises:
284
+ KeyError: If terminal doesn't exist
285
+ """
286
+ if terminal_id not in self.terminals:
287
+ raise KeyError(f"Terminal {terminal_id} not found")
288
+
289
+ terminal_info = self.terminals[terminal_id]
290
+
291
+ # Update last command info
292
+ terminal_info["last_command"] = command.rstrip("\n") # Strip trailing newline for consistency
293
+ terminal_info["last_command_purpose"] = purpose or ""
294
+
295
+ # Use terminal's working directory
296
+ working_dir = terminal_info["cwd"]
297
+
298
+ # Convert Path objects to strings for E2B compatibility
299
+ if hasattr(working_dir, "__fspath__"): # Check if it's a Path-like object
300
+ working_dir = str(working_dir)
301
+
302
+ # Store command in output
303
+ self.outputs[terminal_id].append(
304
+ {"type": "command", "data": command, "timestamp": datetime.now(timezone.utc), "purpose": purpose}
305
+ )
306
+
307
+ # Broadcast command if connection manager available
308
+ if self.connection_manager:
309
+ try:
310
+ await self._broadcast_output(terminal_id, f"$ {command}\n")
311
+ except Exception:
312
+ pass # Don't fail if broadcast fails
313
+
314
+ # Create streaming output handlers
315
+ stdout_handler = await self._create_output_handler(terminal_id, "stdout")
316
+ stderr_handler = await self._create_output_handler(terminal_id, "stderr")
317
+
318
+ # Execute command in sandbox with streaming
319
+ try:
320
+ # Determine timeout settings
321
+ sandbox_timeout = timeout if timeout is not None else 0 # Default to no timeout
322
+
323
+ # If no timeout requested (0), don't use asyncio.wait_for
324
+ if sandbox_timeout == 0:
325
+ result = await self.sandbox.commands.run(
326
+ command,
327
+ cwd=working_dir,
328
+ on_stdout=stdout_handler,
329
+ on_stderr=stderr_handler,
330
+ timeout=0, # No timeout for sandbox
331
+ )
332
+ else:
333
+ # Use timeout with buffer for asyncio
334
+ result = await asyncio.wait_for(
335
+ self.sandbox.commands.run(
336
+ command,
337
+ cwd=working_dir,
338
+ on_stdout=stdout_handler,
339
+ on_stderr=stderr_handler,
340
+ timeout=sandbox_timeout,
341
+ ),
342
+ timeout=sandbox_timeout + 5, # Give 5 seconds more than the sandbox timeout
343
+ )
344
+
345
+ # Store exit code
346
+ self.outputs[terminal_id].append(
347
+ {
348
+ "type": "exit",
349
+ "data": f"Process exited with code {result.exit_code}",
350
+ "exit_code": result.exit_code,
351
+ "timestamp": datetime.now(timezone.utc),
352
+ }
353
+ )
354
+
355
+ # Broadcast exit status
356
+ if self.connection_manager:
357
+ await self._broadcast_output(terminal_id, f"Process exited with code {result.exit_code}\n")
358
+
359
+ # If it was a successful cd command, update the terminal's working directory
360
+ if result.exit_code == 0:
361
+ self._handle_cd_command(command, working_dir, terminal_info)
362
+
363
+ return result.exit_code == 0 # Return True only if command succeeded
364
+
365
+ except asyncio.TimeoutError:
366
+ # Handle timeout specifically
367
+ error_msg = f"Command execution timed out after {sandbox_timeout + 5} seconds"
368
+ self.outputs[terminal_id].append(
369
+ {"type": "stderr", "data": error_msg, "timestamp": datetime.now(timezone.utc)}
370
+ )
371
+
372
+ # Broadcast error
373
+ if self.connection_manager:
374
+ await self._broadcast_output(terminal_id, error_msg)
375
+
376
+ self.outputs[terminal_id].append(
377
+ {
378
+ "type": "exit",
379
+ "data": "Process exited with code 1",
380
+ "exit_code": 1,
381
+ "timestamp": datetime.now(timezone.utc),
382
+ }
383
+ )
384
+
385
+ # Broadcast exit status
386
+ if self.connection_manager:
387
+ await self._broadcast_output(terminal_id, "Process exited with code 1\n")
388
+
389
+ return False # Command failed due to timeout
390
+
391
+ except Exception as e:
392
+ # Store error
393
+ error_msg = f"Command failed: {str(e)}"
394
+ self.outputs[terminal_id].append(
395
+ {"type": "stderr", "data": error_msg, "timestamp": datetime.now(timezone.utc)}
396
+ )
397
+
398
+ # Broadcast error
399
+ if self.connection_manager:
400
+ await self._broadcast_output(terminal_id, error_msg)
401
+
402
+ self.outputs[terminal_id].append(
403
+ {
404
+ "type": "exit",
405
+ "data": "Process exited with code 1",
406
+ "exit_code": 1,
407
+ "timestamp": datetime.now(timezone.utc),
408
+ }
409
+ )
410
+
411
+ # Broadcast exit status
412
+ if self.connection_manager:
413
+ await self._broadcast_output(terminal_id, "Process exited with code 1\n")
414
+
415
+ return False # Command failed
416
+
417
+ async def send_input(
418
+ self, terminal_id: str, text: str, submit: bool = True, command_id: Optional[str] = None
419
+ ) -> bool:
420
+ """
421
+ Send input to an active tracked command in the sandbox.
422
+ """
423
+ if terminal_id not in self.terminals:
424
+ raise KeyError(f"Terminal {terminal_id} not found")
425
+
426
+ terminal_info = self.terminals[terminal_id]
427
+ active_commands = terminal_info["active_commands"]
428
+
429
+ if command_id is None:
430
+ if not active_commands:
431
+ raise ValueError(f"No active command is running in terminal {terminal_id}")
432
+ if len(active_commands) > 1:
433
+ raise ValueError(f"Multiple active commands are running in terminal {terminal_id}; provide command_id")
434
+ command_id = next(iter(active_commands))
435
+
436
+ command_info = self.command_history.get(command_id)
437
+ if not command_info or command_info.get("terminal_id") != terminal_id:
438
+ raise ValueError(f"Command ID {command_id} not found in terminal {terminal_id}")
439
+ if command_info.get("status") != "running":
440
+ raise ValueError(f"Command {command_id} is not running in terminal {terminal_id}")
441
+
442
+ pid = command_info.get("pid")
443
+ if pid is None:
444
+ raise ValueError(f"Command {command_id} is not ready for input yet")
445
+
446
+ payload = text
447
+ if submit and not payload.endswith("\n"):
448
+ payload += "\n"
449
+
450
+ try:
451
+ await self.sandbox.commands.send_stdin(pid, payload)
452
+ return True
453
+ except AttributeError as exc:
454
+ raise ValueError("Sandbox command stdin is not supported by this E2B SDK version") from exc
455
+
456
+ async def _broadcast_output(self, terminal_id: str, output: str):
457
+ """Broadcast terminal output to connected clients."""
458
+ if not self.connection_manager:
459
+ return
460
+
461
+ try:
462
+ terminal_output_event = AgentEvent(
463
+ event_type="terminal_output",
464
+ sender="agent",
465
+ content={
466
+ "output": output,
467
+ "terminal_id": terminal_id,
468
+ "thread_id": self.thread_id,
469
+ },
470
+ )
471
+ await self.connection_manager.broadcast_event(terminal_output_event, self.workspace_id, self.thread_id)
472
+ except Exception:
473
+ # Don't let broadcast errors affect command execution
474
+ pass
475
+
476
+ async def send_command_tracked(
477
+ self, terminal_id: str, command: str, purpose: Optional[str] = None, timeout: Optional[int] = None
478
+ ) -> Optional[str]:
479
+ """
480
+ Send a command and return a command ID for tracking.
481
+
482
+ Args:
483
+ terminal_id: ID of the terminal to send command to
484
+ command: The command to execute
485
+ purpose: Optional description of the command's purpose
486
+ timeout: Optional timeout in seconds (0 or None for no timeout)
487
+
488
+ Returns:
489
+ Command ID for tracking, or None if command couldn't be sent
490
+
491
+ Raises:
492
+ KeyError: If terminal doesn't exist
493
+ """
494
+ if terminal_id not in self.terminals:
495
+ raise KeyError(f"Terminal {terminal_id} not found")
496
+
497
+ # Generate command ID
498
+ self.command_counter += 1
499
+ command_id = f"{terminal_id}_{self.command_counter}"
500
+
501
+ # Record command in history
502
+ start_time = datetime.now(timezone.utc)
503
+ self.command_history[command_id] = {
504
+ "command": command.strip(),
505
+ "purpose": purpose,
506
+ "terminal_id": terminal_id,
507
+ "start_time": start_time,
508
+ "status": "running",
509
+ "return_code": None,
510
+ "pid": None,
511
+ "handle": None,
512
+ }
513
+
514
+ # Also track in terminal's active commands
515
+ self.terminals[terminal_id]["active_commands"][command_id] = self.command_history[command_id]
516
+
517
+ # Get terminal info
518
+ terminal_info = self.terminals[terminal_id]
519
+ working_dir = terminal_info["cwd"]
520
+
521
+ # Store command in output
522
+ self.outputs[terminal_id].append(
523
+ {"type": "command", "data": command, "timestamp": datetime.now(timezone.utc), "purpose": purpose}
524
+ )
525
+
526
+ # Broadcast command
527
+ if self.connection_manager:
528
+ await self._broadcast_output(terminal_id, f"$ {command}\n")
529
+
530
+ # Start command execution asynchronously without waiting
531
+ asyncio.create_task(self._execute_command_async(command_id, terminal_id, command, working_dir, timeout))
532
+
533
+ return command_id
534
+
535
+ async def _execute_command_async(
536
+ self, command_id: str, terminal_id: str, command: str, working_dir: str, timeout: Optional[int] = None
537
+ ):
538
+ """Execute a command asynchronously and track its status."""
539
+ try:
540
+ # Convert Path objects to strings for E2B compatibility
541
+ if hasattr(working_dir, "__fspath__"): # Check if it's a Path-like object
542
+ working_dir = str(working_dir)
543
+
544
+ # Create streaming output handlers
545
+ stdout_handler = await self._create_output_handler(terminal_id, "stdout")
546
+ stderr_handler = await self._create_output_handler(terminal_id, "stderr")
547
+
548
+ # Determine timeout settings
549
+ sandbox_timeout = timeout if timeout is not None else 0 # Default to no timeout
550
+
551
+ # Execute with streaming and keep stdin open for interactive prompts.
552
+ try:
553
+ if sandbox_timeout == 0:
554
+ handle = await self.sandbox.commands.run(
555
+ command,
556
+ background=True,
557
+ cwd=working_dir,
558
+ on_stdout=stdout_handler,
559
+ on_stderr=stderr_handler,
560
+ stdin=True,
561
+ timeout=0,
562
+ )
563
+ self.command_history[command_id]["pid"] = handle.pid
564
+ self.command_history[command_id]["handle"] = handle
565
+ result = await handle.wait()
566
+ else:
567
+ handle = await self.sandbox.commands.run(
568
+ command,
569
+ background=True,
570
+ cwd=working_dir,
571
+ on_stdout=stdout_handler,
572
+ on_stderr=stderr_handler,
573
+ stdin=True,
574
+ timeout=sandbox_timeout,
575
+ )
576
+ self.command_history[command_id]["pid"] = handle.pid
577
+ self.command_history[command_id]["handle"] = handle
578
+ result = await asyncio.wait_for(
579
+ handle.wait(),
580
+ timeout=sandbox_timeout + 5, # Give 5 seconds more than the sandbox timeout
581
+ )
582
+ except asyncio.TimeoutError:
583
+ # If the sandbox itself times out or hangs
584
+ raise Exception(f"Command execution timed out after {sandbox_timeout + 5} seconds")
585
+
586
+ # Command completed
587
+ self.command_history[command_id]["status"] = "completed"
588
+ self.command_history[command_id]["return_code"] = result.exit_code
589
+ self.command_history[command_id]["end_time"] = datetime.now(timezone.utc)
590
+
591
+ # Store exit code
592
+ self.outputs[terminal_id].append(
593
+ {
594
+ "type": "exit",
595
+ "data": f"Process exited with code {result.exit_code}",
596
+ "exit_code": result.exit_code,
597
+ "timestamp": datetime.now(timezone.utc),
598
+ }
599
+ )
600
+
601
+ # Broadcast exit status
602
+ if self.connection_manager:
603
+ await self._broadcast_output(terminal_id, f"Process exited with code {result.exit_code}\n")
604
+
605
+ # If it was a successful cd command, update the terminal's working directory
606
+ if result.exit_code == 0 and terminal_id in self.terminals:
607
+ terminal_info = self.terminals[terminal_id]
608
+ self._handle_cd_command(command, working_dir, terminal_info)
609
+
610
+ # Remove from active commands
611
+ if terminal_id in self.terminals:
612
+ self.terminals[terminal_id]["active_commands"].pop(command_id, None)
613
+
614
+ except Exception as e:
615
+ # Command failed
616
+ self.command_history[command_id]["status"] = "failed"
617
+ self.command_history[command_id]["return_code"] = 1
618
+ self.command_history[command_id]["end_time"] = datetime.now(timezone.utc)
619
+
620
+ # Store error
621
+ error_msg = f"Command failed: {str(e)}"
622
+ self.outputs[terminal_id].append(
623
+ {"type": "stderr", "data": error_msg, "timestamp": datetime.now(timezone.utc)}
624
+ )
625
+
626
+ # Broadcast error
627
+ if self.connection_manager:
628
+ await self._broadcast_output(terminal_id, error_msg)
629
+
630
+ # Store exit info
631
+ self.outputs[terminal_id].append(
632
+ {
633
+ "type": "exit",
634
+ "data": "Process exited with code 1",
635
+ "exit_code": 1,
636
+ "timestamp": datetime.now(timezone.utc),
637
+ }
638
+ )
639
+
640
+ # Broadcast exit status
641
+ if self.connection_manager:
642
+ await self._broadcast_output(terminal_id, "Process exited with code 1\n")
643
+
644
+ # Remove from active commands
645
+ if terminal_id in self.terminals:
646
+ self.terminals[terminal_id]["active_commands"].pop(command_id, None)
647
+
648
+ def read_output(self, terminal_id: str, num_chars: int = 1024, offset: int = 0) -> str:
649
+ """
650
+ Read characters from a terminal's output buffer.
651
+
652
+ Args:
653
+ terminal_id: ID of the terminal to read output from
654
+ num_chars: Number of characters to read (default: 1024).
655
+ offset: Number of characters from the end to start reading from (default: 0).
656
+
657
+ Returns:
658
+ The requested characters from the terminal's output buffer.
659
+
660
+ Raises:
661
+ KeyError: If terminal doesn't exist
662
+ """
663
+ if terminal_id not in self.terminals:
664
+ raise KeyError(f"Terminal {terminal_id} not found")
665
+
666
+ # Reconstruct full output from stored outputs
667
+ full_output = ""
668
+ for output in self.outputs[terminal_id]:
669
+ if output["type"] == "command":
670
+ full_output += f"$ {output['data']}\n"
671
+ elif output["type"] in ["stdout", "stderr"]:
672
+ full_output += output["data"]
673
+ if not output["data"].endswith("\n"):
674
+ full_output += "\n"
675
+ elif output["type"] == "exit":
676
+ full_output += f"{output['data']}\n"
677
+
678
+ # Apply offset and num_chars logic similar to LocalTerminalManager
679
+ total_chars = len(full_output)
680
+
681
+ if total_chars == 0:
682
+ return ""
683
+
684
+ if offset == 0:
685
+ # Default behavior: read last num_chars characters
686
+ if total_chars <= num_chars:
687
+ return full_output
688
+ else:
689
+ return full_output[-num_chars:]
690
+ else:
691
+ # With offset: read num_chars characters starting from (end - offset - num_chars)
692
+ start_pos = max(0, total_chars - offset - num_chars)
693
+ end_pos = max(0, total_chars - offset)
694
+
695
+ if start_pos >= end_pos:
696
+ return ""
697
+
698
+ return full_output[start_pos:end_pos]
699
+
700
+ def get_command_status(self, terminal_id: str, command_id: str) -> dict:
701
+ """
702
+ Get the status of a specific command.
703
+
704
+ Args:
705
+ terminal_id: ID of the terminal
706
+ command_id: ID of the command to check
707
+
708
+ Returns:
709
+ Dictionary containing command status information
710
+
711
+ Raises:
712
+ KeyError: If terminal doesn't exist
713
+ """
714
+ if terminal_id not in self.terminals:
715
+ raise KeyError(f"Terminal {terminal_id} not found")
716
+
717
+ if command_id not in self.command_history:
718
+ return {"status": "not_found"}
719
+
720
+ command_info = self.command_history[command_id]
721
+
722
+ # Calculate duration
723
+ if "end_time" in command_info:
724
+ duration = (command_info["end_time"] - command_info["start_time"]).total_seconds()
725
+ else:
726
+ duration = (datetime.now(timezone.utc) - command_info["start_time"]).total_seconds()
727
+
728
+ return {
729
+ "status": command_info["status"],
730
+ "command": command_info["command"],
731
+ "purpose": command_info.get("purpose"),
732
+ "duration": duration,
733
+ "return_code": command_info.get("return_code"),
734
+ "child_pids": [], # No child process tracking in sandbox
735
+ }
736
+
737
+ async def get_terminal_status(self, terminal_id: str) -> dict:
738
+ """
739
+ Get comprehensive status of a terminal including active commands.
740
+
741
+ Args:
742
+ terminal_id: ID of the terminal to check
743
+
744
+ Returns:
745
+ Dictionary containing terminal status and active commands
746
+
747
+ Raises:
748
+ KeyError: If terminal doesn't exist
749
+ """
750
+ if terminal_id not in self.terminals:
751
+ raise KeyError(f"Terminal {terminal_id} not found")
752
+
753
+ terminal_info = self.terminals[terminal_id]
754
+
755
+ # Get active commands for this terminal
756
+ active_commands = {}
757
+ for cmd_id, cmd_info in terminal_info["active_commands"].items():
758
+ active_commands[cmd_id] = self.get_command_status(terminal_id, cmd_id)
759
+
760
+ return {
761
+ "running": True, # Sandbox terminals are always "running"
762
+ "ready_for_commands": len(active_commands) == 0,
763
+ "active_commands": active_commands,
764
+ "last_command": terminal_info.get("last_command", ""),
765
+ }
766
+
767
+ async def cleanup_all(self):
768
+ """Clean up all terminals - useful for interrupt handling"""
769
+ print(f"Cleaning up {len(self.terminals)} terminal(s)")
770
+
771
+ terminal_ids = list(self.terminals.keys())
772
+ for terminal_id in terminal_ids:
773
+ try:
774
+ await self.close_terminal(terminal_id)
775
+ print(f"Closed terminal {terminal_id}")
776
+ except Exception as e:
777
+ print(f"Error closing terminal {terminal_id}: {e}")
778
+ self.terminals.clear()
779
+ self.outputs.clear()
780
+ self.command_history.clear()
781
+
782
+ def _handle_output(self, terminal_id: str, data: str, stream: str):
783
+ """Handle output from process."""
784
+ self.outputs[terminal_id].append({"type": stream, "data": data, "timestamp": datetime.now(timezone.utc)})
785
+
786
+ async def get_output(self, terminal_id: str, **kwargs) -> str:
787
+ """
788
+ Get output from a terminal.
789
+
790
+ Args:
791
+ terminal_id: ID of the terminal
792
+ **kwargs: Optional filters (last_n_lines, since_timestamp, etc.)
793
+
794
+ Returns:
795
+ Terminal output as string
796
+
797
+ Raises:
798
+ KeyError: If terminal doesn't exist
799
+ """
800
+ if terminal_id not in self.terminals:
801
+ raise KeyError(f"Terminal {terminal_id} not found")
802
+
803
+ outputs = self.outputs[terminal_id]
804
+
805
+ # Apply filters if provided
806
+ last_n_lines = kwargs.get("last_n_lines")
807
+ since_timestamp = kwargs.get("since_timestamp")
808
+
809
+ filtered_outputs = outputs
810
+
811
+ if since_timestamp:
812
+ filtered_outputs = [o for o in filtered_outputs if o["timestamp"] > since_timestamp]
813
+
814
+ # Convert to string
815
+ lines = []
816
+ for output in filtered_outputs:
817
+ if output["type"] in ["stdout", "stderr"]:
818
+ lines.append(output["data"])
819
+ elif output["type"] == "command":
820
+ lines.append(f"$ {output['data']}")
821
+ elif output["type"] == "exit":
822
+ lines.append(output["data"])
823
+
824
+ if last_n_lines and len(lines) > last_n_lines:
825
+ lines = lines[-last_n_lines:]
826
+
827
+ return "\n".join(lines)
828
+
829
+ async def close_terminal(self, terminal_id: str) -> None:
830
+ """
831
+ Close a terminal.
832
+
833
+ Args:
834
+ terminal_id: ID of the terminal to close
835
+
836
+ Raises:
837
+ KeyError: If terminal doesn't exist
838
+ """
839
+ if terminal_id not in self.terminals:
840
+ raise KeyError(f"Terminal {terminal_id} not found")
841
+
842
+ # Clean up
843
+ del self.terminals[terminal_id]
844
+ del self.outputs[terminal_id]
845
+
846
+ async def close_all(self) -> None:
847
+ """Close all terminals."""
848
+ terminal_ids = list(self.terminals.keys())
849
+ for terminal_id in terminal_ids:
850
+ await self.close_terminal(terminal_id)
851
+
852
+ async def list_terminals(self) -> Dict[str, Any]:
853
+ """
854
+ Get information about all terminals.
855
+
856
+ Returns:
857
+ Dictionary mapping terminal IDs to terminal info
858
+ """
859
+ result = {}
860
+ for terminal_id, info in self.terminals.items():
861
+ result[terminal_id] = {
862
+ "created_at": info["created_at"].isoformat(),
863
+ "cwd": info["cwd"],
864
+ "has_running_process": info.get("process") is not None,
865
+ "running": True, # Match LocalTerminalManager format
866
+ "last_command": info.get("last_command", ""),
867
+ }
868
+ return result