langchain-agentcore-codeinterpreter 0.0.1__tar.gz → 0.0.2__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 (20) hide show
  1. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/PKG-INFO +1 -1
  2. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/langchain_agentcore_codeinterpreter/sandbox.py +237 -5
  3. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/pyproject.toml +1 -1
  4. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/integration_tests/test_integration.py +44 -4
  5. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/unit_tests/test_imports.py +7 -0
  6. langchain_agentcore_codeinterpreter-0.0.2/tests/unit_tests/test_sandbox.py +473 -0
  7. langchain_agentcore_codeinterpreter-0.0.2/uv.lock +2470 -0
  8. langchain_agentcore_codeinterpreter-0.0.1/tests/unit_tests/test_sandbox.py +0 -206
  9. langchain_agentcore_codeinterpreter-0.0.1/uv.lock +0 -2378
  10. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/.gitignore +0 -0
  11. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/LICENSE +0 -0
  12. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/Makefile +0 -0
  13. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/README.md +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/scripts/check_imports.py +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/__init__.py +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/integration_tests/__init__.py +0 -0
  18. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/integration_tests/test_compile.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/unit_tests/__init__.py +0 -0
  20. {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.2}/tests/unit_tests/test_stream_parsing.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.2
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
@@ -2,14 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import base64
6
7
  import logging
8
+ from concurrent.futures import ThreadPoolExecutor
7
9
  from typing import TYPE_CHECKING, Any
8
10
 
11
+ from botocore.exceptions import ClientError
9
12
  from deepagents.backends.protocol import (
13
+ EditResult,
10
14
  ExecuteResponse,
11
15
  FileDownloadResponse,
12
16
  FileUploadResponse,
17
+ ReadResult,
18
+ WriteResult,
13
19
  )
14
20
  from deepagents.backends.sandbox import BaseSandbox
15
21
 
@@ -18,6 +24,25 @@ if TYPE_CHECKING:
18
24
 
19
25
  logger = logging.getLogger(__name__)
20
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
+ class SessionExpiredError(Exception):
36
+ """Raised when the AgentCore session has expired or been terminated."""
37
+
38
+ def __init__(self, session_id: str, original: ClientError) -> None:
39
+ self.session_id = session_id
40
+ self.original = original
41
+ super().__init__(
42
+ f"AgentCore session '{session_id}' has expired or was terminated. "
43
+ f"Start a new session to continue."
44
+ )
45
+
21
46
 
22
47
  def _extract_text_from_stream(response: dict[str, Any]) -> tuple[str, int | None]:
23
48
  """Extract text output and exit code from a code interpreter response stream.
@@ -118,6 +143,10 @@ class AgentCoreSandbox(BaseSandbox):
118
143
  ``download_files()``, and ``upload_files()`` methods using AgentCore's
119
144
  streaming API.
120
145
 
146
+ Async methods (``aexecute``, ``awrite``, etc.) use a dedicated thread
147
+ pool executor to avoid blocking the default ``asyncio`` executor with
148
+ long-running boto3 stream reads.
149
+
121
150
  The caller is responsible for managing the interpreter lifecycle
122
151
  (``start()`` / ``stop()``).
123
152
 
@@ -159,11 +188,48 @@ class AgentCoreSandbox(BaseSandbox):
159
188
  """
160
189
  return path.lstrip("/")
161
190
 
191
+ def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
192
+ """Invoke the interpreter and eagerly consume the response stream.
193
+
194
+ AgentCore's ``invoke_code_interpreter`` returns a lazy EventStream
195
+ that holds the HTTP connection open until fully iterated. Consuming
196
+ it eagerly releases the connection promptly, which prevents thread
197
+ starvation when multiple sandbox calls are in-flight under
198
+ ``asyncio.to_thread`` or a thread pool executor.
199
+
200
+ Args:
201
+ method: The interpreter method name (e.g. ``executeCommand``).
202
+ params: Parameters to pass to the method.
203
+
204
+ Returns:
205
+ Response dict with the ``"stream"`` key materialized as a list.
206
+
207
+ Raises:
208
+ SessionExpiredError: If the session has expired or been terminated.
209
+ """
210
+ try:
211
+ response = self._interpreter.invoke(method=method, params=params)
212
+ except ClientError as exc:
213
+ error_code = exc.response.get("Error", {}).get("Code", "")
214
+ if error_code == "ResourceNotFoundException":
215
+ raise SessionExpiredError(self.id, exc) from exc
216
+ raise
217
+
218
+ # Eagerly consume the lazy EventStream to release the HTTP connection.
219
+ if "stream" in response:
220
+ response["stream"] = list(response["stream"])
221
+
222
+ return response
223
+
162
224
  @property
163
225
  def id(self) -> str:
164
226
  """Return the AgentCore session ID."""
165
227
  return self._interpreter.session_id or ""
166
228
 
229
+ # ------------------------------------------------------------------
230
+ # Sync methods
231
+ # ------------------------------------------------------------------
232
+
167
233
  def execute(
168
234
  self,
169
235
  command: str,
@@ -183,7 +249,7 @@ class AgentCoreSandbox(BaseSandbox):
183
249
  flag.
184
250
  """
185
251
  try:
186
- response = self._interpreter.invoke(
252
+ response = self._invoke(
187
253
  method="executeCommand", params={"command": command}
188
254
  )
189
255
  output, exit_code = _extract_text_from_stream(response)
@@ -192,6 +258,19 @@ class AgentCoreSandbox(BaseSandbox):
192
258
  exit_code=exit_code if exit_code is not None else 0,
193
259
  truncated=False,
194
260
  )
261
+ except SessionExpiredError:
262
+ logger.error(
263
+ "AgentCore session expired while executing command: %s",
264
+ command[:80],
265
+ )
266
+ return ExecuteResponse(
267
+ output=(
268
+ "Error: AgentCore session has expired. "
269
+ "Start a new session to continue."
270
+ ),
271
+ exit_code=1,
272
+ truncated=False,
273
+ )
195
274
  except Exception as exc:
196
275
  logger.exception("Error executing command: %s", command[:80])
197
276
  msg = f"Error executing command: {exc}"
@@ -216,7 +295,7 @@ class AgentCoreSandbox(BaseSandbox):
216
295
  """
217
296
  try:
218
297
  relative_paths = [self._to_relative_path(p) for p in paths]
219
- response = self._interpreter.invoke(
298
+ response = self._invoke(
220
299
  method="readFiles", params={"paths": relative_paths}
221
300
  )
222
301
  file_contents = _extract_files_from_stream(response, paths)
@@ -229,6 +308,12 @@ class AgentCoreSandbox(BaseSandbox):
229
308
  )
230
309
  for path in paths
231
310
  ]
311
+ except SessionExpiredError:
312
+ logger.error("AgentCore session expired while downloading files: %s", paths)
313
+ return [
314
+ FileDownloadResponse(path=path, content=None, error="permission_denied")
315
+ for path in paths
316
+ ]
232
317
  except Exception:
233
318
  logger.exception("Error downloading files: %s", paths)
234
319
  return [
@@ -261,13 +346,160 @@ class AgentCoreSandbox(BaseSandbox):
261
346
 
262
347
  try:
263
348
  if file_list:
264
- self._interpreter.invoke(
265
- method="writeFiles", params={"content": file_list}
266
- )
349
+ self._invoke(method="writeFiles", params={"content": file_list})
267
350
  return [FileUploadResponse(path=path, error=None) for path, _ in files]
351
+ except SessionExpiredError:
352
+ logger.error(
353
+ "AgentCore session expired while uploading files: %s",
354
+ [p for p, _ in files],
355
+ )
356
+ return [
357
+ FileUploadResponse(path=path, error="permission_denied")
358
+ for path, _ in files
359
+ ]
268
360
  except Exception:
269
361
  logger.exception("Error uploading files: %s", [p for p, _ in files])
270
362
  return [
271
363
  FileUploadResponse(path=path, error="permission_denied")
272
364
  for path, _ in files
273
365
  ]
366
+
367
+ # ------------------------------------------------------------------
368
+ # Async overrides — use a dedicated executor to avoid starving the
369
+ # default asyncio thread pool with long-running boto3 stream reads.
370
+ # ------------------------------------------------------------------
371
+
372
+ async def aexecute(
373
+ self,
374
+ command: str,
375
+ *,
376
+ timeout: int | None = None,
377
+ ) -> ExecuteResponse:
378
+ """Async version of :meth:`execute`.
379
+
380
+ Runs the sync method in a dedicated thread pool executor to avoid
381
+ blocking the default ``asyncio`` executor.
382
+
383
+ Args:
384
+ command: Shell command string to execute.
385
+ timeout: Unused. Accepted for interface compatibility.
386
+
387
+ Returns:
388
+ Response containing the command output, exit code, and truncation
389
+ flag.
390
+ """
391
+ loop = asyncio.get_running_loop()
392
+ return await loop.run_in_executor(
393
+ _AGENTCORE_EXECUTOR,
394
+ lambda: self.execute(command, timeout=timeout),
395
+ )
396
+
397
+ async def aread(
398
+ self,
399
+ file_path: str,
400
+ offset: int = 0,
401
+ limit: int = 2000,
402
+ ) -> ReadResult:
403
+ """Async version of :meth:`read`.
404
+
405
+ Runs the sync method in a dedicated thread pool executor.
406
+
407
+ Args:
408
+ file_path: Absolute path to the file to read.
409
+ offset: Starting line number (0-indexed).
410
+ limit: Maximum number of lines to return.
411
+
412
+ Returns:
413
+ ``ReadResult`` with ``file_data`` on success or ``error`` on
414
+ failure.
415
+ """
416
+ loop = asyncio.get_running_loop()
417
+ return await loop.run_in_executor(
418
+ _AGENTCORE_EXECUTOR,
419
+ lambda: self.read(file_path, offset, limit),
420
+ )
421
+
422
+ async def awrite(
423
+ self,
424
+ file_path: str,
425
+ content: str,
426
+ ) -> WriteResult:
427
+ """Async version of :meth:`write`.
428
+
429
+ Runs the sync method in a dedicated thread pool executor.
430
+
431
+ Args:
432
+ file_path: Absolute path for the new file.
433
+ content: UTF-8 text content to write.
434
+
435
+ Returns:
436
+ ``WriteResult`` with ``path`` on success or ``error`` on failure.
437
+ """
438
+ loop = asyncio.get_running_loop()
439
+ return await loop.run_in_executor(
440
+ _AGENTCORE_EXECUTOR,
441
+ lambda: self.write(file_path, content),
442
+ )
443
+
444
+ async def aedit(
445
+ self,
446
+ file_path: str,
447
+ old_string: str,
448
+ new_string: str,
449
+ replace_all: bool = False, # noqa: FBT001, FBT002
450
+ ) -> EditResult:
451
+ """Async version of :meth:`edit`.
452
+
453
+ Runs the sync method in a dedicated thread pool executor.
454
+
455
+ Args:
456
+ file_path: Absolute path to the file to edit.
457
+ old_string: The exact substring to find.
458
+ new_string: The replacement string.
459
+ replace_all: If ``True``, replace every occurrence.
460
+
461
+ Returns:
462
+ ``EditResult`` with ``path`` and ``occurrences`` on success,
463
+ or ``error`` on failure.
464
+ """
465
+ loop = asyncio.get_running_loop()
466
+ return await loop.run_in_executor(
467
+ _AGENTCORE_EXECUTOR,
468
+ lambda: self.edit(file_path, old_string, new_string, replace_all),
469
+ )
470
+
471
+ async def aupload_files(
472
+ self, files: list[tuple[str, bytes]]
473
+ ) -> list[FileUploadResponse]:
474
+ """Async version of :meth:`upload_files`.
475
+
476
+ Runs the sync method in a dedicated thread pool executor.
477
+
478
+ Args:
479
+ files: List of ``(path, content)`` tuples to upload.
480
+
481
+ Returns:
482
+ List of :class:`FileUploadResponse` objects.
483
+ """
484
+ loop = asyncio.get_running_loop()
485
+ return await loop.run_in_executor(
486
+ _AGENTCORE_EXECUTOR,
487
+ lambda: self.upload_files(files),
488
+ )
489
+
490
+ async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
491
+ """Async version of :meth:`download_files`.
492
+
493
+ Runs the sync method in a dedicated thread pool executor.
494
+
495
+ Args:
496
+ paths: List of file paths to download.
497
+
498
+ Returns:
499
+ List of :class:`FileDownloadResponse` objects.
500
+ """
501
+ loop = asyncio.get_running_loop()
502
+ return await loop.run_in_executor(
503
+ _AGENTCORE_EXECUTOR,
504
+ lambda: self.download_files(paths),
505
+ )
@@ -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.2"
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