langchain-agentcore-codeinterpreter 0.0.3__tar.gz → 0.0.5__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.3 → langchain_agentcore_codeinterpreter-0.0.5}/Makefile +2 -2
  2. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/PKG-INFO +1 -1
  3. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/langchain_agentcore_codeinterpreter/sandbox.py +221 -14
  4. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/pyproject.toml +8 -2
  5. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/sandbox/test_sandbox.py +60 -5
  6. langchain_agentcore_codeinterpreter-0.0.5/tests/unit_tests/test_sandbox.py +968 -0
  7. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/uv.lock +631 -617
  8. langchain_agentcore_codeinterpreter-0.0.3/tests/unit_tests/test_sandbox.py +0 -510
  9. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/.gitignore +0 -0
  10. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/LICENSE +0 -0
  11. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/README.md +0 -0
  12. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  13. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/scripts/check_imports.py +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/__init__.py +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/__init__.py +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/sandbox/__init__.py +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/integration_tests/test_compile.py +0 -0
  18. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/__init__.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/test_imports.py +0 -0
  20. {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.5}/tests/unit_tests/test_stream_parsing.py +0 -0
@@ -30,10 +30,10 @@ test: ## Run individual unit test: make test TEST_FILE=tests/unit_tests/test.py
30
30
  env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN uv run --group test pytest --disable-socket --allow-unix-socket $(TEST_FILE)
31
31
 
32
32
  integration_tests: ## Run all integration tests
33
- uv run --group test --group test_integration pytest -n auto $(TEST_FILE)
33
+ uv run --group test --group test_integration pytest -n auto --benchmark-disable $(TEST_FILE)
34
34
 
35
35
  integration_test: ## Run individual integration test: make integration_test TEST_FILE=tests/integration_tests/integ_test.py
36
- uv run --group test --group test_integration pytest -n auto $(TEST_FILE)
36
+ uv run --group test --group test_integration pytest -n auto --benchmark-disable $(TEST_FILE)
37
37
 
38
38
  coverage_tests: ## Run unit tests with coverage
39
39
  env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN uv run --group test pytest --cov=langchain_agentcore_codeinterpreter --cov-report=html --cov-report=term-missing --cov-branch tests/unit_tests/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langchain-agentcore-codeinterpreter
3
- Version: 0.0.3
3
+ Version: 0.0.5
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
@@ -14,6 +14,9 @@ from deepagents.backends.protocol import (
14
14
  ExecuteResponse,
15
15
  FileDownloadResponse,
16
16
  FileUploadResponse,
17
+ GlobResult,
18
+ GrepResult,
19
+ LsResult,
17
20
  ReadResult,
18
21
  WriteResult,
19
22
  )
@@ -167,6 +170,15 @@ class AgentCoreSandbox(BaseSandbox):
167
170
  The caller is responsible for managing the interpreter lifecycle
168
171
  (``start()`` / ``stop()``).
169
172
 
173
+ !!! note
174
+
175
+ When the sandbox working directory is not ``/`` (e.g.
176
+ ``/opt/amazon/genesis1p-tools/var/``), paths must be resolved
177
+ against the real cwd before shell preflight commands and stripped of
178
+ the cwd prefix before the AgentCore ``writeFiles``/``readFiles`` APIs.
179
+ Pass the known cwd via the ``cwd`` constructor argument, or let the
180
+ sandbox detect it automatically on the first path operation via ``pwd``.
181
+
170
182
  Example:
171
183
  .. code-block:: python
172
184
 
@@ -185,26 +197,114 @@ class AgentCoreSandbox(BaseSandbox):
185
197
  interpreter.stop()
186
198
  """
187
199
 
188
- def __init__(self, *, interpreter: CodeInterpreter) -> None:
200
+ def __init__(
201
+ self,
202
+ *,
203
+ interpreter: CodeInterpreter,
204
+ cwd: str | None = None,
205
+ ) -> None:
189
206
  """Create a backend wrapping an active CodeInterpreter session.
190
207
 
191
208
  Args:
192
209
  interpreter: A started :class:`CodeInterpreter` instance.
210
+ cwd: The sandbox working directory (e.g.
211
+ ``"/opt/amazon/genesis1p-tools/var"``). When provided,
212
+ ``write()`` uses it to resolve virtual paths to real absolute
213
+ paths and to strip the prefix before the AgentCore
214
+ ``writeFiles`` API. When omitted, the cwd is detected
215
+ automatically on the first path operation via ``pwd``.
193
216
  """
194
217
  self._interpreter = interpreter
218
+ self._cwd: str | None = cwd.rstrip("/") if cwd is not None else None
219
+
220
+ def _get_cwd(self) -> str:
221
+ """Return the sandbox working directory, detecting it lazily if needed.
222
+
223
+ Returns:
224
+ The working directory with any trailing slash stripped.
225
+ """
226
+ if self._cwd is None:
227
+ result = self.execute("pwd")
228
+ if result.exit_code != 0 or not result.output.strip():
229
+ raise RuntimeError(
230
+ f"Failed to detect sandbox working directory: "
231
+ f"exit_code={result.exit_code}, output={result.output!r}"
232
+ )
233
+ self._cwd = result.output.strip().rstrip("/")
234
+ return self._cwd
235
+
236
+ def _to_relative_path(self, path: str) -> str:
237
+ """Strip the cwd prefix (or leading slashes) for AgentCore file APIs.
195
238
 
196
- @staticmethod
197
- def _to_relative_path(path: str) -> str:
198
- """Strip leading slashes and ``./`` prefixes for AgentCore APIs.
239
+ When the sandbox cwd is known and ``path`` starts with it, the cwd
240
+ prefix is removed so the AgentCore ``writeFiles``/``readFiles`` APIs
241
+ receive a cwd-relative path. For paths that do not start with the cwd,
242
+ the standard leading-slash stripping is applied.
199
243
 
200
244
  Args:
201
245
  path: File path (absolute or relative).
202
246
 
203
247
  Returns:
204
- Relative path string.
248
+ Path relative to the sandbox cwd, with no leading ``/`` or ``./``.
205
249
  """
250
+ if self._cwd is not None:
251
+ cwd_prefix = self._cwd + "/"
252
+ if path.startswith(cwd_prefix):
253
+ return path[len(cwd_prefix) :]
206
254
  return _normalize_relative_path(path)
207
255
 
256
+ def _to_absolute_path(self, path: str) -> str:
257
+ """Resolve a path to a real absolute path under the sandbox cwd.
258
+
259
+ Paths already under the cwd are returned unchanged. Virtual paths
260
+ (e.g. ``/workspace/hello.py``) and relative paths are prepended with
261
+ the cwd so that shell commands (``makedirs``, etc.) operate on the
262
+ real filesystem location.
263
+
264
+ Args:
265
+ path: File path to resolve.
266
+
267
+ Returns:
268
+ Absolute path under the sandbox cwd.
269
+ """
270
+ cwd = self._get_cwd()
271
+ if not cwd:
272
+ return path
273
+ if path.startswith(cwd + "/") or path == cwd:
274
+ return path
275
+ return cwd + "/" + path.lstrip("/")
276
+
277
+ def write(self, file_path: str, content: str) -> WriteResult:
278
+ """Create a new file in the sandbox, failing if it already exists.
279
+
280
+ Normalizes ``file_path`` to a real absolute path under the sandbox cwd
281
+ before the preflight shell command (so ``makedirs`` operates on the
282
+ correct location) and before uploading (so the AgentCore ``writeFiles``
283
+ API receives a cwd-relative path rather than a doubled absolute path).
284
+
285
+ Args:
286
+ file_path: Destination path for the new file. May be a real
287
+ absolute path under the sandbox cwd, a virtual absolute path
288
+ (e.g. ``/workspace/hello.py``), or a relative path.
289
+ content: UTF-8 text content to write.
290
+
291
+ Returns:
292
+ ``WriteResult`` with ``path`` set to the resolved absolute path on
293
+ success, or ``error`` on failure.
294
+ """
295
+ abs_path = self._to_absolute_path(file_path)
296
+ preflight_error = self._write_preflight(abs_path)
297
+ if preflight_error is not None:
298
+ return preflight_error
299
+ responses = self.upload_files([(abs_path, content.encode("utf-8"))])
300
+ assert responses, "upload_files returned no responses"
301
+ response = responses[0]
302
+ if response.error:
303
+ return WriteResult(
304
+ error=f"Failed to write file '{abs_path}': {response.error}"
305
+ )
306
+ return WriteResult(path=abs_path)
307
+
208
308
  def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
209
309
  """Invoke the interpreter and eagerly consume the response stream.
210
310
 
@@ -311,19 +411,20 @@ class AgentCoreSandbox(BaseSandbox):
311
411
  as the input paths.
312
412
  """
313
413
  try:
414
+ self._get_cwd()
314
415
  relative_paths = [self._to_relative_path(p) for p in paths]
315
416
  response = self._invoke(
316
417
  method="readFiles", params={"paths": relative_paths}
317
418
  )
318
- file_contents = _extract_files_from_stream(response, paths)
419
+ file_contents = _extract_files_from_stream(response, relative_paths)
319
420
 
320
421
  return [
321
422
  FileDownloadResponse(
322
- path=path,
323
- content=file_contents.get(path),
324
- error=None if path in file_contents else "file_not_found",
423
+ path=original,
424
+ content=file_contents.get(rel),
425
+ error=None if rel in file_contents else "file_not_found",
325
426
  )
326
- for path in paths
427
+ for original, rel in zip(paths, relative_paths)
327
428
  ]
328
429
  except SessionExpiredError:
329
430
  logger.error("AgentCore session expired while downloading files: %s", paths)
@@ -341,7 +442,7 @@ class AgentCoreSandbox(BaseSandbox):
341
442
  def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
342
443
  """Upload files to the AgentCore sandbox.
343
444
 
344
- Text files are sent directly; binary files are base64-encoded.
445
+ Text files are sent directly; binary files are sent as raw blobs.
345
446
 
346
447
  Args:
347
448
  files: List of ``(path, content)`` tuples to upload.
@@ -350,7 +451,7 @@ class AgentCoreSandbox(BaseSandbox):
350
451
  List of :class:`FileUploadResponse` objects in the same order
351
452
  as the input files.
352
453
  """
353
- file_list: list[dict[str, str]] = []
454
+ file_list: list[dict[str, str | bytes]] = []
354
455
 
355
456
  for path, content in files:
356
457
  rel_path = self._to_relative_path(path)
@@ -358,8 +459,7 @@ class AgentCoreSandbox(BaseSandbox):
358
459
  text_content = content.decode("utf-8")
359
460
  file_list.append({"path": rel_path, "text": text_content})
360
461
  except UnicodeDecodeError:
361
- encoded = base64.b64encode(content).decode("ascii")
362
- file_list.append({"path": rel_path, "blob": encoded})
462
+ file_list.append({"path": rel_path, "blob": content})
363
463
 
364
464
  try:
365
465
  if file_list:
@@ -381,6 +481,53 @@ class AgentCoreSandbox(BaseSandbox):
381
481
  for path, _ in files
382
482
  ]
383
483
 
484
+ def ls(self, path: str) -> LsResult:
485
+ """List a directory, resolving ``path`` against the sandbox cwd."""
486
+ return super().ls(self._to_absolute_path(path))
487
+
488
+ def read(
489
+ self,
490
+ file_path: str,
491
+ offset: int = 0,
492
+ limit: int = 2000,
493
+ ) -> ReadResult:
494
+ """Read a file, resolving ``file_path`` against the sandbox cwd."""
495
+ return super().read(self._to_absolute_path(file_path), offset, limit)
496
+
497
+ def grep(
498
+ self,
499
+ pattern: str,
500
+ path: str | None = None,
501
+ glob: str | None = None,
502
+ ) -> GrepResult:
503
+ """Search file contents, resolving ``path`` against the sandbox cwd."""
504
+ resolved = self._to_absolute_path(path) if path else path
505
+ return super().grep(pattern, resolved, glob)
506
+
507
+ def glob(
508
+ self,
509
+ pattern: str,
510
+ path: str | None = None,
511
+ ) -> GlobResult:
512
+ """Match paths by glob, resolving ``path`` against the sandbox cwd."""
513
+ resolved = self._to_absolute_path(path) if path else path
514
+ return super().glob(pattern, resolved)
515
+
516
+ def edit(
517
+ self,
518
+ file_path: str,
519
+ old_string: str,
520
+ new_string: str,
521
+ replace_all: bool = False, # noqa: FBT001, FBT002
522
+ ) -> EditResult:
523
+ """Edit a file, resolving ``file_path`` against the sandbox cwd."""
524
+ return super().edit(
525
+ self._to_absolute_path(file_path),
526
+ old_string,
527
+ new_string,
528
+ replace_all,
529
+ )
530
+
384
531
  # ------------------------------------------------------------------
385
532
  # Async overrides — use a dedicated executor to avoid starving the
386
533
  # default asyncio thread pool with long-running boto3 stream reads.
@@ -520,3 +667,63 @@ class AgentCoreSandbox(BaseSandbox):
520
667
  _AGENTCORE_EXECUTOR,
521
668
  lambda: self.download_files(paths),
522
669
  )
670
+
671
+ async def als(self, path: str) -> LsResult:
672
+ """Async version of :meth:`ls`.
673
+
674
+ Args:
675
+ path: Directory path to list, resolved against the sandbox cwd.
676
+
677
+ Returns:
678
+ ``LsResult`` with directory entries on success or ``error`` on
679
+ failure.
680
+ """
681
+ loop = asyncio.get_running_loop()
682
+ return await loop.run_in_executor(
683
+ _AGENTCORE_EXECUTOR,
684
+ lambda: self.ls(path),
685
+ )
686
+
687
+ async def agrep(
688
+ self,
689
+ pattern: str,
690
+ path: str | None = None,
691
+ glob: str | None = None,
692
+ ) -> GrepResult:
693
+ """Async version of :meth:`grep`.
694
+
695
+ Args:
696
+ pattern: Literal string to search for.
697
+ path: Directory or file to search in, resolved against the sandbox
698
+ cwd. When ``None``, the ``BaseSandbox`` default is used.
699
+ glob: Optional file-name glob to restrict the search.
700
+
701
+ Returns:
702
+ ``GrepResult`` with a list of matches or ``error`` on failure.
703
+ """
704
+ loop = asyncio.get_running_loop()
705
+ return await loop.run_in_executor(
706
+ _AGENTCORE_EXECUTOR,
707
+ lambda: self.grep(pattern, path, glob),
708
+ )
709
+
710
+ async def aglob(
711
+ self,
712
+ pattern: str,
713
+ path: str | None = None,
714
+ ) -> GlobResult:
715
+ """Async version of :meth:`glob`.
716
+
717
+ Args:
718
+ pattern: Glob pattern to match.
719
+ path: Directory to search in, resolved against the sandbox cwd.
720
+ When ``None``, the ``BaseSandbox`` default is used.
721
+
722
+ Returns:
723
+ ``GlobResult`` with a list of matches or ``error`` on failure.
724
+ """
725
+ loop = asyncio.get_running_loop()
726
+ return await loop.run_in_executor(
727
+ _AGENTCORE_EXECUTOR,
728
+ lambda: self.glob(pattern, path),
729
+ )
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-agentcore-codeinterpreter"
7
- version = "0.0.3"
7
+ version = "0.0.5"
8
8
  description = "Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -46,7 +46,7 @@ dev = [
46
46
  ]
47
47
 
48
48
  [tool.uv]
49
- prerelease = "allow"
49
+ prerelease = "if-necessary-or-explicit"
50
50
 
51
51
  [tool.ruff.lint]
52
52
  select = [
@@ -62,6 +62,12 @@ disallow_untyped_defs = "True"
62
62
 
63
63
  [tool.pytest.ini_options]
64
64
  addopts = "--strict-markers --strict-config --durations=5"
65
+ filterwarnings = [
66
+ # `bedrock-agentcore` (third-party) still uses class-based pydantic `Config`
67
+ # in `bedrock_agentcore.runtime.context.RequestContext`. Remove once upstream
68
+ # migrates to `ConfigDict`.
69
+ "ignore:Support for class-based .config. is deprecated:DeprecationWarning",
70
+ ]
65
71
  markers = [
66
72
  "requires: mark tests as requiring a specific library",
67
73
  "asyncio: mark tests as requiring asyncio",
@@ -82,20 +82,50 @@ class TestAgentCoreSandboxIntegration:
82
82
  assert results[0].error is not None
83
83
 
84
84
  def test_binary_roundtrip(self, sandbox: AgentCoreSandbox) -> None:
85
- """Binary content uploaded via base64 may be returned as base64 text."""
86
- import base64
87
-
85
+ """Binary content uploaded as a blob should round-trip unchanged."""
88
86
  content = b"\x00\x01\x02\xff\xfe\xfd"
89
87
  sandbox.upload_files([("binary_test.bin", content)])
90
88
  results = sandbox.download_files(["binary_test.bin"])
91
89
  assert results[0].error is None
92
- # AgentCore returns blob uploads as text containing the base64 string
93
- assert results[0].content in (content, base64.b64encode(content))
90
+ assert results[0].content == content
94
91
 
95
92
  def test_session_id(self, sandbox: AgentCoreSandbox) -> None:
96
93
  """The sandbox should have a non-empty session ID."""
97
94
  assert sandbox.id != ""
98
95
 
96
+ def test_read_plane_roundtrip_virtual_path(self, sandbox: AgentCoreSandbox) -> None:
97
+ """Read plane round-trips a virtual path against a non-root cwd."""
98
+ vpath = "/workspace/readplane_roundtrip.txt"
99
+ vdir = "/workspace"
100
+ content = "needle in haystack\nsecond line\n"
101
+
102
+ write_result = sandbox.write(vpath, content)
103
+ assert write_result.error is None, write_result.error
104
+
105
+ read_result = sandbox.read(vpath)
106
+ assert read_result.error is None, read_result.error
107
+ assert read_result.file_data is not None
108
+ assert read_result.file_data["content"].strip() == content.strip()
109
+
110
+ ls_result = sandbox.ls(vdir)
111
+ assert ls_result.error is None, ls_result.error
112
+ assert ls_result.entries is not None
113
+ assert any("readplane_roundtrip.txt" in e["path"] for e in ls_result.entries)
114
+
115
+ grep_result = sandbox.grep("needle", vdir)
116
+ assert grep_result.error is None, grep_result.error
117
+ assert grep_result.matches
118
+
119
+ glob_result = sandbox.glob("*.txt", vdir)
120
+ assert glob_result.error is None, glob_result.error
121
+ assert glob_result.matches
122
+
123
+ edit_result = sandbox.edit(vpath, "needle", "pin")
124
+ assert edit_result.error is None, edit_result.error
125
+ after = sandbox.read(vpath)
126
+ assert after.file_data is not None
127
+ assert "pin in haystack" in after.file_data["content"]
128
+
99
129
  # ------------------------------------------------------------------
100
130
  # Async integration tests
101
131
  # ------------------------------------------------------------------
@@ -135,3 +165,28 @@ class TestAgentCoreSandboxIntegration:
135
165
  for i, result in enumerate(results):
136
166
  assert result.exit_code == 0
137
167
  assert f"concurrent-{i}" in result.output
168
+
169
+ def test_aread_plane_roundtrip_virtual_path(
170
+ self, sandbox: AgentCoreSandbox
171
+ ) -> None:
172
+ """Async read plane round-trips a virtual path against a non-root cwd."""
173
+ vpath = "/workspace/areadplane_roundtrip.txt"
174
+ vdir = "/workspace"
175
+
176
+ async def run() -> None:
177
+ write_result = await sandbox.awrite(vpath, "async needle\n")
178
+ assert write_result.error is None, write_result.error
179
+
180
+ read_result = await sandbox.aread(vpath)
181
+ assert read_result.error is None, read_result.error
182
+
183
+ ls_result = await sandbox.als(vdir)
184
+ assert ls_result.error is None, ls_result.error
185
+
186
+ grep_result = await sandbox.agrep("needle", vdir)
187
+ assert grep_result.error is None, grep_result.error
188
+
189
+ glob_result = await sandbox.aglob("*.txt", vdir)
190
+ assert glob_result.error is None, glob_result.error
191
+
192
+ asyncio.run(run())