langchain-agentcore-codeinterpreter 0.0.1__tar.gz → 0.0.3__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.
Files changed (22) hide show
  1. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/PKG-INFO +1 -1
  2. langchain_agentcore_codeinterpreter-0.0.3/langchain_agentcore_codeinterpreter/sandbox.py +522 -0
  3. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/pyproject.toml +1 -1
  4. langchain_agentcore_codeinterpreter-0.0.1/tests/integration_tests/test_integration.py → langchain_agentcore_codeinterpreter-0.0.3/tests/integration_tests/sandbox/test_sandbox.py +44 -4
  5. langchain_agentcore_codeinterpreter-0.0.3/tests/unit_tests/__init__.py +0 -0
  6. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/unit_tests/test_imports.py +7 -0
  7. langchain_agentcore_codeinterpreter-0.0.3/tests/unit_tests/test_sandbox.py +510 -0
  8. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/unit_tests/test_stream_parsing.py +48 -0
  9. langchain_agentcore_codeinterpreter-0.0.3/uv.lock +2571 -0
  10. langchain_agentcore_codeinterpreter-0.0.1/langchain_agentcore_codeinterpreter/sandbox.py +0 -273
  11. langchain_agentcore_codeinterpreter-0.0.1/tests/unit_tests/test_sandbox.py +0 -206
  12. langchain_agentcore_codeinterpreter-0.0.1/uv.lock +0 -2378
  13. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/.gitignore +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/LICENSE +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/Makefile +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/README.md +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  18. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/scripts/check_imports.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/__init__.py +0 -0
  20. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/integration_tests/__init__.py +0 -0
  21. {langchain_agentcore_codeinterpreter-0.0.1/tests/unit_tests → langchain_agentcore_codeinterpreter-0.0.3/tests/integration_tests/sandbox}/__init__.py +0 -0
  22. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/integration_tests/test_compile.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-agentcore-codeinterpreter
3
- Version: 0.0.1
3
+ Version: 0.0.3
4
4
  Summary: Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents
5
5
  Project-URL: Source Code, https://github.com/langchain-ai/langchain-aws/tree/main/libs/agentcore-codeinterpreter
6
6
  Project-URL: Repository, https://github.com/langchain-ai/langchain-aws
@@ -0,0 +1,522 @@
1
+ """Amazon Bedrock AgentCore Code Interpreter sandbox backend implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import logging
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from botocore.exceptions import ClientError
12
+ from deepagents.backends.protocol import (
13
+ EditResult,
14
+ ExecuteResponse,
15
+ FileDownloadResponse,
16
+ FileUploadResponse,
17
+ ReadResult,
18
+ WriteResult,
19
+ )
20
+ from deepagents.backends.sandbox import BaseSandbox
21
+
22
+ if TYPE_CHECKING:
23
+ from bedrock_agentcore.tools.code_interpreter_client import CodeInterpreter
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Dedicated thread pool for AgentCore boto3 calls. Isolates sandbox I/O
28
+ # from the default asyncio executor so long-running stream reads don't
29
+ # starve other async work (LLM calls, tool dispatch, etc.).
30
+ _AGENTCORE_EXECUTOR = ThreadPoolExecutor(
31
+ max_workers=4, thread_name_prefix="agentcore-sandbox"
32
+ )
33
+
34
+
35
+ def _normalize_relative_path(path: str) -> str:
36
+ """Strip leading slashes and ``./`` prefixes to a canonical relative path.
37
+
38
+ Args:
39
+ path: File path (absolute or relative).
40
+
41
+ Returns:
42
+ Canonical relative path string with no leading ``/`` or ``./``.
43
+ """
44
+ path = path.lstrip("/")
45
+ while path.startswith("./"):
46
+ path = path[2:]
47
+ return path
48
+
49
+
50
+ class SessionExpiredError(Exception):
51
+ """Raised when the AgentCore session has expired or been terminated."""
52
+
53
+ def __init__(self, session_id: str, original: ClientError) -> None:
54
+ self.session_id = session_id
55
+ self.original = original
56
+ super().__init__(
57
+ f"AgentCore session '{session_id}' has expired or was terminated. "
58
+ f"Start a new session to continue."
59
+ )
60
+
61
+
62
+ def _extract_text_from_stream(response: dict[str, Any]) -> tuple[str, int | None]:
63
+ """Extract text output and exit code from a code interpreter response stream.
64
+
65
+ Iterates through the streamed response events and collects text content,
66
+ error messages, and the exit code.
67
+
68
+ Args:
69
+ response: Response dict from a code interpreter invocation.
70
+
71
+ Returns:
72
+ Tuple of (output_text, exit_code). The exit code is ``None`` when
73
+ the response stream does not include one.
74
+ """
75
+ output_parts: list[str] = []
76
+ exit_code: int | None = None
77
+
78
+ for event in response.get("stream", []):
79
+ if "result" not in event:
80
+ continue
81
+
82
+ result = event["result"]
83
+
84
+ if "exitCode" in result:
85
+ exit_code = result["exitCode"]
86
+
87
+ for content_item in result.get("content", []):
88
+ content_type = content_item.get("type")
89
+
90
+ if content_type == "text":
91
+ text = content_item.get("text", "")
92
+ output_parts.append(text)
93
+ elif content_type == "error":
94
+ error_msg = content_item.get("text", "Unknown error")
95
+ output_parts.append(f"Error: {error_msg}")
96
+ if exit_code is None:
97
+ exit_code = 1
98
+
99
+ return "\n".join(output_parts), exit_code
100
+
101
+
102
+ def _extract_files_from_stream(
103
+ response: dict[str, Any],
104
+ requested_paths: list[str],
105
+ ) -> dict[str, bytes]:
106
+ """Extract file contents from a code interpreter ``readFiles`` response.
107
+
108
+ Matches ``file://`` URIs in the response back to the original requested
109
+ paths by stripping leading slashes for comparison.
110
+
111
+ Args:
112
+ response: Response dict from a code interpreter ``readFiles``
113
+ invocation.
114
+ requested_paths: The original paths that were requested, used to
115
+ map URIs back to caller-provided names.
116
+
117
+ Returns:
118
+ Dict mapping original requested paths to their contents as bytes.
119
+ """
120
+ path_lookup: dict[str, str] = {}
121
+ for path in requested_paths:
122
+ path_lookup[_normalize_relative_path(path)] = path
123
+
124
+ files: dict[str, bytes] = {}
125
+
126
+ for event in response.get("stream", []):
127
+ if "result" not in event:
128
+ continue
129
+ for item in event["result"].get("content", []):
130
+ if item.get("type") != "resource":
131
+ continue
132
+ resource = item.get("resource", {})
133
+ uri = resource.get("uri", "")
134
+ file_path = _normalize_relative_path(uri.replace("file://", ""))
135
+
136
+ content: bytes | None = None
137
+ if "text" in resource:
138
+ content = resource["text"].encode("utf-8")
139
+ elif "blob" in resource:
140
+ blob = resource["blob"]
141
+ # The AgentCore stream may deliver blob as already-decoded bytes.
142
+ # Only base64-decode when it arrives as encoded text.
143
+ content = blob if isinstance(blob, bytes) else base64.b64decode(blob)
144
+
145
+ if content is not None:
146
+ original_path = path_lookup.get(file_path, file_path)
147
+ files[original_path] = content
148
+
149
+ return files
150
+
151
+
152
+ class AgentCoreSandbox(BaseSandbox):
153
+ """AgentCore Code Interpreter sandbox conforming to SandboxBackendProtocol.
154
+
155
+ Wraps an active :class:`CodeInterpreter` session to execute shell commands
156
+ and manage files in a secure, isolated MicroVM environment.
157
+
158
+ This implementation inherits all file operation methods from
159
+ :class:`BaseSandbox` and implements the required ``execute()``,
160
+ ``download_files()``, and ``upload_files()`` methods using AgentCore's
161
+ streaming API.
162
+
163
+ Async methods (``aexecute``, ``awrite``, etc.) use a dedicated thread
164
+ pool executor to avoid blocking the default ``asyncio`` executor with
165
+ long-running boto3 stream reads.
166
+
167
+ The caller is responsible for managing the interpreter lifecycle
168
+ (``start()`` / ``stop()``).
169
+
170
+ Example:
171
+ .. code-block:: python
172
+
173
+ from bedrock_agentcore.tools.code_interpreter_client import (
174
+ CodeInterpreter,
175
+ )
176
+ from langchain_agentcore_codeinterpreter import AgentCoreSandbox
177
+
178
+ interpreter = CodeInterpreter(region="us-west-2")
179
+ interpreter.start()
180
+
181
+ backend = AgentCoreSandbox(interpreter=interpreter)
182
+ result = backend.execute("echo hello")
183
+ print(result.output)
184
+
185
+ interpreter.stop()
186
+ """
187
+
188
+ def __init__(self, *, interpreter: CodeInterpreter) -> None:
189
+ """Create a backend wrapping an active CodeInterpreter session.
190
+
191
+ Args:
192
+ interpreter: A started :class:`CodeInterpreter` instance.
193
+ """
194
+ self._interpreter = interpreter
195
+
196
+ @staticmethod
197
+ def _to_relative_path(path: str) -> str:
198
+ """Strip leading slashes and ``./`` prefixes for AgentCore APIs.
199
+
200
+ Args:
201
+ path: File path (absolute or relative).
202
+
203
+ Returns:
204
+ Relative path string.
205
+ """
206
+ return _normalize_relative_path(path)
207
+
208
+ def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
209
+ """Invoke the interpreter and eagerly consume the response stream.
210
+
211
+ AgentCore's ``invoke_code_interpreter`` returns a lazy EventStream
212
+ that holds the HTTP connection open until fully iterated. Consuming
213
+ it eagerly releases the connection promptly, which prevents thread
214
+ starvation when multiple sandbox calls are in-flight under
215
+ ``asyncio.to_thread`` or a thread pool executor.
216
+
217
+ Args:
218
+ method: The interpreter method name (e.g. ``executeCommand``).
219
+ params: Parameters to pass to the method.
220
+
221
+ Returns:
222
+ Response dict with the ``"stream"`` key materialized as a list.
223
+
224
+ Raises:
225
+ SessionExpiredError: If the session has expired or been terminated.
226
+ """
227
+ try:
228
+ response = self._interpreter.invoke(method=method, params=params)
229
+ except ClientError as exc:
230
+ error_code = exc.response.get("Error", {}).get("Code", "")
231
+ if error_code == "ResourceNotFoundException":
232
+ raise SessionExpiredError(self.id, exc) from exc
233
+ raise
234
+
235
+ # Eagerly consume the lazy EventStream to release the HTTP connection.
236
+ if "stream" in response:
237
+ response["stream"] = list(response["stream"])
238
+
239
+ return response
240
+
241
+ @property
242
+ def id(self) -> str:
243
+ """Return the AgentCore session ID."""
244
+ return self._interpreter.session_id or ""
245
+
246
+ # ------------------------------------------------------------------
247
+ # Sync methods
248
+ # ------------------------------------------------------------------
249
+
250
+ def execute(
251
+ self,
252
+ command: str,
253
+ *,
254
+ timeout: int | None = None, # noqa: ARG002
255
+ ) -> ExecuteResponse:
256
+ """Execute a shell command inside the sandbox.
257
+
258
+ Args:
259
+ command: Shell command string to execute.
260
+ timeout: Unused. AgentCore does not support per-command timeouts.
261
+ Accepted for interface compatibility with
262
+ :class:`SandboxBackendProtocol`.
263
+
264
+ Returns:
265
+ Response containing the command output, exit code, and truncation
266
+ flag.
267
+ """
268
+ try:
269
+ response = self._invoke(
270
+ method="executeCommand", params={"command": command}
271
+ )
272
+ output, exit_code = _extract_text_from_stream(response)
273
+ return ExecuteResponse(
274
+ output=output,
275
+ exit_code=exit_code if exit_code is not None else 0,
276
+ truncated=False,
277
+ )
278
+ except SessionExpiredError:
279
+ logger.error(
280
+ "AgentCore session expired while executing command: %s",
281
+ command[:80],
282
+ )
283
+ return ExecuteResponse(
284
+ output=(
285
+ "Error: AgentCore session has expired. "
286
+ "Start a new session to continue."
287
+ ),
288
+ exit_code=1,
289
+ truncated=False,
290
+ )
291
+ except Exception as exc:
292
+ logger.exception("Error executing command: %s", command[:80])
293
+ msg = f"Error executing command: {exc}"
294
+ return ExecuteResponse(
295
+ output=msg,
296
+ exit_code=1,
297
+ truncated=False,
298
+ )
299
+
300
+ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
301
+ """Download files from the AgentCore sandbox.
302
+
303
+ Uses AgentCore's ``readFiles`` API. Supports partial success —
304
+ individual file downloads may fail without affecting others.
305
+
306
+ Args:
307
+ paths: List of file paths to download.
308
+
309
+ Returns:
310
+ List of :class:`FileDownloadResponse` objects in the same order
311
+ as the input paths.
312
+ """
313
+ try:
314
+ relative_paths = [self._to_relative_path(p) for p in paths]
315
+ response = self._invoke(
316
+ method="readFiles", params={"paths": relative_paths}
317
+ )
318
+ file_contents = _extract_files_from_stream(response, paths)
319
+
320
+ return [
321
+ FileDownloadResponse(
322
+ path=path,
323
+ content=file_contents.get(path),
324
+ error=None if path in file_contents else "file_not_found",
325
+ )
326
+ for path in paths
327
+ ]
328
+ except SessionExpiredError:
329
+ logger.error("AgentCore session expired while downloading files: %s", paths)
330
+ return [
331
+ FileDownloadResponse(path=path, content=None, error="permission_denied")
332
+ for path in paths
333
+ ]
334
+ except Exception:
335
+ logger.exception("Error downloading files: %s", paths)
336
+ return [
337
+ FileDownloadResponse(path=path, content=None, error="file_not_found")
338
+ for path in paths
339
+ ]
340
+
341
+ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
342
+ """Upload files to the AgentCore sandbox.
343
+
344
+ Text files are sent directly; binary files are base64-encoded.
345
+
346
+ Args:
347
+ files: List of ``(path, content)`` tuples to upload.
348
+
349
+ Returns:
350
+ List of :class:`FileUploadResponse` objects in the same order
351
+ as the input files.
352
+ """
353
+ file_list: list[dict[str, str]] = []
354
+
355
+ for path, content in files:
356
+ rel_path = self._to_relative_path(path)
357
+ try:
358
+ text_content = content.decode("utf-8")
359
+ file_list.append({"path": rel_path, "text": text_content})
360
+ except UnicodeDecodeError:
361
+ encoded = base64.b64encode(content).decode("ascii")
362
+ file_list.append({"path": rel_path, "blob": encoded})
363
+
364
+ try:
365
+ if file_list:
366
+ self._invoke(method="writeFiles", params={"content": file_list})
367
+ return [FileUploadResponse(path=path, error=None) for path, _ in files]
368
+ except SessionExpiredError:
369
+ logger.error(
370
+ "AgentCore session expired while uploading files: %s",
371
+ [p for p, _ in files],
372
+ )
373
+ return [
374
+ FileUploadResponse(path=path, error="permission_denied")
375
+ for path, _ in files
376
+ ]
377
+ except Exception:
378
+ logger.exception("Error uploading files: %s", [p for p, _ in files])
379
+ return [
380
+ FileUploadResponse(path=path, error="permission_denied")
381
+ for path, _ in files
382
+ ]
383
+
384
+ # ------------------------------------------------------------------
385
+ # Async overrides — use a dedicated executor to avoid starving the
386
+ # default asyncio thread pool with long-running boto3 stream reads.
387
+ # ------------------------------------------------------------------
388
+
389
+ async def aexecute(
390
+ self,
391
+ command: str,
392
+ *,
393
+ timeout: int | None = None,
394
+ ) -> ExecuteResponse:
395
+ """Async version of :meth:`execute`.
396
+
397
+ Runs the sync method in a dedicated thread pool executor to avoid
398
+ blocking the default ``asyncio`` executor.
399
+
400
+ Args:
401
+ command: Shell command string to execute.
402
+ timeout: Unused. Accepted for interface compatibility.
403
+
404
+ Returns:
405
+ Response containing the command output, exit code, and truncation
406
+ flag.
407
+ """
408
+ loop = asyncio.get_running_loop()
409
+ return await loop.run_in_executor(
410
+ _AGENTCORE_EXECUTOR,
411
+ lambda: self.execute(command, timeout=timeout),
412
+ )
413
+
414
+ async def aread(
415
+ self,
416
+ file_path: str,
417
+ offset: int = 0,
418
+ limit: int = 2000,
419
+ ) -> ReadResult:
420
+ """Async version of :meth:`read`.
421
+
422
+ Runs the sync method in a dedicated thread pool executor.
423
+
424
+ Args:
425
+ file_path: Absolute path to the file to read.
426
+ offset: Starting line number (0-indexed).
427
+ limit: Maximum number of lines to return.
428
+
429
+ Returns:
430
+ ``ReadResult`` with ``file_data`` on success or ``error`` on
431
+ failure.
432
+ """
433
+ loop = asyncio.get_running_loop()
434
+ return await loop.run_in_executor(
435
+ _AGENTCORE_EXECUTOR,
436
+ lambda: self.read(file_path, offset, limit),
437
+ )
438
+
439
+ async def awrite(
440
+ self,
441
+ file_path: str,
442
+ content: str,
443
+ ) -> WriteResult:
444
+ """Async version of :meth:`write`.
445
+
446
+ Runs the sync method in a dedicated thread pool executor.
447
+
448
+ Args:
449
+ file_path: Absolute path for the new file.
450
+ content: UTF-8 text content to write.
451
+
452
+ Returns:
453
+ ``WriteResult`` with ``path`` on success or ``error`` on failure.
454
+ """
455
+ loop = asyncio.get_running_loop()
456
+ return await loop.run_in_executor(
457
+ _AGENTCORE_EXECUTOR,
458
+ lambda: self.write(file_path, content),
459
+ )
460
+
461
+ async def aedit(
462
+ self,
463
+ file_path: str,
464
+ old_string: str,
465
+ new_string: str,
466
+ replace_all: bool = False, # noqa: FBT001, FBT002
467
+ ) -> EditResult:
468
+ """Async version of :meth:`edit`.
469
+
470
+ Runs the sync method in a dedicated thread pool executor.
471
+
472
+ Args:
473
+ file_path: Absolute path to the file to edit.
474
+ old_string: The exact substring to find.
475
+ new_string: The replacement string.
476
+ replace_all: If ``True``, replace every occurrence.
477
+
478
+ Returns:
479
+ ``EditResult`` with ``path`` and ``occurrences`` on success,
480
+ or ``error`` on failure.
481
+ """
482
+ loop = asyncio.get_running_loop()
483
+ return await loop.run_in_executor(
484
+ _AGENTCORE_EXECUTOR,
485
+ lambda: self.edit(file_path, old_string, new_string, replace_all),
486
+ )
487
+
488
+ async def aupload_files(
489
+ self, files: list[tuple[str, bytes]]
490
+ ) -> list[FileUploadResponse]:
491
+ """Async version of :meth:`upload_files`.
492
+
493
+ Runs the sync method in a dedicated thread pool executor.
494
+
495
+ Args:
496
+ files: List of ``(path, content)`` tuples to upload.
497
+
498
+ Returns:
499
+ List of :class:`FileUploadResponse` objects.
500
+ """
501
+ loop = asyncio.get_running_loop()
502
+ return await loop.run_in_executor(
503
+ _AGENTCORE_EXECUTOR,
504
+ lambda: self.upload_files(files),
505
+ )
506
+
507
+ async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
508
+ """Async version of :meth:`download_files`.
509
+
510
+ Runs the sync method in a dedicated thread pool executor.
511
+
512
+ Args:
513
+ paths: List of file paths to download.
514
+
515
+ Returns:
516
+ List of :class:`FileDownloadResponse` objects.
517
+ """
518
+ loop = asyncio.get_running_loop()
519
+ return await loop.run_in_executor(
520
+ _AGENTCORE_EXECUTOR,
521
+ lambda: self.download_files(paths),
522
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-agentcore-codeinterpreter"
7
- version = "0.0.1"
7
+ version = "0.0.3"
8
8
  description = "Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -6,8 +6,9 @@ Code Interpreter service is available.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import os
10
- from typing import TYPE_CHECKING
11
+ from typing import TYPE_CHECKING, Any
11
12
 
12
13
  import pytest
13
14
 
@@ -16,7 +17,6 @@ from langchain_agentcore_codeinterpreter import AgentCoreSandbox
16
17
  if TYPE_CHECKING:
17
18
  from collections.abc import Iterator
18
19
 
19
-
20
20
  _SKIP_REASON = "AWS credentials not configured (set AWS_REGION or AWS_DEFAULT_REGION)"
21
21
 
22
22
 
@@ -83,15 +83,55 @@ class TestAgentCoreSandboxIntegration:
83
83
 
84
84
  def test_binary_roundtrip(self, sandbox: AgentCoreSandbox) -> None:
85
85
  """Binary content uploaded via base64 may be returned as base64 text."""
86
+ import base64
87
+
86
88
  content = b"\x00\x01\x02\xff\xfe\xfd"
87
89
  sandbox.upload_files([("binary_test.bin", content)])
88
90
  results = sandbox.download_files(["binary_test.bin"])
89
91
  assert results[0].error is None
90
92
  # AgentCore returns blob uploads as text containing the base64 string
91
- import base64
92
-
93
93
  assert results[0].content in (content, base64.b64encode(content))
94
94
 
95
95
  def test_session_id(self, sandbox: AgentCoreSandbox) -> None:
96
96
  """The sandbox should have a non-empty session ID."""
97
97
  assert sandbox.id != ""
98
+
99
+ # ------------------------------------------------------------------
100
+ # Async integration tests
101
+ # ------------------------------------------------------------------
102
+
103
+ def test_aexecute_echo(self, sandbox: AgentCoreSandbox) -> None:
104
+ """aexecute() should work against a live session."""
105
+ result = asyncio.run(sandbox.aexecute("echo async hello"))
106
+ assert result.exit_code == 0
107
+ assert "async hello" in result.output
108
+
109
+ def test_aexecute_python(self, sandbox: AgentCoreSandbox) -> None:
110
+ """aexecute() should handle Python execution."""
111
+ result = asyncio.run(sandbox.aexecute("python3 -c \"print('async works')\""))
112
+ assert result.exit_code == 0
113
+ assert "async works" in result.output
114
+
115
+ def test_aupload_and_adownload_roundtrip(self, sandbox: AgentCoreSandbox) -> None:
116
+ """Async upload and download should produce identical content."""
117
+ content = b"async round trip content"
118
+ upload_result = asyncio.run(
119
+ sandbox.aupload_files([("async_roundtrip.txt", content)])
120
+ )
121
+ assert upload_result[0].error is None
122
+
123
+ download_result = asyncio.run(sandbox.adownload_files(["async_roundtrip.txt"]))
124
+ assert download_result[0].error is None
125
+ assert download_result[0].content == content
126
+
127
+ def test_concurrent_aexecute(self, sandbox: AgentCoreSandbox) -> None:
128
+ """Multiple concurrent async executions should not deadlock."""
129
+
130
+ async def run_concurrent() -> list[Any]:
131
+ tasks = [sandbox.aexecute(f"echo concurrent-{i}") for i in range(3)]
132
+ return await asyncio.gather(*tasks)
133
+
134
+ results = asyncio.run(run_concurrent())
135
+ for i, result in enumerate(results):
136
+ assert result.exit_code == 0
137
+ assert f"concurrent-{i}" in result.output
@@ -15,3 +15,10 @@ def test_import_package() -> None:
15
15
  def test_import_sandbox_class() -> None:
16
16
  """The public AgentCoreSandbox class should be importable."""
17
17
  assert AgentCoreSandbox is not None
18
+
19
+
20
+ def test_import_session_expired_error() -> None:
21
+ """SessionExpiredError should be importable from the sandbox module."""
22
+ from langchain_agentcore_codeinterpreter.sandbox import SessionExpiredError
23
+
24
+ assert SessionExpiredError is not None