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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""FileSystem implementation for sandbox environments."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import base64
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Iterator, List, Optional, Union, BinaryIO
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from ..services.file_system import FileSystem
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SandboxFileSystem(FileSystem):
|
|
14
|
+
"""FileSystem implementation that operates within a sandbox."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, sandbox: Any, root_path: str = "/home/user/workspace"):
|
|
17
|
+
"""
|
|
18
|
+
Initialize sandbox filesystem.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
sandbox: The sandbox instance (e.g., E2B Sandbox)
|
|
22
|
+
root_path: Root path within the sandbox
|
|
23
|
+
"""
|
|
24
|
+
self.sandbox = sandbox
|
|
25
|
+
self.root_path = root_path
|
|
26
|
+
|
|
27
|
+
def _resolve_path(self, path: str) -> str:
|
|
28
|
+
"""Resolve path relative to root."""
|
|
29
|
+
if os.path.isabs(path):
|
|
30
|
+
return path
|
|
31
|
+
if path == ".":
|
|
32
|
+
return self.root_path
|
|
33
|
+
return os.path.join(self.root_path, path)
|
|
34
|
+
|
|
35
|
+
# Synchronous methods using E2B v1.5.0 API
|
|
36
|
+
def open(self, path: str, mode: str = "r", encoding: Optional[str] = None) -> Any:
|
|
37
|
+
"""Open is not directly supported in sandbox - use read/write methods instead."""
|
|
38
|
+
raise NotImplementedError("Direct file handles not supported in sandbox. Use read_text/write_text instead.")
|
|
39
|
+
|
|
40
|
+
def read_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
41
|
+
"""Read text from file."""
|
|
42
|
+
full_path = self._resolve_path(path)
|
|
43
|
+
try:
|
|
44
|
+
content = self.sandbox.files.read(full_path)
|
|
45
|
+
if isinstance(content, bytes):
|
|
46
|
+
return content.decode(encoding)
|
|
47
|
+
return content
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise FileNotFoundError(f"Could not read file {path}: {e}")
|
|
50
|
+
|
|
51
|
+
def read_bytes(self, path: str) -> bytes:
|
|
52
|
+
"""Read bytes from file."""
|
|
53
|
+
full_path = self._resolve_path(path)
|
|
54
|
+
try:
|
|
55
|
+
# For binary data, use base64 encoding via shell commands
|
|
56
|
+
# to avoid E2B files API text encoding issues
|
|
57
|
+
result = self.sandbox.commands.run(f"base64 {full_path}")
|
|
58
|
+
|
|
59
|
+
if result.exit_code != 0:
|
|
60
|
+
raise FileNotFoundError(f"Could not read file {path}")
|
|
61
|
+
|
|
62
|
+
# Decode the base64 content
|
|
63
|
+
return base64.b64decode(result.stdout.strip())
|
|
64
|
+
|
|
65
|
+
except Exception as e:
|
|
66
|
+
raise FileNotFoundError(f"Could not read file {path}: {e}")
|
|
67
|
+
|
|
68
|
+
def write_text(self, path: str, content: str, encoding: str = "utf-8") -> None:
|
|
69
|
+
"""Write text to file."""
|
|
70
|
+
full_path = self._resolve_path(path)
|
|
71
|
+
try:
|
|
72
|
+
# Ensure parent directory exists
|
|
73
|
+
parent_dir = os.path.dirname(full_path)
|
|
74
|
+
if parent_dir != self.root_path:
|
|
75
|
+
self.sandbox.commands.run(f"mkdir -p {parent_dir}")
|
|
76
|
+
|
|
77
|
+
self.sandbox.files.write(full_path, content)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise OSError(f"Could not write file {path}: {e}")
|
|
80
|
+
|
|
81
|
+
def write_bytes(self, path: str, content: bytes) -> None:
|
|
82
|
+
"""Write bytes to file."""
|
|
83
|
+
full_path = self._resolve_path(path)
|
|
84
|
+
try:
|
|
85
|
+
# Ensure parent directory exists
|
|
86
|
+
parent_dir = os.path.dirname(full_path)
|
|
87
|
+
if parent_dir != self.root_path:
|
|
88
|
+
self.sandbox.commands.run(f"mkdir -p {parent_dir}")
|
|
89
|
+
|
|
90
|
+
# For binary data, use base64 encoding and write via shell commands
|
|
91
|
+
# to avoid E2B files API text encoding issues
|
|
92
|
+
encoded_content = base64.b64encode(content).decode("ascii")
|
|
93
|
+
|
|
94
|
+
# Write the base64 encoded content and decode it
|
|
95
|
+
result = self.sandbox.commands.run(f"echo '{encoded_content}' | base64 -d > {full_path}")
|
|
96
|
+
|
|
97
|
+
if result.exit_code != 0:
|
|
98
|
+
raise OSError(f"Failed to write binary file {path}: {result.stderr}")
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise OSError(f"Could not write file {path}: {e}")
|
|
102
|
+
|
|
103
|
+
def exists(self, path: str) -> bool:
|
|
104
|
+
"""Check if path exists."""
|
|
105
|
+
full_path = self._resolve_path(path)
|
|
106
|
+
try:
|
|
107
|
+
result = self.sandbox.commands.run(f"test -e {full_path}")
|
|
108
|
+
return result.exit_code == 0
|
|
109
|
+
except:
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def is_file(self, path: str) -> bool:
|
|
113
|
+
"""Check if path is a file."""
|
|
114
|
+
full_path = self._resolve_path(path)
|
|
115
|
+
try:
|
|
116
|
+
result = self.sandbox.commands.run(f"test -f {full_path}")
|
|
117
|
+
return result.exit_code == 0
|
|
118
|
+
except:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
def is_dir(self, path: str) -> bool:
|
|
122
|
+
"""Check if path is a directory."""
|
|
123
|
+
full_path = self._resolve_path(path)
|
|
124
|
+
try:
|
|
125
|
+
result = self.sandbox.commands.run(f"test -d {full_path}")
|
|
126
|
+
return result.exit_code == 0
|
|
127
|
+
except:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def stat(self, path: str) -> Dict[str, Any]:
|
|
131
|
+
"""Get file statistics."""
|
|
132
|
+
full_path = self._resolve_path(path)
|
|
133
|
+
try:
|
|
134
|
+
# Use stat command to get file info
|
|
135
|
+
result = self.sandbox.commands.run(f"stat -c '%s %Y %Z' {full_path}")
|
|
136
|
+
if result.exit_code != 0:
|
|
137
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
138
|
+
|
|
139
|
+
size, mtime, ctime = result.stdout.strip().split()
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"size": int(size),
|
|
143
|
+
"modified_time": int(mtime),
|
|
144
|
+
"created_time": int(ctime),
|
|
145
|
+
"is_file": self.is_file(path),
|
|
146
|
+
"is_directory": self.is_dir(path),
|
|
147
|
+
}
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise OSError(f"Could not stat {path}: {e}")
|
|
150
|
+
|
|
151
|
+
def mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> None:
|
|
152
|
+
"""Create directory."""
|
|
153
|
+
full_path = self._resolve_path(path)
|
|
154
|
+
|
|
155
|
+
# Check if directory already exists
|
|
156
|
+
if self.exists(path):
|
|
157
|
+
if not exist_ok:
|
|
158
|
+
raise FileExistsError(f"Directory already exists: {path}")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
if parents:
|
|
163
|
+
result = self.sandbox.commands.run(f"mkdir -p {full_path}")
|
|
164
|
+
else:
|
|
165
|
+
result = self.sandbox.commands.run(f"mkdir {full_path}")
|
|
166
|
+
|
|
167
|
+
if result.exit_code != 0:
|
|
168
|
+
raise OSError(f"Failed to create directory {path}: {result.stderr}")
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise OSError(f"Could not create directory {path}: {e}")
|
|
171
|
+
|
|
172
|
+
def remove(self, path: str, missing_ok: bool = False) -> None:
|
|
173
|
+
"""Remove file."""
|
|
174
|
+
full_path = self._resolve_path(path)
|
|
175
|
+
|
|
176
|
+
if not self.exists(path):
|
|
177
|
+
if not missing_ok:
|
|
178
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
result = self.sandbox.commands.run(f"rm -f {full_path}")
|
|
183
|
+
if result.exit_code != 0:
|
|
184
|
+
raise OSError(f"Failed to remove file {path}: {result.stderr}")
|
|
185
|
+
except Exception as e:
|
|
186
|
+
raise OSError(f"Could not remove file {path}: {e}")
|
|
187
|
+
|
|
188
|
+
def rmdir(self, path: str) -> None:
|
|
189
|
+
"""Remove empty directory."""
|
|
190
|
+
full_path = self._resolve_path(path)
|
|
191
|
+
try:
|
|
192
|
+
result = self.sandbox.commands.run(f"rmdir {full_path}")
|
|
193
|
+
if result.exit_code != 0:
|
|
194
|
+
raise OSError(f"Failed to remove directory {path}: {result.stderr}")
|
|
195
|
+
except Exception as e:
|
|
196
|
+
raise OSError(f"Could not remove directory {path}: {e}")
|
|
197
|
+
|
|
198
|
+
def rmtree(self, path: str) -> None:
|
|
199
|
+
"""Remove directory tree."""
|
|
200
|
+
full_path = self._resolve_path(path)
|
|
201
|
+
try:
|
|
202
|
+
result = self.sandbox.commands.run(f"rm -rf {full_path}")
|
|
203
|
+
if result.exit_code != 0:
|
|
204
|
+
raise OSError(f"Failed to remove directory tree {path}: {result.stderr}")
|
|
205
|
+
except Exception as e:
|
|
206
|
+
raise OSError(f"Could not remove directory tree {path}: {e}")
|
|
207
|
+
|
|
208
|
+
def listdir(self, path: str) -> List[str]:
|
|
209
|
+
"""List directory contents."""
|
|
210
|
+
full_path = self._resolve_path(path)
|
|
211
|
+
|
|
212
|
+
if not self.is_dir(path):
|
|
213
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
result = self.sandbox.commands.run(f"ls -1 {full_path}")
|
|
217
|
+
if result.exit_code != 0:
|
|
218
|
+
raise FileNotFoundError(f"Directory not found: {path}")
|
|
219
|
+
|
|
220
|
+
if not result.stdout.strip():
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
return [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
|
224
|
+
except Exception as e:
|
|
225
|
+
raise OSError(f"Could not list directory {path}: {e}")
|
|
226
|
+
|
|
227
|
+
def iterdir(self, path: str) -> Iterator[str]:
|
|
228
|
+
"""Iterate directory contents."""
|
|
229
|
+
return iter(self.listdir(path))
|
|
230
|
+
|
|
231
|
+
def glob(self, pattern: str) -> List[str]:
|
|
232
|
+
"""Find paths matching pattern."""
|
|
233
|
+
try:
|
|
234
|
+
# Handle different types of glob patterns
|
|
235
|
+
if pattern.startswith("**/"):
|
|
236
|
+
# Recursive pattern like **/*.py or **/*
|
|
237
|
+
remaining_pattern = pattern[3:] # Remove '**/'
|
|
238
|
+
|
|
239
|
+
if remaining_pattern == "*":
|
|
240
|
+
# Pattern is **/* - find all files and directories recursively
|
|
241
|
+
result = self.sandbox.commands.run(
|
|
242
|
+
f"cd {self.root_path} && find . -mindepth 1 2>/dev/null | sed 's|^./||' | sort"
|
|
243
|
+
)
|
|
244
|
+
else:
|
|
245
|
+
# Pattern like **/*.py - find files matching pattern recursively
|
|
246
|
+
if "*" in remaining_pattern or "?" in remaining_pattern:
|
|
247
|
+
# Use find with -name for pattern matching
|
|
248
|
+
result = self.sandbox.commands.run(
|
|
249
|
+
f"cd {self.root_path} && find . -name '{remaining_pattern}' 2>/dev/null | sed 's|^./||' | sort"
|
|
250
|
+
)
|
|
251
|
+
else:
|
|
252
|
+
# Exact filename search recursively
|
|
253
|
+
result = self.sandbox.commands.run(
|
|
254
|
+
f"cd {self.root_path} && find . -name '{remaining_pattern}' 2>/dev/null | sed 's|^./||' | sort"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
elif "*" in pattern or "?" in pattern:
|
|
258
|
+
# Simple glob pattern like *.py or test*.txt
|
|
259
|
+
if "/" in pattern:
|
|
260
|
+
# Pattern has directory component like subdir/*.txt
|
|
261
|
+
parent_dir = os.path.dirname(pattern)
|
|
262
|
+
filename_pattern = os.path.basename(pattern)
|
|
263
|
+
result = self.sandbox.commands.run(
|
|
264
|
+
f"cd {self.root_path} && find {parent_dir} -maxdepth 1 -name '{filename_pattern}' 2>/dev/null | sort"
|
|
265
|
+
)
|
|
266
|
+
else:
|
|
267
|
+
# Simple pattern like *.py in current directory
|
|
268
|
+
result = self.sandbox.commands.run(
|
|
269
|
+
f"cd {self.root_path} && find . -maxdepth 1 -name '{pattern}' 2>/dev/null | sed 's|^./||' | sort"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
# No wildcards - check if exact path exists
|
|
274
|
+
if self.exists(pattern):
|
|
275
|
+
return [pattern]
|
|
276
|
+
else:
|
|
277
|
+
return []
|
|
278
|
+
|
|
279
|
+
# Process the result
|
|
280
|
+
if result.exit_code == 0 and result.stdout.strip():
|
|
281
|
+
paths = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
|
282
|
+
# Filter out empty strings and current directory
|
|
283
|
+
return [path for path in paths if path and path != "."]
|
|
284
|
+
else:
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
# If any error occurs, return empty list to match expected behavior
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
def is_binary_file(self, path: str) -> bool:
|
|
292
|
+
"""Check if file is binary."""
|
|
293
|
+
full_path = self._resolve_path(path)
|
|
294
|
+
try:
|
|
295
|
+
# Use file command to detect binary
|
|
296
|
+
result = self.sandbox.commands.run(f"file -b --mime {full_path}")
|
|
297
|
+
return result.exit_code == 0 and "charset=binary" in result.stdout
|
|
298
|
+
except:
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
def get_name(self, path: str) -> str:
|
|
302
|
+
"""Get basename of path."""
|
|
303
|
+
return os.path.basename(path)
|
|
304
|
+
|
|
305
|
+
def get_suffix(self, path: str) -> str:
|
|
306
|
+
"""Get file extension."""
|
|
307
|
+
return os.path.splitext(path)[1]
|
|
308
|
+
|
|
309
|
+
def get_parent(self, path: str) -> str:
|
|
310
|
+
"""Get parent directory."""
|
|
311
|
+
parent = os.path.dirname(path)
|
|
312
|
+
# Return "." for root-level files to match LocalFileSystem behavior
|
|
313
|
+
return parent if parent else "."
|
|
314
|
+
|
|
315
|
+
def get_parents(self, path: str) -> List[str]:
|
|
316
|
+
"""Get all parent directories."""
|
|
317
|
+
parents = []
|
|
318
|
+
current = os.path.dirname(path)
|
|
319
|
+
while current and current != "/":
|
|
320
|
+
parents.append(current)
|
|
321
|
+
current = os.path.dirname(current)
|
|
322
|
+
return parents
|
|
323
|
+
|
|
324
|
+
def relative_to(self, path: str, other: str) -> str:
|
|
325
|
+
"""Get relative path."""
|
|
326
|
+
return os.path.relpath(path, other)
|
|
327
|
+
|
|
328
|
+
def join_path(self, *parts: str) -> str:
|
|
329
|
+
"""Join path components."""
|
|
330
|
+
return os.path.join(*parts)
|
|
331
|
+
|
|
332
|
+
def is_absolute(self, path: str) -> bool:
|
|
333
|
+
"""Check if path is absolute."""
|
|
334
|
+
return os.path.isabs(path)
|
|
335
|
+
|
|
336
|
+
def get_path(self, path: str) -> Path:
|
|
337
|
+
"""Get a Path object for the given path."""
|
|
338
|
+
resolved = self._resolve_path(path)
|
|
339
|
+
# Since _resolve_path returns a string for sandbox filesystem,
|
|
340
|
+
# we need to create a Path object from it
|
|
341
|
+
return Path(resolved)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Local development implementation of sandbox manager."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional, List
|
|
4
|
+
|
|
5
|
+
from .base import SandboxConfig, SandboxManager
|
|
6
|
+
from ..services.file_system import FileSystem, LocalFileSystem
|
|
7
|
+
from ..services.base import TerminalManager, BrowserManager
|
|
8
|
+
from ..services.terminal import LocalTerminalManager
|
|
9
|
+
from ..services.browser import PlaywrightBrowserManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LocalSandboxManager(SandboxManager):
|
|
13
|
+
"""Local development implementation that doesn't use actual sandboxes."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, connection_manager=None):
|
|
16
|
+
"""Initialize local sandbox manager."""
|
|
17
|
+
self.connection_manager = connection_manager
|
|
18
|
+
# Use local services
|
|
19
|
+
self.filesystem = None
|
|
20
|
+
self.terminal_manager = None
|
|
21
|
+
self.browser_manager = None
|
|
22
|
+
|
|
23
|
+
async def create_sandbox(
|
|
24
|
+
self,
|
|
25
|
+
workspace_id: str,
|
|
26
|
+
thread_id: str,
|
|
27
|
+
config: Optional[SandboxConfig] = None,
|
|
28
|
+
workspace: Optional[Any] = None,
|
|
29
|
+
connection_manager: Optional[Any] = None,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Create a 'local' sandbox (just returns a fake ID)."""
|
|
32
|
+
# In local mode, we don't actually create sandboxes
|
|
33
|
+
# Use a simple consistent ID
|
|
34
|
+
sandbox_id = "local"
|
|
35
|
+
|
|
36
|
+
# Initialize local services if not already done
|
|
37
|
+
if self.filesystem is None and workspace:
|
|
38
|
+
self.filesystem = LocalFileSystem(root_path=workspace.directory_path)
|
|
39
|
+
if self.terminal_manager is None:
|
|
40
|
+
self.terminal_manager = LocalTerminalManager(workspace_id, thread_id, self.connection_manager)
|
|
41
|
+
if self.browser_manager is None:
|
|
42
|
+
self.browser_manager = PlaywrightBrowserManager()
|
|
43
|
+
|
|
44
|
+
return sandbox_id
|
|
45
|
+
|
|
46
|
+
async def destroy_sandbox(self, sandbox_id: str) -> None:
|
|
47
|
+
"""Destroy a 'local' sandbox (no-op in local mode)."""
|
|
48
|
+
# In local mode, we don't need to destroy anything
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
async def get_sandbox_status(self, sandbox_id: str) -> Dict[str, Any]:
|
|
52
|
+
"""Get sandbox status (always 'alive' in local mode)."""
|
|
53
|
+
return {
|
|
54
|
+
"sandbox_id": sandbox_id,
|
|
55
|
+
"is_alive": True,
|
|
56
|
+
"is_local": True,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async def commit_changes(self, sandbox_id: str, message: str, files: Optional[List[str]] = None) -> str:
|
|
60
|
+
"""Commit changes (no-op in local mode, returns empty string)."""
|
|
61
|
+
# In local mode, we don't manage git
|
|
62
|
+
return ""
|
|
63
|
+
|
|
64
|
+
async def push_changes(self, sandbox_id: str) -> bool:
|
|
65
|
+
"""Push changes (no-op in local mode, returns False)."""
|
|
66
|
+
# In local mode, we don't manage git
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def get_filesystem(self, sandbox_id: str) -> FileSystem:
|
|
70
|
+
"""Get filesystem for local development."""
|
|
71
|
+
if self.filesystem is None:
|
|
72
|
+
raise ValueError("Filesystem not initialized - call create_sandbox first")
|
|
73
|
+
return self.filesystem
|
|
74
|
+
|
|
75
|
+
def get_terminal_manager(self, sandbox_id: str) -> TerminalManager:
|
|
76
|
+
"""Get terminal manager for local development."""
|
|
77
|
+
if self.terminal_manager is None:
|
|
78
|
+
raise ValueError("Terminal manager not initialized - call create_sandbox first")
|
|
79
|
+
return self.terminal_manager
|
|
80
|
+
|
|
81
|
+
def get_browser_manager(self, sandbox_id: str) -> BrowserManager:
|
|
82
|
+
"""Get browser manager for local development."""
|
|
83
|
+
if self.browser_manager is None:
|
|
84
|
+
raise ValueError("Browser manager not initialized - call create_sandbox first")
|
|
85
|
+
return self.browser_manager
|
|
86
|
+
|
|
87
|
+
async def get_host(self, sandbox_id: str, port: int) -> str:
|
|
88
|
+
"""Always returns localhost for local development."""
|
|
89
|
+
return f"localhost:{port}"
|
|
90
|
+
|
|
91
|
+
async def pause_sandbox(self, sandbox_id: str) -> str:
|
|
92
|
+
"""Pause sandbox (no-op in local mode, returns sandbox_id)."""
|
|
93
|
+
# In local mode, we don't pause sandboxes
|
|
94
|
+
# Just return the sandbox_id as the "persistent" ID
|
|
95
|
+
return sandbox_id
|
|
96
|
+
|
|
97
|
+
async def resume_sandbox(self, persistent_sandbox_id: str, workspace_id: str, thread_id: str) -> str:
|
|
98
|
+
"""Resume sandbox (no-op in local mode, returns same ID)."""
|
|
99
|
+
# In local mode, we don't actually resume sandboxes
|
|
100
|
+
# Just return the same ID
|
|
101
|
+
return persistent_sandbox_id
|
|
102
|
+
|
|
103
|
+
def has_sandbox(self, sandbox_id: str) -> bool:
|
|
104
|
+
"""Check if a sandbox is currently active (always true in local mode if initialized)."""
|
|
105
|
+
# In local mode, if we have services initialized, the "sandbox" is active
|
|
106
|
+
return self.filesystem is not None
|
|
107
|
+
|
|
108
|
+
async def adopt_sandbox(self, sandbox_id: str, workspace_id: str, thread_id: str) -> str:
|
|
109
|
+
"""Adopt sandbox (no-op in local mode, returns same ID)."""
|
|
110
|
+
# In local mode, we don't actually adopt sandboxes
|
|
111
|
+
# Just return the same ID
|
|
112
|
+
return sandbox_id
|
|
113
|
+
|
|
114
|
+
async def sync_sandbox_env_vars(self, sandbox_id: str, workspace_id: str, sandbox: Optional[Any] = None, skip_integration_env_sync: bool = False) -> None:
|
|
115
|
+
"""Sync environment variables (no-op in local mode)."""
|
|
116
|
+
# In local mode, environment variables are managed by the local system
|
|
117
|
+
# No need to sync to a sandbox profile
|
|
118
|
+
pass
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Terminal manager state serialization utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any, Optional, List
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from kolega_code.models.sandbox_terminal_state import SandboxTerminalState, TerminalInfo, TerminalOutput
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TerminalStateSerializer:
|
|
9
|
+
"""Handles serialization and deserialization of terminal manager state."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def serialize_to_model(terminal_manager, workspace_id: str, sandbox_id: str) -> SandboxTerminalState:
|
|
13
|
+
"""Serialize terminal manager state to a SandboxTerminalState model."""
|
|
14
|
+
state = SandboxTerminalState(workspace_id=workspace_id, sandbox_id=sandbox_id)
|
|
15
|
+
|
|
16
|
+
if not hasattr(terminal_manager, "terminals"):
|
|
17
|
+
return state # Can't serialize local terminal managers
|
|
18
|
+
|
|
19
|
+
# Serialize terminals
|
|
20
|
+
for terminal_id, info in terminal_manager.terminals.items():
|
|
21
|
+
state.terminals[terminal_id] = TerminalInfo(
|
|
22
|
+
terminal_id=terminal_id,
|
|
23
|
+
created_at=info["created_at"],
|
|
24
|
+
cwd=info["cwd"],
|
|
25
|
+
env=info["env"],
|
|
26
|
+
last_command=info.get("last_command", ""),
|
|
27
|
+
last_command_purpose=info.get("last_command_purpose", ""),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Serialize outputs with size tracking
|
|
31
|
+
total_size = 0
|
|
32
|
+
for terminal_id, outputs in terminal_manager.outputs.items():
|
|
33
|
+
terminal_outputs = []
|
|
34
|
+
terminal_size = 0
|
|
35
|
+
|
|
36
|
+
# Process outputs in reverse order (keep most recent)
|
|
37
|
+
for output in reversed(outputs):
|
|
38
|
+
terminal_output = TerminalOutput(
|
|
39
|
+
type=output["type"],
|
|
40
|
+
data=output["data"],
|
|
41
|
+
timestamp=output["timestamp"],
|
|
42
|
+
purpose=output.get("purpose"),
|
|
43
|
+
exit_code=output.get("exit_code"),
|
|
44
|
+
)
|
|
45
|
+
output_size = len(output["data"].encode("utf-8"))
|
|
46
|
+
|
|
47
|
+
# Check if adding this output would exceed limits
|
|
48
|
+
if (
|
|
49
|
+
terminal_size + output_size > state.MAX_OUTPUT_PER_TERMINAL
|
|
50
|
+
or total_size + output_size > state.MAX_OUTPUT_SIZE
|
|
51
|
+
):
|
|
52
|
+
# Add truncation notice at the beginning
|
|
53
|
+
if not any(o.type == "truncation" for o in terminal_outputs):
|
|
54
|
+
terminal_outputs.insert(
|
|
55
|
+
0,
|
|
56
|
+
TerminalOutput(
|
|
57
|
+
type="truncation",
|
|
58
|
+
data="[Earlier terminal output truncated due to size limits]",
|
|
59
|
+
timestamp=datetime.now(timezone.utc),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
terminal_outputs.insert(0, terminal_output) # Insert at beginning to maintain order
|
|
65
|
+
terminal_size += output_size
|
|
66
|
+
total_size += output_size
|
|
67
|
+
|
|
68
|
+
state.outputs[terminal_id] = terminal_outputs
|
|
69
|
+
|
|
70
|
+
# Break if we've hit total size limit
|
|
71
|
+
if total_size >= state.MAX_OUTPUT_SIZE:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
state.total_output_size = total_size
|
|
75
|
+
state.default_terminal_id = getattr(terminal_manager, "_default_terminal_id", None)
|
|
76
|
+
|
|
77
|
+
return state
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def restore_from_model(terminal_manager, state: SandboxTerminalState) -> None:
|
|
81
|
+
"""Restore terminal manager state from a SandboxTerminalState model."""
|
|
82
|
+
if not state or not hasattr(terminal_manager, "terminals"):
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Clear existing state
|
|
86
|
+
terminal_manager.terminals.clear()
|
|
87
|
+
terminal_manager.outputs.clear()
|
|
88
|
+
|
|
89
|
+
# Restore terminals
|
|
90
|
+
for terminal_id, terminal_info in state.terminals.items():
|
|
91
|
+
terminal_manager.terminals[terminal_id] = {
|
|
92
|
+
"created_at": terminal_info.created_at,
|
|
93
|
+
"cwd": terminal_info.cwd,
|
|
94
|
+
"env": terminal_info.env,
|
|
95
|
+
"process": None, # Process references can't be restored
|
|
96
|
+
"last_command": terminal_info.last_command,
|
|
97
|
+
"last_command_purpose": terminal_info.last_command_purpose,
|
|
98
|
+
"active_commands": {}, # Active commands start fresh
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Restore outputs
|
|
102
|
+
for terminal_id, outputs in state.outputs.items():
|
|
103
|
+
terminal_manager.outputs[terminal_id] = [
|
|
104
|
+
{
|
|
105
|
+
"type": output.type,
|
|
106
|
+
"data": output.data,
|
|
107
|
+
"timestamp": output.timestamp,
|
|
108
|
+
"purpose": output.purpose,
|
|
109
|
+
"exit_code": output.exit_code,
|
|
110
|
+
}
|
|
111
|
+
for output in outputs
|
|
112
|
+
if output.type != "truncation" # Skip truncation notices
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Restore default terminal ID
|
|
116
|
+
if hasattr(terminal_manager, "_default_terminal_id"):
|
|
117
|
+
terminal_manager._default_terminal_id = state.default_terminal_id
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def to_frontend_format(state: SandboxTerminalState) -> Dict[str, Any]:
|
|
121
|
+
"""Convert terminal state to frontend format."""
|
|
122
|
+
terminal_tabs = []
|
|
123
|
+
|
|
124
|
+
for terminal_id, outputs in state.outputs.items():
|
|
125
|
+
content = ""
|
|
126
|
+
for output in outputs:
|
|
127
|
+
if output.type == "command":
|
|
128
|
+
content += f"$ {output.data}\n"
|
|
129
|
+
elif output.type == "truncation":
|
|
130
|
+
content += f"{output.data}\n"
|
|
131
|
+
elif output.type in ["stdout", "stderr"]:
|
|
132
|
+
content += output.data
|
|
133
|
+
if not output.data.endswith("\n"):
|
|
134
|
+
content += "\n"
|
|
135
|
+
elif output.type == "exit":
|
|
136
|
+
content += f"{output.data}\n"
|
|
137
|
+
|
|
138
|
+
if content: # Only add tabs with content
|
|
139
|
+
terminal_tabs.append({"id": terminal_id, "content": content.rstrip()}) # Remove trailing whitespace
|
|
140
|
+
|
|
141
|
+
return {"terminals": terminal_tabs}
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def get_recent_outputs(outputs: List[Dict[str, Any]], max_lines: int = 100) -> List[Dict[str, Any]]:
|
|
145
|
+
"""Get the most recent outputs, limited by line count."""
|
|
146
|
+
if not outputs:
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
recent_outputs = []
|
|
150
|
+
line_count = 0
|
|
151
|
+
|
|
152
|
+
# Process outputs in reverse order
|
|
153
|
+
for output in reversed(outputs):
|
|
154
|
+
if output["type"] in ["stdout", "stderr"]:
|
|
155
|
+
lines = output["data"].count("\n") + 1
|
|
156
|
+
if line_count + lines > max_lines:
|
|
157
|
+
# Partial output to fit within limit
|
|
158
|
+
remaining_lines = max_lines - line_count
|
|
159
|
+
if remaining_lines > 0:
|
|
160
|
+
lines_data = output["data"].split("\n")
|
|
161
|
+
partial_output = output.copy()
|
|
162
|
+
partial_output["data"] = "\n".join(lines_data[-remaining_lines:])
|
|
163
|
+
recent_outputs.insert(0, partial_output)
|
|
164
|
+
break
|
|
165
|
+
line_count += lines
|
|
166
|
+
|
|
167
|
+
recent_outputs.insert(0, output)
|
|
168
|
+
|
|
169
|
+
if output["type"] == "command":
|
|
170
|
+
line_count += 1
|
|
171
|
+
|
|
172
|
+
if line_count >= max_lines:
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
return recent_outputs
|