hopx-ai 0.1.15__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.
Potentially problematic release.
This version of hopx-ai might be problematic. Click here for more details.
- hopx_ai/__init__.py +114 -0
- hopx_ai/_agent_client.py +391 -0
- hopx_ai/_async_agent_client.py +223 -0
- hopx_ai/_async_cache.py +38 -0
- hopx_ai/_async_client.py +230 -0
- hopx_ai/_async_commands.py +58 -0
- hopx_ai/_async_env_vars.py +151 -0
- hopx_ai/_async_files.py +81 -0
- hopx_ai/_async_files_clean.py +489 -0
- hopx_ai/_async_terminal.py +184 -0
- hopx_ai/_client.py +230 -0
- hopx_ai/_generated/__init__.py +22 -0
- hopx_ai/_generated/models.py +502 -0
- hopx_ai/_temp_async_token.py +14 -0
- hopx_ai/_test_env_fix.py +30 -0
- hopx_ai/_utils.py +9 -0
- hopx_ai/_ws_client.py +141 -0
- hopx_ai/async_sandbox.py +763 -0
- hopx_ai/cache.py +97 -0
- hopx_ai/commands.py +174 -0
- hopx_ai/desktop.py +1227 -0
- hopx_ai/env_vars.py +244 -0
- hopx_ai/errors.py +249 -0
- hopx_ai/files.py +489 -0
- hopx_ai/models.py +274 -0
- hopx_ai/models_updated.py +270 -0
- hopx_ai/sandbox.py +1447 -0
- hopx_ai/template/__init__.py +47 -0
- hopx_ai/template/build_flow.py +540 -0
- hopx_ai/template/builder.py +300 -0
- hopx_ai/template/file_hasher.py +81 -0
- hopx_ai/template/ready_checks.py +106 -0
- hopx_ai/template/tar_creator.py +122 -0
- hopx_ai/template/types.py +199 -0
- hopx_ai/terminal.py +164 -0
- hopx_ai-0.1.15.dist-info/METADATA +462 -0
- hopx_ai-0.1.15.dist-info/RECORD +38 -0
- hopx_ai-0.1.15.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Async HTTP client for agent operations with retry logic."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
import httpx
|
|
7
|
+
from .errors import (
|
|
8
|
+
FileNotFoundError,
|
|
9
|
+
FileOperationError,
|
|
10
|
+
CodeExecutionError,
|
|
11
|
+
CommandExecutionError,
|
|
12
|
+
DesktopNotAvailableError,
|
|
13
|
+
AgentError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
TimeoutError as HopxTimeoutError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncAgentHTTPClient:
|
|
22
|
+
"""
|
|
23
|
+
Async HTTP client for agent operations with retry logic and error handling.
|
|
24
|
+
|
|
25
|
+
Features:
|
|
26
|
+
- Connection pooling (reuses TCP connections)
|
|
27
|
+
- Automatic retries with exponential backoff
|
|
28
|
+
- Proper error wrapping to HOPX exceptions
|
|
29
|
+
- Configurable timeouts
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
agent_url: str,
|
|
35
|
+
*,
|
|
36
|
+
jwt_token: Optional[str] = None,
|
|
37
|
+
timeout: int = 30,
|
|
38
|
+
max_retries: int = 3,
|
|
39
|
+
token_refresh_callback: Optional[callable] = None,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Initialize async agent HTTP client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
agent_url: Agent base URL (e.g., https://7777-{id}.domain)
|
|
46
|
+
jwt_token: JWT token for agent authentication
|
|
47
|
+
timeout: Default timeout in seconds
|
|
48
|
+
max_retries: Maximum retry attempts
|
|
49
|
+
token_refresh_callback: Async callback to refresh token on 401
|
|
50
|
+
"""
|
|
51
|
+
self._agent_url = agent_url.rstrip('/')
|
|
52
|
+
self._jwt_token = jwt_token
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._max_retries = max_retries
|
|
55
|
+
self._token_refresh_callback = token_refresh_callback
|
|
56
|
+
|
|
57
|
+
# Build headers
|
|
58
|
+
headers = {}
|
|
59
|
+
if self._jwt_token:
|
|
60
|
+
headers['Authorization'] = f'Bearer {self._jwt_token}'
|
|
61
|
+
|
|
62
|
+
# Create reusable async HTTP client with connection pooling
|
|
63
|
+
self._client = httpx.AsyncClient(
|
|
64
|
+
timeout=httpx.Timeout(timeout),
|
|
65
|
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
66
|
+
headers=headers,
|
|
67
|
+
transport=httpx.AsyncHTTPTransport(
|
|
68
|
+
local_address="0.0.0.0", # Force IPv4
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
logger.debug(f"Async agent client initialized: {self._agent_url}")
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
"""Close the HTTP client."""
|
|
76
|
+
await self._client.aclose()
|
|
77
|
+
|
|
78
|
+
def update_jwt_token(self, token: str) -> None:
|
|
79
|
+
"""Update JWT token for agent authentication."""
|
|
80
|
+
self._jwt_token = token
|
|
81
|
+
self._client.headers['Authorization'] = f'Bearer {token}'
|
|
82
|
+
|
|
83
|
+
def _should_retry(self, status_code: int) -> bool:
|
|
84
|
+
"""Check if request should be retried."""
|
|
85
|
+
return status_code in {429, 500, 502, 503, 504}
|
|
86
|
+
|
|
87
|
+
def _get_retry_delay(self, attempt: int) -> float:
|
|
88
|
+
"""Calculate retry delay with exponential backoff."""
|
|
89
|
+
return min(2 ** attempt, 10) # Max 10 seconds
|
|
90
|
+
|
|
91
|
+
def _wrap_error(
|
|
92
|
+
self,
|
|
93
|
+
error: Exception,
|
|
94
|
+
operation: str,
|
|
95
|
+
context: Optional[Dict[str, Any]] = None
|
|
96
|
+
) -> AgentError:
|
|
97
|
+
"""Wrap httpx errors into HOPX exceptions."""
|
|
98
|
+
context = context or {}
|
|
99
|
+
|
|
100
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
101
|
+
status_code = error.response.status_code
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
error_data = error.response.json()
|
|
105
|
+
error_code = error_data.get('code', '').upper()
|
|
106
|
+
error_message = error_data.get('message', str(error))
|
|
107
|
+
except:
|
|
108
|
+
error_code = None
|
|
109
|
+
error_message = str(error)
|
|
110
|
+
|
|
111
|
+
# Map error codes to specific exceptions
|
|
112
|
+
if error_code == 'FILE_NOT_FOUND':
|
|
113
|
+
return FileNotFoundError(
|
|
114
|
+
error_message,
|
|
115
|
+
path=context.get('path'),
|
|
116
|
+
code='file_not_found'
|
|
117
|
+
)
|
|
118
|
+
elif error_code in ('PATH_NOT_ALLOWED', 'PERMISSION_DENIED'):
|
|
119
|
+
return FileOperationError(
|
|
120
|
+
error_message,
|
|
121
|
+
path=context.get('path'),
|
|
122
|
+
operation=operation,
|
|
123
|
+
code='file_operation_failed'
|
|
124
|
+
)
|
|
125
|
+
elif error_code in ('EXECUTION_FAILED', 'EXECUTION_TIMEOUT', 'INVALID_TOKEN'):
|
|
126
|
+
return CodeExecutionError(
|
|
127
|
+
error_message,
|
|
128
|
+
exit_code=error_data.get('exit_code'),
|
|
129
|
+
code='execution_failed'
|
|
130
|
+
)
|
|
131
|
+
elif error_code == 'COMMAND_FAILED':
|
|
132
|
+
return CommandExecutionError(
|
|
133
|
+
error_message,
|
|
134
|
+
exit_code=error_data.get('exit_code'),
|
|
135
|
+
command=context.get('command'),
|
|
136
|
+
code='command_failed'
|
|
137
|
+
)
|
|
138
|
+
elif error_code == 'DESKTOP_NOT_AVAILABLE':
|
|
139
|
+
return DesktopNotAvailableError(
|
|
140
|
+
error_message,
|
|
141
|
+
missing_dependencies=error_data.get('missing_dependencies', []),
|
|
142
|
+
code='desktop_not_available'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
elif isinstance(error, httpx.TimeoutException):
|
|
146
|
+
return HopxTimeoutError(f"{operation} timed out after {self._timeout}s")
|
|
147
|
+
elif isinstance(error, httpx.NetworkError):
|
|
148
|
+
return NetworkError(f"{operation} failed: {error}")
|
|
149
|
+
|
|
150
|
+
return AgentError(f"{operation} failed: {error}")
|
|
151
|
+
|
|
152
|
+
async def _request(
|
|
153
|
+
self,
|
|
154
|
+
method: str,
|
|
155
|
+
endpoint: str,
|
|
156
|
+
operation: str = "request",
|
|
157
|
+
context: Optional[Dict[str, Any]] = None,
|
|
158
|
+
**kwargs
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""Make HTTP request with retry logic."""
|
|
161
|
+
url = f"{self._agent_url}{endpoint}"
|
|
162
|
+
|
|
163
|
+
for attempt in range(self._max_retries):
|
|
164
|
+
try:
|
|
165
|
+
response = await self._client.request(method, url, **kwargs)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
|
|
168
|
+
# Handle empty responses (204 No Content)
|
|
169
|
+
if response.status_code == 204 or not response.content:
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
if response.headers.get('content-type', '').startswith('application/json'):
|
|
173
|
+
return response.json()
|
|
174
|
+
return {"content": response.content}
|
|
175
|
+
|
|
176
|
+
except httpx.HTTPStatusError as e:
|
|
177
|
+
# Handle 401 Unauthorized - try to refresh token
|
|
178
|
+
if e.response.status_code == 401 and self._token_refresh_callback and attempt == 0:
|
|
179
|
+
logger.info(f"{operation} got 401 Unauthorized, attempting token refresh...")
|
|
180
|
+
try:
|
|
181
|
+
# Call async refresh callback to get new token
|
|
182
|
+
new_token = await self._token_refresh_callback()
|
|
183
|
+
if new_token:
|
|
184
|
+
# Update token and retry request
|
|
185
|
+
self.update_jwt_token(new_token)
|
|
186
|
+
logger.info(f"{operation} token refreshed, retrying request...")
|
|
187
|
+
continue
|
|
188
|
+
except Exception as refresh_error:
|
|
189
|
+
logger.error(f"Token refresh failed: {refresh_error}")
|
|
190
|
+
# Fall through to raise original error
|
|
191
|
+
|
|
192
|
+
if not self._should_retry(e.response.status_code) or attempt == self._max_retries - 1:
|
|
193
|
+
raise self._wrap_error(e, operation, context)
|
|
194
|
+
|
|
195
|
+
await asyncio.sleep(self._get_retry_delay(attempt))
|
|
196
|
+
|
|
197
|
+
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
|
198
|
+
if attempt == self._max_retries - 1:
|
|
199
|
+
raise self._wrap_error(e, operation, context)
|
|
200
|
+
|
|
201
|
+
await asyncio.sleep(self._get_retry_delay(attempt))
|
|
202
|
+
|
|
203
|
+
raise AgentError(f"{operation} failed after {self._max_retries} retries")
|
|
204
|
+
|
|
205
|
+
async def get(self, endpoint: str, operation: str = "GET request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
206
|
+
"""Make GET request."""
|
|
207
|
+
return await self._request("GET", endpoint, operation=operation, context=context, **kwargs)
|
|
208
|
+
|
|
209
|
+
async def post(self, endpoint: str, operation: str = "POST request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
210
|
+
"""Make POST request."""
|
|
211
|
+
return await self._request("POST", endpoint, operation=operation, context=context, **kwargs)
|
|
212
|
+
|
|
213
|
+
async def put(self, endpoint: str, operation: str = "PUT request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
214
|
+
"""Make PUT request."""
|
|
215
|
+
return await self._request("PUT", endpoint, operation=operation, context=context, **kwargs)
|
|
216
|
+
|
|
217
|
+
async def delete(self, endpoint: str, operation: str = "DELETE request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
218
|
+
"""Make DELETE request."""
|
|
219
|
+
return await self._request("DELETE", endpoint, operation=operation, context=context, **kwargs)
|
|
220
|
+
|
|
221
|
+
async def patch(self, endpoint: str, operation: str = "PATCH request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
222
|
+
"""Make PATCH request."""
|
|
223
|
+
return await self._request("PATCH", endpoint, operation=operation, context=context, **kwargs)
|
hopx_ai/_async_cache.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Async cache operations for sandboxes."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
import logging
|
|
5
|
+
from ._async_agent_client import AsyncAgentHTTPClient
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncCache:
|
|
11
|
+
"""Async cache operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, sandbox):
|
|
14
|
+
"""Initialize with sandbox reference."""
|
|
15
|
+
self._sandbox = sandbox
|
|
16
|
+
logger.debug("AsyncCache initialized")
|
|
17
|
+
|
|
18
|
+
async def _get_client(self) -> AsyncAgentHTTPClient:
|
|
19
|
+
"""Get agent client from sandbox."""
|
|
20
|
+
await self._sandbox._ensure_agent_client()
|
|
21
|
+
return self._sandbox._agent_client
|
|
22
|
+
|
|
23
|
+
async def stats(self) -> Dict[str, Any]:
|
|
24
|
+
"""Get cache statistics."""
|
|
25
|
+
client = await self._get_client()
|
|
26
|
+
response = await client.get(
|
|
27
|
+
"/cache/stats",
|
|
28
|
+
operation="get cache stats"
|
|
29
|
+
)
|
|
30
|
+
return response
|
|
31
|
+
|
|
32
|
+
async def clear(self) -> None:
|
|
33
|
+
"""Clear cache."""
|
|
34
|
+
client = await self._get_client()
|
|
35
|
+
await client.post(
|
|
36
|
+
"/cache/clear",
|
|
37
|
+
operation="clear cache"
|
|
38
|
+
)
|
hopx_ai/_async_client.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Async HTTP client with retry logic."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
import httpx
|
|
8
|
+
from .errors import (
|
|
9
|
+
APIError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
ValidationError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ResourceLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
NetworkError,
|
|
17
|
+
TimeoutError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncHTTPClient:
|
|
24
|
+
"""Async HTTP client with automatic retries and error handling."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: Optional[str] = None,
|
|
29
|
+
base_url: str = "https://api.hopx.dev",
|
|
30
|
+
timeout: int = 60,
|
|
31
|
+
max_retries: int = 3,
|
|
32
|
+
):
|
|
33
|
+
# API key priority: param > env var > error
|
|
34
|
+
self.api_key = api_key or os.environ.get("HOPX_API_KEY")
|
|
35
|
+
if not self.api_key:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"API key required. Pass api_key parameter or set HOPX_API_KEY environment variable.\n"
|
|
38
|
+
"Get your API key at: https://hopx.ai"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self.max_retries = max_retries
|
|
44
|
+
|
|
45
|
+
# Force IPv4 to avoid IPv6 timeout issues (270s delay)
|
|
46
|
+
self._client = httpx.AsyncClient(
|
|
47
|
+
base_url=self.base_url,
|
|
48
|
+
timeout=timeout,
|
|
49
|
+
headers=self._default_headers(),
|
|
50
|
+
transport=httpx.AsyncHTTPTransport(
|
|
51
|
+
local_address="0.0.0.0", # Force IPv4
|
|
52
|
+
retries=0 # We handle retries ourselves
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _default_headers(self) -> Dict[str, str]:
|
|
57
|
+
"""Get default headers for all requests."""
|
|
58
|
+
return {
|
|
59
|
+
"X-API-Key": self.api_key,
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"User-Agent": "bunnyshell-python/0.1.0",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def _should_retry(self, status_code: int, attempt: int) -> bool:
|
|
65
|
+
"""Determine if request should be retried."""
|
|
66
|
+
if attempt >= self.max_retries:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Retry on server errors and rate limits
|
|
70
|
+
return status_code in (429, 500, 502, 503, 504)
|
|
71
|
+
|
|
72
|
+
def _get_retry_delay(self, attempt: int, retry_after: Optional[int] = None) -> float:
|
|
73
|
+
"""Calculate retry delay with exponential backoff."""
|
|
74
|
+
if retry_after:
|
|
75
|
+
return float(retry_after)
|
|
76
|
+
|
|
77
|
+
# Exponential backoff: 1s, 2s, 4s, 8s...
|
|
78
|
+
return min(2 ** attempt, 60)
|
|
79
|
+
|
|
80
|
+
def _handle_error(self, response: httpx.Response) -> None:
|
|
81
|
+
"""Convert HTTP errors to appropriate exceptions."""
|
|
82
|
+
try:
|
|
83
|
+
error_data = response.json().get("error", {})
|
|
84
|
+
message = error_data.get("message", response.text)
|
|
85
|
+
code = error_data.get("code")
|
|
86
|
+
request_id = error_data.get("request_id")
|
|
87
|
+
details = error_data.get("details", {})
|
|
88
|
+
except Exception:
|
|
89
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
90
|
+
code = None
|
|
91
|
+
request_id = response.headers.get("X-Request-ID")
|
|
92
|
+
details = {}
|
|
93
|
+
|
|
94
|
+
kwargs = {
|
|
95
|
+
"code": code,
|
|
96
|
+
"request_id": request_id,
|
|
97
|
+
"details": details,
|
|
98
|
+
"status_code": response.status_code,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if response.status_code == 401:
|
|
102
|
+
raise AuthenticationError(message, **kwargs)
|
|
103
|
+
elif response.status_code == 404:
|
|
104
|
+
raise NotFoundError(message, **kwargs)
|
|
105
|
+
elif response.status_code == 400:
|
|
106
|
+
raise ValidationError(message, **kwargs)
|
|
107
|
+
elif response.status_code == 429:
|
|
108
|
+
retry_after = details.get("retry_after_seconds")
|
|
109
|
+
raise RateLimitError(message, retry_after=retry_after, **kwargs)
|
|
110
|
+
elif response.status_code == 403 and "limit" in message.lower():
|
|
111
|
+
raise ResourceLimitError(
|
|
112
|
+
message,
|
|
113
|
+
limit=details.get("limit"),
|
|
114
|
+
current=details.get("current"),
|
|
115
|
+
available=details.get("available"),
|
|
116
|
+
upgrade_url=details.get("upgrade_url"),
|
|
117
|
+
**kwargs
|
|
118
|
+
)
|
|
119
|
+
elif response.status_code >= 500:
|
|
120
|
+
raise ServerError(message, **kwargs)
|
|
121
|
+
else:
|
|
122
|
+
raise APIError(message, **kwargs)
|
|
123
|
+
|
|
124
|
+
async def request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
params: Optional[Dict[str, Any]] = None,
|
|
130
|
+
json: Optional[Dict[str, Any]] = None,
|
|
131
|
+
) -> Dict[str, Any]:
|
|
132
|
+
"""
|
|
133
|
+
Make an async HTTP request with automatic retries.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
method: HTTP method (GET, POST, DELETE, etc.)
|
|
137
|
+
path: API endpoint path (without base URL)
|
|
138
|
+
params: Query parameters
|
|
139
|
+
json: JSON request body
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Response JSON data
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
HopxError: On API errors
|
|
146
|
+
NetworkError: On network errors
|
|
147
|
+
TimeoutError: On timeout
|
|
148
|
+
"""
|
|
149
|
+
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
150
|
+
|
|
151
|
+
# Debug logging
|
|
152
|
+
logger.debug(f"{method} {url}")
|
|
153
|
+
if json:
|
|
154
|
+
logger.debug(f"Request body: {json}")
|
|
155
|
+
if params:
|
|
156
|
+
logger.debug(f"Query params: {params}")
|
|
157
|
+
|
|
158
|
+
for attempt in range(self.max_retries + 1):
|
|
159
|
+
try:
|
|
160
|
+
import time
|
|
161
|
+
start_time = time.time()
|
|
162
|
+
|
|
163
|
+
response = await self._client.request(
|
|
164
|
+
method=method,
|
|
165
|
+
url=url,
|
|
166
|
+
params=params,
|
|
167
|
+
json=json,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
elapsed = time.time() - start_time
|
|
171
|
+
logger.debug(f"Response: {response.status_code} ({elapsed:.3f}s)")
|
|
172
|
+
|
|
173
|
+
# Success
|
|
174
|
+
if response.status_code < 400:
|
|
175
|
+
result = response.json()
|
|
176
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
177
|
+
logger.debug(f"Response body: {result}")
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
# Should we retry?
|
|
181
|
+
if self._should_retry(response.status_code, attempt):
|
|
182
|
+
retry_after = None
|
|
183
|
+
if response.status_code == 429:
|
|
184
|
+
try:
|
|
185
|
+
retry_after = response.json().get("error", {}).get("details", {}).get("retry_after_seconds")
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
delay = self._get_retry_delay(attempt, retry_after)
|
|
190
|
+
logger.debug(f"Retrying in {delay}s (attempt {attempt + 1}/{self.max_retries})")
|
|
191
|
+
await asyncio.sleep(delay)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Error - no retry
|
|
195
|
+
self._handle_error(response)
|
|
196
|
+
|
|
197
|
+
except httpx.TimeoutException as e:
|
|
198
|
+
if attempt < self.max_retries:
|
|
199
|
+
delay = self._get_retry_delay(attempt)
|
|
200
|
+
logger.debug(f"Timeout, retrying in {delay}s")
|
|
201
|
+
await asyncio.sleep(delay)
|
|
202
|
+
continue
|
|
203
|
+
raise TimeoutError(f"Request timed out after {self.timeout}s") from e
|
|
204
|
+
|
|
205
|
+
except httpx.NetworkError as e:
|
|
206
|
+
if attempt < self.max_retries:
|
|
207
|
+
delay = self._get_retry_delay(attempt)
|
|
208
|
+
logger.debug(f"Network error, retrying in {delay}s")
|
|
209
|
+
await asyncio.sleep(delay)
|
|
210
|
+
continue
|
|
211
|
+
raise NetworkError(f"Network error: {e}") from e
|
|
212
|
+
|
|
213
|
+
raise ServerError("Max retries exceeded")
|
|
214
|
+
|
|
215
|
+
async def get(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
216
|
+
"""GET request."""
|
|
217
|
+
return await self.request("GET", path, **kwargs)
|
|
218
|
+
|
|
219
|
+
async def post(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
220
|
+
"""POST request."""
|
|
221
|
+
return await self.request("POST", path, **kwargs)
|
|
222
|
+
|
|
223
|
+
async def delete(self, path: str, **kwargs) -> Dict[str, Any]:
|
|
224
|
+
"""DELETE request."""
|
|
225
|
+
return await self.request("DELETE", path, **kwargs)
|
|
226
|
+
|
|
227
|
+
async def close(self) -> None:
|
|
228
|
+
"""Close the HTTP client."""
|
|
229
|
+
await self._client.aclose()
|
|
230
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Async command execution for sandboxes."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
import logging
|
|
5
|
+
from ._async_agent_client import AsyncAgentHTTPClient
|
|
6
|
+
from .models import ExecutionResult
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncCommands:
|
|
12
|
+
"""Async command execution for sandboxes."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, sandbox):
|
|
15
|
+
"""Initialize with sandbox reference."""
|
|
16
|
+
self._sandbox = sandbox
|
|
17
|
+
logger.debug("AsyncCommands initialized")
|
|
18
|
+
|
|
19
|
+
async def _get_client(self) -> AsyncAgentHTTPClient:
|
|
20
|
+
"""Get agent client from sandbox."""
|
|
21
|
+
await self._sandbox._ensure_agent_client()
|
|
22
|
+
return self._sandbox._agent_client
|
|
23
|
+
|
|
24
|
+
async def run(
|
|
25
|
+
self,
|
|
26
|
+
command: str,
|
|
27
|
+
*,
|
|
28
|
+
timeout_seconds: int = 60,
|
|
29
|
+
env: Optional[Dict[str, str]] = None,
|
|
30
|
+
working_dir: str = "/workspace"
|
|
31
|
+
) -> ExecutionResult:
|
|
32
|
+
"""Run shell command."""
|
|
33
|
+
client = await self._get_client()
|
|
34
|
+
|
|
35
|
+
payload = {
|
|
36
|
+
"command": command,
|
|
37
|
+
"timeout": timeout_seconds,
|
|
38
|
+
"working_dir": working_dir
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if env:
|
|
42
|
+
payload["env"] = env
|
|
43
|
+
|
|
44
|
+
response = await client.post(
|
|
45
|
+
"/commands/run",
|
|
46
|
+
json=payload,
|
|
47
|
+
operation="run command",
|
|
48
|
+
context={"command": command}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return ExecutionResult(
|
|
52
|
+
success=response.get("success", True),
|
|
53
|
+
stdout=response.get("stdout", ""),
|
|
54
|
+
stderr=response.get("stderr", ""),
|
|
55
|
+
exit_code=response.get("exit_code", 0),
|
|
56
|
+
execution_time=response.get("execution_time", 0.0),
|
|
57
|
+
rich_outputs=[]
|
|
58
|
+
)
|