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.
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/PKG-INFO +1 -1
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/langchain_agentcore_codeinterpreter/sandbox.py +358 -27
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/pyproject.toml +1 -1
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/sandbox/test_sandbox.py +10 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/test_sandbox.py +349 -1
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/uv.lock +1036 -1033
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/.gitignore +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/LICENSE +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/Makefile +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/README.md +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/scripts/check_imports.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/sandbox/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/sandbox/test_standard.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/integration_tests/test_compile.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/test_imports.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/tests/unit_tests/test_stream_parsing.py +0 -0
{langchain_agentcore_codeinterpreter-0.0.6 → langchain_agentcore_codeinterpreter-0.0.7}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: langchain-agentcore-codeinterpreter
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
|
|
577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
631
|
-
[p for p, _ in files],
|
|
960
|
+
"Error assembling uploaded parts of %s: %s", path, result.output
|
|
632
961
|
)
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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.
|
|
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 != ""
|