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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ dist/
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/langchain-briq)](https://pypi.org/project/langchain-briq/) [![CI](https://github.com/briq-cloud/langchain-briq/actions/workflows/ci.yml/badge.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/langchain-briq)](https://pypi.org/project/langchain-briq/) [![CI](https://github.com/briq-cloud/langchain-briq/actions/workflows/ci.yml/badge.svg)](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,6 @@
1
+ """briq sandbox integration for Deep Agents."""
2
+
3
+ from langchain_briq._version import __version__
4
+ from langchain_briq.sandbox import BriqSandbox
5
+
6
+ __all__ = ["BriqSandbox", "__version__"]
@@ -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"]