langchain-briq 0.1.0__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_briq-0.1.0/.gitignore +5 -0
- langchain_briq-0.1.0/LICENSE +21 -0
- langchain_briq-0.1.0/PKG-INFO +131 -0
- langchain_briq-0.1.0/README.md +108 -0
- langchain_briq-0.1.0/langchain_briq/__init__.py +6 -0
- langchain_briq-0.1.0/langchain_briq/_version.py +1 -0
- langchain_briq-0.1.0/langchain_briq/py.typed +0 -0
- langchain_briq-0.1.0/langchain_briq/sandbox.py +294 -0
- langchain_briq-0.1.0/pyproject.toml +77 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 briq
|
|
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,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-briq
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: briq sandbox integration for Deep Agents
|
|
5
|
+
Project-URL: Homepage, https://briq.cloud
|
|
6
|
+
Project-URL: Repository, https://github.com/briq-cloud/langchain-briq
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,briq,deepagents,langchain,sandbox
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: <4.0,>=3.11
|
|
20
|
+
Requires-Dist: briq-cloud<0.2,>=0.1.1
|
|
21
|
+
Requires-Dist: deepagents<0.8.0,>=0.7.32
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# langchain-briq
|
|
25
|
+
|
|
26
|
+
[](https://pypi.org/project/langchain-briq/) [](https://github.com/briq-cloud/langchain-briq/actions/workflows/ci.yml)
|
|
27
|
+
|
|
28
|
+
[briq](https://briq.cloud) sandbox integration for [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview). A Deep Agent runs its commands and file tools in a briq sandbox.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from deepagents import create_deep_agent
|
|
32
|
+
from langchain_briq import BriqSandbox
|
|
33
|
+
|
|
34
|
+
backend = BriqSandbox.create(size="medium") # reads BRIQ_API_KEY
|
|
35
|
+
try:
|
|
36
|
+
agent = create_deep_agent(model="anthropic:claude-sonnet-4-5", backend=backend)
|
|
37
|
+
result = agent.invoke(
|
|
38
|
+
{"messages": [{"role": "user", "content": "Write fizzbuzz in Python and run it."}]}
|
|
39
|
+
)
|
|
40
|
+
print(result["messages"][-1].content)
|
|
41
|
+
finally:
|
|
42
|
+
backend.destroy() # removes the sandbox and its disk
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Install: `pip install langchain-briq`. Python 3.11 or later. It depends on `deepagents` and [`briq-cloud`](https://pypi.org/project/briq-cloud/). Get an API key on the briq dashboard and set `BRIQ_API_KEY`.
|
|
46
|
+
|
|
47
|
+
## What it does
|
|
48
|
+
|
|
49
|
+
`BriqSandbox` is a `BaseSandbox`: it implements `execute`, `upload_files`, `download_files` and `id`, and the rest of the Deep Agents filesystem tools (`read`, `write`, `edit`, `ls`, `glob`, `grep`) come from `BaseSandbox`. They run `python3` in the sandbox, which the default image has.
|
|
50
|
+
|
|
51
|
+
| Method | Does |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `BriqSandbox.create(...)` | Makes a sandbox and waits until it runs. |
|
|
54
|
+
| `BriqSandbox.connect(id)` | Reconnects to a sandbox. A sleeping one wakes on the next call. |
|
|
55
|
+
| `backend.execute(command, timeout=)` | Runs a shell command. Returns the output and the exit code. |
|
|
56
|
+
| `backend.upload_files([(path, bytes)])` | Writes files, creates the directories above them. |
|
|
57
|
+
| `backend.download_files([path])` | Reads files. |
|
|
58
|
+
| `backend.id` | The sandbox id. |
|
|
59
|
+
| `backend.destroy()` | Removes the sandbox and its disk. |
|
|
60
|
+
| `backend.sandbox` | The underlying [`briq_cloud.Sandbox`](https://pypi.org/project/briq-cloud/), for previews, stop, start. |
|
|
61
|
+
|
|
62
|
+
`async` methods (`aexecute`, `aupload_files`, `adownload_files`) run the same calls in a thread.
|
|
63
|
+
|
|
64
|
+
### Create options
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
BriqSandbox.create(
|
|
68
|
+
size="large", # small, medium, large or xlarge
|
|
69
|
+
image="ghcr.io/acme/tools:1", # an OCI image with a shell and python3
|
|
70
|
+
env={"MODE": "test"}, # shows in the sandbox's status
|
|
71
|
+
secret_env={"API_TOKEN": token}, # write-only
|
|
72
|
+
sleep="3h", # idle time before it sleeps; "never" stays awake
|
|
73
|
+
default_timeout=600, # seconds a command may run without its own timeout
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- Any other `size` raises `BriqError` with code `unsupported` before a request is sent.
|
|
78
|
+
- `secret_env` raises `unsupported` when the API cannot keep the values write-only.
|
|
79
|
+
- Pass `client=` a `briq_cloud.Briq` to share one client. You close it. Without it, `api_key` and `base_url` default to `BRIQ_API_KEY` and `BRIQ_API_URL`.
|
|
80
|
+
- `with BriqSandbox.create() as backend:` destroys the sandbox when the block ends.
|
|
81
|
+
|
|
82
|
+
### Reconnect
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
backend = BriqSandbox.create(sleep="3h")
|
|
86
|
+
sandbox_id = backend.id
|
|
87
|
+
backend.close() # closes the client, the sandbox keeps running
|
|
88
|
+
|
|
89
|
+
backend = BriqSandbox.connect(sandbox_id)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Behavior
|
|
93
|
+
|
|
94
|
+
- `execute` returns stdout, then stderr inside `<stderr>` tags. A command that outlives its timeout is stopped and ends with exit code `124`. The timeout is 1 second to 24 hours; the default is 30 minutes.
|
|
95
|
+
- A command over about 6 KB is written to a file in the sandbox and run from there. The API takes 8 KB of command per call.
|
|
96
|
+
- `upload_files` and `download_files` return one response per path, in order. Error codes: `invalid_path` (a relative path), `file_not_found`, `permission_denied`, `is_directory`. A file over 100 MiB raises `BriqError` with code `quota`.
|
|
97
|
+
- Errors the model cannot fix (a wrong key, no credits, a plan limit, a sandbox that no longer exists) raise `briq_cloud.BriqError`. Its `code` is stable; see the [client's README](https://pypi.org/project/briq-cloud/).
|
|
98
|
+
|
|
99
|
+
## Not supported
|
|
100
|
+
|
|
101
|
+
- Streaming output: `execute` returns when the command ends.
|
|
102
|
+
- A working directory or environment per command. Put them in the command: `cd /work && make`.
|
|
103
|
+
- Output truncation: `truncated` is always `False`. A command's output over 32 MiB raises `BriqError` with code `quota`.
|
|
104
|
+
|
|
105
|
+
## Develop
|
|
106
|
+
|
|
107
|
+
Needs [uv](https://docs.astral.sh/uv/).
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
git clone https://github.com/briq-cloud/langchain-briq && cd langchain-briq
|
|
111
|
+
uv sync --group test
|
|
112
|
+
uv run pytest # offline
|
|
113
|
+
uv run ruff check . && uv run ruff format --check .
|
|
114
|
+
uv run mypy
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The tests run offline: `tests/unit_tests/test_sandbox.py` drives `BriqSandbox` through the real `briq-cloud` client against an in-memory API. `tests/unit_tests/test_standard_offline.py` runs the standard `SandboxIntegrationTests` suite of `langchain-tests` against a local Docker container (skipped without Docker).
|
|
118
|
+
|
|
119
|
+
Seven tests of that suite expect behavior that `BaseSandbox` itself no longer has (`glob` returns absolute paths, `write` overwrites): they are marked `xfail` in `tests/conftest.py`.
|
|
120
|
+
|
|
121
|
+
Against a real briq sandbox, with a key:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
BRIQ_API_KEY=... uv run pytest tests/integration_tests
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
It makes one `small` sandbox for the run and destroys it.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# langchain-briq
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/langchain-briq/) [](https://github.com/briq-cloud/langchain-briq/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
[briq](https://briq.cloud) sandbox integration for [Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview). A Deep Agent runs its commands and file tools in a briq sandbox.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from deepagents import create_deep_agent
|
|
9
|
+
from langchain_briq import BriqSandbox
|
|
10
|
+
|
|
11
|
+
backend = BriqSandbox.create(size="medium") # reads BRIQ_API_KEY
|
|
12
|
+
try:
|
|
13
|
+
agent = create_deep_agent(model="anthropic:claude-sonnet-4-5", backend=backend)
|
|
14
|
+
result = agent.invoke(
|
|
15
|
+
{"messages": [{"role": "user", "content": "Write fizzbuzz in Python and run it."}]}
|
|
16
|
+
)
|
|
17
|
+
print(result["messages"][-1].content)
|
|
18
|
+
finally:
|
|
19
|
+
backend.destroy() # removes the sandbox and its disk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Install: `pip install langchain-briq`. Python 3.11 or later. It depends on `deepagents` and [`briq-cloud`](https://pypi.org/project/briq-cloud/). Get an API key on the briq dashboard and set `BRIQ_API_KEY`.
|
|
23
|
+
|
|
24
|
+
## What it does
|
|
25
|
+
|
|
26
|
+
`BriqSandbox` is a `BaseSandbox`: it implements `execute`, `upload_files`, `download_files` and `id`, and the rest of the Deep Agents filesystem tools (`read`, `write`, `edit`, `ls`, `glob`, `grep`) come from `BaseSandbox`. They run `python3` in the sandbox, which the default image has.
|
|
27
|
+
|
|
28
|
+
| Method | Does |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `BriqSandbox.create(...)` | Makes a sandbox and waits until it runs. |
|
|
31
|
+
| `BriqSandbox.connect(id)` | Reconnects to a sandbox. A sleeping one wakes on the next call. |
|
|
32
|
+
| `backend.execute(command, timeout=)` | Runs a shell command. Returns the output and the exit code. |
|
|
33
|
+
| `backend.upload_files([(path, bytes)])` | Writes files, creates the directories above them. |
|
|
34
|
+
| `backend.download_files([path])` | Reads files. |
|
|
35
|
+
| `backend.id` | The sandbox id. |
|
|
36
|
+
| `backend.destroy()` | Removes the sandbox and its disk. |
|
|
37
|
+
| `backend.sandbox` | The underlying [`briq_cloud.Sandbox`](https://pypi.org/project/briq-cloud/), for previews, stop, start. |
|
|
38
|
+
|
|
39
|
+
`async` methods (`aexecute`, `aupload_files`, `adownload_files`) run the same calls in a thread.
|
|
40
|
+
|
|
41
|
+
### Create options
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
BriqSandbox.create(
|
|
45
|
+
size="large", # small, medium, large or xlarge
|
|
46
|
+
image="ghcr.io/acme/tools:1", # an OCI image with a shell and python3
|
|
47
|
+
env={"MODE": "test"}, # shows in the sandbox's status
|
|
48
|
+
secret_env={"API_TOKEN": token}, # write-only
|
|
49
|
+
sleep="3h", # idle time before it sleeps; "never" stays awake
|
|
50
|
+
default_timeout=600, # seconds a command may run without its own timeout
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- Any other `size` raises `BriqError` with code `unsupported` before a request is sent.
|
|
55
|
+
- `secret_env` raises `unsupported` when the API cannot keep the values write-only.
|
|
56
|
+
- Pass `client=` a `briq_cloud.Briq` to share one client. You close it. Without it, `api_key` and `base_url` default to `BRIQ_API_KEY` and `BRIQ_API_URL`.
|
|
57
|
+
- `with BriqSandbox.create() as backend:` destroys the sandbox when the block ends.
|
|
58
|
+
|
|
59
|
+
### Reconnect
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
backend = BriqSandbox.create(sleep="3h")
|
|
63
|
+
sandbox_id = backend.id
|
|
64
|
+
backend.close() # closes the client, the sandbox keeps running
|
|
65
|
+
|
|
66
|
+
backend = BriqSandbox.connect(sandbox_id)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Behavior
|
|
70
|
+
|
|
71
|
+
- `execute` returns stdout, then stderr inside `<stderr>` tags. A command that outlives its timeout is stopped and ends with exit code `124`. The timeout is 1 second to 24 hours; the default is 30 minutes.
|
|
72
|
+
- A command over about 6 KB is written to a file in the sandbox and run from there. The API takes 8 KB of command per call.
|
|
73
|
+
- `upload_files` and `download_files` return one response per path, in order. Error codes: `invalid_path` (a relative path), `file_not_found`, `permission_denied`, `is_directory`. A file over 100 MiB raises `BriqError` with code `quota`.
|
|
74
|
+
- Errors the model cannot fix (a wrong key, no credits, a plan limit, a sandbox that no longer exists) raise `briq_cloud.BriqError`. Its `code` is stable; see the [client's README](https://pypi.org/project/briq-cloud/).
|
|
75
|
+
|
|
76
|
+
## Not supported
|
|
77
|
+
|
|
78
|
+
- Streaming output: `execute` returns when the command ends.
|
|
79
|
+
- A working directory or environment per command. Put them in the command: `cd /work && make`.
|
|
80
|
+
- Output truncation: `truncated` is always `False`. A command's output over 32 MiB raises `BriqError` with code `quota`.
|
|
81
|
+
|
|
82
|
+
## Develop
|
|
83
|
+
|
|
84
|
+
Needs [uv](https://docs.astral.sh/uv/).
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
git clone https://github.com/briq-cloud/langchain-briq && cd langchain-briq
|
|
88
|
+
uv sync --group test
|
|
89
|
+
uv run pytest # offline
|
|
90
|
+
uv run ruff check . && uv run ruff format --check .
|
|
91
|
+
uv run mypy
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The tests run offline: `tests/unit_tests/test_sandbox.py` drives `BriqSandbox` through the real `briq-cloud` client against an in-memory API. `tests/unit_tests/test_standard_offline.py` runs the standard `SandboxIntegrationTests` suite of `langchain-tests` against a local Docker container (skipped without Docker).
|
|
95
|
+
|
|
96
|
+
Seven tests of that suite expect behavior that `BaseSandbox` itself no longer has (`glob` returns absolute paths, `write` overwrites): they are marked `xfail` in `tests/conftest.py`.
|
|
97
|
+
|
|
98
|
+
Against a real briq sandbox, with a key:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
BRIQ_API_KEY=... uv run pytest tests/integration_tests
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
It makes one `small` sandbox for the run and destroys it.
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""A Deep Agents sandbox backend that runs in a briq sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
from briq_cloud import Briq, BriqError, Sandbox
|
|
11
|
+
from deepagents.backends.protocol import (
|
|
12
|
+
ExecuteResponse,
|
|
13
|
+
FileDownloadResponse,
|
|
14
|
+
FileUploadResponse,
|
|
15
|
+
)
|
|
16
|
+
from deepagents.backends.sandbox import BaseSandbox
|
|
17
|
+
|
|
18
|
+
#: The sizes briq offers. Anything else is refused before a request is sent.
|
|
19
|
+
SIZES = ("small", "medium", "large", "xlarge")
|
|
20
|
+
|
|
21
|
+
#: Seconds a command may run when the caller sets no timeout.
|
|
22
|
+
DEFAULT_TIMEOUT = 30 * 60
|
|
23
|
+
|
|
24
|
+
#: The API runs a command for 1 second to 24 hours.
|
|
25
|
+
_MIN_TIMEOUT = 1
|
|
26
|
+
_MAX_TIMEOUT = 24 * 60 * 60
|
|
27
|
+
|
|
28
|
+
#: A command longer than this many bytes is written to a file and run from there: the API takes
|
|
29
|
+
#: 8,192 bytes of command per call.
|
|
30
|
+
_MAX_INLINE_COMMAND = 6_000
|
|
31
|
+
|
|
32
|
+
#: Exit status of a command that outlived its timeout (the one of `timeout(1)`).
|
|
33
|
+
_TIMED_OUT_EXIT_CODE = 124
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BriqSandbox(BaseSandbox):
|
|
37
|
+
"""A Deep Agents backend over one briq sandbox.
|
|
38
|
+
|
|
39
|
+
Make a new sandbox, or wrap one that exists:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from langchain_briq import BriqSandbox
|
|
43
|
+
|
|
44
|
+
backend = BriqSandbox.create(size="medium") # reads BRIQ_API_KEY
|
|
45
|
+
try:
|
|
46
|
+
agent = create_deep_agent(model=model, backend=backend)
|
|
47
|
+
...
|
|
48
|
+
finally:
|
|
49
|
+
backend.destroy()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`execute` runs a shell command, `upload_files` and `download_files` move bytes. The file tools
|
|
53
|
+
of Deep Agents (`read`, `write`, `edit`, `ls`, `glob`, `grep`) come from `BaseSandbox` and need
|
|
54
|
+
`python3` in the sandbox.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
sandbox: Sandbox,
|
|
60
|
+
*,
|
|
61
|
+
default_timeout: int = DEFAULT_TIMEOUT,
|
|
62
|
+
client: Briq | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Wrap an existing `briq_cloud.Sandbox`.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
sandbox: The sandbox to run in.
|
|
68
|
+
default_timeout: Seconds a command may run when `execute` gets no `timeout`.
|
|
69
|
+
client: A client this backend closes with `destroy` or `close`. Leave it out when you
|
|
70
|
+
manage the client yourself.
|
|
71
|
+
"""
|
|
72
|
+
self._sandbox = sandbox
|
|
73
|
+
self._default_timeout = default_timeout
|
|
74
|
+
self._owned_client = client
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def create(
|
|
78
|
+
cls,
|
|
79
|
+
*,
|
|
80
|
+
api_key: str | None = None,
|
|
81
|
+
base_url: str | None = None,
|
|
82
|
+
client: Briq | None = None,
|
|
83
|
+
name: str | None = None,
|
|
84
|
+
size: str | None = None,
|
|
85
|
+
image: str | None = None,
|
|
86
|
+
env: Mapping[str, str] | None = None,
|
|
87
|
+
secret_env: Mapping[str, str] | None = None,
|
|
88
|
+
sleep: str | None = None,
|
|
89
|
+
wait_timeout: float = 120.0,
|
|
90
|
+
default_timeout: int = DEFAULT_TIMEOUT,
|
|
91
|
+
) -> BriqSandbox:
|
|
92
|
+
"""Make a sandbox and wait until it runs.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
api_key: A briq API key. Defaults to `BRIQ_API_KEY`.
|
|
96
|
+
base_url: The API origin. Defaults to `BRIQ_API_URL`, then the public API.
|
|
97
|
+
client: A `briq_cloud.Briq` client of your own. You close it.
|
|
98
|
+
name: A name for the sandbox.
|
|
99
|
+
size: `small`, `medium`, `large` or `xlarge`.
|
|
100
|
+
image: An OCI image with a shell and `python3`.
|
|
101
|
+
env: Variables of the sandbox. They show in its status.
|
|
102
|
+
secret_env: Variables kept write-only. A `BriqError` with code `unsupported` when
|
|
103
|
+
the API cannot keep them so.
|
|
104
|
+
sleep: Idle time before it sleeps: `"30m"`, `"3h"`, `"7d"` or `"never"`.
|
|
105
|
+
wait_timeout: Seconds to wait until it runs.
|
|
106
|
+
default_timeout: Seconds a command may run when `execute` gets no `timeout`.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
BriqError: With code `unsupported` for another `size`, and for the API's own refusals.
|
|
110
|
+
"""
|
|
111
|
+
if size is not None and size not in SIZES:
|
|
112
|
+
msg = f"Unsupported size {size!r}: use one of {', '.join(SIZES)}"
|
|
113
|
+
raise BriqError("unsupported", msg)
|
|
114
|
+
owned = client is None
|
|
115
|
+
briq = client or Briq(api_key, base_url=base_url)
|
|
116
|
+
try:
|
|
117
|
+
sandbox = briq.create_sandbox(
|
|
118
|
+
name=name,
|
|
119
|
+
size=size,
|
|
120
|
+
image=image,
|
|
121
|
+
env=env,
|
|
122
|
+
secret_env=secret_env,
|
|
123
|
+
sleep=sleep,
|
|
124
|
+
wait_timeout=wait_timeout,
|
|
125
|
+
)
|
|
126
|
+
except BaseException:
|
|
127
|
+
if owned:
|
|
128
|
+
briq.close()
|
|
129
|
+
raise
|
|
130
|
+
return cls(sandbox, default_timeout=default_timeout, client=briq if owned else None)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def connect(
|
|
134
|
+
cls,
|
|
135
|
+
sandbox_id: str,
|
|
136
|
+
*,
|
|
137
|
+
api_key: str | None = None,
|
|
138
|
+
base_url: str | None = None,
|
|
139
|
+
client: Briq | None = None,
|
|
140
|
+
default_timeout: int = DEFAULT_TIMEOUT,
|
|
141
|
+
) -> BriqSandbox:
|
|
142
|
+
"""Reconnect to a sandbox by id. A sleeping sandbox wakes on the next call."""
|
|
143
|
+
owned = client is None
|
|
144
|
+
briq = client or Briq(api_key, base_url=base_url)
|
|
145
|
+
try:
|
|
146
|
+
sandbox = briq.sandbox(sandbox_id)
|
|
147
|
+
except BaseException:
|
|
148
|
+
if owned:
|
|
149
|
+
briq.close()
|
|
150
|
+
raise
|
|
151
|
+
return cls(sandbox, default_timeout=default_timeout, client=briq if owned else None)
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def id(self) -> str:
|
|
155
|
+
"""The sandbox's id: pass it to `connect` to reach the sandbox again."""
|
|
156
|
+
return self._sandbox.id
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def sandbox(self) -> Sandbox:
|
|
160
|
+
"""The `briq_cloud.Sandbox` underneath, for what this backend does not wrap."""
|
|
161
|
+
return self._sandbox
|
|
162
|
+
|
|
163
|
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
164
|
+
"""Run a shell command and return its output and exit code.
|
|
165
|
+
|
|
166
|
+
stderr follows stdout, inside `<stderr>` tags. A command that outlives `timeout` is
|
|
167
|
+
stopped and ends with exit code 124.
|
|
168
|
+
"""
|
|
169
|
+
if not command.strip():
|
|
170
|
+
return ExecuteResponse(output="", exit_code=0, truncated=False)
|
|
171
|
+
seconds = self._default_timeout if timeout is None else timeout
|
|
172
|
+
seconds = min(max(seconds, _MIN_TIMEOUT), _MAX_TIMEOUT)
|
|
173
|
+
if len(command.encode("utf-8", "replace")) > _MAX_INLINE_COMMAND:
|
|
174
|
+
return self._execute_script(command, seconds)
|
|
175
|
+
return self._run(command, seconds)
|
|
176
|
+
|
|
177
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
178
|
+
"""Write files, creating the directories above them. One response per file, in order."""
|
|
179
|
+
responses: list[FileUploadResponse] = []
|
|
180
|
+
for path, content in files:
|
|
181
|
+
if not _is_absolute(path):
|
|
182
|
+
responses.append(FileUploadResponse(path=path, error="invalid_path"))
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
self._sandbox.write_file(path, content)
|
|
186
|
+
except BriqError as error:
|
|
187
|
+
normalized = _file_error(error)
|
|
188
|
+
if normalized is None:
|
|
189
|
+
raise
|
|
190
|
+
responses.append(FileUploadResponse(path=path, error=normalized))
|
|
191
|
+
else:
|
|
192
|
+
responses.append(FileUploadResponse(path=path))
|
|
193
|
+
return responses
|
|
194
|
+
|
|
195
|
+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
196
|
+
"""Read files. One response per path, in order; a missing file is an error code."""
|
|
197
|
+
responses: list[FileDownloadResponse] = []
|
|
198
|
+
for path in paths:
|
|
199
|
+
if not _is_absolute(path):
|
|
200
|
+
responses.append(FileDownloadResponse(path=path, error="invalid_path"))
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
content = self._sandbox.read_file(path)
|
|
204
|
+
except BriqError as error:
|
|
205
|
+
normalized = _file_error(error)
|
|
206
|
+
if normalized is None:
|
|
207
|
+
raise
|
|
208
|
+
responses.append(FileDownloadResponse(path=path, error=normalized))
|
|
209
|
+
else:
|
|
210
|
+
responses.append(FileDownloadResponse(path=path, content=content))
|
|
211
|
+
return responses
|
|
212
|
+
|
|
213
|
+
def destroy(self) -> None:
|
|
214
|
+
"""Destroy the sandbox and its disk, then close the client this backend made."""
|
|
215
|
+
try:
|
|
216
|
+
self._sandbox.destroy()
|
|
217
|
+
finally:
|
|
218
|
+
self.close()
|
|
219
|
+
|
|
220
|
+
def close(self) -> None:
|
|
221
|
+
"""Close the client this backend made. The sandbox keeps running."""
|
|
222
|
+
if self._owned_client is not None:
|
|
223
|
+
self._owned_client.close()
|
|
224
|
+
self._owned_client = None
|
|
225
|
+
|
|
226
|
+
def __enter__(self) -> BriqSandbox:
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def __exit__(
|
|
230
|
+
self,
|
|
231
|
+
exc_type: type[BaseException] | None,
|
|
232
|
+
exc: BaseException | None,
|
|
233
|
+
tb: TracebackType | None,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Destroy the sandbox when the block ends."""
|
|
236
|
+
try:
|
|
237
|
+
self.destroy()
|
|
238
|
+
except BriqError:
|
|
239
|
+
if exc is None:
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
def _run(self, command: str, seconds: float) -> ExecuteResponse:
|
|
243
|
+
result = self._sandbox.exec(command, timeout=seconds)
|
|
244
|
+
output = result.stdout
|
|
245
|
+
if result.stderr:
|
|
246
|
+
output = _append(output, f"<stderr>{result.stderr.rstrip(chr(10))}</stderr>")
|
|
247
|
+
if result.timed_out or result.exit_code is None:
|
|
248
|
+
note = f"Command timed out after {seconds:g} seconds"
|
|
249
|
+
output = _append(output, note)
|
|
250
|
+
return ExecuteResponse(output=output, exit_code=_TIMED_OUT_EXIT_CODE, truncated=False)
|
|
251
|
+
return ExecuteResponse(output=output, exit_code=result.exit_code, truncated=False)
|
|
252
|
+
|
|
253
|
+
def _execute_script(self, command: str, seconds: float) -> ExecuteResponse:
|
|
254
|
+
"""Run a command too long for one call from a script file, then delete the file."""
|
|
255
|
+
path = f"/tmp/.langchain-briq-{uuid.uuid4().hex}.sh"
|
|
256
|
+
self._sandbox.write_file(path, command.encode("utf-8"))
|
|
257
|
+
try:
|
|
258
|
+
return self._run(f"/bin/sh {path}", seconds)
|
|
259
|
+
finally:
|
|
260
|
+
_quietly(self._sandbox.remove, path)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _append(output: str, line: str) -> str:
|
|
264
|
+
"""`line` on a line of its own after `output`."""
|
|
265
|
+
if not output or output.endswith("\n"):
|
|
266
|
+
return output + line
|
|
267
|
+
return f"{output}\n{line}"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _is_absolute(path: str) -> bool:
|
|
271
|
+
return path.startswith("/") and "\0" not in path
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _quietly(call: Callable[..., object], *args: object) -> None:
|
|
275
|
+
with contextlib.suppress(BriqError):
|
|
276
|
+
call(*args)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _file_error(error: BriqError) -> str | None:
|
|
280
|
+
"""The `FileOperationError` for a failed file call, or `None` when it is not the file's fault.
|
|
281
|
+
|
|
282
|
+
`None` covers what the model cannot fix (credentials, limits, a sandbox that is gone): the
|
|
283
|
+
caller raises it.
|
|
284
|
+
"""
|
|
285
|
+
said = error.message.lower()
|
|
286
|
+
if "permission denied" in said:
|
|
287
|
+
return "permission_denied"
|
|
288
|
+
if "is a directory" in said:
|
|
289
|
+
return "is_directory"
|
|
290
|
+
if error.code == "file_not_found":
|
|
291
|
+
return "file_not_found"
|
|
292
|
+
if error.code == "invalid_argument":
|
|
293
|
+
return "invalid_path"
|
|
294
|
+
return None
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "langchain-briq"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "briq sandbox integration for Deep Agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11,<4.0"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["briq", "langchain", "deepagents", "sandbox", "agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"deepagents>=0.7.32,<0.8.0",
|
|
27
|
+
"briq-cloud>=0.1.1,<0.2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://briq.cloud"
|
|
32
|
+
Repository = "https://github.com/briq-cloud/langchain-briq"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
test = [
|
|
36
|
+
"pytest>=8",
|
|
37
|
+
"pytest-asyncio>=1.0",
|
|
38
|
+
"langchain-tests>=1.1.9",
|
|
39
|
+
"ruff>=0.6",
|
|
40
|
+
"mypy>=1.11",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
path = "langchain_briq/_version.py"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["langchain_briq"]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.sdist]
|
|
50
|
+
include = ["langchain_briq", "README.md", "LICENSE", "pyproject.toml"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
addopts = "-p no:cacheprovider --strict-markers"
|
|
55
|
+
asyncio_mode = "auto"
|
|
56
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
57
|
+
filterwarnings = [
|
|
58
|
+
"ignore:Class-scoped fixtures defined as instance methods",
|
|
59
|
+
]
|
|
60
|
+
markers = [
|
|
61
|
+
"requires: mark tests as requiring a specific library",
|
|
62
|
+
"compile: placeholder test used to compile integration tests without running them",
|
|
63
|
+
"scheduled: tests run on a schedule",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
line-length = 100
|
|
68
|
+
target-version = "py311"
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "C4", "PT"]
|
|
72
|
+
ignore = ["PT018", "PT012"]
|
|
73
|
+
|
|
74
|
+
[tool.mypy]
|
|
75
|
+
strict = true
|
|
76
|
+
python_version = "3.11"
|
|
77
|
+
files = ["langchain_briq", "tests"]
|