langchain-agentcore-codeinterpreter 0.0.2__tar.gz → 0.0.4__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.2 → langchain_agentcore_codeinterpreter-0.0.4}/PKG-INFO +1 -1
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/sandbox.py +130 -15
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/pyproject.toml +2 -2
- langchain_agentcore_codeinterpreter-0.0.4/tests/unit_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_sandbox.py +328 -11
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_stream_parsing.py +48 -0
- langchain_agentcore_codeinterpreter-0.0.4/uv.lock +2556 -0
- langchain_agentcore_codeinterpreter-0.0.2/uv.lock +0 -2470
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/.gitignore +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/LICENSE +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/Makefile +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/README.md +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/scripts/check_imports.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2/tests/unit_tests → langchain_agentcore_codeinterpreter-0.0.4/tests/integration_tests/sandbox}/__init__.py +0 -0
- /langchain_agentcore_codeinterpreter-0.0.2/tests/integration_tests/test_integration.py → /langchain_agentcore_codeinterpreter-0.0.4/tests/integration_tests/sandbox/test_sandbox.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/test_compile.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_imports.py +0 -0
{langchain_agentcore_codeinterpreter-0.0.2 → langchain_agentcore_codeinterpreter-0.0.4}/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.4
|
|
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
|
|
@@ -32,6 +32,21 @@ _AGENTCORE_EXECUTOR = ThreadPoolExecutor(
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
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
|
+
|
|
35
50
|
class SessionExpiredError(Exception):
|
|
36
51
|
"""Raised when the AgentCore session has expired or been terminated."""
|
|
37
52
|
|
|
@@ -104,8 +119,7 @@ def _extract_files_from_stream(
|
|
|
104
119
|
"""
|
|
105
120
|
path_lookup: dict[str, str] = {}
|
|
106
121
|
for path in requested_paths:
|
|
107
|
-
|
|
108
|
-
path_lookup[stripped] = path
|
|
122
|
+
path_lookup[_normalize_relative_path(path)] = path
|
|
109
123
|
|
|
110
124
|
files: dict[str, bytes] = {}
|
|
111
125
|
|
|
@@ -117,13 +131,16 @@ def _extract_files_from_stream(
|
|
|
117
131
|
continue
|
|
118
132
|
resource = item.get("resource", {})
|
|
119
133
|
uri = resource.get("uri", "")
|
|
120
|
-
file_path = uri.replace("file://", "")
|
|
134
|
+
file_path = _normalize_relative_path(uri.replace("file://", ""))
|
|
121
135
|
|
|
122
136
|
content: bytes | None = None
|
|
123
137
|
if "text" in resource:
|
|
124
138
|
content = resource["text"].encode("utf-8")
|
|
125
139
|
elif "blob" in resource:
|
|
126
|
-
|
|
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)
|
|
127
144
|
|
|
128
145
|
if content is not None:
|
|
129
146
|
original_path = path_lookup.get(file_path, file_path)
|
|
@@ -150,6 +167,15 @@ class AgentCoreSandbox(BaseSandbox):
|
|
|
150
167
|
The caller is responsible for managing the interpreter lifecycle
|
|
151
168
|
(``start()`` / ``stop()``).
|
|
152
169
|
|
|
170
|
+
!!! note
|
|
171
|
+
|
|
172
|
+
When the sandbox working directory is not ``/`` (e.g.
|
|
173
|
+
``/opt/amazon/genesis1p-tools/var/``), paths must be resolved
|
|
174
|
+
against the real cwd before shell preflight commands and stripped of
|
|
175
|
+
the cwd prefix before the AgentCore ``writeFiles``/``readFiles`` APIs.
|
|
176
|
+
Pass the known cwd via the ``cwd`` constructor argument, or let the
|
|
177
|
+
sandbox detect it automatically on the first ``write()`` call.
|
|
178
|
+
|
|
153
179
|
Example:
|
|
154
180
|
.. code-block:: python
|
|
155
181
|
|
|
@@ -168,25 +194,113 @@ class AgentCoreSandbox(BaseSandbox):
|
|
|
168
194
|
interpreter.stop()
|
|
169
195
|
"""
|
|
170
196
|
|
|
171
|
-
def __init__(
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
interpreter: CodeInterpreter,
|
|
201
|
+
cwd: str | None = None,
|
|
202
|
+
) -> None:
|
|
172
203
|
"""Create a backend wrapping an active CodeInterpreter session.
|
|
173
204
|
|
|
174
205
|
Args:
|
|
175
206
|
interpreter: A started :class:`CodeInterpreter` instance.
|
|
207
|
+
cwd: The sandbox working directory (e.g.
|
|
208
|
+
``"/opt/amazon/genesis1p-tools/var"``). When provided,
|
|
209
|
+
``write()`` uses it to resolve virtual paths to real absolute
|
|
210
|
+
paths and to strip the prefix before the AgentCore
|
|
211
|
+
``writeFiles`` API. When omitted, the cwd is detected
|
|
212
|
+
automatically on the first ``write()`` call via ``pwd``.
|
|
176
213
|
"""
|
|
177
214
|
self._interpreter = interpreter
|
|
215
|
+
self._cwd: str | None = cwd.rstrip("/") if cwd is not None else None
|
|
216
|
+
|
|
217
|
+
def _get_cwd(self) -> str:
|
|
218
|
+
"""Return the sandbox working directory, detecting it lazily if needed.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
The working directory with any trailing slash stripped.
|
|
222
|
+
"""
|
|
223
|
+
if self._cwd is None:
|
|
224
|
+
result = self.execute("pwd")
|
|
225
|
+
if result.exit_code != 0 or not result.output.strip():
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
f"Failed to detect sandbox working directory: "
|
|
228
|
+
f"exit_code={result.exit_code}, output={result.output!r}"
|
|
229
|
+
)
|
|
230
|
+
self._cwd = result.output.strip().rstrip("/")
|
|
231
|
+
return self._cwd
|
|
232
|
+
|
|
233
|
+
def _to_relative_path(self, path: str) -> str:
|
|
234
|
+
"""Strip the cwd prefix (or leading slashes) for AgentCore file APIs.
|
|
178
235
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
236
|
+
When the sandbox cwd is known and ``path`` starts with it, the cwd
|
|
237
|
+
prefix is removed so the AgentCore ``writeFiles``/``readFiles`` APIs
|
|
238
|
+
receive a cwd-relative path. For paths that do not start with the cwd,
|
|
239
|
+
the standard leading-slash stripping is applied.
|
|
182
240
|
|
|
183
241
|
Args:
|
|
184
242
|
path: File path (absolute or relative).
|
|
185
243
|
|
|
186
244
|
Returns:
|
|
187
|
-
|
|
245
|
+
Path relative to the sandbox cwd, with no leading ``/`` or ``./``.
|
|
188
246
|
"""
|
|
189
|
-
|
|
247
|
+
if self._cwd is not None:
|
|
248
|
+
cwd_prefix = self._cwd + "/"
|
|
249
|
+
if path.startswith(cwd_prefix):
|
|
250
|
+
return path[len(cwd_prefix) :]
|
|
251
|
+
return _normalize_relative_path(path)
|
|
252
|
+
|
|
253
|
+
def _to_absolute_path(self, path: str) -> str:
|
|
254
|
+
"""Resolve a path to a real absolute path under the sandbox cwd.
|
|
255
|
+
|
|
256
|
+
Paths already under the cwd are returned unchanged. Virtual paths
|
|
257
|
+
(e.g. ``/workspace/hello.py``) and relative paths are prepended with
|
|
258
|
+
the cwd so that shell commands (``makedirs``, etc.) operate on the
|
|
259
|
+
real filesystem location.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
path: File path to resolve.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Absolute path under the sandbox cwd.
|
|
266
|
+
"""
|
|
267
|
+
cwd = self._get_cwd()
|
|
268
|
+
if not cwd:
|
|
269
|
+
return path
|
|
270
|
+
if path.startswith(cwd + "/") or path == cwd:
|
|
271
|
+
return path
|
|
272
|
+
return cwd + "/" + path.lstrip("/")
|
|
273
|
+
|
|
274
|
+
def write(self, file_path: str, content: str) -> WriteResult:
|
|
275
|
+
"""Create a new file in the sandbox, failing if it already exists.
|
|
276
|
+
|
|
277
|
+
Normalizes ``file_path`` to a real absolute path under the sandbox cwd
|
|
278
|
+
before the preflight shell command (so ``makedirs`` operates on the
|
|
279
|
+
correct location) and before uploading (so the AgentCore ``writeFiles``
|
|
280
|
+
API receives a cwd-relative path rather than a doubled absolute path).
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
file_path: Destination path for the new file. May be a real
|
|
284
|
+
absolute path under the sandbox cwd, a virtual absolute path
|
|
285
|
+
(e.g. ``/workspace/hello.py``), or a relative path.
|
|
286
|
+
content: UTF-8 text content to write.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
``WriteResult`` with ``path`` set to the resolved absolute path on
|
|
290
|
+
success, or ``error`` on failure.
|
|
291
|
+
"""
|
|
292
|
+
abs_path = self._to_absolute_path(file_path)
|
|
293
|
+
preflight_error = self._write_preflight(abs_path)
|
|
294
|
+
if preflight_error is not None:
|
|
295
|
+
return preflight_error
|
|
296
|
+
responses = self.upload_files([(abs_path, content.encode("utf-8"))])
|
|
297
|
+
assert responses, "upload_files returned no responses"
|
|
298
|
+
response = responses[0]
|
|
299
|
+
if response.error:
|
|
300
|
+
return WriteResult(
|
|
301
|
+
error=f"Failed to write file '{abs_path}': {response.error}"
|
|
302
|
+
)
|
|
303
|
+
return WriteResult(path=abs_path)
|
|
190
304
|
|
|
191
305
|
def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
192
306
|
"""Invoke the interpreter and eagerly consume the response stream.
|
|
@@ -294,19 +408,20 @@ class AgentCoreSandbox(BaseSandbox):
|
|
|
294
408
|
as the input paths.
|
|
295
409
|
"""
|
|
296
410
|
try:
|
|
411
|
+
self._get_cwd()
|
|
297
412
|
relative_paths = [self._to_relative_path(p) for p in paths]
|
|
298
413
|
response = self._invoke(
|
|
299
414
|
method="readFiles", params={"paths": relative_paths}
|
|
300
415
|
)
|
|
301
|
-
file_contents = _extract_files_from_stream(response,
|
|
416
|
+
file_contents = _extract_files_from_stream(response, relative_paths)
|
|
302
417
|
|
|
303
418
|
return [
|
|
304
419
|
FileDownloadResponse(
|
|
305
|
-
path=
|
|
306
|
-
content=file_contents.get(
|
|
307
|
-
error=None if
|
|
420
|
+
path=original,
|
|
421
|
+
content=file_contents.get(rel),
|
|
422
|
+
error=None if rel in file_contents else "file_not_found",
|
|
308
423
|
)
|
|
309
|
-
for
|
|
424
|
+
for original, rel in zip(paths, relative_paths)
|
|
310
425
|
]
|
|
311
426
|
except SessionExpiredError:
|
|
312
427
|
logger.error("AgentCore session expired while downloading files: %s", paths)
|
|
@@ -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.4"
|
|
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 = "
|
|
49
|
+
prerelease = "if-necessary-or-explicit"
|
|
50
50
|
|
|
51
51
|
[tool.ruff.lint]
|
|
52
52
|
select = [
|
|
File without changes
|
|
@@ -24,15 +24,16 @@ from langchain_agentcore_codeinterpreter.sandbox import (
|
|
|
24
24
|
def _make_sandbox(
|
|
25
25
|
invoke_return: dict[str, Any] | None = None,
|
|
26
26
|
session_id: str = "test-session-123",
|
|
27
|
+
cwd: str | None = None,
|
|
27
28
|
) -> tuple[AgentCoreSandbox, MagicMock]:
|
|
28
29
|
"""Create a sandbox with a mocked interpreter."""
|
|
29
30
|
interpreter = MagicMock()
|
|
30
31
|
interpreter.session_id = session_id
|
|
31
32
|
interpreter.invoke.return_value = invoke_return or {"stream": []}
|
|
32
|
-
return AgentCoreSandbox(interpreter=interpreter), interpreter
|
|
33
|
+
return AgentCoreSandbox(interpreter=interpreter, cwd=cwd), interpreter
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def _make_expired_sandbox() -> tuple[AgentCoreSandbox, MagicMock]:
|
|
36
|
+
def _make_expired_sandbox(cwd: str | None = None) -> tuple[AgentCoreSandbox, MagicMock]:
|
|
36
37
|
"""Create a sandbox whose interpreter raises ResourceNotFoundException."""
|
|
37
38
|
interpreter = MagicMock()
|
|
38
39
|
interpreter.session_id = "expired-session"
|
|
@@ -45,7 +46,7 @@ def _make_expired_sandbox() -> tuple[AgentCoreSandbox, MagicMock]:
|
|
|
45
46
|
},
|
|
46
47
|
"InvokeCodeInterpreter",
|
|
47
48
|
)
|
|
48
|
-
return AgentCoreSandbox(interpreter=interpreter), interpreter
|
|
49
|
+
return AgentCoreSandbox(interpreter=interpreter, cwd=cwd), interpreter
|
|
49
50
|
|
|
50
51
|
|
|
51
52
|
# ------------------------------------------------------------------
|
|
@@ -195,7 +196,8 @@ def test_download_files() -> None:
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
]
|
|
198
|
-
}
|
|
199
|
+
},
|
|
200
|
+
cwd="/",
|
|
199
201
|
)
|
|
200
202
|
results = sandbox.download_files(["/test.txt"])
|
|
201
203
|
mock.invoke.assert_called_once_with(
|
|
@@ -207,7 +209,7 @@ def test_download_files() -> None:
|
|
|
207
209
|
|
|
208
210
|
def test_download_files_missing() -> None:
|
|
209
211
|
"""Missing files should be reported as file_not_found."""
|
|
210
|
-
sandbox, _ = _make_sandbox({"stream": [{"result": {"content": []}}]})
|
|
212
|
+
sandbox, _ = _make_sandbox({"stream": [{"result": {"content": []}}]}, cwd="/")
|
|
211
213
|
results = sandbox.download_files(["/missing.txt"])
|
|
212
214
|
assert results[0].error == "file_not_found"
|
|
213
215
|
assert results[0].content is None
|
|
@@ -215,7 +217,7 @@ def test_download_files_missing() -> None:
|
|
|
215
217
|
|
|
216
218
|
def test_download_files_handles_exception() -> None:
|
|
217
219
|
"""SDK errors during download should return file_not_found."""
|
|
218
|
-
sandbox, mock = _make_sandbox()
|
|
220
|
+
sandbox, mock = _make_sandbox(cwd="/")
|
|
219
221
|
mock.invoke.side_effect = RuntimeError("read failed")
|
|
220
222
|
results = sandbox.download_files(["/a.txt"])
|
|
221
223
|
assert results[0].error == "file_not_found"
|
|
@@ -223,11 +225,147 @@ def test_download_files_handles_exception() -> None:
|
|
|
223
225
|
|
|
224
226
|
def test_download_files_handles_session_expiry() -> None:
|
|
225
227
|
"""Session expiry during download should return permission_denied."""
|
|
226
|
-
sandbox, _ = _make_expired_sandbox()
|
|
228
|
+
sandbox, _ = _make_expired_sandbox(cwd="/")
|
|
227
229
|
results = sandbox.download_files(["/a.txt"])
|
|
228
230
|
assert results[0].error == "permission_denied"
|
|
229
231
|
|
|
230
232
|
|
|
233
|
+
def test_download_files_dot_slash_path() -> None:
|
|
234
|
+
"""./-prefixed paths must round-trip through readFiles and lookup."""
|
|
235
|
+
fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
|
236
|
+
sandbox, mock = _make_sandbox(
|
|
237
|
+
{
|
|
238
|
+
"stream": [
|
|
239
|
+
{
|
|
240
|
+
"result": {
|
|
241
|
+
"content": [
|
|
242
|
+
{
|
|
243
|
+
"type": "resource",
|
|
244
|
+
"resource": {
|
|
245
|
+
"uri": "file:///data/foo.png",
|
|
246
|
+
"blob": fake_png,
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
},
|
|
254
|
+
cwd="/",
|
|
255
|
+
)
|
|
256
|
+
results = sandbox.download_files(["./data/foo.png"])
|
|
257
|
+
mock.invoke.assert_called_once_with(
|
|
258
|
+
method="readFiles", params={"paths": ["data/foo.png"]}
|
|
259
|
+
)
|
|
260
|
+
assert results[0].error is None
|
|
261
|
+
assert results[0].content == fake_png
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_download_files_strips_cwd_prefix() -> None:
|
|
265
|
+
"""Absolute paths under cwd should have the prefix stripped for readFiles."""
|
|
266
|
+
cwd = "/opt/sandbox"
|
|
267
|
+
sandbox, mock = _make_sandbox(
|
|
268
|
+
{
|
|
269
|
+
"stream": [
|
|
270
|
+
{
|
|
271
|
+
"result": {
|
|
272
|
+
"content": [
|
|
273
|
+
{
|
|
274
|
+
"type": "resource",
|
|
275
|
+
"resource": {
|
|
276
|
+
"uri": "file:///workspace/hello.py",
|
|
277
|
+
"text": "hello",
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
]
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
]
|
|
284
|
+
},
|
|
285
|
+
cwd=cwd,
|
|
286
|
+
)
|
|
287
|
+
results = sandbox.download_files([f"{cwd}/workspace/hello.py"])
|
|
288
|
+
mock.invoke.assert_called_once_with(
|
|
289
|
+
method="readFiles", params={"paths": ["workspace/hello.py"]}
|
|
290
|
+
)
|
|
291
|
+
assert results[0].content == b"hello"
|
|
292
|
+
assert results[0].error is None
|
|
293
|
+
assert results[0].path == f"{cwd}/workspace/hello.py"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_download_files_virtual_path_with_cwd() -> None:
|
|
297
|
+
"""Virtual paths not under cwd should fall back to leading-slash stripping."""
|
|
298
|
+
sandbox, mock = _make_sandbox(
|
|
299
|
+
{
|
|
300
|
+
"stream": [
|
|
301
|
+
{
|
|
302
|
+
"result": {
|
|
303
|
+
"content": [
|
|
304
|
+
{
|
|
305
|
+
"type": "resource",
|
|
306
|
+
"resource": {
|
|
307
|
+
"uri": "file:///workspace/hello.py",
|
|
308
|
+
"text": "hello",
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
]
|
|
315
|
+
},
|
|
316
|
+
cwd="/opt/sandbox",
|
|
317
|
+
)
|
|
318
|
+
results = sandbox.download_files(["/workspace/hello.py"])
|
|
319
|
+
mock.invoke.assert_called_once_with(
|
|
320
|
+
method="readFiles", params={"paths": ["workspace/hello.py"]}
|
|
321
|
+
)
|
|
322
|
+
assert results[0].content == b"hello"
|
|
323
|
+
assert results[0].error is None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_download_files_lazy_cwd_detection() -> None:
|
|
327
|
+
"""When cwd is not provided, download_files should detect it via pwd."""
|
|
328
|
+
cwd = "/opt/sandbox"
|
|
329
|
+
|
|
330
|
+
def invoke(**kwargs: Any) -> dict[str, Any]:
|
|
331
|
+
if kwargs.get("method") == "executeCommand":
|
|
332
|
+
return {
|
|
333
|
+
"stream": [
|
|
334
|
+
{
|
|
335
|
+
"result": {
|
|
336
|
+
"exitCode": 0,
|
|
337
|
+
"content": [{"type": "text", "text": cwd}],
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
]
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
"stream": [
|
|
344
|
+
{
|
|
345
|
+
"result": {
|
|
346
|
+
"content": [
|
|
347
|
+
{
|
|
348
|
+
"type": "resource",
|
|
349
|
+
"resource": {
|
|
350
|
+
"uri": "file:///workspace/hello.py",
|
|
351
|
+
"text": "hello",
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
]
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
sandbox, mock = _make_sandbox()
|
|
361
|
+
mock.invoke.side_effect = invoke
|
|
362
|
+
|
|
363
|
+
results = sandbox.download_files([f"{cwd}/workspace/hello.py"])
|
|
364
|
+
assert sandbox._cwd == cwd
|
|
365
|
+
assert results[0].content == b"hello"
|
|
366
|
+
assert results[0].error is None
|
|
367
|
+
|
|
368
|
+
|
|
231
369
|
# ------------------------------------------------------------------
|
|
232
370
|
# _to_relative_path()
|
|
233
371
|
# ------------------------------------------------------------------
|
|
@@ -235,9 +373,32 @@ def test_download_files_handles_session_expiry() -> None:
|
|
|
235
373
|
|
|
236
374
|
def test_relative_path_stripping() -> None:
|
|
237
375
|
"""Leading slashes should be stripped; relative paths left as-is."""
|
|
238
|
-
|
|
239
|
-
assert
|
|
240
|
-
assert
|
|
376
|
+
sandbox, _ = _make_sandbox()
|
|
377
|
+
assert sandbox._to_relative_path("/abs/path.txt") == "abs/path.txt"
|
|
378
|
+
assert sandbox._to_relative_path("rel/path.txt") == "rel/path.txt"
|
|
379
|
+
assert sandbox._to_relative_path("///triple.txt") == "triple.txt"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_relative_path_strips_dot_slash() -> None:
|
|
383
|
+
"""./ and repeated ././ prefixes should be stripped."""
|
|
384
|
+
sandbox, _ = _make_sandbox()
|
|
385
|
+
assert sandbox._to_relative_path("./data/foo.png") == "data/foo.png"
|
|
386
|
+
assert sandbox._to_relative_path("././foo.png") == "foo.png"
|
|
387
|
+
assert sandbox._to_relative_path("/./data/foo.png") == "data/foo.png"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_relative_path_strips_cwd_prefix() -> None:
|
|
391
|
+
"""When cwd is known, paths under cwd should have the prefix stripped."""
|
|
392
|
+
sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
|
|
393
|
+
result = sandbox._to_relative_path("/opt/sandbox/workspace/hello.py")
|
|
394
|
+
assert result == "workspace/hello.py"
|
|
395
|
+
assert sandbox._to_relative_path("/opt/sandbox/hello.py") == "hello.py"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def test_relative_path_virtual_path_falls_back_to_strip() -> None:
|
|
399
|
+
"""Paths outside cwd should fall back to leading-slash stripping."""
|
|
400
|
+
sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
|
|
401
|
+
assert sandbox._to_relative_path("/workspace/hello.py") == "workspace/hello.py"
|
|
241
402
|
|
|
242
403
|
|
|
243
404
|
# ------------------------------------------------------------------
|
|
@@ -253,6 +414,162 @@ def test_keyword_only_init() -> None:
|
|
|
253
414
|
assert sandbox.id == "s"
|
|
254
415
|
|
|
255
416
|
|
|
417
|
+
def test_cwd_constructor_stores_stripped_cwd() -> None:
|
|
418
|
+
"""cwd passed at construction should be stored with trailing slash removed."""
|
|
419
|
+
sandbox, _ = _make_sandbox(cwd="/opt/sandbox/")
|
|
420
|
+
assert sandbox._cwd == "/opt/sandbox"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_cwd_defaults_to_none() -> None:
|
|
424
|
+
"""When cwd is not passed, _cwd should start as None (lazy detection)."""
|
|
425
|
+
sandbox, _ = _make_sandbox()
|
|
426
|
+
assert sandbox._cwd is None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ------------------------------------------------------------------
|
|
430
|
+
# _to_absolute_path()
|
|
431
|
+
# ------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_to_absolute_path_already_under_cwd() -> None:
|
|
435
|
+
"""Paths already under the cwd should be returned unchanged."""
|
|
436
|
+
sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
|
|
437
|
+
path = "/opt/sandbox/workspace/hello.py"
|
|
438
|
+
assert sandbox._to_absolute_path(path) == path
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_to_absolute_path_virtual_path_prepends_cwd() -> None:
|
|
442
|
+
"""Virtual paths like /workspace/hello.py should be resolved under cwd."""
|
|
443
|
+
sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
|
|
444
|
+
result = sandbox._to_absolute_path("/workspace/hello.py")
|
|
445
|
+
assert result == "/opt/sandbox/workspace/hello.py"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_to_absolute_path_relative_path_prepends_cwd() -> None:
|
|
449
|
+
"""Relative paths should be resolved under cwd."""
|
|
450
|
+
sandbox, _ = _make_sandbox(cwd="/opt/sandbox")
|
|
451
|
+
result = sandbox._to_absolute_path("workspace/hello.py")
|
|
452
|
+
assert result == "/opt/sandbox/workspace/hello.py"
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def test_to_absolute_path_root_cwd_returns_as_is() -> None:
|
|
456
|
+
"""When cwd is / (stored as empty string), absolute paths are returned as-is."""
|
|
457
|
+
sandbox, _ = _make_sandbox(cwd="/")
|
|
458
|
+
assert sandbox._to_absolute_path("/workspace/hello.py") == "/workspace/hello.py"
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ------------------------------------------------------------------
|
|
462
|
+
# write() — path normalization (issue #1055)
|
|
463
|
+
# ------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _make_successful_invoke(cwd: str = "") -> Any:
|
|
467
|
+
"""Return an invoke side_effect that succeeds for all methods."""
|
|
468
|
+
|
|
469
|
+
def invoke(**kwargs: Any) -> dict[str, Any]:
|
|
470
|
+
if kwargs.get("method") == "executeCommand":
|
|
471
|
+
return {
|
|
472
|
+
"stream": [
|
|
473
|
+
{
|
|
474
|
+
"result": {
|
|
475
|
+
"exitCode": 0,
|
|
476
|
+
"content": [{"type": "text", "text": cwd}],
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
}
|
|
481
|
+
return {"stream": []}
|
|
482
|
+
|
|
483
|
+
return invoke
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_write_strips_cwd_prefix_from_upload_path() -> None:
|
|
487
|
+
"""upload path must be cwd-relative so writeFiles doesn't double the prefix."""
|
|
488
|
+
cwd = "/opt/amazon/genesis1p-tools/var"
|
|
489
|
+
sandbox, mock = _make_sandbox(cwd=cwd)
|
|
490
|
+
mock.invoke.side_effect = _make_successful_invoke(cwd)
|
|
491
|
+
|
|
492
|
+
abs_path = f"{cwd}/workspace/hello.py"
|
|
493
|
+
result = sandbox.write(abs_path, "hello")
|
|
494
|
+
|
|
495
|
+
write_files_calls = [
|
|
496
|
+
c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
|
|
497
|
+
]
|
|
498
|
+
assert len(write_files_calls) == 1
|
|
499
|
+
uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
|
|
500
|
+
assert uploaded_path == "workspace/hello.py"
|
|
501
|
+
assert result.path == abs_path
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def test_write_resolves_virtual_path_for_preflight() -> None:
|
|
505
|
+
"""Virtual paths must be resolved to real absolute paths before preflight."""
|
|
506
|
+
cwd = "/opt/sandbox"
|
|
507
|
+
sandbox, mock = _make_sandbox(cwd=cwd)
|
|
508
|
+
mock.invoke.side_effect = _make_successful_invoke(cwd)
|
|
509
|
+
|
|
510
|
+
result = sandbox.write("/workspace/hello.py", "hello")
|
|
511
|
+
|
|
512
|
+
# The resolved absolute path should be returned and used for the upload.
|
|
513
|
+
assert result.path == "/opt/sandbox/workspace/hello.py"
|
|
514
|
+
write_files_calls = [
|
|
515
|
+
c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
|
|
516
|
+
]
|
|
517
|
+
assert len(write_files_calls) == 1
|
|
518
|
+
uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
|
|
519
|
+
assert uploaded_path == "workspace/hello.py"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_write_lazy_cwd_detection() -> None:
|
|
523
|
+
"""When cwd is not passed at construction, write() detects it via pwd."""
|
|
524
|
+
cwd = "/opt/sandbox"
|
|
525
|
+
sandbox, mock = _make_sandbox()
|
|
526
|
+
mock.invoke.side_effect = _make_successful_invoke(cwd)
|
|
527
|
+
|
|
528
|
+
result = sandbox.write("/workspace/hello.py", "hello")
|
|
529
|
+
|
|
530
|
+
assert sandbox._cwd == cwd
|
|
531
|
+
assert result.path == "/opt/sandbox/workspace/hello.py"
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def test_write_returns_resolved_path_not_virtual_path() -> None:
|
|
535
|
+
"""WriteResult.path must be the resolved absolute path, not the virtual path.
|
|
536
|
+
|
|
537
|
+
Regression test for the execute()-after-write() mismatch: when the LLM writes
|
|
538
|
+
to "/tmp/script.py" and then runs execute("python /tmp/script.py"), it must
|
|
539
|
+
use the same path that was returned by write() — otherwise the shell cannot
|
|
540
|
+
find the file because AgentCore resolves uploads relative to cwd, not to "/".
|
|
541
|
+
"""
|
|
542
|
+
cwd = "/opt/amazon/genesis1p-tools/var"
|
|
543
|
+
sandbox, mock = _make_sandbox(cwd=cwd)
|
|
544
|
+
mock.invoke.side_effect = _make_successful_invoke(cwd)
|
|
545
|
+
|
|
546
|
+
result = sandbox.write("/tmp/script.py", "print('hello')")
|
|
547
|
+
|
|
548
|
+
# The returned path must be the real location so execute() can find the file.
|
|
549
|
+
assert result.path == f"{cwd}/tmp/script.py"
|
|
550
|
+
write_files_calls = [
|
|
551
|
+
c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
|
|
552
|
+
]
|
|
553
|
+
uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
|
|
554
|
+
# AgentCore receives a cwd-relative path, resolving to {cwd}/tmp/script.py.
|
|
555
|
+
assert uploaded_path == "tmp/script.py"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_write_root_cwd_preserves_existing_behavior() -> None:
|
|
559
|
+
"""When cwd is /, write() should behave as it did before the fix."""
|
|
560
|
+
sandbox, mock = _make_sandbox(cwd="/")
|
|
561
|
+
mock.invoke.side_effect = _make_successful_invoke("/")
|
|
562
|
+
|
|
563
|
+
result = sandbox.write("/hello.py", "hello")
|
|
564
|
+
|
|
565
|
+
assert result.path == "/hello.py"
|
|
566
|
+
write_files_calls = [
|
|
567
|
+
c for c in mock.invoke.call_args_list if c.kwargs.get("method") == "writeFiles"
|
|
568
|
+
]
|
|
569
|
+
uploaded_path = write_files_calls[0].kwargs["params"]["content"][0]["path"]
|
|
570
|
+
assert uploaded_path == "hello.py"
|
|
571
|
+
|
|
572
|
+
|
|
256
573
|
# ------------------------------------------------------------------
|
|
257
574
|
# _invoke() — eager stream consumption
|
|
258
575
|
# ------------------------------------------------------------------
|
|
@@ -436,7 +753,7 @@ def test_adownload_files_uses_dedicated_executor() -> None:
|
|
|
436
753
|
}
|
|
437
754
|
]
|
|
438
755
|
}
|
|
439
|
-
sandbox, mock = _make_sandbox(canned_response)
|
|
756
|
+
sandbox, mock = _make_sandbox(canned_response, cwd="/")
|
|
440
757
|
|
|
441
758
|
import threading
|
|
442
759
|
|