langchain-agentcore-codeinterpreter 0.0.6__tar.gz → 0.0.7__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.6 → langchain_agentcore_codeinterpreter-0.0.7}/PKG-INFO +1 -1
  2. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/langchain_agentcore_codeinterpreter/sandbox.py +358 -27
  3. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/pyproject.toml +1 -1
  4. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/sandbox/test_sandbox.py +10 -0
  5. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/test_sandbox.py +349 -1
  6. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/uv.lock +1036 -1033
  7. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/.gitignore +0 -0
  8. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/LICENSE +0 -0
  9. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/Makefile +0 -0
  10. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/README.md +0 -0
  11. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
  12. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/scripts/check_imports.py +0 -0
  13. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/__init__.py +0 -0
  14. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/__init__.py +0 -0
  15. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/sandbox/__init__.py +0 -0
  16. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/sandbox/test_standard.py +0 -0
  17. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/test_compile.py +0 -0
  18. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/__init__.py +0 -0
  19. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/test_imports.py +0 -0
  20. {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/test_stream_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: langchain-agentcore-codeinterpreter
3
- Version: 0.0.6
3
+ Version: 0.0.7
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
@@ -5,12 +5,15 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import base64
7
7
  import inspect
8
+ import json
8
9
  import logging
9
10
  import shlex
10
11
  import time
12
+ import uuid
11
13
  from concurrent.futures import ThreadPoolExecutor
12
14
  from typing import TYPE_CHECKING, Any
13
15
 
16
+ from botocore.eventstream import InvalidPayloadLength
14
17
  from botocore.exceptions import ClientError
15
18
  from deepagents.backends.protocol import (
16
19
  EditResult,
@@ -60,6 +63,120 @@ class _ToolError(Exception):
60
63
  """A tool call the service answered with ``isError`` instead of raising."""
61
64
 
62
65
 
66
+ # AgentCore caps each request payload at 100 MB. Requests are JSON, so blobs
67
+ # travel base64-encoded and text JSON-escaped; the slack covers the envelope.
68
+ _WRITE_FILES_BUDGET_BYTES = 90_000_000
69
+
70
+ # Raw bytes per part when one file must be split; base64 keeps it in budget.
71
+ _PART_BYTES = _WRITE_FILES_BUDGET_BYTES // 4 * 3
72
+
73
+ # botocore rejects response events over 24 MiB, and ``readFiles`` returns every
74
+ # requested file in one event: blobs base64-encoded, text JSON-escaped (twice
75
+ # the size for newlines). Files up to this size are read together.
76
+ _READ_FILES_BUDGET_BYTES = 10_000_000
77
+ _READ_CHUNK_BYTES = 15_000_000
78
+
79
+
80
+ def _write_entry(rel_path: str, content: bytes) -> dict[str, str | bytes]:
81
+ """Build a ``writeFiles`` entry, sending UTF-8 content as text.
82
+
83
+ Args:
84
+ rel_path: Cwd-relative destination path.
85
+ content: File contents.
86
+
87
+ Returns:
88
+ Entry with ``text`` for UTF-8 content, otherwise ``blob``.
89
+ """
90
+ try:
91
+ return {"path": rel_path, "text": content.decode("utf-8")}
92
+ except UnicodeDecodeError:
93
+ return {"path": rel_path, "blob": content}
94
+
95
+
96
+ def _encoded_size(entry: dict[str, str | bytes]) -> int:
97
+ """Estimate the bytes an entry occupies in the serialized request.
98
+
99
+ Args:
100
+ entry: A ``writeFiles`` entry from ``_write_entry``.
101
+
102
+ Returns:
103
+ JSON-escaped text size, or base64 size for blobs, plus the path.
104
+ """
105
+ size = len(json.dumps(entry["path"]))
106
+ if "text" in entry:
107
+ return size + len(json.dumps(entry["text"]))
108
+ return size + 4 * ((len(entry["blob"]) + 2) // 3)
109
+
110
+
111
+ def _group_by_budget(
112
+ sizes: list[tuple[int, int]], budget: int | None = None
113
+ ) -> list[list[int]]:
114
+ """Greedily pack ``(index, size)`` pairs into request-sized groups.
115
+
116
+ Args:
117
+ sizes: Entry indices paired with their sizes, each within ``budget``.
118
+ budget: Maximum combined size per group. Defaults to
119
+ ``_WRITE_FILES_BUDGET_BYTES``.
120
+
121
+ Returns:
122
+ Lists of indices whose combined size fits one request.
123
+ """
124
+ if budget is None:
125
+ budget = _WRITE_FILES_BUDGET_BYTES
126
+ groups: list[list[int]] = []
127
+ total = budget
128
+ for index, size in sizes:
129
+ if total + size > budget:
130
+ groups.append([])
131
+ total = 0
132
+ groups[-1].append(index)
133
+ total += size
134
+ return groups
135
+
136
+
137
+ def _assemble_command(parts: list[str], scratch: str, target: str, size: int) -> str:
138
+ """Build a shell command that concatenates parts into ``target``.
139
+
140
+ Each part is deleted once appended to keep peak disk use near one copy,
141
+ and the result is size-checked before it atomically replaces ``target``.
142
+
143
+ Args:
144
+ parts: Absolute part paths in order.
145
+ scratch: Absolute path to assemble into before the final move.
146
+ target: Absolute destination path.
147
+ size: Expected byte size of the assembled file.
148
+
149
+ Returns:
150
+ A command safe to pass to ``execute``.
151
+ """
152
+ tmp = shlex.quote(scratch)
153
+ appends = " && ".join(
154
+ f"cat {shlex.quote(part)} >> {tmp} && rm -f {shlex.quote(part)}"
155
+ for part in parts
156
+ )
157
+ return (
158
+ f": > {tmp} && {appends} && "
159
+ f'test "$(wc -c < {tmp})" -eq {size} && mv -f {tmp} {shlex.quote(target)}'
160
+ )
161
+
162
+
163
+ def _is_response_too_large(exc: Exception) -> bool:
164
+ """Return whether a ``readFiles`` call failed only because of its size.
165
+
166
+ Args:
167
+ exc: Exception raised by the call.
168
+
169
+ Returns:
170
+ ``True`` for botocore's event size limit or AgentCore's response cap.
171
+ """
172
+ if isinstance(exc, InvalidPayloadLength):
173
+ return True
174
+ if isinstance(exc, ClientError):
175
+ message = exc.response.get("Error", {}).get("Message", "")
176
+ return "Response payload too large" in message
177
+ return False
178
+
179
+
63
180
  def _normalize_relative_path(path: str) -> str:
64
181
  """Strip leading slashes and ``./`` prefixes to a canonical relative path.
65
182
 
@@ -561,7 +678,9 @@ class AgentCoreSandbox(BaseSandbox):
561
678
  """Download files from the AgentCore sandbox.
562
679
 
563
680
  Uses AgentCore's ``readFiles`` API. Supports partial success —
564
- individual file downloads may fail without affecting others.
681
+ individual file downloads may fail without affecting others. Files
682
+ are read in one request when the response fits; otherwise they are
683
+ read in smaller batches, and a large file in base64-encoded chunks.
565
684
 
566
685
  Args:
567
686
  paths: List of file paths to download.
@@ -573,10 +692,20 @@ class AgentCoreSandbox(BaseSandbox):
573
692
  try:
574
693
  self._get_cwd()
575
694
  relative_paths = [self._to_relative_path(p) for p in paths]
576
- response = self._invoke(
577
- method="readFiles", params={"paths": relative_paths}
578
- )
695
+ try:
696
+ response = self._invoke(
697
+ method="readFiles", params={"paths": relative_paths}
698
+ )
699
+ except Exception as exc:
700
+ if not _is_response_too_large(exc):
701
+ raise
702
+ return self._download_separately(paths, relative_paths)
579
703
  file_contents = _extract_files_from_stream(response, relative_paths)
704
+ if len(paths) > 1 and not all(
705
+ rel in file_contents for rel in relative_paths
706
+ ):
707
+ # One missing file fails the whole call, so retry the rest.
708
+ return self._download_separately(paths, relative_paths)
580
709
 
581
710
  return [
582
711
  FileDownloadResponse(
@@ -599,10 +728,140 @@ class AgentCoreSandbox(BaseSandbox):
599
728
  for path in paths
600
729
  ]
601
730
 
731
+ def _download_separately(
732
+ self, paths: list[str], relative_paths: list[str]
733
+ ) -> list[FileDownloadResponse]:
734
+ """Download files that cannot be read in one ``readFiles`` call.
735
+
736
+ Args:
737
+ paths: Caller-provided paths.
738
+ relative_paths: The same paths relative to the sandbox cwd.
739
+
740
+ Returns:
741
+ One response per path, in order.
742
+ """
743
+ cwd = self._get_cwd()
744
+ sizes = self._file_sizes([f"{cwd}/{rel}" for rel in relative_paths])
745
+ results: list[FileDownloadResponse | None] = [None] * len(paths)
746
+ fitting: list[tuple[int, int]] = []
747
+
748
+ for i, size in enumerate(sizes):
749
+ if size is None:
750
+ results[i] = FileDownloadResponse(
751
+ path=paths[i], content=None, error="file_not_found"
752
+ )
753
+ elif size < 0:
754
+ results[i] = FileDownloadResponse(
755
+ path=paths[i], content=None, error="is_directory"
756
+ )
757
+ elif size <= _READ_FILES_BUDGET_BYTES:
758
+ fitting.append((i, size))
759
+
760
+ for group in _group_by_budget(fitting, _READ_FILES_BUDGET_BYTES):
761
+ rels = [relative_paths[i] for i in group]
762
+ try:
763
+ response = self._invoke(method="readFiles", params={"paths": rels})
764
+ except Exception as exc:
765
+ # Heavily escaped text can still exceed the limit; chunk it.
766
+ if not _is_response_too_large(exc):
767
+ raise
768
+ continue
769
+ contents = _extract_files_from_stream(response, rels)
770
+ for i in group:
771
+ content = contents.get(relative_paths[i])
772
+ results[i] = FileDownloadResponse(
773
+ path=paths[i],
774
+ content=content,
775
+ error=None if content is not None else "file_not_found",
776
+ )
777
+
778
+ for i, size in enumerate(sizes):
779
+ if results[i] is None and size is not None:
780
+ results[i] = self._download_in_chunks(paths[i], relative_paths[i], size)
781
+ return [result for result in results if result is not None]
782
+
783
+ def _file_sizes(self, paths: list[str]) -> list[int | None]:
784
+ """Look up file sizes in one command.
785
+
786
+ Args:
787
+ paths: Absolute paths in the sandbox.
788
+
789
+ Returns:
790
+ The size of each regular file, ``-1`` for a directory, and
791
+ ``None`` for a missing path.
792
+
793
+ Raises:
794
+ RuntimeError: If the sizes could not be read.
795
+ """
796
+ quoted = " ".join(shlex.quote(p) for p in paths)
797
+ result = self.execute(
798
+ f'for f in {quoted}; do if [ -d "$f" ]; then echo -1; '
799
+ f'elif [ -f "$f" ]; then wc -c < "$f"; else echo -; fi; done'
800
+ )
801
+ lines = result.output.split()
802
+ if result.exit_code != 0 or len(lines) != len(paths):
803
+ msg = f"Could not read file sizes: {result.output[:200]}"
804
+ raise RuntimeError(msg)
805
+ return [None if line == "-" else int(line) for line in lines]
806
+
807
+ def _download_in_chunks(
808
+ self, path: str, rel_path: str, size: int
809
+ ) -> FileDownloadResponse:
810
+ """Download a large file as base64-encoded chunks.
811
+
812
+ Each chunk is encoded to a scratch file in the sandbox and read back
813
+ as plain ASCII, so the response size does not depend on the content.
814
+
815
+ Args:
816
+ path: Caller-provided path.
817
+ rel_path: The path relative to the sandbox cwd.
818
+ size: Expected file size in bytes.
819
+
820
+ Returns:
821
+ The file contents, or ``file_not_found`` if a chunk could not be
822
+ read or the file changed size while downloading.
823
+ """
824
+ cwd = self._get_cwd()
825
+ scratch = f".download-{uuid.uuid4().hex}"
826
+ source = shlex.quote(f"{cwd}/{rel_path}")
827
+ target = shlex.quote(f"{cwd}/{scratch}")
828
+ chunks: list[bytes] = []
829
+ try:
830
+ for offset in range(0, size, _READ_CHUNK_BYTES):
831
+ result = self.execute(
832
+ f"tail -c +{offset + 1} {source} | "
833
+ f"head -c {_READ_CHUNK_BYTES} | base64 -w0 > {target}"
834
+ )
835
+ if result.exit_code != 0:
836
+ logger.error("Error encoding chunk of %s: %s", path, result.output)
837
+ return FileDownloadResponse(
838
+ path=path, content=None, error="file_not_found"
839
+ )
840
+ response = self._invoke(method="readFiles", params={"paths": [scratch]})
841
+ encoded = _extract_files_from_stream(response, [scratch]).get(scratch)
842
+ if encoded is None:
843
+ logger.error("Error reading chunk of %s", path)
844
+ return FileDownloadResponse(
845
+ path=path, content=None, error="file_not_found"
846
+ )
847
+ chunks.append(base64.b64decode(encoded))
848
+ finally:
849
+ self._remove([f"{cwd}/{scratch}"])
850
+
851
+ content = b"".join(chunks)
852
+ if len(content) != size:
853
+ logger.error("Downloaded %d of %d bytes from %s", len(content), size, path)
854
+ return FileDownloadResponse(path=path, content=None, error="file_not_found")
855
+ return FileDownloadResponse(path=path, content=content, error=None)
856
+
602
857
  def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
603
858
  """Upload files to the AgentCore sandbox.
604
859
 
605
860
  Text files are sent directly; binary files are sent as raw blobs.
861
+ Files are batched into ``writeFiles`` requests that fit AgentCore's
862
+ 100 MB payload cap, so a failed request only fails its own files. A
863
+ file too large for one request is uploaded in parts and assembled
864
+ in the sandbox.
606
865
 
607
866
  Args:
608
867
  files: List of ``(path, content)`` tuples to upload.
@@ -611,35 +870,107 @@ class AgentCoreSandbox(BaseSandbox):
611
870
  List of :class:`FileUploadResponse` objects in the same order
612
871
  as the input files.
613
872
  """
614
- file_list: list[dict[str, str | bytes]] = []
873
+ entries = [_write_entry(self._to_relative_path(p), c) for p, c in files]
874
+ sizes = [_encoded_size(entry) for entry in entries]
875
+ errors: list[str | None] = [None] * len(files)
876
+
877
+ fitting = [
878
+ (i, s) for i, s in enumerate(sizes) if s <= _WRITE_FILES_BUDGET_BYTES
879
+ ]
880
+ for group in _group_by_budget(fitting):
881
+ error = self._write_entries(
882
+ [entries[i] for i in group], [files[i][0] for i in group]
883
+ )
884
+ for i in group:
885
+ errors[i] = error
886
+
887
+ for i, size in enumerate(sizes):
888
+ if size > _WRITE_FILES_BUDGET_BYTES:
889
+ path, content = files[i]
890
+ errors[i] = self._upload_in_parts(
891
+ path, str(entries[i]["path"]), content
892
+ )
615
893
 
616
- for path, content in files:
617
- rel_path = self._to_relative_path(path)
618
- try:
619
- text_content = content.decode("utf-8")
620
- file_list.append({"path": rel_path, "text": text_content})
621
- except UnicodeDecodeError:
622
- file_list.append({"path": rel_path, "blob": content})
894
+ return [
895
+ FileUploadResponse(path=path, error=error)
896
+ for (path, _), error in zip(files, errors)
897
+ ]
898
+
899
+ def _write_entries(
900
+ self, entries: list[dict[str, str | bytes]], paths: list[str]
901
+ ) -> str | None:
902
+ """Send one ``writeFiles`` request.
623
903
 
904
+ Args:
905
+ entries: Entries to write, together within the request budget.
906
+ paths: Caller-provided paths, for logging.
907
+
908
+ Returns:
909
+ ``None`` on success, otherwise the error for every entry.
910
+ """
624
911
  try:
625
- if file_list:
626
- self._invoke(method="writeFiles", params={"content": file_list})
627
- return [FileUploadResponse(path=path, error=None) for path, _ in files]
912
+ self._invoke(method="writeFiles", params={"content": entries})
628
913
  except SessionExpiredError:
914
+ logger.error("AgentCore session expired while uploading files: %s", paths)
915
+ return "permission_denied"
916
+ except Exception:
917
+ logger.exception("Error uploading files: %s", paths)
918
+ return "permission_denied"
919
+ return None
920
+
921
+ def _upload_in_parts(self, path: str, rel_path: str, content: bytes) -> str | None:
922
+ """Upload an oversized file as blob parts and concatenate them in place.
923
+
924
+ Args:
925
+ path: Caller-provided path, for logging and errors.
926
+ rel_path: Cwd-relative destination path.
927
+ content: File contents.
928
+
929
+ Returns:
930
+ ``None`` on success, otherwise an error string. Parts already
931
+ written are removed on failure.
932
+ """
933
+ try:
934
+ cwd = self._get_cwd()
935
+ except RuntimeError:
936
+ logger.exception("Error uploading file in parts: %s", path)
937
+ return "permission_denied"
938
+
939
+ tag = uuid.uuid4().hex
940
+ offsets = range(0, len(content), _PART_BYTES)
941
+ parts = [f"{rel_path}.part-{tag}-{n:05d}" for n in range(len(offsets))]
942
+ scratch = f"{cwd}/{rel_path}.assembling-{tag}"
943
+ written: list[str] = []
944
+
945
+ for part, start in zip(parts, offsets):
946
+ blob = content[start : start + _PART_BYTES]
947
+ error = self._write_entries([{"path": part, "blob": blob}], [path])
948
+ if error is not None:
949
+ self._remove([f"{cwd}/{p}" for p in written])
950
+ return error
951
+ written.append(part)
952
+
953
+ absolute = [f"{cwd}/{p}" for p in parts]
954
+ command = _assemble_command(
955
+ absolute, scratch, f"{cwd}/{rel_path}", len(content)
956
+ )
957
+ result = self.execute(command)
958
+ if result.exit_code != 0:
629
959
  logger.error(
630
- "AgentCore session expired while uploading files: %s",
631
- [p for p, _ in files],
960
+ "Error assembling uploaded parts of %s: %s", path, result.output
632
961
  )
633
- return [
634
- FileUploadResponse(path=path, error="permission_denied")
635
- for path, _ in files
636
- ]
637
- except Exception:
638
- logger.exception("Error uploading files: %s", [p for p, _ in files])
639
- return [
640
- FileUploadResponse(path=path, error="permission_denied")
641
- for path, _ in files
642
- ]
962
+ self._remove([*absolute, scratch])
963
+ return "permission_denied"
964
+ return None
965
+
966
+ def _remove(self, paths: list[str]) -> None:
967
+ """Best-effort removal of leftover upload and download files.
968
+
969
+ Args:
970
+ paths: Absolute paths to delete.
971
+ """
972
+ if paths:
973
+ self.execute("rm -f " + " ".join(shlex.quote(p) for p in paths))
643
974
 
644
975
  def ls(self, path: str) -> LsResult:
645
976
  """List a directory, resolving ``path`` against the sandbox cwd."""
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "langchain-agentcore-codeinterpreter"
7
- version = "0.0.6"
7
+ version = "0.0.7"
8
8
  description = "Amazon Bedrock AgentCore Code Interpreter sandbox integration for Deep Agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -7,6 +7,7 @@ Code Interpreter service is available.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ import hashlib
10
11
  import os
11
12
  from typing import TYPE_CHECKING, Any
12
13
 
@@ -90,6 +91,15 @@ class TestAgentCoreSandboxIntegration:
90
91
  assert results[0].error is None
91
92
  assert results[0].content == content
92
93
 
94
+ def test_download_large_file(self, sandbox: AgentCoreSandbox) -> None:
95
+ """A file too large for one readFiles response downloads intact."""
96
+ sandbox.execute("head -c 20000000 /dev/urandom > large_download.bin")
97
+ digest = sandbox.execute("sha256sum large_download.bin").output.split()[0]
98
+ results = sandbox.download_files(["large_download.bin"])
99
+ assert results[0].error is None
100
+ assert results[0].content is not None
101
+ assert hashlib.sha256(results[0].content).hexdigest() == digest
102
+
93
103
  def test_session_id(self, sandbox: AgentCoreSandbox) -> None:
94
104
  """The sandbox should have a non-empty session ID."""
95
105
  assert sandbox.id != ""