hopx-ai 0.1.10__tar.gz → 0.1.16__tar.gz

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.

Files changed (100) hide show
  1. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/PKG-INFO +10 -8
  2. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/README.md +9 -7
  3. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/__init__.py +2 -2
  4. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_agent_client.py +18 -0
  5. hopx_ai-0.1.16/hopx_ai/_async_agent_client.py +223 -0
  6. hopx_ai-0.1.16/hopx_ai/_async_cache.py +38 -0
  7. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_async_client.py +1 -1
  8. hopx_ai-0.1.16/hopx_ai/_async_commands.py +58 -0
  9. hopx_ai-0.1.16/hopx_ai/_async_env_vars.py +151 -0
  10. hopx_ai-0.1.16/hopx_ai/_async_files.py +81 -0
  11. hopx_ai-0.1.16/hopx_ai/_async_terminal.py +184 -0
  12. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_client.py +1 -1
  13. hopx_ai-0.1.16/hopx_ai/_temp_async_token.py +14 -0
  14. hopx_ai-0.1.16/hopx_ai/_test_env_fix.py +30 -0
  15. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/async_sandbox.py +336 -0
  16. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/env_vars.py +23 -21
  17. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/errors.py +2 -2
  18. hopx_ai-0.1.16/hopx_ai/files.py +489 -0
  19. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/models.py +1 -0
  20. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/sandbox.py +17 -9
  21. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/pyproject.toml +1 -1
  22. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/.gitignore +0 -0
  23. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/CHANGELOG.md +0 -0
  24. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/COOKBOOK_TEST_RESULTS.md +0 -0
  25. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/DESKTOP_AUTOMATION.md +0 -0
  26. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/FIXES_APPLIED_v0.1.9.md +0 -0
  27. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/INVESTIGATION_RESULTS.md +0 -0
  28. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/agent_test_report.json +0 -0
  29. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/bunnyshell/_async_client.py +0 -0
  30. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/bunnyshell/_client.py +0 -0
  31. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/example_dx_showcase.py +0 -0
  32. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_code_execution.py +0 -0
  33. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_commands.py +0 -0
  34. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_complete_workflow.py +0 -0
  35. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_files.py +0 -0
  36. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_v3_1_1_error_codes.py +0 -0
  37. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_v3_1_features.py +0 -0
  38. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/async_iterator.py +0 -0
  39. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/async_quick_start.py +0 -0
  40. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/context_manager.py +0 -0
  41. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/debug_logging.py +0 -0
  42. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_automation.py +0 -0
  43. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_complete_workflow.py +0 -0
  44. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_screenshot_recording.py +0 -0
  45. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_vnc.py +0 -0
  46. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_windows.py +0 -0
  47. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/lazy_iterator.py +0 -0
  48. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/lifecycle.py +0 -0
  49. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/list_sandboxes.py +0 -0
  50. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/quick_start.py +0 -0
  51. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/rotate_api_keys.py +0 -0
  52. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/template_build.py +0 -0
  53. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/template_nodejs.py +0 -0
  54. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/templates.py +0 -0
  55. /hopx_ai-0.1.10/hopx_ai/files.py → /hopx_ai-0.1.16/hopx_ai/_async_files_clean.py +0 -0
  56. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_generated/__init__.py +0 -0
  57. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_generated/models.py +0 -0
  58. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_utils.py +0 -0
  59. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_ws_client.py +0 -0
  60. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/cache.py +0 -0
  61. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/commands.py +0 -0
  62. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/desktop.py +0 -0
  63. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/models_updated.py +0 -0
  64. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/__init__.py +0 -0
  65. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/build_flow.py +0 -0
  66. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/builder.py +0 -0
  67. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/file_hasher.py +0 -0
  68. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/ready_checks.py +0 -0
  69. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/tar_creator.py +0 -0
  70. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/types.py +0 -0
  71. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/terminal.py +0 -0
  72. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_agent_comprehensive.py +0 -0
  73. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_agent_v3_1_1.py +0 -0
  74. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_agent_v3_1_improvements.py +0 -0
  75. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_all_features.py +0 -0
  76. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_api_endpoints.py +0 -0
  77. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_async_live.py +0 -0
  78. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_env_vars.py +0 -0
  79. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_hybrid_complete.py +0 -0
  80. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_hybrid_live.py +0 -0
  81. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_improvements.py +0 -0
  82. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_jwt_api.py +0 -0
  83. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_jwt_auth.py +0 -0
  84. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_jwt_simple.py +0 -0
  85. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_live.py +0 -0
  86. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_logs_polling.py +0 -0
  87. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_logs_simple.py +0 -0
  88. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_qa_comprehensive.py +0 -0
  89. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_qa_simple.py +0 -0
  90. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_sdk_e2e_template.py +0 -0
  91. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_comprehensive.py +0 -0
  92. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_id.py +0 -0
  93. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_id_final.py +0 -0
  94. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_name.py +0 -0
  95. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_websocket_features.py +0 -0
  96. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_with_real_key.py +0 -0
  97. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_working_features.py +0 -0
  98. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/vm_agent_openapi.md +0 -0
  99. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/vm_agent_openapi_script.md +0 -0
  100. {hopx_ai-0.1.10 → hopx_ai-0.1.16}/vm_api_agent_readme.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hopx-ai
3
- Version: 0.1.10
3
+ Version: 0.1.16
4
4
  Summary: Official Python SDK for HOPX.AI Sandboxes
5
5
  Project-URL: Homepage, https://hopx.ai
6
6
  Project-URL: Documentation, https://docs.hopx.ai
@@ -329,18 +329,20 @@ asyncio.run(terminal_session())
329
329
 
330
330
  ```python
331
331
  sandbox = Sandbox.create(
332
- template='code-interpreter',
332
+ template='code-interpreter', # Template defines CPU/RAM/Disk resources
333
333
  api_key='your-api-key',
334
- vcpu=4, # 4 vCPUs
335
- memory_mb=4096, # 4GB RAM
336
- disk_gb=20, # 20GB disk
337
- region='us-west-2', # Specific region
338
- timeout=600, # 10 minute timeout
339
- env_vars={ # Pre-set environment variables
334
+ region='us-west-2', # Preferred region (optional)
335
+ timeout_seconds=600, # Auto-kill after 10 minutes (optional)
336
+ internet_access=True, # Enable internet access (optional, default: True)
337
+ env_vars={ # Pre-set environment variables (optional)
340
338
  'DATABASE_URL': 'postgres://...',
341
339
  'API_KEY': 'sk-...'
342
340
  }
343
341
  )
342
+
343
+ # Note: Resources (vCPU, RAM, Disk) come from the template.
344
+ # To customize resources, create a custom template with Template.build()
345
+ # See: https://docs.hopx.ai/templates
344
346
  ```
345
347
 
346
348
  ## Error Handling
@@ -296,18 +296,20 @@ asyncio.run(terminal_session())
296
296
 
297
297
  ```python
298
298
  sandbox = Sandbox.create(
299
- template='code-interpreter',
299
+ template='code-interpreter', # Template defines CPU/RAM/Disk resources
300
300
  api_key='your-api-key',
301
- vcpu=4, # 4 vCPUs
302
- memory_mb=4096, # 4GB RAM
303
- disk_gb=20, # 20GB disk
304
- region='us-west-2', # Specific region
305
- timeout=600, # 10 minute timeout
306
- env_vars={ # Pre-set environment variables
301
+ region='us-west-2', # Preferred region (optional)
302
+ timeout_seconds=600, # Auto-kill after 10 minutes (optional)
303
+ internet_access=True, # Enable internet access (optional, default: True)
304
+ env_vars={ # Pre-set environment variables (optional)
307
305
  'DATABASE_URL': 'postgres://...',
308
306
  'API_KEY': 'sk-...'
309
307
  }
310
308
  )
309
+
310
+ # Note: Resources (vCPU, RAM, Disk) come from the template.
311
+ # To customize resources, create a custom template with Template.build()
312
+ # See: https://docs.hopx.ai/templates
311
313
  ```
312
314
 
313
315
  ## Error Handling
@@ -39,7 +39,7 @@ from .models import (
39
39
  DisplayInfo,
40
40
  )
41
41
  from .errors import (
42
- BunnyshellError,
42
+ HopxError,
43
43
  APIError,
44
44
  AuthenticationError,
45
45
  NotFoundError,
@@ -85,7 +85,7 @@ __all__ = [
85
85
  "RecordingInfo",
86
86
  "DisplayInfo",
87
87
  # Errors
88
- "BunnyshellError",
88
+ "HopxError",
89
89
  "APIError",
90
90
  "AuthenticationError",
91
91
  "NotFoundError",
@@ -36,6 +36,7 @@ class AgentHTTPClient:
36
36
  jwt_token: Optional[str] = None,
37
37
  timeout: int = 30,
38
38
  max_retries: int = 3,
39
+ token_refresh_callback: Optional[callable] = None,
39
40
  ):
40
41
  """
41
42
  Initialize agent HTTP client.
@@ -45,11 +46,13 @@ class AgentHTTPClient:
45
46
  jwt_token: JWT token for agent authentication
46
47
  timeout: Default timeout in seconds
47
48
  max_retries: Maximum retry attempts
49
+ token_refresh_callback: Callback to refresh token on 401 (returns new token)
48
50
  """
49
51
  self._agent_url = agent_url.rstrip('/')
50
52
  self._jwt_token = jwt_token
51
53
  self._timeout = timeout
52
54
  self._max_retries = max_retries
55
+ self._token_refresh_callback = token_refresh_callback
53
56
 
54
57
  # Build headers
55
58
  headers = {}
@@ -298,6 +301,21 @@ class AgentHTTPClient:
298
301
  return response
299
302
 
300
303
  except httpx.HTTPStatusError as e:
304
+ # Handle 401 Unauthorized - try to refresh token
305
+ if e.response.status_code == 401 and self._token_refresh_callback and attempt == 0:
306
+ logger.info(f"{operation} got 401 Unauthorized, attempting token refresh...")
307
+ try:
308
+ # Call refresh callback to get new token
309
+ new_token = self._token_refresh_callback()
310
+ if new_token:
311
+ # Update token and retry request
312
+ self.update_jwt_token(new_token)
313
+ logger.info(f"{operation} token refreshed, retrying request...")
314
+ continue
315
+ except Exception as refresh_error:
316
+ logger.error(f"Token refresh failed: {refresh_error}")
317
+ # Fall through to raise original error
318
+
301
319
  # Check if should retry
302
320
  if attempt < self._max_retries - 1 and self._should_retry(e.response.status_code):
303
321
  delay = self._get_retry_delay(attempt)
@@ -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)
@@ -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
+ )
@@ -142,7 +142,7 @@ class AsyncHTTPClient:
142
142
  Response JSON data
143
143
 
144
144
  Raises:
145
- BunnyshellError: On API errors
145
+ HopxError: On API errors
146
146
  NetworkError: On network errors
147
147
  TimeoutError: On timeout
148
148
  """
@@ -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
+ )
@@ -0,0 +1,151 @@
1
+ """Async environment variables for sandboxes."""
2
+
3
+ from typing import Dict, Optional
4
+ import logging
5
+ from ._async_agent_client import AsyncAgentHTTPClient
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class AsyncEnvironmentVariables:
11
+ """
12
+ Async environment variable operations.
13
+
14
+ Provides methods for managing environment variables inside the sandbox at runtime.
15
+
16
+ Features:
17
+ - Get all environment variables
18
+ - Set/replace all environment variables
19
+ - Update specific environment variables (merge)
20
+ - Delete individual environment variables
21
+ """
22
+
23
+ def __init__(self, sandbox):
24
+ """Initialize with sandbox reference."""
25
+ self._sandbox = sandbox
26
+ logger.debug("AsyncEnvironmentVariables initialized")
27
+
28
+ async def _get_client(self) -> AsyncAgentHTTPClient:
29
+ """Get agent client from sandbox."""
30
+ await self._sandbox._ensure_agent_client()
31
+ return self._sandbox._agent_client
32
+
33
+ async def get_all(self, *, timeout: Optional[int] = None) -> Dict[str, str]:
34
+ """
35
+ Get all environment variables.
36
+
37
+ Args:
38
+ timeout: Request timeout in seconds (overrides default)
39
+
40
+ Returns:
41
+ Dictionary of environment variables
42
+ """
43
+ logger.debug("Getting all environment variables")
44
+
45
+ client = await self._get_client()
46
+ response = await client.get(
47
+ "/env",
48
+ operation="get environment variables"
49
+ )
50
+
51
+ return response.get("env_vars", {})
52
+
53
+ async def set_all(
54
+ self,
55
+ env_vars: Dict[str, str],
56
+ *,
57
+ timeout: Optional[int] = None
58
+ ) -> Dict[str, str]:
59
+ """
60
+ Set/replace all environment variables.
61
+
62
+ This replaces ALL existing environment variables with the provided ones.
63
+ Use update() if you want to merge instead.
64
+
65
+ Args:
66
+ env_vars: Dictionary of environment variables to set
67
+ timeout: Request timeout in seconds
68
+
69
+ Returns:
70
+ Updated dictionary of all environment variables
71
+ """
72
+ logger.debug(f"Setting {len(env_vars)} environment variables (replace all)")
73
+
74
+ client = await self._get_client()
75
+ response = await client.put(
76
+ "/env",
77
+ json={"env_vars": env_vars},
78
+ operation="set environment variables"
79
+ )
80
+
81
+ return response.get("env_vars", env_vars)
82
+
83
+ async def update(
84
+ self,
85
+ env_vars: Dict[str, str],
86
+ *,
87
+ timeout: Optional[int] = None
88
+ ) -> Dict[str, str]:
89
+ """
90
+ Update specific environment variables (merge).
91
+
92
+ This merges the provided variables with existing ones.
93
+ Existing variables not specified are preserved.
94
+
95
+ Args:
96
+ env_vars: Dictionary of environment variables to update/add
97
+ timeout: Request timeout in seconds
98
+
99
+ Returns:
100
+ Updated dictionary of all environment variables
101
+ """
102
+ logger.debug(f"Updating {len(env_vars)} environment variables (merge)")
103
+
104
+ client = await self._get_client()
105
+ response = await client.patch(
106
+ "/env",
107
+ json={"env_vars": env_vars, "merge": True}, # ✅ FIXED: add merge flag
108
+ operation="update environment variables"
109
+ )
110
+
111
+ # Agent returns 204 No Content - get updated vars
112
+ if not response or not response.get("env_vars"):
113
+ return await self.get_all()
114
+
115
+ return response.get("env_vars", {})
116
+
117
+ async def delete(self, name: str, *, timeout: Optional[int] = None) -> None:
118
+ """
119
+ Delete a specific environment variable.
120
+
121
+ Note: Agent's DELETE /env clears ALL custom variables.
122
+ We work around this by re-setting all vars except the one to delete.
123
+
124
+ Args:
125
+ name: Variable name to delete
126
+ timeout: Request timeout in seconds
127
+ """
128
+ logger.debug(f"Deleting environment variable: {name}")
129
+
130
+ # Get all current env vars
131
+ current_vars = await self.get_all()
132
+
133
+ # Remove the specified variable
134
+ if name in current_vars:
135
+ del current_vars[name]
136
+ # Re-set all env vars without the deleted one
137
+ await self.set_all(current_vars)
138
+ logger.debug(f"Environment variable {name} deleted")
139
+ else:
140
+ logger.debug(f"Environment variable {name} not found (already deleted)")
141
+
142
+ # Convenience methods (aliases)
143
+
144
+ async def get(self, name: str) -> Optional[str]:
145
+ """Get a single environment variable value."""
146
+ all_vars = await self.get_all()
147
+ return all_vars.get(name)
148
+
149
+ async def set(self, name: str, value: str) -> None:
150
+ """Set a single environment variable (convenience method)."""
151
+ await self.update({name: value})