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.
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/PKG-INFO +1 -1
- langchain_agentcore_codeinterpreter-0.0.3/langchain_agentcore_codeinterpreter/sandbox.py +522 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/pyproject.toml +1 -1
- 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
- langchain_agentcore_codeinterpreter-0.0.3/tests/unit_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/unit_tests/test_imports.py +7 -0
- langchain_agentcore_codeinterpreter-0.0.3/tests/unit_tests/test_sandbox.py +510 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/unit_tests/test_stream_parsing.py +48 -0
- langchain_agentcore_codeinterpreter-0.0.3/uv.lock +2571 -0
- langchain_agentcore_codeinterpreter-0.0.1/langchain_agentcore_codeinterpreter/sandbox.py +0 -273
- langchain_agentcore_codeinterpreter-0.0.1/tests/unit_tests/test_sandbox.py +0 -206
- langchain_agentcore_codeinterpreter-0.0.1/uv.lock +0 -2378
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/.gitignore +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/LICENSE +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/Makefile +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/README.md +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/scripts/check_imports.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/integration_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1/tests/unit_tests → langchain_agentcore_codeinterpreter-0.0.3/tests/integration_tests/sandbox}/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/tests/integration_tests/test_compile.py +0 -0
{langchain_agentcore_codeinterpreter-0.0.1 → langchain_agentcore_codeinterpreter-0.0.3}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langchain-agentcore-codeinterpreter
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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
|
|
File without changes
|
|
@@ -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
|