langchain-islo 0.0.1__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_islo-0.0.1/.github/workflows/ci.yml +34 -0
- langchain_islo-0.0.1/.github/workflows/workflow.yml +50 -0
- langchain_islo-0.0.1/.gitignore +7 -0
- langchain_islo-0.0.1/CHANGELOG.md +12 -0
- langchain_islo-0.0.1/LICENSE +21 -0
- langchain_islo-0.0.1/Makefile +60 -0
- langchain_islo-0.0.1/PKG-INFO +110 -0
- langchain_islo-0.0.1/README.md +84 -0
- langchain_islo-0.0.1/docs/DOCS_PR.md +50 -0
- langchain_islo-0.0.1/docs/PUBLISHING.md +49 -0
- langchain_islo-0.0.1/docs/islo.mdx +81 -0
- langchain_islo-0.0.1/docs/issue-reply.md +36 -0
- langchain_islo-0.0.1/langchain_islo/__init__.py +6 -0
- langchain_islo-0.0.1/langchain_islo/_version.py +5 -0
- langchain_islo-0.0.1/langchain_islo/provider.py +92 -0
- langchain_islo-0.0.1/langchain_islo/sandbox.py +298 -0
- langchain_islo-0.0.1/pyproject.toml +91 -0
- langchain_islo-0.0.1/tests/__init__.py +0 -0
- langchain_islo-0.0.1/tests/integration_tests/__init__.py +0 -0
- langchain_islo-0.0.1/tests/integration_tests/test_sandbox.py +33 -0
- langchain_islo-0.0.1/tests/test_import.py +9 -0
- langchain_islo-0.0.1/tests/unit_tests/__init__.py +0 -0
- langchain_islo-0.0.1/tests/unit_tests/test_sandbox.py +183 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: Lint and test (py${{ matrix.python-version }})
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
- uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
- name: Install
|
|
26
|
+
run: |
|
|
27
|
+
python -m pip install --upgrade pip
|
|
28
|
+
pip install -e . ruff pytest pytest-socket "langchain-tests>=1.1.6"
|
|
29
|
+
- name: Lint
|
|
30
|
+
run: |
|
|
31
|
+
ruff check langchain_islo tests
|
|
32
|
+
ruff format langchain_islo tests --diff
|
|
33
|
+
- name: Unit tests
|
|
34
|
+
run: pytest tests/unit_tests tests/test_import.py -q --disable-socket --allow-unix-socket
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishes langchain-islo to PyPI via Trusted Publishing (OIDC, no token).
|
|
4
|
+
# Matches the PyPI pending publisher: repo islo-labs/langchain-islo,
|
|
5
|
+
# workflow file workflow.yml, environment "pypi".
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
name: Build distribution
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.12"
|
|
24
|
+
- name: Build sdist and wheel
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade build
|
|
27
|
+
python -m build
|
|
28
|
+
- name: Check metadata
|
|
29
|
+
run: |
|
|
30
|
+
python -m pip install --upgrade twine
|
|
31
|
+
python -m twine check dist/*
|
|
32
|
+
- uses: actions/upload-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
publish:
|
|
38
|
+
name: Publish to PyPI
|
|
39
|
+
needs: build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment: pypi
|
|
42
|
+
permissions:
|
|
43
|
+
id-token: write # required for Trusted Publishing
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/download-artifact@v4
|
|
46
|
+
with:
|
|
47
|
+
name: dist
|
|
48
|
+
path: dist/
|
|
49
|
+
- name: Publish
|
|
50
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.0.1
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `IsloSandbox`: a `deepagents` `BaseSandbox` backend for [Islo](https://islo.dev),
|
|
8
|
+
implementing `execute()`, `upload_files()`, `download_files()`, and `id`.
|
|
9
|
+
Filesystem tools (`ls`, `read`, `write`, `edit`, `glob`, `grep`) are inherited
|
|
10
|
+
from `BaseSandbox`.
|
|
11
|
+
- `IsloProvider`: optional lifecycle helper (`get_or_create()` / `delete()`).
|
|
12
|
+
- Validated against the `deepagents` `SandboxIntegrationTests` standard suite.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Islo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
.PHONY: format lint type typecheck test tests integration_test integration_tests test_watch help lint_package
|
|
2
|
+
|
|
3
|
+
.DEFAULT_GOAL := help
|
|
4
|
+
|
|
5
|
+
.EXPORT_ALL_VARIABLES:
|
|
6
|
+
UV_FROZEN = true
|
|
7
|
+
|
|
8
|
+
######################
|
|
9
|
+
# TESTING
|
|
10
|
+
######################
|
|
11
|
+
|
|
12
|
+
TEST_FILE ?= tests/unit_tests/
|
|
13
|
+
PYTEST_EXTRA ?=
|
|
14
|
+
|
|
15
|
+
integration_test integration_tests: TEST_FILE=tests/integration_tests/
|
|
16
|
+
|
|
17
|
+
test: ## Run unit tests
|
|
18
|
+
test tests:
|
|
19
|
+
uv run --group test pytest -vvv $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE)
|
|
20
|
+
|
|
21
|
+
integration_test: ## Run integration tests
|
|
22
|
+
integration_test integration_tests:
|
|
23
|
+
uv run --group test pytest -vvv --timeout 120 $(TEST_FILE)
|
|
24
|
+
|
|
25
|
+
test_watch: ## Run tests in watch mode
|
|
26
|
+
uv run --group test ptw --now . -- -vv $(TEST_FILE)
|
|
27
|
+
|
|
28
|
+
######################
|
|
29
|
+
# LINTING AND FORMATTING
|
|
30
|
+
######################
|
|
31
|
+
|
|
32
|
+
PYTHON_FILES=.
|
|
33
|
+
lint format: PYTHON_FILES=.
|
|
34
|
+
lint_package: ## Lint only the package
|
|
35
|
+
lint_package: PYTHON_FILES=langchain_islo
|
|
36
|
+
|
|
37
|
+
lint: ## Run linters and type checker
|
|
38
|
+
lint lint_package:
|
|
39
|
+
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff check $(PYTHON_FILES)
|
|
40
|
+
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff format $(PYTHON_FILES) --diff
|
|
41
|
+
$(MAKE) type
|
|
42
|
+
|
|
43
|
+
type: ## Run type checker
|
|
44
|
+
type typecheck:
|
|
45
|
+
uv run --all-groups ty check langchain_islo
|
|
46
|
+
|
|
47
|
+
format: ## Run code formatters
|
|
48
|
+
format:
|
|
49
|
+
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff format $(PYTHON_FILES)
|
|
50
|
+
[ "$(PYTHON_FILES)" = "" ] || uv run --all-groups ruff check --fix $(PYTHON_FILES)
|
|
51
|
+
|
|
52
|
+
######################
|
|
53
|
+
# HELP
|
|
54
|
+
######################
|
|
55
|
+
|
|
56
|
+
help: ## Show this help message
|
|
57
|
+
@echo "Usage: make [target] [TEST_FILE=path/to/tests/]"
|
|
58
|
+
@echo ""
|
|
59
|
+
@echo "Targets:"
|
|
60
|
+
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z_-]+:.*##/ {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-islo
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Islo (islo.dev) sandbox integration for Deep Agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/islo-labs/langchain-islo
|
|
6
|
+
Project-URL: Repository, https://github.com/islo-labs/langchain-islo
|
|
7
|
+
Project-URL: Documentation, https://docs.islo.dev
|
|
8
|
+
Project-URL: Issue Tracker, https://github.com/islo-labs/langchain-islo/issues
|
|
9
|
+
Author: Islo
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agents,deepagents,islo,langchain,sandbox
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: <4.0,>=3.11
|
|
22
|
+
Requires-Dist: deepagents<0.7.0,>=0.6.8
|
|
23
|
+
Requires-Dist: httpx>=0.21.2
|
|
24
|
+
Requires-Dist: islo>=0.3.3
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# langchain-islo
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/langchain-islo/#history)
|
|
30
|
+
[](https://opensource.org/licenses/MIT)
|
|
31
|
+
|
|
32
|
+
[Islo](https://islo.dev) sandbox integration for [Deep Agents](https://github.com/langchain-ai/deepagents).
|
|
33
|
+
|
|
34
|
+
Islo provides long-running, reconnect-surviving AI sandboxes on real Linux VMs,
|
|
35
|
+
with pause/resume, snapshots, and a gateway layer for secret isolation and
|
|
36
|
+
egress policies.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install langchain-islo
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Set your API key (keys look like `ak_...`):
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export ISLO_API_KEY="ak_..."
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Wrap an existing Islo sandbox and use it as a Deep Agents backend:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from islo import Islo
|
|
56
|
+
from langchain_islo import IsloSandbox
|
|
57
|
+
|
|
58
|
+
client = Islo() # reads ISLO_API_KEY
|
|
59
|
+
sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
|
|
60
|
+
|
|
61
|
+
backend = IsloSandbox(client=client, sandbox=sandbox)
|
|
62
|
+
|
|
63
|
+
result = backend.execute("echo hello")
|
|
64
|
+
print(result.output) # "hello"
|
|
65
|
+
|
|
66
|
+
# Filesystem tools are inherited from BaseSandbox:
|
|
67
|
+
backend.write("/workspace/app.py", "print('hi')\n")
|
|
68
|
+
print(backend.read("/workspace/app.py").file_data["content"])
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Lifecycle helper
|
|
72
|
+
|
|
73
|
+
`IsloProvider` creates, attaches to, and deletes sandboxes for you:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from langchain_islo import IsloProvider
|
|
77
|
+
|
|
78
|
+
provider = IsloProvider() # reads ISLO_API_KEY
|
|
79
|
+
backend = provider.get_or_create(image="ubuntu:24.04")
|
|
80
|
+
try:
|
|
81
|
+
print(backend.execute("uname -a").output)
|
|
82
|
+
finally:
|
|
83
|
+
provider.delete(sandbox_id=backend.id)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What you get
|
|
87
|
+
|
|
88
|
+
`IsloSandbox` subclasses `deepagents.backends.sandbox.BaseSandbox`, so it
|
|
89
|
+
implements the full `SandboxBackendProtocol`:
|
|
90
|
+
|
|
91
|
+
| Method | Backed by |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `execute()` | Islo `exec_in_sandbox` + result polling (commands run via `sh -c`) |
|
|
94
|
+
| `upload_files()` / `download_files()` | Islo sandbox files endpoint (raw bytes) |
|
|
95
|
+
| `ls` / `read` / `write` / `edit` / `glob` / `grep` | Inherited from `BaseSandbox` |
|
|
96
|
+
| `id` | Islo sandbox id |
|
|
97
|
+
|
|
98
|
+
Async variants (`aexecute`, `aupload_files`, `adownload_files`, ...) are provided
|
|
99
|
+
by the base class via thread offloading.
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
| Env var | Default | Purpose |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `ISLO_API_KEY` | — | Bearer token used to authenticate |
|
|
106
|
+
| `ISLO_BASE_URL` | `https://api.islo.dev` | Control-plane base URL |
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# langchain-islo
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/langchain-islo/#history)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
[Islo](https://islo.dev) sandbox integration for [Deep Agents](https://github.com/langchain-ai/deepagents).
|
|
7
|
+
|
|
8
|
+
Islo provides long-running, reconnect-surviving AI sandboxes on real Linux VMs,
|
|
9
|
+
with pause/resume, snapshots, and a gateway layer for secret isolation and
|
|
10
|
+
egress policies.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install langchain-islo
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Set your API key (keys look like `ak_...`):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export ISLO_API_KEY="ak_..."
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Wrap an existing Islo sandbox and use it as a Deep Agents backend:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from islo import Islo
|
|
30
|
+
from langchain_islo import IsloSandbox
|
|
31
|
+
|
|
32
|
+
client = Islo() # reads ISLO_API_KEY
|
|
33
|
+
sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
|
|
34
|
+
|
|
35
|
+
backend = IsloSandbox(client=client, sandbox=sandbox)
|
|
36
|
+
|
|
37
|
+
result = backend.execute("echo hello")
|
|
38
|
+
print(result.output) # "hello"
|
|
39
|
+
|
|
40
|
+
# Filesystem tools are inherited from BaseSandbox:
|
|
41
|
+
backend.write("/workspace/app.py", "print('hi')\n")
|
|
42
|
+
print(backend.read("/workspace/app.py").file_data["content"])
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Lifecycle helper
|
|
46
|
+
|
|
47
|
+
`IsloProvider` creates, attaches to, and deletes sandboxes for you:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from langchain_islo import IsloProvider
|
|
51
|
+
|
|
52
|
+
provider = IsloProvider() # reads ISLO_API_KEY
|
|
53
|
+
backend = provider.get_or_create(image="ubuntu:24.04")
|
|
54
|
+
try:
|
|
55
|
+
print(backend.execute("uname -a").output)
|
|
56
|
+
finally:
|
|
57
|
+
provider.delete(sandbox_id=backend.id)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## What you get
|
|
61
|
+
|
|
62
|
+
`IsloSandbox` subclasses `deepagents.backends.sandbox.BaseSandbox`, so it
|
|
63
|
+
implements the full `SandboxBackendProtocol`:
|
|
64
|
+
|
|
65
|
+
| Method | Backed by |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `execute()` | Islo `exec_in_sandbox` + result polling (commands run via `sh -c`) |
|
|
68
|
+
| `upload_files()` / `download_files()` | Islo sandbox files endpoint (raw bytes) |
|
|
69
|
+
| `ls` / `read` / `write` / `edit` / `glob` / `grep` | Inherited from `BaseSandbox` |
|
|
70
|
+
| `id` | Islo sandbox id |
|
|
71
|
+
|
|
72
|
+
Async variants (`aexecute`, `aupload_files`, `adownload_files`, ...) are provided
|
|
73
|
+
by the base class via thread offloading.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
| Env var | Default | Purpose |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `ISLO_API_KEY` | — | Bearer token used to authenticate |
|
|
80
|
+
| `ISLO_BASE_URL` | `https://api.islo.dev` | Control-plane base URL |
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Docs PR to `langchain-ai/docs`
|
|
2
|
+
|
|
3
|
+
> Do this **after** `langchain-islo` is published to PyPI. The maintainers
|
|
4
|
+
> (issue #3777) only accept docs PRs for *already-published* sandbox packages.
|
|
5
|
+
|
|
6
|
+
## 1. Add the integration page
|
|
7
|
+
|
|
8
|
+
Copy [`islo.mdx`](./islo.mdx) to:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
src/oss/python/integrations/sandboxes/islo.mdx
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This mirrors the structure of the existing `daytona.mdx` / `modal.mdx` /
|
|
15
|
+
`runloop.mdx` pages (Installation → Create a sandbox backend → Use with Deep
|
|
16
|
+
Agents → Cleanup).
|
|
17
|
+
|
|
18
|
+
## 2. Register it in the index
|
|
19
|
+
|
|
20
|
+
In `src/oss/python/integrations/sandboxes/index.mdx`, add an Islo card to the
|
|
21
|
+
grid (alphabetical-ish, alongside the others):
|
|
22
|
+
|
|
23
|
+
```mdx
|
|
24
|
+
<a href="/oss/integrations/sandboxes/islo" className="flex items-center justify-center gap-1.5 p-2 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 no-underline">
|
|
25
|
+
<img className="block dark:hidden w-5 h-5" src="/images/providers/light/islo.svg" alt="" />
|
|
26
|
+
<img className="hidden dark:block w-5 h-5" src="/images/providers/dark/islo.svg" alt="" />
|
|
27
|
+
<span className="font-semibold">Islo</span>
|
|
28
|
+
</a>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 3. Add the provider logo
|
|
32
|
+
|
|
33
|
+
Add light/dark SVGs (the index references both):
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
src/images/providers/light/islo.svg
|
|
37
|
+
src/images/providers/dark/islo.svg
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
(Check `docs.json` / the nav config for whether the new page must also be listed
|
|
41
|
+
in the sidebar navigation — grep for `daytona` to find every place it's
|
|
42
|
+
referenced and add `islo` in the same spots.)
|
|
43
|
+
|
|
44
|
+
## 4. Open the PR
|
|
45
|
+
|
|
46
|
+
Title: `docs: add Islo sandbox integration`
|
|
47
|
+
|
|
48
|
+
Body: link the published PyPI package (`https://pypi.org/project/langchain-islo/`)
|
|
49
|
+
and the issue (`langchain-ai/deepagents#3777`), and note it follows the
|
|
50
|
+
[sandbox contributing guide](https://docs.langchain.com/oss/python/contributing/implement-langchain#sandboxes).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Publishing `langchain-islo` to PyPI
|
|
2
|
+
|
|
3
|
+
The maintainer's guidance on `langchain-ai/deepagents#3777` is: **ship as a
|
|
4
|
+
standalone PyPI package**, then open a docs PR. This is the publish half.
|
|
5
|
+
|
|
6
|
+
## Preconditions
|
|
7
|
+
|
|
8
|
+
- [ ] Repo created (suggested: `islo-labs/langchain-islo`) with this package at root.
|
|
9
|
+
- [ ] PyPI account + project owned by the Islo/Incredibuild org.
|
|
10
|
+
- [ ] CI green: `make lint` and `make test` (unit tests run offline).
|
|
11
|
+
|
|
12
|
+
## Build & check
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv build # or: python -m build
|
|
16
|
+
uvx twine check dist/*
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Test it from a clean env (optional but recommended)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
python -m venv /tmp/islo-check && . /tmp/islo-check/bin/activate
|
|
23
|
+
pip install dist/langchain_islo-*.whl
|
|
24
|
+
python -c "from langchain_islo import IsloSandbox, IsloProvider; print('ok')"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Publish
|
|
28
|
+
|
|
29
|
+
Trusted publishing (recommended) via GitHub Actions, or manually:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uvx twine upload dist/*
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Live integration test (needs a real account)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export ISLO_API_KEY="ak_..."
|
|
39
|
+
make integration_test
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This runs the deepagents standard suite (`SandboxIntegrationTests`) against a
|
|
43
|
+
real Islo sandbox.
|
|
44
|
+
|
|
45
|
+
## After publishing
|
|
46
|
+
|
|
47
|
+
1. Confirm `pip install langchain-islo` works from PyPI.
|
|
48
|
+
2. Open the docs PR — see [`DOCS_PR.md`](./DOCS_PR.md).
|
|
49
|
+
3. Post the follow-up on issue #3777 — see [`issue-reply.md`](./issue-reply.md).
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "IsloSandbox integration"
|
|
3
|
+
description: "Integrate with the IsloSandbox sandbox backend using LangChain Python."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
[Islo](https://islo.dev) provides long-running, reconnect-surviving sandboxes on real Linux VMs, with pause/resume, snapshots, and a gateway layer for secret isolation and egress policies. See the [Islo docs](https://docs.islo.dev) for signup, authentication, and platform details.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
<CodeGroup>
|
|
11
|
+
```bash pip
|
|
12
|
+
pip install langchain-islo
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```bash uv
|
|
16
|
+
uv add langchain-islo
|
|
17
|
+
```
|
|
18
|
+
</CodeGroup>
|
|
19
|
+
|
|
20
|
+
Set your API key (keys look like `ak_...`):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
export ISLO_API_KEY="ak_..."
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Create a sandbox backend
|
|
27
|
+
|
|
28
|
+
In Python, you create the sandbox using the provider SDK, then wrap it with the [deepagents backend](/oss/deepagents/backends).
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from islo import Islo
|
|
32
|
+
|
|
33
|
+
from langchain_islo import IsloSandbox
|
|
34
|
+
|
|
35
|
+
client = Islo() # reads ISLO_API_KEY
|
|
36
|
+
sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
|
|
37
|
+
backend = IsloSandbox(client=client, sandbox=sandbox)
|
|
38
|
+
|
|
39
|
+
result = backend.execute("echo hello")
|
|
40
|
+
print(result.output)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Use with Deep Agents
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from islo import Islo
|
|
47
|
+
from langchain_anthropic import ChatAnthropic
|
|
48
|
+
|
|
49
|
+
from deepagents import create_deep_agent
|
|
50
|
+
from langchain_islo import IsloSandbox
|
|
51
|
+
|
|
52
|
+
client = Islo()
|
|
53
|
+
sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
|
|
54
|
+
backend = IsloSandbox(client=client, sandbox=sandbox)
|
|
55
|
+
|
|
56
|
+
agent = create_deep_agent(
|
|
57
|
+
model=ChatAnthropic(model="claude-sonnet-4-20250514"),
|
|
58
|
+
system_prompt="You are a coding assistant with sandbox access.",
|
|
59
|
+
backend=backend,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
result = agent.invoke(
|
|
63
|
+
{
|
|
64
|
+
"messages": [
|
|
65
|
+
{"role": "user", "content": "Create a hello world Python script and run it"}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Cleanup
|
|
72
|
+
|
|
73
|
+
You are responsible for managing the sandbox lifecycle via the Islo SDK.
|
|
74
|
+
When you are done, pause, stop, or delete the sandbox:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
client.sandboxes.pause_sandbox(sandbox.name) # resume later with resume_sandbox
|
|
78
|
+
client.sandboxes.delete_sandbox(sandbox.name) # permanently delete
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
See also: [Sandboxes](/oss/deepagents/sandboxes).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Draft reply for langchain-ai/deepagents#3777
|
|
2
|
+
|
|
3
|
+
> Post this once the package is on PyPI and the docs PR is open. It (a) confirms
|
|
4
|
+
> you followed their standalone-package guidance and (b) gives the feedback
|
|
5
|
+
> `mdrxy` explicitly asked for on recommending community integrations.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Thanks @mdrxy — totally understand the maintenance/security rationale for not
|
|
10
|
+
taking new sandboxes in-tree.
|
|
11
|
+
|
|
12
|
+
We went the standalone route as suggested:
|
|
13
|
+
|
|
14
|
+
- 📦 Published **`langchain-islo`** to PyPI: https://pypi.org/project/langchain-islo/
|
|
15
|
+
- It subclasses `BaseSandbox` and implements `execute()` / `upload_files()` /
|
|
16
|
+
`download_files()` / `id`, and passes the `SandboxIntegrationTests` standard
|
|
17
|
+
suite (`tests/integration_tests/test_sandbox.py`), per the
|
|
18
|
+
[contributing guide](https://docs.langchain.com/oss/python/contributing/implement-langchain#sandboxes).
|
|
19
|
+
- 📄 Docs PR adding the integration page: <link to langchain-ai/docs PR>
|
|
20
|
+
|
|
21
|
+
On your open question about how to surface community integration packages — a
|
|
22
|
+
few ideas from the implementer's seat:
|
|
23
|
+
|
|
24
|
+
1. **A "Community" section on the Sandboxes index page** (separate from the
|
|
25
|
+
first-party cards) that links out to third-party PyPI packages + their docs.
|
|
26
|
+
Zero maintenance for you, discoverable for users.
|
|
27
|
+
2. **An optional entry-point / plugin hook** so `deepagents-cli --sandbox <name>`
|
|
28
|
+
can discover installed third-party backends without a hardcoded `choices`
|
|
29
|
+
list in `sandbox_factory.py`. Today adding a CLI sandbox name requires an
|
|
30
|
+
in-tree edit, which is the one thing a standalone package *can't* do — an
|
|
31
|
+
entry-point group (e.g. `deepagents.sandboxes`) would close that gap.
|
|
32
|
+
3. **A lightweight "verified" badge** keyed off the standard test suite passing
|
|
33
|
+
in the package's own CI, so users can tell which community packages meet the
|
|
34
|
+
contract.
|
|
35
|
+
|
|
36
|
+
Happy to prototype (1) or (2) as a PR if useful.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Lifecycle provider for Islo sandboxes.
|
|
2
|
+
|
|
3
|
+
[`IsloProvider`][langchain_islo.provider.IsloProvider] is an optional
|
|
4
|
+
convenience that creates, attaches to, and deletes Islo sandboxes and returns a
|
|
5
|
+
ready-to-use [`IsloSandbox`][langchain_islo.sandbox.IsloSandbox]. It is
|
|
6
|
+
duck-typed to the lifecycle shape deepagents expects of a sandbox provider
|
|
7
|
+
(``get_or_create`` / ``delete``) without importing the CLI package, mirroring the
|
|
8
|
+
``RunloopProvider`` pattern.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from islo import Islo
|
|
16
|
+
|
|
17
|
+
from langchain_islo.sandbox import IsloSandbox
|
|
18
|
+
|
|
19
|
+
_DEFAULT_IMAGE = "ubuntu:24.04"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class IsloProvider:
|
|
23
|
+
"""Create, attach to, and delete Islo sandboxes.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
```python
|
|
27
|
+
from langchain_islo import IsloProvider
|
|
28
|
+
|
|
29
|
+
provider = IsloProvider(api_key="ak_...")
|
|
30
|
+
backend = provider.get_or_create(image="ubuntu:24.04")
|
|
31
|
+
try:
|
|
32
|
+
print(backend.execute("uname -a").output)
|
|
33
|
+
finally:
|
|
34
|
+
provider.delete(sandbox_id=backend.id)
|
|
35
|
+
```
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
client: Islo | None = None,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
image: str = _DEFAULT_IMAGE,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize the provider.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
client: A pre-configured ``islo.Islo`` client. If omitted, one is
|
|
49
|
+
created (reading ``ISLO_API_KEY`` / ``ISLO_BASE_URL`` from the
|
|
50
|
+
environment, or ``api_key`` when provided).
|
|
51
|
+
api_key: API key used to construct a client when ``client`` is not
|
|
52
|
+
supplied.
|
|
53
|
+
image: Default container image used when creating new sandboxes.
|
|
54
|
+
"""
|
|
55
|
+
if client is None:
|
|
56
|
+
client = Islo(api_key=api_key) if api_key else Islo()
|
|
57
|
+
self._client = client
|
|
58
|
+
self._default_image = image
|
|
59
|
+
|
|
60
|
+
def get_or_create(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
sandbox_id: str | None = None,
|
|
64
|
+
**kwargs: Any,
|
|
65
|
+
) -> IsloSandbox:
|
|
66
|
+
"""Attach to an existing sandbox or create a new one.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
sandbox_id: If provided, attach to the existing Islo sandbox with
|
|
70
|
+
this id instead of creating a new one.
|
|
71
|
+
kwargs: Forwarded to ``client.sandboxes.create_sandbox(...)`` when
|
|
72
|
+
creating (e.g. ``image``, ``vcpus``, ``memory_mb``, ``env``,
|
|
73
|
+
``gateway_profile``, ``snapshot_name``).
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A ready-to-use ``IsloSandbox``.
|
|
77
|
+
"""
|
|
78
|
+
if sandbox_id is not None:
|
|
79
|
+
sandbox = self._client.sandboxes.get_sandbox_by_id(sandbox_id)
|
|
80
|
+
else:
|
|
81
|
+
kwargs.setdefault("image", self._default_image)
|
|
82
|
+
sandbox = self._client.sandboxes.create_sandbox(**kwargs)
|
|
83
|
+
return IsloSandbox(client=self._client, sandbox=sandbox)
|
|
84
|
+
|
|
85
|
+
def delete(self, *, sandbox_id: str, **_kwargs: Any) -> None:
|
|
86
|
+
"""Delete an Islo sandbox by id.
|
|
87
|
+
|
|
88
|
+
Islo's delete API is keyed by sandbox *name*, so the id is resolved to a
|
|
89
|
+
name first.
|
|
90
|
+
"""
|
|
91
|
+
sandbox = self._client.sandboxes.get_sandbox_by_id(sandbox_id)
|
|
92
|
+
self._client.sandboxes.delete_sandbox(sandbox.name)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Islo sandbox backend implementation.
|
|
2
|
+
|
|
3
|
+
[`IsloSandbox`][langchain_islo.sandbox.IsloSandbox] wraps an Islo sandbox
|
|
4
|
+
(https://islo.dev) and conforms to deepagents'
|
|
5
|
+
[`SandboxBackendProtocol`][deepagents.backends.protocol.SandboxBackendProtocol].
|
|
6
|
+
|
|
7
|
+
It subclasses [`BaseSandbox`][deepagents.backends.sandbox.BaseSandbox], so the
|
|
8
|
+
filesystem tools (``ls``, ``read``, ``write``, ``edit``, ``glob``, ``grep``) are
|
|
9
|
+
provided for free on top of three primitives implemented here:
|
|
10
|
+
|
|
11
|
+
- ``execute`` -- run a shell command via Islo's exec API (submit + poll).
|
|
12
|
+
- ``upload_files`` / ``download_files`` -- transfer raw bytes over Islo's
|
|
13
|
+
sandbox files endpoint.
|
|
14
|
+
|
|
15
|
+
Notes:
|
|
16
|
+
The deepagents base class sends *full shell command strings* (pipes,
|
|
17
|
+
redirects, here-docs). Islo's ``exec_in_sandbox`` executes an ``argv`` list
|
|
18
|
+
directly without a shell, so every command is wrapped in
|
|
19
|
+
``["/bin/sh", "-c", command]``.
|
|
20
|
+
|
|
21
|
+
Islo's generated ``upload_file`` / ``download_file`` SDK methods do not carry
|
|
22
|
+
file bytes, so byte transfer is performed against the sandbox files endpoint
|
|
23
|
+
on the *compute* base URL, reusing the client's resolved auth headers (the
|
|
24
|
+
same approach used by the Islo SDK's own ``islo.custom.files`` helpers).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import io
|
|
30
|
+
import posixpath
|
|
31
|
+
import shlex
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
from deepagents.backends.protocol import (
|
|
36
|
+
FILE_NOT_FOUND,
|
|
37
|
+
INVALID_PATH,
|
|
38
|
+
IS_DIRECTORY,
|
|
39
|
+
PERMISSION_DENIED,
|
|
40
|
+
ExecuteResponse,
|
|
41
|
+
FileDownloadResponse,
|
|
42
|
+
FileOperationError,
|
|
43
|
+
FileUploadResponse,
|
|
44
|
+
)
|
|
45
|
+
from deepagents.backends.sandbox import BaseSandbox
|
|
46
|
+
from islo.custom.exec import exec_and_wait_sync
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from islo import Islo
|
|
50
|
+
from islo.types import SandboxResponse
|
|
51
|
+
|
|
52
|
+
# Islo executes an argv list, not a shell line. deepagents emits shell command
|
|
53
|
+
# strings (pipes, redirects, here-docs), so each command is run through `sh -c`.
|
|
54
|
+
_SHELL: tuple[str, str] = ("/bin/sh", "-c")
|
|
55
|
+
|
|
56
|
+
_TIMEOUT_EXIT_CODE = 124
|
|
57
|
+
"""Exit code returned when a command exceeds its timeout (matches `timeout(1)`)."""
|
|
58
|
+
|
|
59
|
+
_HTTP_NOT_FOUND = 404
|
|
60
|
+
_HTTP_FORBIDDEN = 403
|
|
61
|
+
_HTTP_BAD_REQUEST = 400
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class IsloSandbox(BaseSandbox):
|
|
65
|
+
"""Islo sandbox backend conforming to ``SandboxBackendProtocol``.
|
|
66
|
+
|
|
67
|
+
Inherits all filesystem operations from ``BaseSandbox`` and implements
|
|
68
|
+
``execute``, ``upload_files``, ``download_files``, and ``id`` using the Islo
|
|
69
|
+
Python SDK (https://github.com/islo-labs/python-sdk).
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
```python
|
|
73
|
+
from islo import Islo
|
|
74
|
+
from langchain_islo import IsloSandbox
|
|
75
|
+
|
|
76
|
+
client = Islo() # reads ISLO_API_KEY
|
|
77
|
+
sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
|
|
78
|
+
backend = IsloSandbox(client=client, sandbox=sandbox)
|
|
79
|
+
|
|
80
|
+
result = backend.execute("echo hello")
|
|
81
|
+
print(result.output) # "hello"
|
|
82
|
+
```
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
client: Islo,
|
|
89
|
+
sandbox: SandboxResponse,
|
|
90
|
+
timeout: int = 30 * 60,
|
|
91
|
+
poll_interval: float = 0.5,
|
|
92
|
+
http_timeout: float = 60.0,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Wrap an existing Islo sandbox.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
client: An authenticated ``islo.Islo`` client.
|
|
98
|
+
sandbox: An Islo ``SandboxResponse`` (e.g. from
|
|
99
|
+
``client.sandboxes.create_sandbox(...)``). Sandbox operations are
|
|
100
|
+
keyed by ``sandbox.name``.
|
|
101
|
+
timeout: Default command timeout in seconds used by ``execute()``
|
|
102
|
+
when no explicit timeout is given. A value of ``0`` waits
|
|
103
|
+
indefinitely.
|
|
104
|
+
poll_interval: Seconds between polls of the exec result while waiting
|
|
105
|
+
for a command to finish.
|
|
106
|
+
http_timeout: Per-request timeout in seconds for file
|
|
107
|
+
upload/download transfers.
|
|
108
|
+
"""
|
|
109
|
+
self._client = client
|
|
110
|
+
self._sandbox = sandbox
|
|
111
|
+
self._default_timeout = timeout
|
|
112
|
+
self._poll_interval = poll_interval
|
|
113
|
+
self._http_timeout = http_timeout
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def id(self) -> str:
|
|
117
|
+
"""Return the Islo sandbox id."""
|
|
118
|
+
return self._sandbox.id
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def name(self) -> str:
|
|
122
|
+
"""Return the Islo sandbox name (the key most Islo APIs use)."""
|
|
123
|
+
return self._sandbox.name
|
|
124
|
+
|
|
125
|
+
# -- command execution ---------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def execute(
|
|
128
|
+
self,
|
|
129
|
+
command: str,
|
|
130
|
+
*,
|
|
131
|
+
timeout: int | None = None,
|
|
132
|
+
) -> ExecuteResponse:
|
|
133
|
+
"""Execute a shell command inside the sandbox.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
command: Full shell command string to execute.
|
|
137
|
+
timeout: Maximum seconds to wait for completion. If ``None``, uses
|
|
138
|
+
the backend's default timeout. A value of ``0`` waits
|
|
139
|
+
indefinitely.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
``ExecuteResponse`` with combined stdout/stderr, the exit code, and a
|
|
143
|
+
truncation flag.
|
|
144
|
+
"""
|
|
145
|
+
effective_timeout = self._default_timeout if timeout is None else timeout
|
|
146
|
+
# The exec helper treats `timeout=None` as "poll indefinitely", which is
|
|
147
|
+
# the semantic deepagents assigns to a timeout of 0.
|
|
148
|
+
helper_timeout = None if effective_timeout == 0 else float(effective_timeout)
|
|
149
|
+
|
|
150
|
+
result = exec_and_wait_sync(
|
|
151
|
+
self._client,
|
|
152
|
+
self._sandbox.name,
|
|
153
|
+
[_SHELL[0], _SHELL[1], command],
|
|
154
|
+
timeout=helper_timeout,
|
|
155
|
+
poll_interval=self._poll_interval,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if result.timed_out:
|
|
159
|
+
return ExecuteResponse(
|
|
160
|
+
output=f"Command timed out after {effective_timeout} seconds",
|
|
161
|
+
exit_code=_TIMEOUT_EXIT_CODE,
|
|
162
|
+
truncated=False,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
output = result.stdout
|
|
166
|
+
if result.stderr:
|
|
167
|
+
output = f"{output}{result.stderr}" if output else result.stderr
|
|
168
|
+
|
|
169
|
+
return ExecuteResponse(
|
|
170
|
+
output=output,
|
|
171
|
+
exit_code=result.exit_code,
|
|
172
|
+
truncated=False,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# -- file transfer -------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
178
|
+
"""Upload files into the sandbox.
|
|
179
|
+
|
|
180
|
+
Supports partial success: per-file failures are returned as errors on the
|
|
181
|
+
corresponding ``FileUploadResponse`` rather than raised. Parent
|
|
182
|
+
directories are created before upload.
|
|
183
|
+
"""
|
|
184
|
+
responses: list[FileUploadResponse | None] = []
|
|
185
|
+
pending: list[tuple[int, str, bytes]] = []
|
|
186
|
+
|
|
187
|
+
for path, content in files:
|
|
188
|
+
if not path.startswith("/"):
|
|
189
|
+
responses.append(FileUploadResponse(path=path, error=INVALID_PATH))
|
|
190
|
+
continue
|
|
191
|
+
responses.append(None) # placeholder, filled after transfer
|
|
192
|
+
pending.append((len(responses) - 1, path, content))
|
|
193
|
+
|
|
194
|
+
if not pending:
|
|
195
|
+
return [r for r in responses if r is not None]
|
|
196
|
+
|
|
197
|
+
# Ensure parent directories exist (upload contract requirement).
|
|
198
|
+
parents = sorted(
|
|
199
|
+
{posixpath.dirname(p) for _, p, _ in pending if posixpath.dirname(p)}
|
|
200
|
+
)
|
|
201
|
+
if parents:
|
|
202
|
+
quoted = " ".join(shlex.quote(d) for d in parents)
|
|
203
|
+
self.execute(f"mkdir -p {quoted}")
|
|
204
|
+
|
|
205
|
+
base_url = self._compute_base_url()
|
|
206
|
+
headers = self._auth_headers()
|
|
207
|
+
with httpx.Client(timeout=self._http_timeout) as http:
|
|
208
|
+
for idx, path, content in pending:
|
|
209
|
+
filename = posixpath.basename(path) or "file"
|
|
210
|
+
try:
|
|
211
|
+
response = http.post(
|
|
212
|
+
f"{base_url}/sandboxes/{self._sandbox.name}/files",
|
|
213
|
+
params={"path": path},
|
|
214
|
+
headers=headers,
|
|
215
|
+
files={"file": (filename, io.BytesIO(content))},
|
|
216
|
+
)
|
|
217
|
+
response.raise_for_status()
|
|
218
|
+
except httpx.HTTPStatusError as exc:
|
|
219
|
+
responses[idx] = FileUploadResponse(
|
|
220
|
+
path=path,
|
|
221
|
+
error=_map_http_status(exc.response.status_code),
|
|
222
|
+
)
|
|
223
|
+
except httpx.HTTPError as exc: # network/timeout errors
|
|
224
|
+
responses[idx] = FileUploadResponse(path=path, error=str(exc))
|
|
225
|
+
else:
|
|
226
|
+
responses[idx] = FileUploadResponse(path=path, error=None)
|
|
227
|
+
|
|
228
|
+
return [r for r in responses if r is not None]
|
|
229
|
+
|
|
230
|
+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
231
|
+
"""Download files from the sandbox.
|
|
232
|
+
|
|
233
|
+
Supports partial success: per-file failures are returned as errors on the
|
|
234
|
+
corresponding ``FileDownloadResponse`` rather than raised.
|
|
235
|
+
"""
|
|
236
|
+
responses: list[FileDownloadResponse] = []
|
|
237
|
+
base_url = self._compute_base_url()
|
|
238
|
+
headers = self._auth_headers()
|
|
239
|
+
|
|
240
|
+
with httpx.Client(timeout=self._http_timeout) as http:
|
|
241
|
+
for path in paths:
|
|
242
|
+
if not path.startswith("/"):
|
|
243
|
+
responses.append(
|
|
244
|
+
FileDownloadResponse(
|
|
245
|
+
path=path, content=None, error=INVALID_PATH
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
response = http.get(
|
|
251
|
+
f"{base_url}/sandboxes/{self._sandbox.name}/files",
|
|
252
|
+
params={"path": path},
|
|
253
|
+
headers=headers,
|
|
254
|
+
)
|
|
255
|
+
response.raise_for_status()
|
|
256
|
+
except httpx.HTTPStatusError as exc:
|
|
257
|
+
responses.append(
|
|
258
|
+
FileDownloadResponse(
|
|
259
|
+
path=path,
|
|
260
|
+
content=None,
|
|
261
|
+
error=_map_http_status(exc.response.status_code),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
except httpx.HTTPError as exc:
|
|
265
|
+
responses.append(
|
|
266
|
+
FileDownloadResponse(path=path, content=None, error=str(exc))
|
|
267
|
+
)
|
|
268
|
+
else:
|
|
269
|
+
responses.append(
|
|
270
|
+
FileDownloadResponse(
|
|
271
|
+
path=path, content=response.content, error=None
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return responses
|
|
276
|
+
|
|
277
|
+
# -- internals -----------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
def _compute_base_url(self) -> str:
|
|
280
|
+
"""Resolve the Islo *compute* base URL hosting the exec/files endpoints."""
|
|
281
|
+
return self._client._client_wrapper.get_environment().compute.rstrip("/")
|
|
282
|
+
|
|
283
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
284
|
+
"""Resolve fresh auth headers (honors token refresh) for raw requests."""
|
|
285
|
+
return self._client._client_wrapper.get_headers()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _map_http_status(status_code: int) -> FileOperationError | str:
|
|
289
|
+
"""Map an HTTP status to a standardized ``FileOperationError`` literal."""
|
|
290
|
+
if status_code == _HTTP_NOT_FOUND:
|
|
291
|
+
return FILE_NOT_FOUND
|
|
292
|
+
if status_code == _HTTP_FORBIDDEN:
|
|
293
|
+
return PERMISSION_DENIED
|
|
294
|
+
if status_code == _HTTP_BAD_REQUEST:
|
|
295
|
+
return INVALID_PATH
|
|
296
|
+
if status_code == 409: # noqa: PLR2004 # conflict: path is a directory
|
|
297
|
+
return IS_DIRECTORY
|
|
298
|
+
return f"http_{status_code}"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-islo"
|
|
7
|
+
description = "Islo (islo.dev) sandbox integration for Deep Agents"
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Islo" }]
|
|
11
|
+
keywords = ["deepagents", "langchain", "sandbox", "islo", "agents"]
|
|
12
|
+
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
21
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
version = "0.0.1"
|
|
25
|
+
requires-python = ">=3.11,<4.0"
|
|
26
|
+
dependencies = [
|
|
27
|
+
"deepagents>=0.6.8,<0.7.0",
|
|
28
|
+
"islo>=0.3.3",
|
|
29
|
+
"httpx>=0.21.2",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["langchain_islo"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/islo-labs/langchain-islo"
|
|
37
|
+
Repository = "https://github.com/islo-labs/langchain-islo"
|
|
38
|
+
Documentation = "https://docs.islo.dev"
|
|
39
|
+
"Issue Tracker" = "https://github.com/islo-labs/langchain-islo/issues"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
test = [
|
|
43
|
+
"pytest>=8.3.4,<10.0.0",
|
|
44
|
+
"pytest-cov",
|
|
45
|
+
"pytest-socket",
|
|
46
|
+
"pytest-xdist",
|
|
47
|
+
"pytest-timeout>=2.3.1,<3.0.0",
|
|
48
|
+
"pytest-asyncio>=1.3.0",
|
|
49
|
+
"ruff>=0.14.0,<1.0.0",
|
|
50
|
+
"ty>=0.0.1,<1.0.0",
|
|
51
|
+
"langchain-tests>=1.1.6",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.ruff.format]
|
|
55
|
+
docstring-code-format = true
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["ALL"]
|
|
59
|
+
ignore = [
|
|
60
|
+
"COM812", # Messes with the formatter
|
|
61
|
+
"ISC001", # Messes with the formatter
|
|
62
|
+
"ANN401", # Dynamically typed expressions (typing.Any) — needed for **kwargs passthrough
|
|
63
|
+
"SLF001", # Private member access — required to reuse the Islo client's resolved env/headers
|
|
64
|
+
"FBT001", # Boolean positional arg — inherited signature shape from deepagents
|
|
65
|
+
"FBT002", # Boolean default positional arg
|
|
66
|
+
]
|
|
67
|
+
extend-safe-fixes = ["PLR6201"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.pydocstyle]
|
|
70
|
+
convention = "google"
|
|
71
|
+
ignore-var-parameters = true
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint.flake8-tidy-imports]
|
|
74
|
+
ban-relative-imports = "all"
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
77
|
+
"tests/**/*.py" = ["D", "S101", "SLF001", "PLR2004"]
|
|
78
|
+
|
|
79
|
+
[tool.coverage.run]
|
|
80
|
+
omit = ["tests/*"]
|
|
81
|
+
|
|
82
|
+
[tool.pytest.ini_options]
|
|
83
|
+
addopts = "--strict-markers --strict-config --durations=5"
|
|
84
|
+
testpaths = ["tests"]
|
|
85
|
+
markers = [
|
|
86
|
+
"requires: mark tests as requiring a specific library",
|
|
87
|
+
"compile: mark placeholder test used to compile integration tests without running them",
|
|
88
|
+
"scheduled: mark tests to run in scheduled testing",
|
|
89
|
+
]
|
|
90
|
+
asyncio_mode = "auto"
|
|
91
|
+
filterwarnings = []
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from islo import Islo
|
|
8
|
+
from langchain_tests.integration_tests import SandboxIntegrationTests
|
|
9
|
+
|
|
10
|
+
from langchain_islo import IsloSandbox
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
from deepagents.backends.protocol import SandboxBackendProtocol
|
|
16
|
+
|
|
17
|
+
# Requires a live Islo account: set ISLO_API_KEY before running these.
|
|
18
|
+
pytestmark = pytest.mark.skipif(
|
|
19
|
+
not os.environ.get("ISLO_API_KEY"),
|
|
20
|
+
reason="ISLO_API_KEY not set; skipping live Islo integration tests",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestIsloSandboxStandard(SandboxIntegrationTests):
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def sandbox(self) -> Iterator[SandboxBackendProtocol]:
|
|
27
|
+
client = Islo()
|
|
28
|
+
sandbox = client.sandboxes.create_sandbox(image="ubuntu:24.04")
|
|
29
|
+
backend = IsloSandbox(client=client, sandbox=sandbox)
|
|
30
|
+
try:
|
|
31
|
+
yield backend
|
|
32
|
+
finally:
|
|
33
|
+
client.sandboxes.delete_sandbox(sandbox.name)
|
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
from islo.custom.exec import ExecResult
|
|
9
|
+
|
|
10
|
+
from langchain_islo.sandbox import IsloSandbox, _map_http_status
|
|
11
|
+
|
|
12
|
+
TIMEOUT_EXIT_CODE = 124
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_sandbox() -> tuple[IsloSandbox, MagicMock]:
|
|
16
|
+
client = MagicMock()
|
|
17
|
+
client._client_wrapper.get_environment.return_value = SimpleNamespace(
|
|
18
|
+
compute="https://ca.compute.islo.dev",
|
|
19
|
+
control="https://api.islo.dev",
|
|
20
|
+
)
|
|
21
|
+
client._client_wrapper.get_headers.return_value = {
|
|
22
|
+
"Authorization": "Bearer ak_test"
|
|
23
|
+
}
|
|
24
|
+
sandbox = SimpleNamespace(id="sb-123", name="my-sandbox", status="running")
|
|
25
|
+
return IsloSandbox(client=client, sandbox=sandbox), client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_id_and_name() -> None:
|
|
29
|
+
sb, _ = _make_sandbox()
|
|
30
|
+
assert sb.id == "sb-123"
|
|
31
|
+
assert sb.name == "my-sandbox"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_execute_combines_stdout_and_stderr() -> None:
|
|
35
|
+
sb, _ = _make_sandbox()
|
|
36
|
+
with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
|
|
37
|
+
mock_exec.return_value = ExecResult(stdout="out", stderr="err", exit_code=0)
|
|
38
|
+
result = sb.execute("echo hi")
|
|
39
|
+
|
|
40
|
+
assert result.output == "outerr"
|
|
41
|
+
assert result.exit_code == 0
|
|
42
|
+
assert result.truncated is False
|
|
43
|
+
|
|
44
|
+
# Command must be wrapped in a shell so pipes/redirects/here-docs work.
|
|
45
|
+
args, _ = mock_exec.call_args
|
|
46
|
+
assert args[0] is sb._client
|
|
47
|
+
assert args[1] == "my-sandbox"
|
|
48
|
+
assert args[2] == ["/bin/sh", "-c", "echo hi"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_execute_stdout_only() -> None:
|
|
52
|
+
sb, _ = _make_sandbox()
|
|
53
|
+
with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
|
|
54
|
+
mock_exec.return_value = ExecResult(stdout="hello", stderr="", exit_code=0)
|
|
55
|
+
result = sb.execute("echo hello")
|
|
56
|
+
assert result.output == "hello"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_execute_timeout_maps_to_124() -> None:
|
|
60
|
+
sb, _ = _make_sandbox()
|
|
61
|
+
with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
|
|
62
|
+
mock_exec.return_value = ExecResult(
|
|
63
|
+
stdout="", stderr="", exit_code=-1, timed_out=True
|
|
64
|
+
)
|
|
65
|
+
result = sb.execute("sleep 999", timeout=5)
|
|
66
|
+
assert result.exit_code == TIMEOUT_EXIT_CODE
|
|
67
|
+
assert "timed out" in result.output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_execute_timeout_zero_means_wait_forever() -> None:
|
|
71
|
+
sb, _ = _make_sandbox()
|
|
72
|
+
with patch("langchain_islo.sandbox.exec_and_wait_sync") as mock_exec:
|
|
73
|
+
mock_exec.return_value = ExecResult(stdout="", stderr="", exit_code=0)
|
|
74
|
+
sb.execute("echo hi", timeout=0)
|
|
75
|
+
_, kwargs = mock_exec.call_args
|
|
76
|
+
assert kwargs["timeout"] is None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_upload_rejects_relative_path() -> None:
|
|
80
|
+
sb, _ = _make_sandbox()
|
|
81
|
+
responses = sb.upload_files([("relative/path.txt", b"data")])
|
|
82
|
+
assert len(responses) == 1
|
|
83
|
+
assert responses[0].path == "relative/path.txt"
|
|
84
|
+
assert responses[0].error == "invalid_path"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_upload_success_creates_parents_and_posts() -> None:
|
|
88
|
+
sb, _ = _make_sandbox()
|
|
89
|
+
http = MagicMock()
|
|
90
|
+
http.__enter__.return_value = http
|
|
91
|
+
response = MagicMock()
|
|
92
|
+
response.raise_for_status.return_value = None
|
|
93
|
+
http.post.return_value = response
|
|
94
|
+
|
|
95
|
+
with (
|
|
96
|
+
patch("langchain_islo.sandbox.httpx.Client", return_value=http),
|
|
97
|
+
patch.object(sb, "execute") as mock_execute,
|
|
98
|
+
):
|
|
99
|
+
responses = sb.upload_files([("/workspace/app.py", b"print('hi')")])
|
|
100
|
+
|
|
101
|
+
assert responses[0].error is None
|
|
102
|
+
# mkdir -p for the parent directory should have been issued.
|
|
103
|
+
mkdir_cmd = mock_execute.call_args[0][0]
|
|
104
|
+
assert mkdir_cmd.startswith("mkdir -p ")
|
|
105
|
+
assert "/workspace" in mkdir_cmd
|
|
106
|
+
# The POST hits the compute files endpoint with the path param.
|
|
107
|
+
url = http.post.call_args[0][0]
|
|
108
|
+
assert url == "https://ca.compute.islo.dev/sandboxes/my-sandbox/files"
|
|
109
|
+
assert http.post.call_args[1]["params"] == {"path": "/workspace/app.py"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_upload_partial_success() -> None:
|
|
113
|
+
sb, _ = _make_sandbox()
|
|
114
|
+
http = MagicMock()
|
|
115
|
+
http.__enter__.return_value = http
|
|
116
|
+
ok = MagicMock()
|
|
117
|
+
ok.raise_for_status.return_value = None
|
|
118
|
+
bad_response = MagicMock(status_code=403)
|
|
119
|
+
err = httpx.HTTPStatusError("forbidden", request=MagicMock(), response=bad_response)
|
|
120
|
+
bad = MagicMock()
|
|
121
|
+
bad.raise_for_status.side_effect = err
|
|
122
|
+
http.post.side_effect = [ok, bad]
|
|
123
|
+
|
|
124
|
+
with (
|
|
125
|
+
patch("langchain_islo.sandbox.httpx.Client", return_value=http),
|
|
126
|
+
patch.object(sb, "execute"),
|
|
127
|
+
):
|
|
128
|
+
responses = sb.upload_files([("/ok.txt", b"a"), ("/denied.txt", b"b")])
|
|
129
|
+
|
|
130
|
+
assert responses[0].error is None
|
|
131
|
+
assert responses[1].error == "permission_denied"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_download_success_returns_bytes() -> None:
|
|
135
|
+
sb, _ = _make_sandbox()
|
|
136
|
+
http = MagicMock()
|
|
137
|
+
http.__enter__.return_value = http
|
|
138
|
+
response = MagicMock(content=b"file-bytes")
|
|
139
|
+
response.raise_for_status.return_value = None
|
|
140
|
+
http.get.return_value = response
|
|
141
|
+
|
|
142
|
+
with patch("langchain_islo.sandbox.httpx.Client", return_value=http):
|
|
143
|
+
responses = sb.download_files(["/workspace/app.py"])
|
|
144
|
+
|
|
145
|
+
assert responses[0].content == b"file-bytes"
|
|
146
|
+
assert responses[0].error is None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_download_not_found() -> None:
|
|
150
|
+
sb, _ = _make_sandbox()
|
|
151
|
+
http = MagicMock()
|
|
152
|
+
http.__enter__.return_value = http
|
|
153
|
+
missing = MagicMock(status_code=404)
|
|
154
|
+
err = httpx.HTTPStatusError("nope", request=MagicMock(), response=missing)
|
|
155
|
+
response = MagicMock()
|
|
156
|
+
response.raise_for_status.side_effect = err
|
|
157
|
+
http.get.return_value = response
|
|
158
|
+
|
|
159
|
+
with patch("langchain_islo.sandbox.httpx.Client", return_value=http):
|
|
160
|
+
responses = sb.download_files(["/missing.txt"])
|
|
161
|
+
|
|
162
|
+
assert responses[0].content is None
|
|
163
|
+
assert responses[0].error == "file_not_found"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_download_rejects_relative_path() -> None:
|
|
167
|
+
sb, _ = _make_sandbox()
|
|
168
|
+
responses = sb.download_files(["relative.txt"])
|
|
169
|
+
assert responses[0].error == "invalid_path"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize(
|
|
173
|
+
("status", "expected"),
|
|
174
|
+
[
|
|
175
|
+
(404, "file_not_found"),
|
|
176
|
+
(403, "permission_denied"),
|
|
177
|
+
(400, "invalid_path"),
|
|
178
|
+
(409, "is_directory"),
|
|
179
|
+
(500, "http_500"),
|
|
180
|
+
],
|
|
181
|
+
)
|
|
182
|
+
def test_map_http_status(status: int, expected: str) -> None:
|
|
183
|
+
assert _map_http_status(status) == expected
|