langchain-agentcore-codeinterpreter 0.0.3__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.3 → langchain_agentcore_codeinterpreter-0.0.4}/PKG-INFO +1 -1
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/sandbox.py +108 -10
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/pyproject.toml +2 -2
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_sandbox.py +295 -15
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/uv.lock +528 -543
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/.gitignore +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/LICENSE +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/Makefile +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/README.md +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/langchain_agentcore_codeinterpreter/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/scripts/check_imports.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/sandbox/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/sandbox/test_sandbox.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/integration_tests/test_compile.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/__init__.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_imports.py +0 -0
- {langchain_agentcore_codeinterpreter-0.0.3 → langchain_agentcore_codeinterpreter-0.0.4}/tests/unit_tests/test_stream_parsing.py +0 -0
{langchain_agentcore_codeinterpreter-0.0.3 → 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
|
|
@@ -167,6 +167,15 @@ class AgentCoreSandbox(BaseSandbox):
|
|
|
167
167
|
The caller is responsible for managing the interpreter lifecycle
|
|
168
168
|
(``start()`` / ``stop()``).
|
|
169
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
|
+
|
|
170
179
|
Example:
|
|
171
180
|
.. code-block:: python
|
|
172
181
|
|
|
@@ -185,26 +194,114 @@ class AgentCoreSandbox(BaseSandbox):
|
|
|
185
194
|
interpreter.stop()
|
|
186
195
|
"""
|
|
187
196
|
|
|
188
|
-
def __init__(
|
|
197
|
+
def __init__(
|
|
198
|
+
self,
|
|
199
|
+
*,
|
|
200
|
+
interpreter: CodeInterpreter,
|
|
201
|
+
cwd: str | None = None,
|
|
202
|
+
) -> None:
|
|
189
203
|
"""Create a backend wrapping an active CodeInterpreter session.
|
|
190
204
|
|
|
191
205
|
Args:
|
|
192
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``.
|
|
193
213
|
"""
|
|
194
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
|
|
195
232
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
233
|
+
def _to_relative_path(self, path: str) -> str:
|
|
234
|
+
"""Strip the cwd prefix (or leading slashes) for AgentCore file APIs.
|
|
235
|
+
|
|
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.
|
|
199
240
|
|
|
200
241
|
Args:
|
|
201
242
|
path: File path (absolute or relative).
|
|
202
243
|
|
|
203
244
|
Returns:
|
|
204
|
-
|
|
245
|
+
Path relative to the sandbox cwd, with no leading ``/`` or ``./``.
|
|
205
246
|
"""
|
|
247
|
+
if self._cwd is not None:
|
|
248
|
+
cwd_prefix = self._cwd + "/"
|
|
249
|
+
if path.startswith(cwd_prefix):
|
|
250
|
+
return path[len(cwd_prefix) :]
|
|
206
251
|
return _normalize_relative_path(path)
|
|
207
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)
|
|
304
|
+
|
|
208
305
|
def _invoke(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
209
306
|
"""Invoke the interpreter and eagerly consume the response stream.
|
|
210
307
|
|
|
@@ -311,19 +408,20 @@ class AgentCoreSandbox(BaseSandbox):
|
|
|
311
408
|
as the input paths.
|
|
312
409
|
"""
|
|
313
410
|
try:
|
|
411
|
+
self._get_cwd()
|
|
314
412
|
relative_paths = [self._to_relative_path(p) for p in paths]
|
|
315
413
|
response = self._invoke(
|
|
316
414
|
method="readFiles", params={"paths": relative_paths}
|
|
317
415
|
)
|
|
318
|
-
file_contents = _extract_files_from_stream(response,
|
|
416
|
+
file_contents = _extract_files_from_stream(response, relative_paths)
|
|
319
417
|
|
|
320
418
|
return [
|
|
321
419
|
FileDownloadResponse(
|
|
322
|
-
path=
|
|
323
|
-
content=file_contents.get(
|
|
324
|
-
error=None if
|
|
420
|
+
path=original,
|
|
421
|
+
content=file_contents.get(rel),
|
|
422
|
+
error=None if rel in file_contents else "file_not_found",
|
|
325
423
|
)
|
|
326
|
-
for
|
|
424
|
+
for original, rel in zip(paths, relative_paths)
|
|
327
425
|
]
|
|
328
426
|
except SessionExpiredError:
|
|
329
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 = [
|
|
@@ -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,7 +225,7 @@ 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
|
|
|
@@ -248,7 +250,8 @@ def test_download_files_dot_slash_path() -> None:
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
]
|
|
251
|
-
}
|
|
253
|
+
},
|
|
254
|
+
cwd="/",
|
|
252
255
|
)
|
|
253
256
|
results = sandbox.download_files(["./data/foo.png"])
|
|
254
257
|
mock.invoke.assert_called_once_with(
|
|
@@ -258,6 +261,111 @@ def test_download_files_dot_slash_path() -> None:
|
|
|
258
261
|
assert results[0].content == fake_png
|
|
259
262
|
|
|
260
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
|
+
|
|
261
369
|
# ------------------------------------------------------------------
|
|
262
370
|
# _to_relative_path()
|
|
263
371
|
# ------------------------------------------------------------------
|
|
@@ -265,16 +373,32 @@ def test_download_files_dot_slash_path() -> None:
|
|
|
265
373
|
|
|
266
374
|
def test_relative_path_stripping() -> None:
|
|
267
375
|
"""Leading slashes should be stripped; relative paths left as-is."""
|
|
268
|
-
|
|
269
|
-
assert
|
|
270
|
-
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"
|
|
271
380
|
|
|
272
381
|
|
|
273
382
|
def test_relative_path_strips_dot_slash() -> None:
|
|
274
383
|
"""./ and repeated ././ prefixes should be stripped."""
|
|
275
|
-
|
|
276
|
-
assert
|
|
277
|
-
assert
|
|
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"
|
|
278
402
|
|
|
279
403
|
|
|
280
404
|
# ------------------------------------------------------------------
|
|
@@ -290,6 +414,162 @@ def test_keyword_only_init() -> None:
|
|
|
290
414
|
assert sandbox.id == "s"
|
|
291
415
|
|
|
292
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
|
+
|
|
293
573
|
# ------------------------------------------------------------------
|
|
294
574
|
# _invoke() — eager stream consumption
|
|
295
575
|
# ------------------------------------------------------------------
|
|
@@ -473,7 +753,7 @@ def test_adownload_files_uses_dedicated_executor() -> None:
|
|
|
473
753
|
}
|
|
474
754
|
]
|
|
475
755
|
}
|
|
476
|
-
sandbox, mock = _make_sandbox(canned_response)
|
|
756
|
+
sandbox, mock = _make_sandbox(canned_response, cwd="/")
|
|
477
757
|
|
|
478
758
|
import threading
|
|
479
759
|
|