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.

@@ -0,0 +1,763 @@
1
+ """Async Sandbox class - for async/await usage."""
2
+
3
+ from typing import Optional, List, AsyncIterator, Dict
4
+ from typing import Any
5
+ from datetime import datetime, timedelta
6
+ from dataclasses import dataclass
7
+ from .models import SandboxInfo, Template
8
+ from ._async_client import AsyncHTTPClient
9
+
10
+ from datetime import datetime, timedelta
11
+ from dataclasses import dataclass
12
+
13
+ @dataclass
14
+ class TokenData:
15
+ """JWT token data."""
16
+ token: str
17
+ expires_at: datetime
18
+
19
+ # Global token cache (shared between AsyncSandbox instances)
20
+ _token_cache: Dict[str, TokenData] = {}
21
+
22
+ from ._utils import remove_none_values
23
+
24
+
25
+ class AsyncSandbox:
26
+ """
27
+ Async Bunnyshell Sandbox - lightweight VM management with async/await.
28
+
29
+ For async Python applications (FastAPI, aiohttp, etc.)
30
+
31
+ Example:
32
+ >>> from bunnyshell import AsyncSandbox
33
+ >>>
34
+ >>> async with AsyncSandbox.create(template="nodejs") as sandbox:
35
+ ... info = await sandbox.get_info()
36
+ ... print(info.public_host)
37
+ # Automatically cleaned up!
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ sandbox_id: str,
43
+ *,
44
+ api_key: Optional[str] = None,
45
+ base_url: str = "https://api.hopx.dev",
46
+ timeout: int = 60,
47
+ max_retries: int = 3,
48
+ ):
49
+ """
50
+ Initialize AsyncSandbox instance.
51
+
52
+ Note: Prefer using AsyncSandbox.create() or AsyncSandbox.connect() instead.
53
+
54
+ Args:
55
+ sandbox_id: Sandbox ID
56
+ api_key: API key (or use HOPX_API_KEY env var)
57
+ base_url: API base URL
58
+ timeout: Request timeout in seconds
59
+ max_retries: Maximum number of retries
60
+ """
61
+ self.sandbox_id = sandbox_id
62
+ self._client = AsyncHTTPClient(
63
+ api_key=api_key,
64
+ base_url=base_url,
65
+ timeout=timeout,
66
+ max_retries=max_retries,
67
+ )
68
+ self._agent_client = None
69
+ self._jwt_token = None
70
+
71
+ # =============================================================================
72
+ # CLASS METHODS (Static - for creating/listing sandboxes)
73
+ # =============================================================================
74
+
75
+ @classmethod
76
+ async def create(
77
+ cls,
78
+ template: Optional[str] = None,
79
+ *,
80
+ template_id: Optional[str] = None,
81
+ region: Optional[str] = None,
82
+ timeout_seconds: Optional[int] = None,
83
+ internet_access: Optional[bool] = None,
84
+ env_vars: Optional[Dict[str, str]] = None,
85
+ api_key: Optional[str] = None,
86
+ base_url: str = "https://api.hopx.dev",
87
+ ) -> "AsyncSandbox":
88
+ """
89
+ Create a new sandbox (async).
90
+
91
+ You can create a sandbox in two ways:
92
+ 1. From template ID (resources auto-loaded from template)
93
+ 2. Custom sandbox (specify template name + resources)
94
+
95
+ Args:
96
+ template: Template name for custom sandbox (e.g., "code-interpreter", "nodejs")
97
+ template_id: Template ID to create from (resources auto-loaded, no vcpu/memory needed)
98
+ region: Preferred region (optional)
99
+ timeout_seconds: Auto-kill timeout in seconds (optional, default: no timeout)
100
+ internet_access: Enable internet access (optional, default: True)
101
+ env_vars: Environment variables (optional)
102
+ api_key: API key (or use HOPX_API_KEY env var)
103
+ base_url: API base URL
104
+
105
+ Returns:
106
+ AsyncSandbox instance
107
+
108
+ Examples:
109
+ >>> # Create from template ID with timeout
110
+ >>> sandbox = await AsyncSandbox.create(
111
+ ... template_id="282",
112
+ ... timeout_seconds=600,
113
+ ... internet_access=False
114
+ ... )
115
+
116
+ >>> # Create custom sandbox
117
+ >>> sandbox = await AsyncSandbox.create(
118
+ ... template="nodejs",
119
+ ... timeout_seconds=300
120
+ ... )
121
+ """
122
+ client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
123
+
124
+ # Validate parameters
125
+ if template_id:
126
+ # Create from template ID (resources from template)
127
+ # Convert template_id to string if it's an int (API may return int from build)
128
+ data = remove_none_values({
129
+ "template_id": str(template_id),
130
+ "region": region,
131
+ "timeout_seconds": timeout_seconds,
132
+ "internet_access": internet_access,
133
+ "env_vars": env_vars,
134
+ })
135
+ elif template:
136
+ # Create from template name (resources from template)
137
+ data = remove_none_values({
138
+ "template_name": template,
139
+ "region": region,
140
+ "timeout_seconds": timeout_seconds,
141
+ "internet_access": internet_access,
142
+ "env_vars": env_vars,
143
+ })
144
+ else:
145
+ raise ValueError("Either 'template' or 'template_id' must be provided")
146
+
147
+ response = await client.post("/v1/sandboxes", json=data)
148
+ sandbox_id = response["id"]
149
+
150
+ return cls(
151
+ sandbox_id=sandbox_id,
152
+ api_key=api_key,
153
+ base_url=base_url,
154
+ )
155
+
156
+ @classmethod
157
+ async def connect(
158
+ cls,
159
+ sandbox_id: str,
160
+ *,
161
+ api_key: Optional[str] = None,
162
+ base_url: str = "https://api.hopx.dev",
163
+ ) -> "AsyncSandbox":
164
+ """
165
+ Connect to an existing sandbox (async).
166
+
167
+ Args:
168
+ sandbox_id: Sandbox ID
169
+ api_key: API key (or use HOPX_API_KEY env var)
170
+ base_url: API base URL
171
+
172
+ Returns:
173
+ AsyncSandbox instance
174
+
175
+ Example:
176
+ >>> sandbox = await AsyncSandbox.connect("sandbox_id")
177
+ >>> info = await sandbox.get_info()
178
+ """
179
+ instance = cls(
180
+ sandbox_id=sandbox_id,
181
+ api_key=api_key,
182
+ base_url=base_url,
183
+ )
184
+
185
+ # Verify it exists
186
+ await instance.get_info()
187
+
188
+ return instance
189
+
190
+ @classmethod
191
+ async def list(
192
+ cls,
193
+ *,
194
+ status: Optional[str] = None,
195
+ region: Optional[str] = None,
196
+ limit: int = 100,
197
+ api_key: Optional[str] = None,
198
+ base_url: str = "https://api.hopx.dev",
199
+ ) -> List["AsyncSandbox"]:
200
+ """
201
+ List all sandboxes (async).
202
+
203
+ Args:
204
+ status: Filter by status
205
+ region: Filter by region
206
+ limit: Maximum number of results
207
+ api_key: API key
208
+ base_url: API base URL
209
+
210
+ Returns:
211
+ List of AsyncSandbox instances
212
+
213
+ Example:
214
+ >>> sandboxes = await AsyncSandbox.list(status="running")
215
+ >>> for sb in sandboxes:
216
+ ... info = await sb.get_info()
217
+ ... print(info.public_host)
218
+ """
219
+ client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
220
+
221
+ params = remove_none_values({
222
+ "status": status,
223
+ "region": region,
224
+ "limit": limit,
225
+ })
226
+
227
+ response = await client.get("/v1/sandboxes", params=params)
228
+ sandboxes_data = response.get("data", [])
229
+
230
+ return [
231
+ cls(
232
+ sandbox_id=sb["id"],
233
+ api_key=api_key,
234
+ base_url=base_url,
235
+ )
236
+ for sb in sandboxes_data
237
+ ]
238
+
239
+ @classmethod
240
+ async def iter(
241
+ cls,
242
+ *,
243
+ status: Optional[str] = None,
244
+ region: Optional[str] = None,
245
+ api_key: Optional[str] = None,
246
+ base_url: str = "https://api.hopx.dev",
247
+ ) -> AsyncIterator["AsyncSandbox"]:
248
+ """
249
+ Lazy async iterator for sandboxes.
250
+
251
+ Yields sandboxes one by one, fetching pages as needed.
252
+
253
+ Args:
254
+ status: Filter by status
255
+ region: Filter by region
256
+ api_key: API key
257
+ base_url: API base URL
258
+
259
+ Yields:
260
+ AsyncSandbox instances
261
+
262
+ Example:
263
+ >>> async for sandbox in AsyncSandbox.iter(status="running"):
264
+ ... info = await sandbox.get_info()
265
+ ... print(info.public_host)
266
+ ... if found:
267
+ ... break # Doesn't fetch remaining pages
268
+ """
269
+ client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
270
+ limit = 100
271
+ has_more = True
272
+ cursor = None
273
+
274
+ while has_more:
275
+ params = {"limit": limit}
276
+ if status:
277
+ params["status"] = status
278
+ if region:
279
+ params["region"] = region
280
+ if cursor:
281
+ params["cursor"] = cursor
282
+
283
+ response = await client.get("/v1/sandboxes", params=params)
284
+
285
+ for item in response.get("data", []):
286
+ yield cls(
287
+ sandbox_id=item["id"],
288
+ api_key=api_key,
289
+ base_url=base_url,
290
+ )
291
+
292
+ has_more = response.get("has_more", False)
293
+ cursor = response.get("next_cursor")
294
+
295
+ @classmethod
296
+ async def list_templates(
297
+ cls,
298
+ *,
299
+ category: Optional[str] = None,
300
+ language: Optional[str] = None,
301
+ api_key: Optional[str] = None,
302
+ base_url: str = "https://api.hopx.dev",
303
+ ) -> List[Template]:
304
+ """
305
+ List available templates (async).
306
+
307
+ Args:
308
+ category: Filter by category
309
+ language: Filter by language
310
+ api_key: API key
311
+ base_url: API base URL
312
+
313
+ Returns:
314
+ List of Template objects
315
+
316
+ Example:
317
+ >>> templates = await AsyncSandbox.list_templates()
318
+ >>> for t in templates:
319
+ ... print(f"{t.name}: {t.display_name}")
320
+ """
321
+ client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
322
+
323
+ params = remove_none_values({
324
+ "category": category,
325
+ "language": language,
326
+ })
327
+
328
+ response = await client.get("/v1/templates", params=params)
329
+ templates_data = response.get("data", [])
330
+
331
+ return [Template(**t) for t in templates_data]
332
+
333
+ @classmethod
334
+ async def get_template(
335
+ cls,
336
+ name: str,
337
+ *,
338
+ api_key: Optional[str] = None,
339
+ base_url: str = "https://api.hopx.dev",
340
+ ) -> Template:
341
+ """
342
+ Get template details (async).
343
+
344
+ Args:
345
+ name: Template name
346
+ api_key: API key
347
+ base_url: API base URL
348
+
349
+ Returns:
350
+ Template object
351
+
352
+ Example:
353
+ >>> template = await AsyncSandbox.get_template("nodejs")
354
+ >>> print(template.description)
355
+ """
356
+ client = AsyncHTTPClient(api_key=api_key, base_url=base_url)
357
+ response = await client.get(f"/v1/templates/{name}")
358
+ return Template(**response)
359
+
360
+ # =============================================================================
361
+ # INSTANCE METHODS (for managing individual sandbox)
362
+ # =============================================================================
363
+
364
+ async def get_info(self) -> SandboxInfo:
365
+ """
366
+ Get current sandbox information (async).
367
+
368
+ Returns:
369
+ SandboxInfo with current state
370
+
371
+ Example:
372
+ >>> info = await sandbox.get_info()
373
+ >>> print(f"Status: {info.status}")
374
+ """
375
+ response = await self._client.get(f"/v1/sandboxes/{self.sandbox_id}")
376
+ return SandboxInfo(
377
+ sandbox_id=response["id"],
378
+ template_id=response.get("template_id"),
379
+ template_name=response.get("template_name"),
380
+ organization_id=response.get("organization_id", ""),
381
+ node_id=response.get("node_id"),
382
+ region=response.get("region"),
383
+ status=response["status"],
384
+ public_host=response.get("public_host") or response.get("direct_url", ""),
385
+ vcpu=response.get("resources", {}).get("vcpu"),
386
+ memory_mb=response.get("resources", {}).get("memory_mb"),
387
+ disk_mb=response.get("resources", {}).get("disk_mb"),
388
+ created_at=response.get("created_at"),
389
+ started_at=None,
390
+ end_at=None,
391
+ )
392
+
393
+ async def stop(self) -> None:
394
+ """Stop the sandbox (async)."""
395
+ await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/stop")
396
+
397
+ async def start(self) -> None:
398
+ """Start a stopped sandbox (async)."""
399
+ await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/start")
400
+
401
+ async def pause(self) -> None:
402
+ """Pause the sandbox (async)."""
403
+ await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/pause")
404
+
405
+ async def resume(self) -> None:
406
+ """Resume a paused sandbox (async)."""
407
+ await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/resume")
408
+
409
+ async def kill(self) -> None:
410
+ """
411
+ Destroy the sandbox immediately (async).
412
+
413
+ This action is irreversible.
414
+
415
+ Example:
416
+ >>> await sandbox.kill()
417
+ """
418
+ await self._client.delete(f"/v1/sandboxes/{self.sandbox_id}")
419
+
420
+ # =============================================================================
421
+ # ASYNC CONTEXT MANAGER (auto-cleanup)
422
+ # =============================================================================
423
+
424
+ async def __aenter__(self) -> "AsyncSandbox":
425
+ """Async context manager entry."""
426
+ return self
427
+
428
+ async def __aexit__(self, *args) -> None:
429
+ """Async context manager exit - auto cleanup."""
430
+ try:
431
+ await self.kill()
432
+ except Exception:
433
+ # Ignore errors on cleanup
434
+ pass
435
+
436
+ # =============================================================================
437
+ # UTILITY METHODS
438
+ # =============================================================================
439
+
440
+ def __repr__(self) -> str:
441
+ return f"<AsyncSandbox {self.sandbox_id}>"
442
+
443
+ def __str__(self) -> str:
444
+ return f"AsyncSandbox(id={self.sandbox_id})"
445
+
446
+
447
+ # =============================================================================
448
+ # AGENT OPERATIONS (Code Execution)
449
+ # =============================================================================
450
+
451
+ async def _ensure_valid_token(self) -> None:
452
+ """Ensure JWT token is valid and refresh if needed."""
453
+ token_data = _token_cache.get(self.sandbox_id)
454
+
455
+ if token_data is None:
456
+ # Get initial token
457
+ await self.refresh_token()
458
+ else:
459
+ # Check if token expires soon (within 1 hour)
460
+ time_until_expiry = token_data.expires_at - datetime.now(token_data.expires_at.tzinfo)
461
+ if time_until_expiry < timedelta(hours=1):
462
+ await self.refresh_token()
463
+
464
+ async def _ensure_agent_client(self) -> None:
465
+ """Ensure agent HTTP client is initialized."""
466
+ if self._agent_client is None:
467
+ from ._async_agent_client import AsyncAgentHTTPClient
468
+ import asyncio
469
+
470
+ # Get sandbox info to get agent URL
471
+ info = await self.get_info()
472
+ agent_url = info.public_host.rstrip('/')
473
+
474
+ # Ensure JWT token is valid
475
+ await self._ensure_valid_token()
476
+
477
+ # Get JWT token for agent authentication
478
+ jwt_token = _token_cache.get(self.sandbox_id)
479
+ jwt_token_str = jwt_token.token if jwt_token else None
480
+
481
+ # Create agent client with token refresh callback
482
+ async def refresh_token_callback():
483
+ """Async callback to refresh token when agent returns 401."""
484
+ await self.refresh_token()
485
+ token_data = _token_cache.get(self.sandbox_id)
486
+ return token_data.token if token_data else None
487
+
488
+ self._agent_client = AsyncAgentHTTPClient(
489
+ agent_url=agent_url,
490
+ jwt_token=jwt_token_str,
491
+ timeout=60,
492
+ max_retries=3,
493
+ token_refresh_callback=refresh_token_callback
494
+ )
495
+
496
+ # Wait for agent to be ready
497
+ max_wait = 30
498
+ retry_delay = 1.5
499
+
500
+ for attempt in range(max_wait):
501
+ try:
502
+ health = await self._agent_client.get("/health", operation="agent health check")
503
+ if health.get("status") == "healthy":
504
+ break
505
+ except Exception as e:
506
+ if attempt < max_wait - 1:
507
+ await asyncio.sleep(retry_delay)
508
+ continue
509
+
510
+ async def run_code(
511
+ self,
512
+ code: str,
513
+ *,
514
+ language: str = "python",
515
+ timeout_seconds: int = 60,
516
+ env: Optional[Dict[str, str]] = None,
517
+ working_dir: str = "/workspace",
518
+ ):
519
+ """
520
+ Execute code with rich output capture (async).
521
+
522
+ Args:
523
+ code: Code to execute
524
+ language: Language (python, javascript, bash, go)
525
+ timeout_seconds: Execution timeout in seconds
526
+ env: Optional environment variables
527
+ working_dir: Working directory
528
+
529
+ Returns:
530
+ ExecutionResult with stdout, stderr, rich_outputs
531
+ """
532
+ await self._ensure_agent_client()
533
+
534
+ from .models import ExecutionResult, RichOutput
535
+
536
+ payload = {
537
+ "language": language,
538
+ "code": code,
539
+ "working_dir": working_dir,
540
+ "timeout": timeout_seconds
541
+ }
542
+
543
+ if env:
544
+ payload["env"] = env
545
+
546
+ response = await self._agent_client.post(
547
+ "/execute",
548
+ json=payload,
549
+ operation="execute code",
550
+ context={"language": language}
551
+ )
552
+
553
+ # Parse response
554
+ rich_outputs = []
555
+ if response and isinstance(response, dict):
556
+ rich_outputs_data = response.get("rich_outputs") or []
557
+ for output in rich_outputs_data:
558
+ if output:
559
+ rich_outputs.append(RichOutput(
560
+ type=output.get("type", ""),
561
+ data=output.get("data", {}),
562
+ metadata=output.get("metadata"),
563
+ timestamp=output.get("timestamp")
564
+ ))
565
+
566
+ result = ExecutionResult(
567
+ success=response.get("success", True) if response else False,
568
+ stdout=response.get("stdout", "") if response else "",
569
+ stderr=response.get("stderr", "") if response else "",
570
+ exit_code=response.get("exit_code", 0) if response else 1,
571
+ execution_time=response.get("execution_time", 0.0) if response else 0.0,
572
+ rich_outputs=rich_outputs
573
+ )
574
+
575
+ return result
576
+
577
+ async def run_code_async(
578
+ self,
579
+ code: str,
580
+ *,
581
+ language: str = "python",
582
+ timeout_seconds: int = 60,
583
+ env: Optional[Dict[str, str]] = None,
584
+ ) -> str:
585
+ """
586
+ Execute code asynchronously (non-blocking, returns execution ID).
587
+
588
+ Returns:
589
+ Execution ID for tracking
590
+ """
591
+ await self._ensure_agent_client()
592
+
593
+ payload = {
594
+ "language": language,
595
+ "code": code,
596
+ "timeout": timeout_seconds,
597
+ "async": True
598
+ }
599
+
600
+ if env:
601
+ payload["env"] = env
602
+
603
+ response = await self._agent_client.post(
604
+ "/execute",
605
+ json=payload,
606
+ operation="execute code async"
607
+ )
608
+
609
+ return response.get("execution_id", "")
610
+
611
+ async def list_processes(self) -> List[Dict[str, Any]]:
612
+ """List running processes in sandbox."""
613
+ await self._ensure_agent_client()
614
+
615
+ response = await self._agent_client.get(
616
+ "/processes",
617
+ operation="list processes"
618
+ )
619
+
620
+ return response.get("processes", [])
621
+
622
+ async def kill_process(self, process_id: str) -> Dict[str, Any]:
623
+ """Kill a process by ID."""
624
+ await self._ensure_agent_client()
625
+
626
+ response = await self._agent_client.post(
627
+ f"/processes/{process_id}/kill",
628
+ operation="kill process",
629
+ context={"process_id": process_id}
630
+ )
631
+
632
+ return response
633
+
634
+ async def get_metrics_snapshot(self) -> Dict[str, Any]:
635
+ """Get agent metrics snapshot."""
636
+ await self._ensure_agent_client()
637
+
638
+ response = await self._agent_client.get(
639
+ "/metrics",
640
+ operation="get metrics"
641
+ )
642
+
643
+ return response
644
+
645
+ async def refresh_token(self) -> None:
646
+ """Refresh JWT token for agent authentication."""
647
+ response = await self._client.post(f"/v1/sandboxes/{self.sandbox_id}/token/refresh")
648
+
649
+ if "auth_token" in response and "token_expires_at" in response:
650
+ _token_cache[self.sandbox_id] = TokenData(
651
+ token=response["auth_token"],
652
+ expires_at=datetime.fromisoformat(response["token_expires_at"].replace("Z", "+00:00"))
653
+ )
654
+
655
+ # Update agent client's JWT token if already initialized
656
+ if self._agent_client is not None:
657
+ self._agent_client.update_jwt_token(response["auth_token"])
658
+
659
+ # =============================================================================
660
+ # PROPERTIES - Access to specialized operations
661
+ # =============================================================================
662
+
663
+ @property
664
+ def files(self):
665
+ """Access file operations (lazy init)."""
666
+ if not hasattr(self, '_files'):
667
+ from ._async_files import AsyncFiles
668
+ self._files = AsyncFiles(self)
669
+ return self._files
670
+
671
+ @property
672
+ def commands(self):
673
+ """Access command operations (lazy init)."""
674
+ if not hasattr(self, '_commands'):
675
+ from ._async_commands import AsyncCommands
676
+ self._commands = AsyncCommands(self)
677
+ return self._commands
678
+
679
+ @property
680
+ def env(self):
681
+ """Access environment variable operations (lazy init)."""
682
+ if not hasattr(self, '_env'):
683
+ from ._async_env_vars import AsyncEnvironmentVariables
684
+ self._env = AsyncEnvironmentVariables(self)
685
+ return self._env
686
+
687
+ @property
688
+ def cache(self):
689
+ """Access cache operations (lazy init)."""
690
+ if not hasattr(self, '_cache'):
691
+ from ._async_cache import AsyncCache
692
+ self._cache = AsyncCache(self)
693
+ return self._cache
694
+
695
+ @property
696
+ def terminal(self):
697
+ """Access terminal operations (lazy init)."""
698
+ if not hasattr(self, '_terminal'):
699
+ from ._async_terminal import AsyncTerminal
700
+ self._terminal = AsyncTerminal(self)
701
+ return self._terminal
702
+
703
+ async def run_ipython(
704
+ self,
705
+ code: str,
706
+ *,
707
+ timeout_seconds: int = 60,
708
+ env: Optional[Dict[str, str]] = None
709
+ ):
710
+ """Execute Python code in IPython environment (async)."""
711
+ await self._ensure_agent_client()
712
+
713
+ from .models import ExecutionResult, RichOutput
714
+
715
+ payload = {
716
+ "code": code,
717
+ "timeout": timeout_seconds,
718
+ "ipython": True
719
+ }
720
+
721
+ if env:
722
+ payload["env"] = env
723
+
724
+ response = await self._agent_client.post(
725
+ "/execute",
726
+ json=payload,
727
+ operation="execute ipython code"
728
+ )
729
+
730
+ # Parse response
731
+ rich_outputs = []
732
+ if response and isinstance(response, dict):
733
+ rich_outputs_data = response.get("rich_outputs") or []
734
+ for output in rich_outputs_data:
735
+ if output:
736
+ rich_outputs.append(RichOutput(
737
+ type=output.get("type", ""),
738
+ data=output.get("data", {}),
739
+ metadata=output.get("metadata"),
740
+ timestamp=output.get("timestamp")
741
+ ))
742
+
743
+ return ExecutionResult(
744
+ success=response.get("success", True) if response else False,
745
+ stdout=response.get("stdout", "") if response else "",
746
+ stderr=response.get("stderr", "") if response else "",
747
+ exit_code=response.get("exit_code", 0) if response else 1,
748
+ execution_time=response.get("execution_time", 0.0) if response else 0.0,
749
+ rich_outputs=rich_outputs
750
+ )
751
+
752
+ async def run_code_stream(self, code: str, *, language: str = "python", timeout_seconds: int = 60):
753
+ """
754
+ Stream code execution output (async generator).
755
+
756
+ Yields stdout/stderr as they're produced.
757
+ """
758
+ await self._ensure_agent_client()
759
+
760
+ # For now, return regular execution result
761
+ # TODO: Implement WebSocket streaming for async
762
+ result = await self.run_code(code, language=language, timeout_seconds=timeout_seconds)
763
+ yield result.stdout