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,200 @@
|
|
|
1
|
+
# Cloud Sandbox Implementation
|
|
2
|
+
|
|
3
|
+
This directory contains the cloud sandbox implementation for running AI agents in isolated environments using E2B.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
The sandbox system provides a pluggable architecture that allows agents to run in cloud-based isolated environments instead of locally. The implementation uses E2B as the sandbox provider but is designed to support other providers in the future.
|
|
8
|
+
|
|
9
|
+
### Key Components
|
|
10
|
+
|
|
11
|
+
1. **Base Interfaces** (`base.py`)
|
|
12
|
+
- `SandboxConfig`: Configuration for sandbox creation (git URL, branch, environment variables)
|
|
13
|
+
- `ProjectManifest`: Project configuration for dependencies and setup
|
|
14
|
+
- `SandboxManager`: Abstract base class for sandbox management
|
|
15
|
+
|
|
16
|
+
2. **E2B Implementation** (`sandbox_e2b.py`)
|
|
17
|
+
- `E2BSandboxManager`: Concrete implementation using E2B sandboxes
|
|
18
|
+
- Handles Git authentication using GitLab group access tokens
|
|
19
|
+
- Manages sandbox lifecycle (create, destroy, commit, push)
|
|
20
|
+
- Mounts workspace files from S3 for agent access
|
|
21
|
+
|
|
22
|
+
3. **Sandbox-Aware Services**
|
|
23
|
+
- `SandboxFileSystem`: FileSystem implementation that operates within sandbox
|
|
24
|
+
- `SandboxTerminalManager`: Terminal manager for executing commands in sandbox
|
|
25
|
+
- `SandboxBrowserManager`: Browser manager placeholder (not yet implemented)
|
|
26
|
+
|
|
27
|
+
4. **Utilities** (`utils.py`)
|
|
28
|
+
- Helper functions for common sandbox operations
|
|
29
|
+
- Git status parsing and diff generation
|
|
30
|
+
- Project test execution
|
|
31
|
+
|
|
32
|
+
## How It Works
|
|
33
|
+
|
|
34
|
+
1. **Host Job Integration**: When a host application job starts and sandbox mode is enabled:
|
|
35
|
+
- Creates an E2B sandbox with the workspace's Git repository
|
|
36
|
+
- Clones the repository using GitLab group access token
|
|
37
|
+
- Mounts workspace files from S3 as read-only
|
|
38
|
+
- Installs dependencies if a manifest file exists
|
|
39
|
+
- Injects sandbox-aware services into the agent
|
|
40
|
+
|
|
41
|
+
2. **Agent Execution**: The agent runs with:
|
|
42
|
+
- Filesystem operations redirected to sandbox
|
|
43
|
+
- Terminal commands executed in sandbox
|
|
44
|
+
- Access to uploaded workspace files at `/home/user/workspace/kolega-project-files`
|
|
45
|
+
- All changes isolated from local environment
|
|
46
|
+
|
|
47
|
+
3. **Git Workflow**: After agent completion:
|
|
48
|
+
- Detects modified files using `git status`
|
|
49
|
+
- Commits changes with descriptive message
|
|
50
|
+
- Pushes changes back to GitLab repository
|
|
51
|
+
|
|
52
|
+
4. **Workspace Files**: Users can upload files through the UI which are:
|
|
53
|
+
- Stored in S3 at `<bucket>/<workspace_id>/<filename>`
|
|
54
|
+
- Automatically mounted in sandboxes at `/home/user/workspace/kolega-project-files`
|
|
55
|
+
- Available as read-only to prevent accidental modifications
|
|
56
|
+
- Excluded from git tracking via `.gitignore`
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
### Environment Variables
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Enable sandbox mode
|
|
64
|
+
USE_SANDBOX=true
|
|
65
|
+
|
|
66
|
+
# Sandbox provider (currently only 'e2b' supported)
|
|
67
|
+
SANDBOX_PROVIDER=e2b
|
|
68
|
+
|
|
69
|
+
# E2B API key from https://e2b.dev
|
|
70
|
+
E2B_API_KEY=your_e2b_api_key
|
|
71
|
+
|
|
72
|
+
# E2B template (must use custom template with s3fs)
|
|
73
|
+
E2B_TEMPLATE=your_custom_template_id
|
|
74
|
+
|
|
75
|
+
# GitLab group access token with read/write permissions
|
|
76
|
+
GITLAB_GROUP_ACCESS_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
|
77
|
+
|
|
78
|
+
# S3 Configuration for workspace files
|
|
79
|
+
S3_BUCKET_NAME=your-kolega-files-bucket
|
|
80
|
+
AWS_ACCESS_KEY_ID=your-access-key
|
|
81
|
+
AWS_SECRET_ACCESS_KEY=your-secret-key
|
|
82
|
+
AWS_REGION=us-east-1 # Optional, defaults to us-east-1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### E2B Template Requirements
|
|
86
|
+
|
|
87
|
+
The E2B template must have s3fs installed. See `backend/e2b-template/` for the custom template that includes:
|
|
88
|
+
- Base E2B code interpreter
|
|
89
|
+
- s3fs for mounting S3 buckets
|
|
90
|
+
- Proper permissions setup
|
|
91
|
+
|
|
92
|
+
Build and deploy the template:
|
|
93
|
+
```bash
|
|
94
|
+
cd backend/e2b-template
|
|
95
|
+
e2b template build
|
|
96
|
+
# Update E2B_TEMPLATE env var with the new template ID
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Project Manifest
|
|
100
|
+
|
|
101
|
+
Projects can include a `.kolega-manifest.yaml` file in the repository root:
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
name: my-project
|
|
105
|
+
runtime: node:18 # or python:3.11, etc.
|
|
106
|
+
|
|
107
|
+
# Optional: Commands to install dependencies
|
|
108
|
+
install_commands:
|
|
109
|
+
- npm install
|
|
110
|
+
|
|
111
|
+
# Optional: Commands to run before install
|
|
112
|
+
environment_setup:
|
|
113
|
+
- npm config set registry https://registry.npmjs.org/
|
|
114
|
+
|
|
115
|
+
# Optional: Development server command (runs automatically in background)
|
|
116
|
+
dev_server_command: npm run dev
|
|
117
|
+
|
|
118
|
+
# Optional: Test commands
|
|
119
|
+
test_commands:
|
|
120
|
+
- npm test
|
|
121
|
+
|
|
122
|
+
# Optional: Build command
|
|
123
|
+
build_command: npm run build
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
#### Dev Server Auto-Start
|
|
127
|
+
|
|
128
|
+
When a `dev_server_command` is specified in the manifest, it will automatically start in the background after dependencies are installed. This is useful for:
|
|
129
|
+
|
|
130
|
+
- React/Vue/Angular development servers
|
|
131
|
+
- API servers (Express, FastAPI, etc.)
|
|
132
|
+
- Documentation servers
|
|
133
|
+
- Any long-running development process
|
|
134
|
+
|
|
135
|
+
Example configurations:
|
|
136
|
+
|
|
137
|
+
**React App:**
|
|
138
|
+
```yaml
|
|
139
|
+
name: my-react-app
|
|
140
|
+
runtime: node:18
|
|
141
|
+
install_commands:
|
|
142
|
+
- npm install
|
|
143
|
+
dev_server_command: npm start -- --port 9001
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Python FastAPI:**
|
|
148
|
+
```yaml
|
|
149
|
+
name: my-api
|
|
150
|
+
runtime: python:3.11
|
|
151
|
+
install_commands:
|
|
152
|
+
- pip install -r requirements.txt
|
|
153
|
+
dev_server_command: uvicorn main:app --reload --host 0.0.0.0 --port 9001
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Next.js App:**
|
|
158
|
+
```yaml
|
|
159
|
+
name: my-nextjs-app
|
|
160
|
+
runtime: node:18
|
|
161
|
+
install_commands:
|
|
162
|
+
- npm install
|
|
163
|
+
dev_server_command: npm run dev -- -p 9001
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Minimal Project (no dependencies):**
|
|
167
|
+
```yaml
|
|
168
|
+
name: simple-static-site
|
|
169
|
+
runtime: node:18
|
|
170
|
+
# No install_commands needed for a simple static site
|
|
171
|
+
dev_server_command: python -m http.server 9001
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Usage
|
|
175
|
+
|
|
176
|
+
The sandbox is automatically used when:
|
|
177
|
+
1. `USE_SANDBOX=true` in environment
|
|
178
|
+
2. Workspace has a GitLab repository (`gitlab_project_url` is set)
|
|
179
|
+
3. Agent job is started by the host application
|
|
180
|
+
|
|
181
|
+
No code changes are required in agents - they continue using the standard FileSystem and TerminalManager interfaces.
|
|
182
|
+
|
|
183
|
+
### Accessing Workspace Files
|
|
184
|
+
|
|
185
|
+
Agents can access uploaded workspace files at:
|
|
186
|
+
```python
|
|
187
|
+
workspace_files = "/home/user/workspace/kolega-project-files"
|
|
188
|
+
|
|
189
|
+
# List files
|
|
190
|
+
import os
|
|
191
|
+
files = os.listdir(workspace_files)
|
|
192
|
+
|
|
193
|
+
# Read a file
|
|
194
|
+
with open(f"{workspace_files}/document.pdf", "rb") as f:
|
|
195
|
+
content = f.read()
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Testing
|
|
199
|
+
|
|
200
|
+
The implementation includes comprehensive tests:
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Sandbox services for cloud-based agent execution."""
|
|
2
|
+
|
|
3
|
+
from .base import SandboxConfig, ProjectManifest, SandboxManager
|
|
4
|
+
from .filesystem import SandboxFileSystem
|
|
5
|
+
from .async_filesystem import AsyncSandboxFileSystem
|
|
6
|
+
from .terminal import SandboxTerminalManager
|
|
7
|
+
from .browser import SandboxBrowserManager
|
|
8
|
+
from .local import LocalSandboxManager
|
|
9
|
+
from .utils import get_modified_files_from_sandbox
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SandboxConfig",
|
|
13
|
+
"ProjectManifest",
|
|
14
|
+
"SandboxManager",
|
|
15
|
+
"SandboxFileSystem",
|
|
16
|
+
"AsyncSandboxFileSystem",
|
|
17
|
+
"SandboxTerminalManager",
|
|
18
|
+
"SandboxBrowserManager",
|
|
19
|
+
"LocalSandboxManager",
|
|
20
|
+
"get_modified_files_from_sandbox",
|
|
21
|
+
]
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Async 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
|
+
import asyncio
|
|
10
|
+
import nest_asyncio
|
|
11
|
+
|
|
12
|
+
from ..services.file_system import FileSystem
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncSandboxFileSystem(FileSystem):
|
|
16
|
+
"""FileSystem implementation that operates within an async sandbox.
|
|
17
|
+
|
|
18
|
+
This class provides a synchronous interface to an async sandbox by using
|
|
19
|
+
nest_asyncio to allow running async operations in sync contexts.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, sandbox: Any, root_path: str = "/home/user/workspace"):
|
|
23
|
+
"""
|
|
24
|
+
Initialize async sandbox filesystem.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
sandbox: The async sandbox instance (e.g., AsyncE2B Sandbox)
|
|
28
|
+
root_path: Root path within the sandbox
|
|
29
|
+
"""
|
|
30
|
+
self.sandbox = sandbox
|
|
31
|
+
self.root_path = root_path
|
|
32
|
+
self._nest_asyncio_applied = False
|
|
33
|
+
|
|
34
|
+
def _run_async(self, coro):
|
|
35
|
+
"""Run an async coroutine in a sync context using nest_asyncio."""
|
|
36
|
+
try:
|
|
37
|
+
# Try to get the current running loop
|
|
38
|
+
loop = asyncio.get_running_loop()
|
|
39
|
+
|
|
40
|
+
# Apply nest_asyncio only once and only when needed
|
|
41
|
+
if not self._nest_asyncio_applied:
|
|
42
|
+
try:
|
|
43
|
+
# Only apply if we're not in a uvloop context
|
|
44
|
+
if not hasattr(loop, "__module__") or "uvloop" not in loop.__module__:
|
|
45
|
+
nest_asyncio.apply()
|
|
46
|
+
self._nest_asyncio_applied = True
|
|
47
|
+
except:
|
|
48
|
+
# If patching fails, we'll try alternative approaches
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
except RuntimeError:
|
|
52
|
+
# No running loop, create a new one
|
|
53
|
+
loop = asyncio.new_event_loop()
|
|
54
|
+
asyncio.set_event_loop(loop)
|
|
55
|
+
# Apply nest_asyncio for the new loop
|
|
56
|
+
if not self._nest_asyncio_applied:
|
|
57
|
+
nest_asyncio.apply()
|
|
58
|
+
self._nest_asyncio_applied = True
|
|
59
|
+
|
|
60
|
+
# Use nest_asyncio to run the coroutine if it was applied
|
|
61
|
+
if self._nest_asyncio_applied:
|
|
62
|
+
return loop.run_until_complete(coro)
|
|
63
|
+
else:
|
|
64
|
+
# If we couldn't apply nest_asyncio (e.g., uvloop),
|
|
65
|
+
# we need to use a different approach
|
|
66
|
+
import concurrent.futures
|
|
67
|
+
import threading
|
|
68
|
+
|
|
69
|
+
# Create a new event loop in a separate thread
|
|
70
|
+
def run_in_new_loop():
|
|
71
|
+
# Lazy import to avoid circular dependency
|
|
72
|
+
from kolega_code.sandbox.event_loop import cleanup_event_loop
|
|
73
|
+
|
|
74
|
+
new_loop = asyncio.new_event_loop()
|
|
75
|
+
asyncio.set_event_loop(new_loop)
|
|
76
|
+
try:
|
|
77
|
+
return new_loop.run_until_complete(coro)
|
|
78
|
+
finally:
|
|
79
|
+
# Proper cleanup following asyncio.Runner pattern
|
|
80
|
+
cleanup_event_loop(new_loop)
|
|
81
|
+
|
|
82
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
83
|
+
future = executor.submit(run_in_new_loop)
|
|
84
|
+
return future.result()
|
|
85
|
+
|
|
86
|
+
def _resolve_path(self, path: str) -> str:
|
|
87
|
+
"""Resolve path relative to root."""
|
|
88
|
+
if os.path.isabs(path):
|
|
89
|
+
return path
|
|
90
|
+
if path == ".":
|
|
91
|
+
return self.root_path
|
|
92
|
+
return os.path.join(self.root_path, path)
|
|
93
|
+
|
|
94
|
+
# Synchronous methods wrapping async E2B API
|
|
95
|
+
def open(self, path: str, mode: str = "r", encoding: Optional[str] = None) -> Any:
|
|
96
|
+
"""Open is not directly supported in sandbox - use read/write methods instead."""
|
|
97
|
+
raise NotImplementedError("Direct file handles not supported in sandbox. Use read_text/write_text instead.")
|
|
98
|
+
|
|
99
|
+
def read_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
100
|
+
"""Read text from file."""
|
|
101
|
+
full_path = self._resolve_path(path)
|
|
102
|
+
try:
|
|
103
|
+
|
|
104
|
+
async def _read():
|
|
105
|
+
return await self.sandbox.files.read(full_path)
|
|
106
|
+
|
|
107
|
+
content = self._run_async(_read())
|
|
108
|
+
if isinstance(content, bytes):
|
|
109
|
+
return content.decode(encoding)
|
|
110
|
+
return content
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise FileNotFoundError(f"Could not read file {path}: {e}")
|
|
113
|
+
|
|
114
|
+
def read_bytes(self, path: str) -> bytes:
|
|
115
|
+
"""Read bytes from file."""
|
|
116
|
+
full_path = self._resolve_path(path)
|
|
117
|
+
try:
|
|
118
|
+
# For binary data, use base64 encoding via shell commands
|
|
119
|
+
# to avoid E2B files API text encoding issues
|
|
120
|
+
async def _read_bytes():
|
|
121
|
+
result = await self.sandbox.commands.run(f"base64 {full_path}")
|
|
122
|
+
if result.exit_code != 0:
|
|
123
|
+
raise FileNotFoundError(f"Could not read file {path}")
|
|
124
|
+
# Decode the base64 content
|
|
125
|
+
return base64.b64decode(result.stdout.strip())
|
|
126
|
+
|
|
127
|
+
return self._run_async(_read_bytes())
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
raise FileNotFoundError(f"Could not read file {path}: {e}")
|
|
131
|
+
|
|
132
|
+
def write_text(self, path: str, content: str, encoding: str = "utf-8") -> None:
|
|
133
|
+
"""Write text to file."""
|
|
134
|
+
full_path = self._resolve_path(path)
|
|
135
|
+
try:
|
|
136
|
+
|
|
137
|
+
async def _write():
|
|
138
|
+
# Ensure parent directory exists
|
|
139
|
+
parent_dir = os.path.dirname(full_path)
|
|
140
|
+
if parent_dir != self.root_path:
|
|
141
|
+
await self.sandbox.commands.run(f"mkdir -p {parent_dir}")
|
|
142
|
+
|
|
143
|
+
await self.sandbox.files.write(full_path, content)
|
|
144
|
+
|
|
145
|
+
self._run_async(_write())
|
|
146
|
+
except Exception as e:
|
|
147
|
+
raise OSError(f"Could not write file {path}: {e}")
|
|
148
|
+
|
|
149
|
+
def write_bytes(self, path: str, content: bytes) -> None:
|
|
150
|
+
"""Write bytes to file."""
|
|
151
|
+
full_path = self._resolve_path(path)
|
|
152
|
+
try:
|
|
153
|
+
|
|
154
|
+
async def _write_bytes():
|
|
155
|
+
# Ensure parent directory exists
|
|
156
|
+
parent_dir = os.path.dirname(full_path)
|
|
157
|
+
if parent_dir != self.root_path:
|
|
158
|
+
await self.sandbox.commands.run(f"mkdir -p {parent_dir}")
|
|
159
|
+
|
|
160
|
+
# For binary data, use base64 encoding and write via shell commands
|
|
161
|
+
# to avoid E2B files API text encoding issues
|
|
162
|
+
encoded_content = base64.b64encode(content).decode("ascii")
|
|
163
|
+
|
|
164
|
+
# Write the base64 encoded content and decode it
|
|
165
|
+
result = await self.sandbox.commands.run(f"echo '{encoded_content}' | base64 -d > {full_path}")
|
|
166
|
+
|
|
167
|
+
if result.exit_code != 0:
|
|
168
|
+
raise OSError(f"Failed to write binary file {path}: {result.stderr}")
|
|
169
|
+
|
|
170
|
+
self._run_async(_write_bytes())
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
raise OSError(f"Could not write file {path}: {e}")
|
|
174
|
+
|
|
175
|
+
def exists(self, path: str) -> bool:
|
|
176
|
+
"""Check if path exists."""
|
|
177
|
+
full_path = self._resolve_path(path)
|
|
178
|
+
try:
|
|
179
|
+
|
|
180
|
+
async def _exists():
|
|
181
|
+
result = await self.sandbox.commands.run(f"test -e {full_path}")
|
|
182
|
+
return result.exit_code == 0
|
|
183
|
+
|
|
184
|
+
return self._run_async(_exists())
|
|
185
|
+
except:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def is_file(self, path: str) -> bool:
|
|
189
|
+
"""Check if path is a file."""
|
|
190
|
+
full_path = self._resolve_path(path)
|
|
191
|
+
try:
|
|
192
|
+
|
|
193
|
+
async def _is_file():
|
|
194
|
+
result = await self.sandbox.commands.run(f"test -f {full_path}")
|
|
195
|
+
return result.exit_code == 0
|
|
196
|
+
|
|
197
|
+
return self._run_async(_is_file())
|
|
198
|
+
except:
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def is_dir(self, path: str) -> bool:
|
|
202
|
+
"""Check if path is a directory."""
|
|
203
|
+
full_path = self._resolve_path(path)
|
|
204
|
+
try:
|
|
205
|
+
|
|
206
|
+
async def _is_dir():
|
|
207
|
+
result = await self.sandbox.commands.run(f"test -d {full_path}")
|
|
208
|
+
return result.exit_code == 0
|
|
209
|
+
|
|
210
|
+
return self._run_async(_is_dir())
|
|
211
|
+
except:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
def stat(self, path: str) -> Dict[str, Any]:
|
|
215
|
+
"""Get file statistics."""
|
|
216
|
+
full_path = self._resolve_path(path)
|
|
217
|
+
try:
|
|
218
|
+
|
|
219
|
+
async def _stat():
|
|
220
|
+
# Use stat command to get file info
|
|
221
|
+
result = await self.sandbox.commands.run(f"stat -c '%s %Y %Z' {full_path}")
|
|
222
|
+
if result.exit_code != 0:
|
|
223
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
224
|
+
|
|
225
|
+
size, mtime, ctime = result.stdout.strip().split()
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
"size": int(size),
|
|
229
|
+
"modified_time": int(mtime),
|
|
230
|
+
"created_time": int(ctime),
|
|
231
|
+
"is_file": await self._async_is_file(full_path),
|
|
232
|
+
"is_directory": await self._async_is_dir(full_path),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return self._run_async(_stat())
|
|
236
|
+
except Exception as e:
|
|
237
|
+
raise OSError(f"Could not stat {path}: {e}")
|
|
238
|
+
|
|
239
|
+
async def _async_is_file(self, full_path: str) -> bool:
|
|
240
|
+
"""Async helper to check if path is a file."""
|
|
241
|
+
try:
|
|
242
|
+
result = await self.sandbox.commands.run(f"test -f {full_path}")
|
|
243
|
+
return result.exit_code == 0
|
|
244
|
+
except Exception:
|
|
245
|
+
# E2B throws exception on non-zero exit codes
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
async def _async_is_dir(self, full_path: str) -> bool:
|
|
249
|
+
"""Async helper to check if path is a directory."""
|
|
250
|
+
try:
|
|
251
|
+
result = await self.sandbox.commands.run(f"test -d {full_path}")
|
|
252
|
+
return result.exit_code == 0
|
|
253
|
+
except Exception:
|
|
254
|
+
# E2B throws exception on non-zero exit codes
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
def mkdir(self, path: str, parents: bool = False, exist_ok: bool = False) -> None:
|
|
258
|
+
"""Create directory."""
|
|
259
|
+
full_path = self._resolve_path(path)
|
|
260
|
+
|
|
261
|
+
# Check if directory already exists
|
|
262
|
+
if self.exists(path):
|
|
263
|
+
if not exist_ok:
|
|
264
|
+
raise FileExistsError(f"Directory already exists: {path}")
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
|
|
269
|
+
async def _mkdir():
|
|
270
|
+
if parents:
|
|
271
|
+
result = await self.sandbox.commands.run(f"mkdir -p {full_path}")
|
|
272
|
+
else:
|
|
273
|
+
result = await self.sandbox.commands.run(f"mkdir {full_path}")
|
|
274
|
+
|
|
275
|
+
if result.exit_code != 0:
|
|
276
|
+
raise OSError(f"Failed to create directory {path}: {result.stderr}")
|
|
277
|
+
|
|
278
|
+
self._run_async(_mkdir())
|
|
279
|
+
except Exception as e:
|
|
280
|
+
raise OSError(f"Could not create directory {path}: {e}")
|
|
281
|
+
|
|
282
|
+
def remove(self, path: str, missing_ok: bool = False) -> None:
|
|
283
|
+
"""Remove file."""
|
|
284
|
+
full_path = self._resolve_path(path)
|
|
285
|
+
|
|
286
|
+
if not self.exists(path):
|
|
287
|
+
if not missing_ok:
|
|
288
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
|
|
293
|
+
async def _remove():
|
|
294
|
+
result = await self.sandbox.commands.run(f"rm -f {full_path}")
|
|
295
|
+
if result.exit_code != 0:
|
|
296
|
+
raise OSError(f"Failed to remove file {path}: {result.stderr}")
|
|
297
|
+
|
|
298
|
+
self._run_async(_remove())
|
|
299
|
+
except Exception as e:
|
|
300
|
+
raise OSError(f"Could not remove file {path}: {e}")
|
|
301
|
+
|
|
302
|
+
def rmdir(self, path: str) -> None:
|
|
303
|
+
"""Remove empty directory."""
|
|
304
|
+
full_path = self._resolve_path(path)
|
|
305
|
+
try:
|
|
306
|
+
|
|
307
|
+
async def _rmdir():
|
|
308
|
+
result = await self.sandbox.commands.run(f"rmdir {full_path}")
|
|
309
|
+
if result.exit_code != 0:
|
|
310
|
+
raise OSError(f"Failed to remove directory {path}: {result.stderr}")
|
|
311
|
+
|
|
312
|
+
self._run_async(_rmdir())
|
|
313
|
+
except Exception as e:
|
|
314
|
+
raise OSError(f"Could not remove directory {path}: {e}")
|
|
315
|
+
|
|
316
|
+
def rmtree(self, path: str) -> None:
|
|
317
|
+
"""Remove directory tree."""
|
|
318
|
+
full_path = self._resolve_path(path)
|
|
319
|
+
try:
|
|
320
|
+
|
|
321
|
+
async def _rmtree():
|
|
322
|
+
result = await self.sandbox.commands.run(f"rm -rf {full_path}")
|
|
323
|
+
if result.exit_code != 0:
|
|
324
|
+
raise OSError(f"Failed to remove directory tree {path}: {result.stderr}")
|
|
325
|
+
|
|
326
|
+
self._run_async(_rmtree())
|
|
327
|
+
except Exception as e:
|
|
328
|
+
raise OSError(f"Could not remove directory tree {path}: {e}")
|
|
329
|
+
|
|
330
|
+
def listdir(self, path: str) -> List[str]:
|
|
331
|
+
"""List directory contents."""
|
|
332
|
+
full_path = self._resolve_path(path)
|
|
333
|
+
|
|
334
|
+
if not self.is_dir(path):
|
|
335
|
+
raise NotADirectoryError(f"Not a directory: {path}")
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
|
|
339
|
+
async def _listdir():
|
|
340
|
+
result = await self.sandbox.commands.run(f"ls -1 {full_path}")
|
|
341
|
+
if result.exit_code != 0:
|
|
342
|
+
raise FileNotFoundError(f"Directory not found: {path}")
|
|
343
|
+
|
|
344
|
+
if not result.stdout.strip():
|
|
345
|
+
return []
|
|
346
|
+
|
|
347
|
+
return [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
|
348
|
+
|
|
349
|
+
return self._run_async(_listdir())
|
|
350
|
+
except Exception as e:
|
|
351
|
+
raise OSError(f"Could not list directory {path}: {e}")
|
|
352
|
+
|
|
353
|
+
def iterdir(self, path: str) -> Iterator[str]:
|
|
354
|
+
"""Iterate directory contents."""
|
|
355
|
+
return iter(self.listdir(path))
|
|
356
|
+
|
|
357
|
+
def glob(self, pattern: str) -> List[str]:
|
|
358
|
+
"""Find paths matching pattern."""
|
|
359
|
+
try:
|
|
360
|
+
|
|
361
|
+
async def _glob():
|
|
362
|
+
# Handle different types of glob patterns
|
|
363
|
+
if pattern.startswith("**/"):
|
|
364
|
+
# Recursive pattern like **/*.py or **/*
|
|
365
|
+
remaining_pattern = pattern[3:] # Remove '**/'
|
|
366
|
+
|
|
367
|
+
if remaining_pattern == "*":
|
|
368
|
+
# Pattern is **/* - find all files and directories recursively
|
|
369
|
+
result = await self.sandbox.commands.run(
|
|
370
|
+
f"cd {self.root_path} && find . -mindepth 1 2>/dev/null | sed 's|^./||' | sort"
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
# Pattern like **/*.py - find files matching pattern recursively
|
|
374
|
+
if "*" in remaining_pattern or "?" in remaining_pattern:
|
|
375
|
+
# Use find with -name for pattern matching
|
|
376
|
+
result = await self.sandbox.commands.run(
|
|
377
|
+
f"cd {self.root_path} && find . -name '{remaining_pattern}' 2>/dev/null | sed 's|^./||' | sort"
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
# Exact filename search recursively
|
|
381
|
+
result = await self.sandbox.commands.run(
|
|
382
|
+
f"cd {self.root_path} && find . -name '{remaining_pattern}' 2>/dev/null | sed 's|^./||' | sort"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
elif "*" in pattern or "?" in pattern:
|
|
386
|
+
# Simple glob pattern like *.py or test*.txt
|
|
387
|
+
if "/" in pattern:
|
|
388
|
+
# Pattern has directory component like subdir/*.txt
|
|
389
|
+
parent_dir = os.path.dirname(pattern)
|
|
390
|
+
filename_pattern = os.path.basename(pattern)
|
|
391
|
+
result = await self.sandbox.commands.run(
|
|
392
|
+
f"cd {self.root_path} && find {parent_dir} -maxdepth 1 -name '{filename_pattern}' 2>/dev/null | sort"
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
# Simple pattern like *.py in current directory
|
|
396
|
+
result = await self.sandbox.commands.run(
|
|
397
|
+
f"cd {self.root_path} && find . -maxdepth 1 -name '{pattern}' 2>/dev/null | sed 's|^./||' | sort"
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
else:
|
|
401
|
+
# No wildcards - check if exact path exists
|
|
402
|
+
if self.exists(pattern):
|
|
403
|
+
return [pattern]
|
|
404
|
+
else:
|
|
405
|
+
return []
|
|
406
|
+
|
|
407
|
+
# Process the result
|
|
408
|
+
if result.exit_code == 0 and result.stdout.strip():
|
|
409
|
+
paths = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
|
|
410
|
+
# Filter out empty strings and current directory
|
|
411
|
+
return [path for path in paths if path and path != "."]
|
|
412
|
+
else:
|
|
413
|
+
return []
|
|
414
|
+
|
|
415
|
+
return self._run_async(_glob())
|
|
416
|
+
|
|
417
|
+
except Exception as e:
|
|
418
|
+
# If any error occurs, return empty list to match expected behavior
|
|
419
|
+
return []
|
|
420
|
+
|
|
421
|
+
def is_binary_file(self, path: str) -> bool:
|
|
422
|
+
"""Check if file is binary."""
|
|
423
|
+
full_path = self._resolve_path(path)
|
|
424
|
+
try:
|
|
425
|
+
|
|
426
|
+
async def _is_binary():
|
|
427
|
+
# Use file command to detect binary
|
|
428
|
+
result = await self.sandbox.commands.run(f"file -b --mime {full_path}")
|
|
429
|
+
return result.exit_code == 0 and "charset=binary" in result.stdout
|
|
430
|
+
|
|
431
|
+
return self._run_async(_is_binary())
|
|
432
|
+
except:
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
def get_name(self, path: str) -> str:
|
|
436
|
+
"""Get basename of path."""
|
|
437
|
+
return os.path.basename(path)
|
|
438
|
+
|
|
439
|
+
def get_suffix(self, path: str) -> str:
|
|
440
|
+
"""Get file extension."""
|
|
441
|
+
return os.path.splitext(path)[1]
|
|
442
|
+
|
|
443
|
+
def get_parent(self, path: str) -> str:
|
|
444
|
+
"""Get parent directory."""
|
|
445
|
+
parent = os.path.dirname(path)
|
|
446
|
+
# Return "." for root-level files to match LocalFileSystem behavior
|
|
447
|
+
return parent if parent else "."
|
|
448
|
+
|
|
449
|
+
def get_parents(self, path: str) -> List[str]:
|
|
450
|
+
"""Get all parent directories."""
|
|
451
|
+
parents = []
|
|
452
|
+
current = os.path.dirname(path)
|
|
453
|
+
while current and current != "/":
|
|
454
|
+
parents.append(current)
|
|
455
|
+
current = os.path.dirname(current)
|
|
456
|
+
return parents
|
|
457
|
+
|
|
458
|
+
def relative_to(self, path: str, other: str) -> str:
|
|
459
|
+
"""Get relative path."""
|
|
460
|
+
return os.path.relpath(path, other)
|
|
461
|
+
|
|
462
|
+
def join_path(self, *parts: str) -> str:
|
|
463
|
+
"""Join path components."""
|
|
464
|
+
return os.path.join(*parts)
|
|
465
|
+
|
|
466
|
+
def is_absolute(self, path: str) -> bool:
|
|
467
|
+
"""Check if path is absolute."""
|
|
468
|
+
return os.path.isabs(path)
|
|
469
|
+
|
|
470
|
+
def get_path(self, path: str) -> Path:
|
|
471
|
+
"""Get a Path object for the given path."""
|
|
472
|
+
resolved = self._resolve_path(path)
|
|
473
|
+
# Since _resolve_path returns a string for sandbox filesystem,
|
|
474
|
+
# we need to create a Path object from it
|
|
475
|
+
return Path(resolved)
|