maf-sandbox-deepagents 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.
- maf_sandbox_deepagents-0.1.0/LICENSE +21 -0
- maf_sandbox_deepagents-0.1.0/PKG-INFO +92 -0
- maf_sandbox_deepagents-0.1.0/README.md +67 -0
- maf_sandbox_deepagents-0.1.0/pyproject.toml +92 -0
- maf_sandbox_deepagents-0.1.0/pyproject.toml.orig +80 -0
- maf_sandbox_deepagents-0.1.0/src/maf_sandbox_deepagents/__init__.py +61 -0
- maf_sandbox_deepagents-0.1.0/src/maf_sandbox_deepagents/_sandbox.py +830 -0
- maf_sandbox_deepagents-0.1.0/src/maf_sandbox_deepagents/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOKOLAI BV
|
|
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,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: maf-sandbox-deepagents
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A maf-sandbox router as a Deep Agents sandbox backend — any backend the router admits (Docker, ACA Sandboxes) behind LangChain's BaseSandbox, with the router's isolation floor and egress policy still enforced.
|
|
5
|
+
Keywords: deepagents,langchain,langgraph,sandbox,isolation,ai-agents
|
|
6
|
+
Author: SOKOLAI BV
|
|
7
|
+
Author-email: SOKOLAI BV <info@sokolai.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Typing :: Typed
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Dist: maf-sandbox>=0.38.0,<0.39
|
|
19
|
+
Requires-Dist: deepagents>=0.7.13,<0.8
|
|
20
|
+
Requires-Python: >=3.12, <3.15
|
|
21
|
+
Project-URL: Homepage, https://www.sokol.ai
|
|
22
|
+
Project-URL: Source, https://github.com/sokolaidev/maf-extensions
|
|
23
|
+
Project-URL: Issues, https://github.com/sokolaidev/maf-extensions/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# maf-sandbox-deepagents
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/maf-sandbox-deepagents/) [](https://pypi.org/project/maf-sandbox-deepagents/) [](https://github.com/sokolaidev/maf-extensions/blob/main/LICENSE)
|
|
29
|
+
|
|
30
|
+
> **Experimental.** This package is early-stage (pre-1.0, `Development Status :: 4 - Beta`) — its API may change or be removed in a future release without notice. Importing it emits a one-time `MafSandboxDeepagentsExperimentalWarning`; suppress it with `warnings.filterwarnings("ignore", category=maf_sandbox_deepagents.MafSandboxDeepagentsExperimentalWarning)` once you've read the notice.
|
|
31
|
+
|
|
32
|
+
This package is not affiliated with, endorsed by, or a product of LangChain, Inc. or Microsoft — it is a third-party bridge between [Deep Agents](https://docs.langchain.com/oss/python/deepagents/sandboxes) and the `maf-sandbox` suite.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
deepagents -> maf_sandbox_deepagents -> maf_sandbox (router) -> a backend -> the sandbox
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A `maf-sandbox` router as a Deep Agents sandbox. Deep Agents gives an agent one `execute` tool over a sandbox object the host constructs, and derives its file tools from that; the cloud providers it ships with (LangSmith, Daytona, E2B, Modal, Runloop, Vercel) each wrap a vendor client. `MafSandbox` is that object over a `SandboxRouter` instead — so a LangChain or LangGraph agent runs its shell in a Docker container or an Azure Container Apps sandbox, and the host keeps the router's decisions: a backend below the isolation floor is refused at construction, egress is closed unless the spec names hosts, and the sandbox is keyed from the request context and purged with the conversation.
|
|
39
|
+
|
|
40
|
+
It is the suite in the other direction. The packaged kinds (`bicep_validate`, `execute_code`) attach to a Microsoft Agent Framework agent as tools; this package attaches a *backend* to a Deep Agents agent and lets the agent write its own commands. What that gives up is said below.
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from deepagents import create_deep_agent
|
|
46
|
+
from maf_sandbox import Isolation, SandboxKey, SandboxRouter
|
|
47
|
+
from maf_sandbox_deepagents import MafSandbox, deepagents_spec
|
|
48
|
+
from maf_sandbox_docker import DockerSandboxBackend, DockerSandboxConfig
|
|
49
|
+
|
|
50
|
+
router = SandboxRouter([DockerSandboxBackend(DockerSandboxConfig())], min_isolation=Isolation.CONTAINER)
|
|
51
|
+
spec = deepagents_spec("python:3.12-alpine")
|
|
52
|
+
|
|
53
|
+
# One sandbox per agent in a conversation: scope, thread and agent directory come from the host's request context.
|
|
54
|
+
sandbox = MafSandbox(router, SandboxKey(scope="tenant-a", thread_id="thread-1", agent_dir="coder"), spec)
|
|
55
|
+
|
|
56
|
+
agent = create_deep_agent(model=..., backend=sandbox, system_prompt="...")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`deepagents_spec` builds the spec Deep Agents needs — `EXEC`, `FILES_IN` and `FILES_OUT` — with the protocol's default base, and derives egress: `egress_allow=("pypi.org",)` runs the sandbox `ALLOWLIST` with that host, and no hosts runs it `CLOSED`. There is no open posture to ask for, because the agent writes the commands, and a spec built another way that asks for `UNRESTRICTED` is refused. `MafSandbox` refuses that, a spec that lacks a required capability, a router with no backend, and a spec that leaves the base to the backend, and asks the router at construction whether the backend can serve it, so a misconfigured host fails before an agent is built.
|
|
60
|
+
|
|
61
|
+
**Paths are guest paths**, as they are for every sandbox Deep Agents ships: the agent's `read_file`, `write_file` and the rest name them absolutely, and `execute` runs in the base. The adapter's own upload and download take an absolute path anywhere in the guest, or a relative one under the base. Inside `spec.work_dir` they go through the backend's file plane, which refuses a path through a link as `invalid_path`; outside it — Deep Agents keeps its offloaded history under `/conversation_history/` and its large-edit temporaries under `/tmp/` — they go through the same shell the agent already has, in base64 chunks, under the same caps, so the file plane's confinement widens nothing that `execute` had not already opened. Put `spec.work_dir` in the system prompt so the model knows where its own files are; the example does. A backend-allocated base (`work_dir=None`) is refused at construction, because nothing could then tell the model where it is.
|
|
62
|
+
|
|
63
|
+
The sandbox is acquired on the first operation, a command or a file transfer, and reused warm after that. It lives until the host disposes it: `await sandbox.aclose()`, or the router's `dispose_scope(scope, thread_id)` on the host's own conversation-delete path — the backstop every sandbox in the suite answers to, and the one to wire, because LangGraph fires nothing when a thread is deleted. [`examples/docker_bicep`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-deepagents/examples/docker_bicep) runs the whole thing end to end; it moves to `samples/` once this package is published, because the samples install from PyPI.
|
|
64
|
+
|
|
65
|
+
## What the adapter maps
|
|
66
|
+
|
|
67
|
+
| Deep Agents | `maf_sandbox` |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `execute(command, timeout)` | `BoundedExec.exec_bounded(command, working_directory=".", timeout=..., max_output_bytes=...)` — run in the sandbox's storage base, a shell string the backend runs as `sh -c`, under one deadline that a cold acquire spends part of and cannot outlive, and under `max_output_bytes` (1 MiB by default), which the backend enforces before it buffers anything: output past it is dropped whole and the result says so with `truncated=True`; a timeout, an overflow, the caller's cancellation or any failure that kept the result from coming back also condemns the sandbox, because nothing establishes that the process stopped when the host stopped waiting: every call is admitted through the router's call lifecycle, which every call over the key shares whichever adapter made it, the delete runs when the last call in flight leaves, so a parallel tool call is never cut off underneath, that release goes to the process's own loop after an unfinished command, so the delete neither extends the caller's wait nor dies with the caller's loop, and a later call is admitted once it is done, so the next command starts cold; `stdout` and `stderr` come back as one stream with `[stderr]` on the second, the way Deep Agents' own backends render it, and that stream is held to the same budget once rendered, since the prefixes grow it |
|
|
70
|
+
| `upload_files([(path, bytes)])` | `Sandbox.write_file` under the base, one call per file, and the core's `write_file_over_exec` outside it, which stages the chunks in a sibling named for the call and moves it into place once the last landed, so a reader or a second writer sees a whole file and never two interleaved, under `spec.files_in`: a batch over `max_files` is refused whole, a file over `max_bytes_per_file` or past `max_total_bytes` is refused alone; a path through a link under the base is refused as `invalid_path` and an unsearchable ancestor as `permission_denied`, what either road refuses comes back as Deep Agents' code for the core's `FileRefusal`, and a write the plane did not refuse and did not finish condemns the sandbox and fails the batch, since the plane does not promise a whole file or none; a shell write that does not finish, on a timeout, an output overflow, the caller's cancellation or any other failure, condemns the sandbox and fails the whole batch, because what it had put there goes with it |
|
|
71
|
+
| `download_files([path])` | `Sandbox.stat_file` then `Sandbox.read_file` under the base, and the core's `read_file_over_exec` outside it, a probe that tells a file behind an unsearchable ancestor from an absent one and a base64 read under the cap, under `spec.files_out`: a batch over `max_files` is refused whole, and each file is read under the smaller of `max_bytes_per_file` and what `max_total_bytes` has left, refusing rather than truncating; a missing file is `file_not_found`, a directory `is_directory`, a link under the base or a parent that is a file `invalid_path`, an unreadable file `permission_denied`; a plane stat or read that times out fails that file alone and the sandbox stays, since a read changes nothing in it and the bound is the backend's own refusal of an entry it cannot serve; a shell read that does not finish, on a timeout, an overflow, the caller's cancellation or any other failure, condemns the sandbox and fails the rest of the batch |
|
|
72
|
+
| `id` | An opaque hash of what names the sandbox — the key, the kind, the serving backend instance and the egress posture — because Deep Agents may render it to the model and a scope is often a tenant; two adapters over one router and key share it, since they reach one sandbox, and two backends of one name reaching two engines do not |
|
|
73
|
+
| `aclose()` | `SandboxRouter.enter_call` exclusively, so every call over the key has left and a delete one of them queued has run, then `dispose_kind` for the one instance this adapter acquired, so a packaged kind serving the same conversation, or another adapter over the same key with a different backend or egress, keeps its own; before any acquire, or once a queued delete took the instance, there is nothing of this adapter's to delete |
|
|
74
|
+
|
|
75
|
+
Both surfaces are served: the `a*` methods are native, and the synchronous ones, which a sync tool on a LangGraph worker thread calls, run the same coroutine on the core's `SyncRunner`, one loop on a thread of its own, shared by every adapter in the process and started by the first sync call, so a backend that caches a client per loop holds one, not one per adapter or per call; a forked child starts its own on its first sync call, since a fork carries the loop but not its thread.
|
|
76
|
+
|
|
77
|
+
## What it costs, honestly
|
|
78
|
+
|
|
79
|
+
**The agent writes the shell.** The packaged kinds run a fixed argv and never a shell; here the model's command string is what runs, which is Deep Agents' model and what its `BaseSandbox` documents. The container boundary and the egress policy are the controls, and nothing above them is. Do not put a credential in the image.
|
|
80
|
+
|
|
81
|
+
**Cleanup is disposal.** A kind can claim confinement to its own call directory and earn warm reuse through `RECLAIM`; an arbitrary shell cannot, so the router cleans this sandbox by deleting it. It stays warm across the turns of one conversation and goes when the host disposes it.
|
|
82
|
+
|
|
83
|
+
**Deep Agents' derived file tools need `python3` in the guest.** `ls`, `read_file`, `edit_file`, `glob` and `grep` are shell-and-Python snippets Deep Agents runs through `execute`, and `write_file` runs one too, a preflight that creates the parent directory, before it hands the bytes to `upload_files`. On an image without an interpreter — [`images/bicep-sandbox`](https://github.com/sokolaidev/maf-extensions/tree/main/images/bicep-sandbox) is one — only `execute`, `delete` (a `test` and an `rm -rf` through `execute`) and this package's own `upload_files` and `download_files` work. The image is the host's to choose, and the sample says so in its prompt.
|
|
84
|
+
|
|
85
|
+
**No labels and no declared outputs.** MAF's information-flow declarations, the per-call guest path, declared outputs with a landing sink, and host tools called from inside the guest are the kinds' contract with the framework, and Deep Agents has no place to put them. A host that needs them attaches a kind to a MAF agent; this package is for a host that already has a Deep Agents agent.
|
|
86
|
+
|
|
87
|
+
## Requirements
|
|
88
|
+
|
|
89
|
+
- Python 3.12 or newer.
|
|
90
|
+
- `deepagents` 0.7.x. Its backend protocol has changed between 0.x minors, so one minor is admitted at a time.
|
|
91
|
+
- An image with `sh`, `mkdir`, `mv`, `rm`, `base64` and `wc` (the core's `SHELL_UTILITIES`), which the shell road runs for an upload or download outside the base (`test`, `printf` and `echo` are the shell's own in busybox, dash and bash). Docker's exec probe checks only `sh`, so an image without `base64` or `wc` constructs fine and fails on the first transfer outside the base; `python:3.12-alpine` and the bicep image have all six.
|
|
92
|
+
- A `maf-sandbox` backend that implements `BoundedExec` (the adapter runs nothing through a sandbox that cannot bound its output), and declares `EXEC`, `FILES_IN` and `FILES_OUT` — [`maf-sandbox-docker`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-docker) and [`maf-sandbox-acas`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-acas) do; `maf-sandbox-wslc` does not declare `FILES_OUT`, so the router refuses it here.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# maf-sandbox-deepagents
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/maf-sandbox-deepagents/) [](https://pypi.org/project/maf-sandbox-deepagents/) [](https://github.com/sokolaidev/maf-extensions/blob/main/LICENSE)
|
|
4
|
+
|
|
5
|
+
> **Experimental.** This package is early-stage (pre-1.0, `Development Status :: 4 - Beta`) — its API may change or be removed in a future release without notice. Importing it emits a one-time `MafSandboxDeepagentsExperimentalWarning`; suppress it with `warnings.filterwarnings("ignore", category=maf_sandbox_deepagents.MafSandboxDeepagentsExperimentalWarning)` once you've read the notice.
|
|
6
|
+
|
|
7
|
+
This package is not affiliated with, endorsed by, or a product of LangChain, Inc. or Microsoft — it is a third-party bridge between [Deep Agents](https://docs.langchain.com/oss/python/deepagents/sandboxes) and the `maf-sandbox` suite.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
deepagents -> maf_sandbox_deepagents -> maf_sandbox (router) -> a backend -> the sandbox
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A `maf-sandbox` router as a Deep Agents sandbox. Deep Agents gives an agent one `execute` tool over a sandbox object the host constructs, and derives its file tools from that; the cloud providers it ships with (LangSmith, Daytona, E2B, Modal, Runloop, Vercel) each wrap a vendor client. `MafSandbox` is that object over a `SandboxRouter` instead — so a LangChain or LangGraph agent runs its shell in a Docker container or an Azure Container Apps sandbox, and the host keeps the router's decisions: a backend below the isolation floor is refused at construction, egress is closed unless the spec names hosts, and the sandbox is keyed from the request context and purged with the conversation.
|
|
14
|
+
|
|
15
|
+
It is the suite in the other direction. The packaged kinds (`bicep_validate`, `execute_code`) attach to a Microsoft Agent Framework agent as tools; this package attaches a *backend* to a Deep Agents agent and lets the agent write its own commands. What that gives up is said below.
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from deepagents import create_deep_agent
|
|
21
|
+
from maf_sandbox import Isolation, SandboxKey, SandboxRouter
|
|
22
|
+
from maf_sandbox_deepagents import MafSandbox, deepagents_spec
|
|
23
|
+
from maf_sandbox_docker import DockerSandboxBackend, DockerSandboxConfig
|
|
24
|
+
|
|
25
|
+
router = SandboxRouter([DockerSandboxBackend(DockerSandboxConfig())], min_isolation=Isolation.CONTAINER)
|
|
26
|
+
spec = deepagents_spec("python:3.12-alpine")
|
|
27
|
+
|
|
28
|
+
# One sandbox per agent in a conversation: scope, thread and agent directory come from the host's request context.
|
|
29
|
+
sandbox = MafSandbox(router, SandboxKey(scope="tenant-a", thread_id="thread-1", agent_dir="coder"), spec)
|
|
30
|
+
|
|
31
|
+
agent = create_deep_agent(model=..., backend=sandbox, system_prompt="...")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`deepagents_spec` builds the spec Deep Agents needs — `EXEC`, `FILES_IN` and `FILES_OUT` — with the protocol's default base, and derives egress: `egress_allow=("pypi.org",)` runs the sandbox `ALLOWLIST` with that host, and no hosts runs it `CLOSED`. There is no open posture to ask for, because the agent writes the commands, and a spec built another way that asks for `UNRESTRICTED` is refused. `MafSandbox` refuses that, a spec that lacks a required capability, a router with no backend, and a spec that leaves the base to the backend, and asks the router at construction whether the backend can serve it, so a misconfigured host fails before an agent is built.
|
|
35
|
+
|
|
36
|
+
**Paths are guest paths**, as they are for every sandbox Deep Agents ships: the agent's `read_file`, `write_file` and the rest name them absolutely, and `execute` runs in the base. The adapter's own upload and download take an absolute path anywhere in the guest, or a relative one under the base. Inside `spec.work_dir` they go through the backend's file plane, which refuses a path through a link as `invalid_path`; outside it — Deep Agents keeps its offloaded history under `/conversation_history/` and its large-edit temporaries under `/tmp/` — they go through the same shell the agent already has, in base64 chunks, under the same caps, so the file plane's confinement widens nothing that `execute` had not already opened. Put `spec.work_dir` in the system prompt so the model knows where its own files are; the example does. A backend-allocated base (`work_dir=None`) is refused at construction, because nothing could then tell the model where it is.
|
|
37
|
+
|
|
38
|
+
The sandbox is acquired on the first operation, a command or a file transfer, and reused warm after that. It lives until the host disposes it: `await sandbox.aclose()`, or the router's `dispose_scope(scope, thread_id)` on the host's own conversation-delete path — the backstop every sandbox in the suite answers to, and the one to wire, because LangGraph fires nothing when a thread is deleted. [`examples/docker_bicep`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-deepagents/examples/docker_bicep) runs the whole thing end to end; it moves to `samples/` once this package is published, because the samples install from PyPI.
|
|
39
|
+
|
|
40
|
+
## What the adapter maps
|
|
41
|
+
|
|
42
|
+
| Deep Agents | `maf_sandbox` |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `execute(command, timeout)` | `BoundedExec.exec_bounded(command, working_directory=".", timeout=..., max_output_bytes=...)` — run in the sandbox's storage base, a shell string the backend runs as `sh -c`, under one deadline that a cold acquire spends part of and cannot outlive, and under `max_output_bytes` (1 MiB by default), which the backend enforces before it buffers anything: output past it is dropped whole and the result says so with `truncated=True`; a timeout, an overflow, the caller's cancellation or any failure that kept the result from coming back also condemns the sandbox, because nothing establishes that the process stopped when the host stopped waiting: every call is admitted through the router's call lifecycle, which every call over the key shares whichever adapter made it, the delete runs when the last call in flight leaves, so a parallel tool call is never cut off underneath, that release goes to the process's own loop after an unfinished command, so the delete neither extends the caller's wait nor dies with the caller's loop, and a later call is admitted once it is done, so the next command starts cold; `stdout` and `stderr` come back as one stream with `[stderr]` on the second, the way Deep Agents' own backends render it, and that stream is held to the same budget once rendered, since the prefixes grow it |
|
|
45
|
+
| `upload_files([(path, bytes)])` | `Sandbox.write_file` under the base, one call per file, and the core's `write_file_over_exec` outside it, which stages the chunks in a sibling named for the call and moves it into place once the last landed, so a reader or a second writer sees a whole file and never two interleaved, under `spec.files_in`: a batch over `max_files` is refused whole, a file over `max_bytes_per_file` or past `max_total_bytes` is refused alone; a path through a link under the base is refused as `invalid_path` and an unsearchable ancestor as `permission_denied`, what either road refuses comes back as Deep Agents' code for the core's `FileRefusal`, and a write the plane did not refuse and did not finish condemns the sandbox and fails the batch, since the plane does not promise a whole file or none; a shell write that does not finish, on a timeout, an output overflow, the caller's cancellation or any other failure, condemns the sandbox and fails the whole batch, because what it had put there goes with it |
|
|
46
|
+
| `download_files([path])` | `Sandbox.stat_file` then `Sandbox.read_file` under the base, and the core's `read_file_over_exec` outside it, a probe that tells a file behind an unsearchable ancestor from an absent one and a base64 read under the cap, under `spec.files_out`: a batch over `max_files` is refused whole, and each file is read under the smaller of `max_bytes_per_file` and what `max_total_bytes` has left, refusing rather than truncating; a missing file is `file_not_found`, a directory `is_directory`, a link under the base or a parent that is a file `invalid_path`, an unreadable file `permission_denied`; a plane stat or read that times out fails that file alone and the sandbox stays, since a read changes nothing in it and the bound is the backend's own refusal of an entry it cannot serve; a shell read that does not finish, on a timeout, an overflow, the caller's cancellation or any other failure, condemns the sandbox and fails the rest of the batch |
|
|
47
|
+
| `id` | An opaque hash of what names the sandbox — the key, the kind, the serving backend instance and the egress posture — because Deep Agents may render it to the model and a scope is often a tenant; two adapters over one router and key share it, since they reach one sandbox, and two backends of one name reaching two engines do not |
|
|
48
|
+
| `aclose()` | `SandboxRouter.enter_call` exclusively, so every call over the key has left and a delete one of them queued has run, then `dispose_kind` for the one instance this adapter acquired, so a packaged kind serving the same conversation, or another adapter over the same key with a different backend or egress, keeps its own; before any acquire, or once a queued delete took the instance, there is nothing of this adapter's to delete |
|
|
49
|
+
|
|
50
|
+
Both surfaces are served: the `a*` methods are native, and the synchronous ones, which a sync tool on a LangGraph worker thread calls, run the same coroutine on the core's `SyncRunner`, one loop on a thread of its own, shared by every adapter in the process and started by the first sync call, so a backend that caches a client per loop holds one, not one per adapter or per call; a forked child starts its own on its first sync call, since a fork carries the loop but not its thread.
|
|
51
|
+
|
|
52
|
+
## What it costs, honestly
|
|
53
|
+
|
|
54
|
+
**The agent writes the shell.** The packaged kinds run a fixed argv and never a shell; here the model's command string is what runs, which is Deep Agents' model and what its `BaseSandbox` documents. The container boundary and the egress policy are the controls, and nothing above them is. Do not put a credential in the image.
|
|
55
|
+
|
|
56
|
+
**Cleanup is disposal.** A kind can claim confinement to its own call directory and earn warm reuse through `RECLAIM`; an arbitrary shell cannot, so the router cleans this sandbox by deleting it. It stays warm across the turns of one conversation and goes when the host disposes it.
|
|
57
|
+
|
|
58
|
+
**Deep Agents' derived file tools need `python3` in the guest.** `ls`, `read_file`, `edit_file`, `glob` and `grep` are shell-and-Python snippets Deep Agents runs through `execute`, and `write_file` runs one too, a preflight that creates the parent directory, before it hands the bytes to `upload_files`. On an image without an interpreter — [`images/bicep-sandbox`](https://github.com/sokolaidev/maf-extensions/tree/main/images/bicep-sandbox) is one — only `execute`, `delete` (a `test` and an `rm -rf` through `execute`) and this package's own `upload_files` and `download_files` work. The image is the host's to choose, and the sample says so in its prompt.
|
|
59
|
+
|
|
60
|
+
**No labels and no declared outputs.** MAF's information-flow declarations, the per-call guest path, declared outputs with a landing sink, and host tools called from inside the guest are the kinds' contract with the framework, and Deep Agents has no place to put them. A host that needs them attaches a kind to a MAF agent; this package is for a host that already has a Deep Agents agent.
|
|
61
|
+
|
|
62
|
+
## Requirements
|
|
63
|
+
|
|
64
|
+
- Python 3.12 or newer.
|
|
65
|
+
- `deepagents` 0.7.x. Its backend protocol has changed between 0.x minors, so one minor is admitted at a time.
|
|
66
|
+
- An image with `sh`, `mkdir`, `mv`, `rm`, `base64` and `wc` (the core's `SHELL_UTILITIES`), which the shell road runs for an upload or download outside the base (`test`, `printf` and `echo` are the shell's own in busybox, dash and bash). Docker's exec probe checks only `sh`, so an image without `base64` or `wc` constructs fine and fails on the first transfer outside the base; `python:3.12-alpine` and the bicep image have all six.
|
|
67
|
+
- A `maf-sandbox` backend that implements `BoundedExec` (the adapter runs nothing through a sandbox that cannot bound its output), and declares `EXEC`, `FILES_IN` and `FILES_OUT` — [`maf-sandbox-docker`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-docker) and [`maf-sandbox-acas`](https://github.com/sokolaidev/maf-extensions/tree/main/packages/maf-sandbox-acas) do; `maf-sandbox-wslc` does not declare `FILES_OUT`, so the router refuses it here.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maf-sandbox-deepagents"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A maf-sandbox router as a Deep Agents sandbox backend — any backend the router admits (Docker, ACA Sandboxes) behind LangChain's BaseSandbox, with the router's isolation floor and egress policy still enforced."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.15"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"deepagents",
|
|
11
|
+
"langchain",
|
|
12
|
+
"langgraph",
|
|
13
|
+
"sandbox",
|
|
14
|
+
"isolation",
|
|
15
|
+
"ai-agents",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"maf-sandbox>=0.38.0,<0.39",
|
|
29
|
+
"deepagents>=0.7.13,<0.8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "SOKOLAI BV"
|
|
34
|
+
email = "info@sokolai.com"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://www.sokol.ai"
|
|
38
|
+
Source = "https://github.com/sokolaidev/maf-extensions"
|
|
39
|
+
Issues = "https://github.com/sokolaidev/maf-extensions/issues"
|
|
40
|
+
|
|
41
|
+
[tool.uv.sources.maf-sandbox]
|
|
42
|
+
workspace = true
|
|
43
|
+
|
|
44
|
+
[tool.uv.build-backend]
|
|
45
|
+
module-name = "maf_sandbox_deepagents"
|
|
46
|
+
module-root = "src"
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py312"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
extend-select = [
|
|
54
|
+
"I",
|
|
55
|
+
"UP",
|
|
56
|
+
"D100",
|
|
57
|
+
"D101",
|
|
58
|
+
"D103",
|
|
59
|
+
"D104",
|
|
60
|
+
"E501",
|
|
61
|
+
"W505",
|
|
62
|
+
"TD",
|
|
63
|
+
"FIX",
|
|
64
|
+
"ERA001",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.per-file-ignores]
|
|
68
|
+
"tests/**" = [
|
|
69
|
+
"D101",
|
|
70
|
+
"D103",
|
|
71
|
+
"E501",
|
|
72
|
+
"W505",
|
|
73
|
+
]
|
|
74
|
+
"examples/**" = [
|
|
75
|
+
"E501",
|
|
76
|
+
"W505",
|
|
77
|
+
"I001",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.pycodestyle]
|
|
81
|
+
max-doc-length = 100
|
|
82
|
+
|
|
83
|
+
[tool.pyright]
|
|
84
|
+
include = ["src"]
|
|
85
|
+
typeCheckingMode = "strict"
|
|
86
|
+
|
|
87
|
+
[tool.pytest.ini_options]
|
|
88
|
+
testpaths = ["tests"]
|
|
89
|
+
|
|
90
|
+
[build-system]
|
|
91
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
92
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maf-sandbox-deepagents"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A maf-sandbox router as a Deep Agents sandbox backend — any backend the router admits (Docker, ACA Sandboxes) behind LangChain's BaseSandbox, with the router's isolation floor and egress policy still enforced."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.15"
|
|
7
|
+
authors = [{ name = "SOKOLAI BV", email = "info@sokolai.com" }]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
license-files = ["LICENSE"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"deepagents",
|
|
12
|
+
"langchain",
|
|
13
|
+
"langgraph",
|
|
14
|
+
"sandbox",
|
|
15
|
+
"isolation",
|
|
16
|
+
"ai-agents",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
# The router and the protocol this adapter drives. Upper-bounded because maf-sandbox has
|
|
30
|
+
# not reached a stable API, so every release before 1.0.0 may include breaking changes;
|
|
31
|
+
# moving either end of this range has an order to it — see RELEASING.md. The number is
|
|
32
|
+
# what `scripts/set_dependents_range.py` moves.
|
|
33
|
+
"maf-sandbox>=0.38.0,<0.39",
|
|
34
|
+
# `BaseSandbox` and the response types this adapter answers with. Deep Agents changes its
|
|
35
|
+
# backend protocol between 0.x minors (0.6 and 0.7 both did), so one minor is admitted.
|
|
36
|
+
"deepagents>=0.7.13,<0.8",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://www.sokol.ai"
|
|
41
|
+
Source = "https://github.com/sokolaidev/maf-extensions"
|
|
42
|
+
Issues = "https://github.com/sokolaidev/maf-extensions/issues"
|
|
43
|
+
|
|
44
|
+
[tool.uv.sources]
|
|
45
|
+
maf-sandbox = { workspace = true }
|
|
46
|
+
|
|
47
|
+
[tool.uv.build-backend]
|
|
48
|
+
module-name = "maf_sandbox_deepagents"
|
|
49
|
+
module-root = "src"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 100
|
|
53
|
+
target-version = "py312"
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
extend-select = ["I", "UP", "D100", "D101", "D103", "D104", "E501", "W505", "TD", "FIX", "ERA001"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint.per-file-ignores]
|
|
59
|
+
# ruff resolves [tool.ruff] per file by the NEAREST ancestor pyproject.toml that has one, so
|
|
60
|
+
# this package carries its own copy rather than inheriting one from the workspace root.
|
|
61
|
+
"tests/**" = ["D101", "D103", "E501", "W505"]
|
|
62
|
+
# `examples/` holds a sample written to the root's rules, parked here until this package is on the
|
|
63
|
+
# index; it keeps those rules so it moves to `samples/` unchanged.
|
|
64
|
+
"examples/**" = ["E501", "W505", "I001"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.pycodestyle]
|
|
67
|
+
max-doc-length = 100
|
|
68
|
+
|
|
69
|
+
# Self-contained type checking, scoped to this package — strict. `tests/` stays out (fakes,
|
|
70
|
+
# not signal for a strict checker).
|
|
71
|
+
[tool.pyright]
|
|
72
|
+
include = ["src"]
|
|
73
|
+
typeCheckingMode = "strict"
|
|
74
|
+
|
|
75
|
+
[tool.pytest.ini_options]
|
|
76
|
+
testpaths = ["tests"]
|
|
77
|
+
|
|
78
|
+
[build-system]
|
|
79
|
+
requires = ["uv_build>=0.11.24,<0.12.0"]
|
|
80
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""A maf-sandbox router as a Deep Agents sandbox backend.
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
deepagents -> maf_sandbox_deepagents -> maf_sandbox (router) -> a backend -> the sandbox
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
:class:`MafSandbox` implements Deep Agents' ``BaseSandbox`` over a
|
|
8
|
+
:class:`~maf_sandbox.SandboxRouter`: the agent gets Deep Agents' own ``execute`` and file tools,
|
|
9
|
+
and the host keeps the router's decisions — the isolation floor, the egress mode, the
|
|
10
|
+
capability match, keying from the request context, and purge by conversation.
|
|
11
|
+
|
|
12
|
+
This package imports ``maf_sandbox`` and ``deepagents``, and no backend and no agent framework.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from ._sandbox import (
|
|
16
|
+
DEEPAGENTS_KIND,
|
|
17
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
18
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
19
|
+
DEFAULT_WORK_DIR,
|
|
20
|
+
REQUIRED_CAPABILITIES,
|
|
21
|
+
SANDBOX_UNAVAILABLE,
|
|
22
|
+
STORAGE_BASE,
|
|
23
|
+
MafSandbox,
|
|
24
|
+
deepagents_spec,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DEEPAGENTS_KIND",
|
|
29
|
+
"DEFAULT_EXEC_TIMEOUT_SECONDS",
|
|
30
|
+
"DEFAULT_MAX_OUTPUT_BYTES",
|
|
31
|
+
"DEFAULT_WORK_DIR",
|
|
32
|
+
"REQUIRED_CAPABILITIES",
|
|
33
|
+
"SANDBOX_UNAVAILABLE",
|
|
34
|
+
"STORAGE_BASE",
|
|
35
|
+
"MafSandbox",
|
|
36
|
+
"MafSandboxDeepagentsExperimentalWarning",
|
|
37
|
+
"deepagents_spec",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Experimental package (Beta): importing it emits a UserWarning rather than a FutureWarning,
|
|
41
|
+
# so a host running under `python -W error` can still import it.
|
|
42
|
+
import warnings as _warnings
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MafSandboxDeepagentsExperimentalWarning(UserWarning):
|
|
46
|
+
"""Warning category for maf-sandbox-deepagents's experimental-package notice."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _warn_experimental() -> None:
|
|
50
|
+
message = (
|
|
51
|
+
"maf_sandbox_deepagents is experimental and may change or be removed in future versions "
|
|
52
|
+
"without notice."
|
|
53
|
+
)
|
|
54
|
+
try:
|
|
55
|
+
_warnings.warn(message, category=MafSandboxDeepagentsExperimentalWarning, stacklevel=2)
|
|
56
|
+
except MafSandboxDeepagentsExperimentalWarning:
|
|
57
|
+
# Deliberate: under `-W error` an informational notice must not fail the import.
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_warn_experimental()
|
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
"""The adapter: a ``maf_sandbox`` router behind Deep Agents' ``BaseSandbox``.
|
|
2
|
+
|
|
3
|
+
Deep Agents hands an agent one ``execute`` tool over a sandbox object the host constructs, and
|
|
4
|
+
derives its file tools from ``execute`` and ``upload_files``. This module implements that
|
|
5
|
+
object over a :class:`~maf_sandbox.SandboxRouter`: the router still refuses a backend below
|
|
6
|
+
the host's isolation floor or one that cannot enforce the spec's egress mode, and the sandbox
|
|
7
|
+
is still keyed from the host's request context and purged with the conversation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import concurrent.futures
|
|
14
|
+
import dataclasses
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
19
|
+
import posixpath
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import PurePosixPath
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from deepagents.backends.protocol import (
|
|
26
|
+
FILE_NOT_FOUND,
|
|
27
|
+
INVALID_PATH,
|
|
28
|
+
IS_DIRECTORY,
|
|
29
|
+
PERMISSION_DENIED,
|
|
30
|
+
ExecuteResponse,
|
|
31
|
+
FileDownloadResponse,
|
|
32
|
+
FileUploadResponse,
|
|
33
|
+
)
|
|
34
|
+
from deepagents.backends.sandbox import BaseSandbox
|
|
35
|
+
from maf_sandbox import (
|
|
36
|
+
DEFAULT_TRANSFER_LIMITS,
|
|
37
|
+
BoundedExec,
|
|
38
|
+
Capability,
|
|
39
|
+
Cleanup,
|
|
40
|
+
Egress,
|
|
41
|
+
EgressRule,
|
|
42
|
+
ExecResult,
|
|
43
|
+
FileRefusal,
|
|
44
|
+
Isolation,
|
|
45
|
+
IsolationScope,
|
|
46
|
+
NoSandboxBackend,
|
|
47
|
+
Sandbox,
|
|
48
|
+
SandboxExecOutputLimitExceeded,
|
|
49
|
+
SandboxFileRefused,
|
|
50
|
+
SandboxKey,
|
|
51
|
+
SandboxRouter,
|
|
52
|
+
SandboxShellTransferFailed,
|
|
53
|
+
SandboxShellTransferUnfinished,
|
|
54
|
+
SandboxSpec,
|
|
55
|
+
SandboxTransferCapExceeded,
|
|
56
|
+
SyncRunner,
|
|
57
|
+
TransferLimits,
|
|
58
|
+
entry_refusal,
|
|
59
|
+
file_refusal,
|
|
60
|
+
read_file_over_exec,
|
|
61
|
+
write_file_over_exec,
|
|
62
|
+
)
|
|
63
|
+
from maf_sandbox.paths import posix_work_dir_ancestors
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"DEEPAGENTS_KIND",
|
|
67
|
+
"DEFAULT_EXEC_TIMEOUT_SECONDS",
|
|
68
|
+
"DEFAULT_MAX_OUTPUT_BYTES",
|
|
69
|
+
"DEFAULT_WORK_DIR",
|
|
70
|
+
"REQUIRED_CAPABILITIES",
|
|
71
|
+
"SANDBOX_UNAVAILABLE",
|
|
72
|
+
"STORAGE_BASE",
|
|
73
|
+
"MafSandbox",
|
|
74
|
+
"deepagents_spec",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
logger = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
#: The ``kind`` a Deep Agents sandbox is keyed under. Part of the sandbox's identity, so an
|
|
80
|
+
#: agent's shell never shares a container with a packaged kind serving the same conversation.
|
|
81
|
+
DEEPAGENTS_KIND = "deepagents"
|
|
82
|
+
|
|
83
|
+
#: What Deep Agents needs from a backend: a shell, files pushed in for ``upload_files`` and
|
|
84
|
+
#: ``write_file``, files pulled out for ``download_files``. The other derived tools run on
|
|
85
|
+
#: ``execute`` alone, and ``write_file`` runs a Python preflight there before it uploads.
|
|
86
|
+
REQUIRED_CAPABILITIES: frozenset[Capability] = frozenset(
|
|
87
|
+
{Capability.EXEC, Capability.FILES_IN, Capability.FILES_OUT}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS = 120.0
|
|
91
|
+
|
|
92
|
+
#: The most combined stdout and stderr one command may return, in bytes. Above the 500 KiB
|
|
93
|
+
#: page Deep Agents' own ``read_file`` renders, so a large read still comes back whole; a
|
|
94
|
+
#: command past it is refused rather than truncated, which is the suite's bounded-exec contract.
|
|
95
|
+
DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
|
|
96
|
+
|
|
97
|
+
#: The protocol's default base, read off the spec rather than spelled a second time here.
|
|
98
|
+
DEFAULT_WORK_DIR: str | None = SandboxSpec(kind=DEEPAGENTS_KIND).work_dir
|
|
99
|
+
|
|
100
|
+
#: The acquired sandbox's storage base, addressed relatively: every command runs there and
|
|
101
|
+
#: every upload and download path is relative to it. The backend resolves it to the base the
|
|
102
|
+
#: spec named; a spec that names none is refused at construction.
|
|
103
|
+
STORAGE_BASE = "."
|
|
104
|
+
|
|
105
|
+
#: What the model reads when the router or the backend could not serve a call. Fixed, because
|
|
106
|
+
#: the provider's own message can carry an endpoint or a tenant and a tool result is persisted
|
|
107
|
+
#: into the transcript; the detail goes to the log.
|
|
108
|
+
SANDBOX_UNAVAILABLE = "Error: sandbox unavailable — the command did not run."
|
|
109
|
+
|
|
110
|
+
#: Says only what every backend can establish: the wait ended. Whether the command was
|
|
111
|
+
#: stopped is the backend's own contract, and differs between them.
|
|
112
|
+
_TIMED_OUT = "Error: the command did not finish within {seconds:g} seconds."
|
|
113
|
+
#: Distinct from :data:`SANDBOX_UNAVAILABLE`: the command may have run, and the result is what
|
|
114
|
+
#: did not come back.
|
|
115
|
+
_EXEC_FAILED = "Error: the sandbox did not return the command's result; see the host log."
|
|
116
|
+
#: The whole output is dropped, not cut: the backend refuses past the budget and returns none
|
|
117
|
+
#: of it, so a partial result is never mistaken for a program that printed less.
|
|
118
|
+
_OUTPUT_DROPPED = "Error: the command's output exceeded {limit} bytes and was dropped."
|
|
119
|
+
_UNBOUNDED = (
|
|
120
|
+
"Error: sandbox unavailable — the backend cannot bound the command's output, so the "
|
|
121
|
+
"command did not run."
|
|
122
|
+
)
|
|
123
|
+
_UPLOAD_FAILED = "upload failed; see the host log"
|
|
124
|
+
#: A shell transfer that did not finish — a timeout, an output overflow, the caller leaving —
|
|
125
|
+
#: condemns the sandbox, and everything the batch had put there goes with it. Condemned, not
|
|
126
|
+
#: disposed: the delete is queued with the router and may still be pending, or fail.
|
|
127
|
+
_UPLOAD_BATCH_LOST = (
|
|
128
|
+
"the sandbox is condemned after a transfer did not finish; the batch did not land"
|
|
129
|
+
)
|
|
130
|
+
_DOWNLOAD_BATCH_LOST = (
|
|
131
|
+
"the sandbox is condemned after a transfer did not finish; the rest of the batch was not read"
|
|
132
|
+
)
|
|
133
|
+
_DOWNLOAD_FAILED = "download failed; see the host log"
|
|
134
|
+
_SIZE_UNKNOWN = "the sandbox could not report the file's size"
|
|
135
|
+
_TOO_MANY_FILES = "the batch has more files than {direction}.max_files allows"
|
|
136
|
+
_NO_SHELL_ROAD = (
|
|
137
|
+
"the path is outside the storage base and the backend cannot run a bounded command to reach it"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
_OVER_FILE_CAP = "the file is larger than {direction}.max_bytes_per_file"
|
|
141
|
+
_OVER_TOTAL_CAP = "the batch would exceed {direction}.max_total_bytes"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def deepagents_spec(
|
|
145
|
+
image: str | None = None,
|
|
146
|
+
*,
|
|
147
|
+
image_id: str | None = None,
|
|
148
|
+
egress_allow: tuple[str | EgressRule, ...] = (),
|
|
149
|
+
work_dir: str | None = DEFAULT_WORK_DIR,
|
|
150
|
+
files_in: TransferLimits = DEFAULT_TRANSFER_LIMITS,
|
|
151
|
+
files_out: TransferLimits = DEFAULT_TRANSFER_LIMITS,
|
|
152
|
+
min_isolation: Isolation | None = None,
|
|
153
|
+
kind: str = DEEPAGENTS_KIND,
|
|
154
|
+
) -> SandboxSpec:
|
|
155
|
+
"""The spec a Deep Agents sandbox asks for.
|
|
156
|
+
|
|
157
|
+
Egress is derived, never passed: named hosts run :data:`~maf_sandbox.Egress.ALLOWLIST` with
|
|
158
|
+
those hosts as the payload, and none runs :data:`~maf_sandbox.Egress.CLOSED`. The open
|
|
159
|
+
posture is not expressible, because the agent writes the shell commands this sandbox runs.
|
|
160
|
+
|
|
161
|
+
``work_dir`` names the base the agent's file paths resolve under. It is the one field of
|
|
162
|
+
the spec :class:`MafSandbox` refuses ``None`` for.
|
|
163
|
+
"""
|
|
164
|
+
return SandboxSpec(
|
|
165
|
+
kind=kind,
|
|
166
|
+
image=image,
|
|
167
|
+
image_id=image_id,
|
|
168
|
+
egress_allow=tuple(egress_allow),
|
|
169
|
+
egress=Egress.ALLOWLIST if egress_allow else Egress.CLOSED,
|
|
170
|
+
work_dir=work_dir,
|
|
171
|
+
requires=REQUIRED_CAPABILITIES,
|
|
172
|
+
files_in=files_in,
|
|
173
|
+
files_out=files_out,
|
|
174
|
+
min_isolation=min_isolation,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
#: One loop on a thread of its own for the synchronous surface, shared by every adapter in
|
|
179
|
+
#: the process: Deep Agents calls that surface from sync tools, which LangGraph runs on a
|
|
180
|
+
#: worker thread with no loop, and a backend may cache a client per loop (ACAS does).
|
|
181
|
+
_SYNC = SyncRunner(thread_name="maf-sandbox-deepagents")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _response(result: ExecResult, *, max_output_bytes: int | None = None) -> ExecuteResponse:
|
|
185
|
+
"""One combined stream, the way Deep Agents' own backends render it.
|
|
186
|
+
|
|
187
|
+
Each ``stderr`` line is prefixed so the model can tell the two apart, and the prefix says
|
|
188
|
+
whose words they are: a producer that took the field is speaking there, not the program.
|
|
189
|
+
The prefixes grow the stream, so it is held to ``max_output_bytes`` again once rendered:
|
|
190
|
+
the budget is what reaches the model, and a stream of short lines must not step over it.
|
|
191
|
+
"""
|
|
192
|
+
parts: list[str] = []
|
|
193
|
+
if result.stdout:
|
|
194
|
+
parts.append(result.stdout)
|
|
195
|
+
if result.stderr:
|
|
196
|
+
label = "[note]" if result.producer_owns_stderr else "[stderr]"
|
|
197
|
+
parts.extend(f"{label} {line}" for line in result.stderr.strip().splitlines())
|
|
198
|
+
output = "\n".join(parts) if parts else "<no output>"
|
|
199
|
+
if max_output_bytes is not None and len(output.encode("utf-8")) > max_output_bytes:
|
|
200
|
+
return ExecuteResponse(
|
|
201
|
+
output=_OUTPUT_DROPPED.format(limit=max_output_bytes), exit_code=None, truncated=True
|
|
202
|
+
)
|
|
203
|
+
return ExecuteResponse(output=output, exit_code=result.exit_code, truncated=False)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclasses.dataclass
|
|
207
|
+
class _Call:
|
|
208
|
+
"""One admitted operation: its owner token, its admission, and the sandbox it acquired."""
|
|
209
|
+
|
|
210
|
+
owner: str
|
|
211
|
+
admission: Any
|
|
212
|
+
sandbox: Sandbox | None = None
|
|
213
|
+
condemned: bool = False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class _BatchLost(Exception):
|
|
217
|
+
"""The sandbox went in the middle of a batch; ``response`` answers the file that took it."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, response: FileDownloadResponse | None = None) -> None:
|
|
220
|
+
super().__init__()
|
|
221
|
+
self.response = response
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
#: Deep Agents' code for each refusal the core's file roads name.
|
|
225
|
+
_CODES = {
|
|
226
|
+
FileRefusal.NOT_FOUND: FILE_NOT_FOUND,
|
|
227
|
+
FileRefusal.IS_DIRECTORY: IS_DIRECTORY,
|
|
228
|
+
FileRefusal.PERMISSION_DENIED: PERMISSION_DENIED,
|
|
229
|
+
FileRefusal.INVALID_PATH: INVALID_PATH,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class MafSandbox(BaseSandbox):
|
|
234
|
+
"""A :class:`~maf_sandbox.SandboxRouter` as a Deep Agents sandbox.
|
|
235
|
+
|
|
236
|
+
One instance serves one conversation: ``key`` is the host's scope, thread and agent
|
|
237
|
+
directory, read from the host's request context and never from the model. The sandbox is
|
|
238
|
+
acquired on first use — get-or-create, so it stays warm across turns — and lives until the
|
|
239
|
+
host disposes it: :meth:`aclose` here, or the router's ``dispose_scope`` on the host's own
|
|
240
|
+
conversation-delete path, which is what every other sandbox in the suite answers to.
|
|
241
|
+
|
|
242
|
+
Construction refuses what the router refuses — no backend at all, one below the isolation
|
|
243
|
+
floor, one that cannot enforce the spec's egress mode, a missing capability — so a
|
|
244
|
+
misconfigured host fails before an agent is built, not on its first command.
|
|
245
|
+
|
|
246
|
+
Paths are guest paths, as they are for every sandbox Deep Agents ships: the agent's file
|
|
247
|
+
tools name them absolutely. Inside ``spec.work_dir`` the adapter's upload and download go
|
|
248
|
+
through the backend's file plane; outside it, where Deep Agents keeps its offloaded history
|
|
249
|
+
and its large-edit temporaries, they go through the shell the agent already has, in base64
|
|
250
|
+
chunks, under the same caps. The host puts ``spec.work_dir`` in the prompt so the model
|
|
251
|
+
knows where its own files are; a spec leaving the base to the backend is refused, because
|
|
252
|
+
nothing could then tell the model.
|
|
253
|
+
|
|
254
|
+
Deep Agents' derived file tools (``ls``, ``read_file``, ``write_file``, ``edit_file``,
|
|
255
|
+
``glob``, ``grep``) run ``python3`` inside the guest, ``write_file`` for the preflight that
|
|
256
|
+
creates the parent directory before it uploads; on an image without it only ``execute``,
|
|
257
|
+
``delete`` and this class's own upload and download work. The image is the host's to choose.
|
|
258
|
+
|
|
259
|
+
Every command runs under ``max_output_bytes``, enforced by the backend before it buffers
|
|
260
|
+
the output: the model writes the command, so what it prints is bounded on the host or the
|
|
261
|
+
command does not run.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
router: SandboxRouter,
|
|
267
|
+
key: SandboxKey,
|
|
268
|
+
spec: SandboxSpec,
|
|
269
|
+
*,
|
|
270
|
+
exec_timeout_seconds: float = DEFAULT_EXEC_TIMEOUT_SECONDS,
|
|
271
|
+
max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
272
|
+
) -> None:
|
|
273
|
+
missing = REQUIRED_CAPABILITIES - spec.requires
|
|
274
|
+
if missing:
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"the {spec.kind!r} spec does not require {sorted(str(c) for c in missing)}, "
|
|
277
|
+
"which Deep Agents' execute, write and download tools need; build it with "
|
|
278
|
+
"deepagents_spec()"
|
|
279
|
+
)
|
|
280
|
+
if spec.isolation_scope is not IsolationScope.CONVERSATION:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
"a Deep Agents sandbox serves a whole conversation, so the spec cannot ask for "
|
|
283
|
+
f"isolation_scope={spec.isolation_scope!r}"
|
|
284
|
+
)
|
|
285
|
+
if key.call_id:
|
|
286
|
+
raise ValueError("key.call_id must be empty: one sandbox serves the conversation")
|
|
287
|
+
if spec.egress is Egress.UNRESTRICTED:
|
|
288
|
+
# `deepagents_spec` cannot express it; a spec built another way must not either.
|
|
289
|
+
raise ValueError(
|
|
290
|
+
"spec.egress must be CLOSED or ALLOWLIST: the model writes the commands, so an "
|
|
291
|
+
"open network is not a posture this sandbox takes"
|
|
292
|
+
)
|
|
293
|
+
if spec.work_dir is None:
|
|
294
|
+
# Deep Agents' file tools take guest paths the model spells out, so the host has to
|
|
295
|
+
# be able to tell it the base; a backend-allocated one is knowable to neither.
|
|
296
|
+
raise ValueError(
|
|
297
|
+
"spec.work_dir must name the storage base: Deep Agents addresses files by "
|
|
298
|
+
"guest path, and a base the backend allocates is one nothing can tell the model"
|
|
299
|
+
)
|
|
300
|
+
# The backends' own rule for a named base, applied here so a relative, empty or
|
|
301
|
+
# NUL-bearing base is a host configuration error at construction, not a sandbox
|
|
302
|
+
# unavailable on the first command.
|
|
303
|
+
posix_work_dir_ancestors(spec.work_dir)
|
|
304
|
+
if not math.isfinite(exec_timeout_seconds) or exec_timeout_seconds <= 0:
|
|
305
|
+
raise ValueError("exec_timeout_seconds must be a finite positive number of seconds")
|
|
306
|
+
if type(max_output_bytes) is not int or max_output_bytes <= 0:
|
|
307
|
+
raise ValueError("max_output_bytes must be a positive integer of bytes")
|
|
308
|
+
if not router.enabled:
|
|
309
|
+
raise NoSandboxBackend("no sandbox backend is configured")
|
|
310
|
+
router.ensure_can_serve(spec)
|
|
311
|
+
self._router = router
|
|
312
|
+
self._key = key
|
|
313
|
+
self._spec = spec
|
|
314
|
+
self._timeout = float(exec_timeout_seconds)
|
|
315
|
+
self._max_output_bytes = max_output_bytes
|
|
316
|
+
#: The engine instance the last acquire handed back, and what `aclose` deletes.
|
|
317
|
+
self._instance_id: str | None = None
|
|
318
|
+
backend = router.backend_for(spec)
|
|
319
|
+
identity = [
|
|
320
|
+
# The backend object, not only its name: two backends may carry one name and
|
|
321
|
+
# reach two engines (two Docker daemons, two ACA resources), and Deep Agents asks
|
|
322
|
+
# for an id unique to the backend it is given. The object lives as long as the
|
|
323
|
+
# router this adapter holds, so its identity cannot be reused under it.
|
|
324
|
+
"" if backend is None else f"{backend.name}@{id(backend):x}",
|
|
325
|
+
key.scope,
|
|
326
|
+
key.thread_id,
|
|
327
|
+
key.agent_dir,
|
|
328
|
+
spec.kind,
|
|
329
|
+
str(spec.egress),
|
|
330
|
+
sorted(str(entry) for entry in spec.egress_allow),
|
|
331
|
+
]
|
|
332
|
+
digest = hashlib.sha256(json.dumps(identity, separators=(",", ":")).encode()).hexdigest()
|
|
333
|
+
# The id names the sandbox, as a provider's own id would: two adapters over one key,
|
|
334
|
+
# kind, backend and egress posture reach one sandbox through the router's get-or-create,
|
|
335
|
+
# and say so; a backend keys a container on the egress posture too. Opaque rather than
|
|
336
|
+
# the key spelled out, because Deep Agents may render it to the model and a scope is
|
|
337
|
+
# often a tenant or a user.
|
|
338
|
+
self._id = f"maf-sandbox-{digest[:16]}"
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def id(self) -> str:
|
|
342
|
+
return self._id
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def key(self) -> SandboxKey:
|
|
346
|
+
"""What this sandbox is keyed by."""
|
|
347
|
+
return self._key
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def spec(self) -> SandboxSpec:
|
|
351
|
+
"""The sandbox this instance asks the router for."""
|
|
352
|
+
return self._spec
|
|
353
|
+
|
|
354
|
+
@property
|
|
355
|
+
def router(self) -> SandboxRouter:
|
|
356
|
+
"""The router serving this sandbox."""
|
|
357
|
+
return self._router
|
|
358
|
+
|
|
359
|
+
async def _open(self, bound: float) -> tuple[_Call, Sandbox] | None:
|
|
360
|
+
"""Admit a call and acquire its sandbox under one deadline.
|
|
361
|
+
|
|
362
|
+
Admission is the router's call lifecycle, shared by every call over this key from
|
|
363
|
+
this adapter or another: a delete one call queued runs when the last of them leaves,
|
|
364
|
+
and a new call waits for it, so nothing in flight is cut off and nothing reaches an
|
|
365
|
+
instance being deleted. ``None`` when the sandbox is unavailable, with the reason in
|
|
366
|
+
the log; ``TimeoutError`` when the deadline passed.
|
|
367
|
+
"""
|
|
368
|
+
owner = uuid.uuid4().hex
|
|
369
|
+
call: _Call | None = None
|
|
370
|
+
try:
|
|
371
|
+
async with asyncio.timeout(bound):
|
|
372
|
+
try:
|
|
373
|
+
admission = await self._router.enter_call(
|
|
374
|
+
self._key, self._spec, owner=owner, timeout=bound
|
|
375
|
+
)
|
|
376
|
+
except TimeoutError:
|
|
377
|
+
raise
|
|
378
|
+
except Exception:
|
|
379
|
+
logger.exception("%s: the call was not admitted", self._id)
|
|
380
|
+
return None
|
|
381
|
+
call = _Call(owner, admission)
|
|
382
|
+
try:
|
|
383
|
+
sandbox = await self._router.acquire(
|
|
384
|
+
self._key, self._spec, _admission=admission
|
|
385
|
+
)
|
|
386
|
+
except asyncio.CancelledError:
|
|
387
|
+
# The deadline, or the caller leaving: the admission must not outlive
|
|
388
|
+
# the call, or an exclusive close and every queued delete wait on it.
|
|
389
|
+
await self._leave(call, deferred=True)
|
|
390
|
+
call = None
|
|
391
|
+
raise
|
|
392
|
+
except Exception:
|
|
393
|
+
logger.exception("%s: the sandbox could not be acquired", self._id)
|
|
394
|
+
await self._leave(call)
|
|
395
|
+
return None
|
|
396
|
+
except TimeoutError:
|
|
397
|
+
if call is not None:
|
|
398
|
+
await self._leave(call, deferred=True)
|
|
399
|
+
raise
|
|
400
|
+
self._instance_id = sandbox.instance_id
|
|
401
|
+
call.sandbox = sandbox
|
|
402
|
+
return call, sandbox
|
|
403
|
+
|
|
404
|
+
def _condemn(self, call: _Call) -> None:
|
|
405
|
+
"""Queue the instance's delete with the router, for when the last call over this key
|
|
406
|
+
leaves. `aclose` keeps naming the instance: a delete that failed is retried there, and
|
|
407
|
+
one that landed makes that a no-op under the protocol."""
|
|
408
|
+
if call.condemned or call.sandbox is None:
|
|
409
|
+
return
|
|
410
|
+
call.condemned = True
|
|
411
|
+
self._router.queue_cleanup(
|
|
412
|
+
self._key,
|
|
413
|
+
self._spec,
|
|
414
|
+
admission=call.admission,
|
|
415
|
+
sandbox=call.sandbox,
|
|
416
|
+
owner=call.owner,
|
|
417
|
+
rung=Cleanup.DISPOSE,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
def _log_unfinished(
|
|
421
|
+
self, what: str, path: str, unfinished: SandboxShellTransferUnfinished
|
|
422
|
+
) -> None:
|
|
423
|
+
"""A timeout or an overflow is a warning; a failure that kept the result from coming
|
|
424
|
+
back is an error, with the provider's words and traceback for the host."""
|
|
425
|
+
expected = isinstance(unfinished.__cause__, (TimeoutError, SandboxExecOutputLimitExceeded))
|
|
426
|
+
logger.log(
|
|
427
|
+
logging.WARNING if expected else logging.ERROR,
|
|
428
|
+
"%s: %s of %r did not finish: %s",
|
|
429
|
+
self._id,
|
|
430
|
+
what,
|
|
431
|
+
path,
|
|
432
|
+
unfinished,
|
|
433
|
+
exc_info=not expected,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
async def _leave(self, call: _Call, *, deferred: bool = False) -> None:
|
|
437
|
+
"""Release the admission; the last call out runs whatever delete was queued.
|
|
438
|
+
|
|
439
|
+
After an unfinished command that release goes to the process's own loop, so the
|
|
440
|
+
delete never extends the caller's wait and outlives a loop ``asyncio.run`` closes on
|
|
441
|
+
return; the next call over the key is admitted once it is done.
|
|
442
|
+
"""
|
|
443
|
+
if not (call.condemned or deferred):
|
|
444
|
+
await self._router.release_call(self._key, self._spec.kind, owner=call.owner)
|
|
445
|
+
return
|
|
446
|
+
# `SyncRunner.submit` takes the coroutine itself and runs it on the process's loop.
|
|
447
|
+
released = _SYNC.submit(
|
|
448
|
+
self._router.release_call(self._key, self._spec.kind, owner=call.owner)
|
|
449
|
+
)
|
|
450
|
+
released.add_done_callback(self._left)
|
|
451
|
+
|
|
452
|
+
def _left(self, future: concurrent.futures.Future[None]) -> None:
|
|
453
|
+
if future.cancelled():
|
|
454
|
+
logger.warning("%s: the release after an unfinished command was cancelled", self._id)
|
|
455
|
+
elif (failure := future.exception()) is not None:
|
|
456
|
+
logger.error(
|
|
457
|
+
"%s: the release after an unfinished command failed: %s", self._id, failure
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
async def _run_bounded(
|
|
461
|
+
self, call: _Call, command: str, *, timeout: float, max_output_bytes: int
|
|
462
|
+
) -> ExecResult:
|
|
463
|
+
"""``exec_bounded``, with the instance condemned whenever the command's end is unknown.
|
|
464
|
+
|
|
465
|
+
A timeout says the wait ended, an overflow that the host stopped reading, a
|
|
466
|
+
cancellation that the caller left, and any other failure that the result did not come
|
|
467
|
+
back: none says the guest process stopped, and the backends do not establish it
|
|
468
|
+
either, so the instance goes before anything reuses it.
|
|
469
|
+
"""
|
|
470
|
+
sandbox = call.sandbox
|
|
471
|
+
assert isinstance(sandbox, BoundedExec)
|
|
472
|
+
try:
|
|
473
|
+
return await sandbox.exec_bounded(
|
|
474
|
+
command,
|
|
475
|
+
working_directory=STORAGE_BASE,
|
|
476
|
+
timeout=timeout,
|
|
477
|
+
max_output_bytes=max_output_bytes,
|
|
478
|
+
)
|
|
479
|
+
except BaseException:
|
|
480
|
+
self._condemn(call)
|
|
481
|
+
raise
|
|
482
|
+
|
|
483
|
+
def _inside_base(self, path: str) -> bool:
|
|
484
|
+
"""Whether ``path`` names something under the storage base, the file plane's reach."""
|
|
485
|
+
if not path.startswith("/"):
|
|
486
|
+
return True
|
|
487
|
+
# Both sides normalized: a spec may spell the base with `..`, and the backends resolve
|
|
488
|
+
# it before they confine to it.
|
|
489
|
+
base = PurePosixPath(posixpath.normpath(self._spec.work_dir or "/"))
|
|
490
|
+
normalized = PurePosixPath(posixpath.normpath(path))
|
|
491
|
+
return normalized == base or base in normalized.parents
|
|
492
|
+
|
|
493
|
+
# --- execute ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
async def aexecute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
496
|
+
if not command or not isinstance(command, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
497
|
+
return ExecuteResponse(output="Error: Command must be a non-empty string.", exit_code=1)
|
|
498
|
+
bound = self._timeout if timeout is None else float(timeout)
|
|
499
|
+
if not math.isfinite(bound) or bound <= 0:
|
|
500
|
+
raise ValueError(f"timeout must be a finite positive number of seconds, got {timeout}")
|
|
501
|
+
# One deadline over the acquire and the command: a cold create spends part of the
|
|
502
|
+
# budget Deep Agents defines as the wait for the command, is cut off if it outlives
|
|
503
|
+
# it, and the command gets the rest.
|
|
504
|
+
started = time.monotonic()
|
|
505
|
+
try:
|
|
506
|
+
opened = await self._open(bound)
|
|
507
|
+
except TimeoutError:
|
|
508
|
+
return ExecuteResponse(output=_TIMED_OUT.format(seconds=bound), exit_code=None)
|
|
509
|
+
if opened is None:
|
|
510
|
+
return ExecuteResponse(output=SANDBOX_UNAVAILABLE, exit_code=None)
|
|
511
|
+
call, sandbox = opened
|
|
512
|
+
try:
|
|
513
|
+
if not isinstance(sandbox, BoundedExec):
|
|
514
|
+
logger.error("%s: the backend has no exec_bounded, so no command runs", self._id)
|
|
515
|
+
return ExecuteResponse(output=_UNBOUNDED, exit_code=None)
|
|
516
|
+
remaining = bound - (time.monotonic() - started)
|
|
517
|
+
if remaining <= 0:
|
|
518
|
+
return ExecuteResponse(output=_TIMED_OUT.format(seconds=bound), exit_code=None)
|
|
519
|
+
try:
|
|
520
|
+
result = await self._run_bounded(
|
|
521
|
+
call, command, timeout=remaining, max_output_bytes=self._max_output_bytes
|
|
522
|
+
)
|
|
523
|
+
except TimeoutError:
|
|
524
|
+
return ExecuteResponse(output=_TIMED_OUT.format(seconds=bound), exit_code=None)
|
|
525
|
+
except SandboxExecOutputLimitExceeded:
|
|
526
|
+
return ExecuteResponse(
|
|
527
|
+
output=_OUTPUT_DROPPED.format(limit=self._max_output_bytes),
|
|
528
|
+
exit_code=None,
|
|
529
|
+
truncated=True,
|
|
530
|
+
)
|
|
531
|
+
except Exception:
|
|
532
|
+
logger.exception("%s: the command's result could not be read", self._id)
|
|
533
|
+
return ExecuteResponse(output=_EXEC_FAILED, exit_code=None)
|
|
534
|
+
return _response(result, max_output_bytes=self._max_output_bytes)
|
|
535
|
+
finally:
|
|
536
|
+
await self._leave(call)
|
|
537
|
+
|
|
538
|
+
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
539
|
+
return _SYNC.run(self.aexecute(command, timeout=timeout))
|
|
540
|
+
|
|
541
|
+
# --- files in --------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
async def aupload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
544
|
+
if not files:
|
|
545
|
+
# Nothing to land: no call, so no sandbox is created for a batch of none.
|
|
546
|
+
return []
|
|
547
|
+
limits = self._spec.files_in
|
|
548
|
+
if len(files) > limits.max_files:
|
|
549
|
+
refusal = _TOO_MANY_FILES.format(direction="files_in")
|
|
550
|
+
return [FileUploadResponse(path=path, error=refusal) for path, _ in files]
|
|
551
|
+
try:
|
|
552
|
+
opened = await self._open(self._timeout)
|
|
553
|
+
except TimeoutError:
|
|
554
|
+
opened = None
|
|
555
|
+
if opened is None:
|
|
556
|
+
return [FileUploadResponse(path=path, error=_UPLOAD_FAILED) for path, _ in files]
|
|
557
|
+
call, sandbox = opened
|
|
558
|
+
try:
|
|
559
|
+
responses: list[FileUploadResponse] = []
|
|
560
|
+
sent = 0
|
|
561
|
+
for path, content in files:
|
|
562
|
+
# Checked before the write it would have prevented, and counted only for what
|
|
563
|
+
# crossed: a refused file leaves the budget where it was.
|
|
564
|
+
error: str | None
|
|
565
|
+
try:
|
|
566
|
+
if len(content) > limits.max_bytes_per_file:
|
|
567
|
+
error = _OVER_FILE_CAP.format(direction="files_in")
|
|
568
|
+
elif sent + len(content) > limits.max_total_bytes:
|
|
569
|
+
error = _OVER_TOTAL_CAP.format(direction="files_in")
|
|
570
|
+
elif self._inside_base(path):
|
|
571
|
+
error = await self._upload_via_plane(call, sandbox, path, content)
|
|
572
|
+
elif isinstance(sandbox, BoundedExec):
|
|
573
|
+
error = await self._upload_via_shell(call, sandbox, path, content)
|
|
574
|
+
else:
|
|
575
|
+
error = _NO_SHELL_ROAD
|
|
576
|
+
except _BatchLost:
|
|
577
|
+
return [FileUploadResponse(path=p, error=_UPLOAD_BATCH_LOST) for p, _ in files]
|
|
578
|
+
if error is None:
|
|
579
|
+
sent += len(content)
|
|
580
|
+
responses.append(FileUploadResponse(path=path, error=error))
|
|
581
|
+
return responses
|
|
582
|
+
finally:
|
|
583
|
+
await self._leave(call)
|
|
584
|
+
|
|
585
|
+
async def _upload_via_plane(
|
|
586
|
+
self, call: _Call, sandbox: Sandbox, path: str, content: bytes
|
|
587
|
+
) -> str | None:
|
|
588
|
+
"""Write under the base through the file plane; the error code, or ``None``.
|
|
589
|
+
|
|
590
|
+
A write the plane did not refuse and did not finish — a transport failure, the caller
|
|
591
|
+
leaving — may have left part of the file, and the plane does not promise otherwise, so
|
|
592
|
+
the instance is condemned and the batch ends, as on the shell road.
|
|
593
|
+
"""
|
|
594
|
+
try:
|
|
595
|
+
await sandbox.write_file(path, content, working_directory=STORAGE_BASE)
|
|
596
|
+
except asyncio.CancelledError:
|
|
597
|
+
self._condemn(call)
|
|
598
|
+
raise
|
|
599
|
+
except Exception as failed:
|
|
600
|
+
refusal = file_refusal(failed)
|
|
601
|
+
if refusal is None:
|
|
602
|
+
logger.exception("%s: upload of %r failed", self._id, path)
|
|
603
|
+
self._condemn(call)
|
|
604
|
+
raise _BatchLost() from None
|
|
605
|
+
logger.info("%s: upload of %r refused: %s", self._id, path, failed)
|
|
606
|
+
return _CODES[refusal]
|
|
607
|
+
return None
|
|
608
|
+
|
|
609
|
+
async def _upload_via_shell(
|
|
610
|
+
self, call: _Call, sandbox: BoundedExec, path: str, content: bytes
|
|
611
|
+
) -> str | None:
|
|
612
|
+
"""Write outside the base through the shell the agent already has.
|
|
613
|
+
|
|
614
|
+
Deep Agents writes its offloaded history under ``/conversation_history`` and its
|
|
615
|
+
large-edit temporaries under ``/tmp``, which the file plane, confined to the base,
|
|
616
|
+
cannot reach; ``execute`` can, so this widens nothing. The caps were applied by the
|
|
617
|
+
caller. The road is the core's, and lands the file whole or not at all; a command
|
|
618
|
+
whose end is unknown condemns the instance and ends the batch, with every file it
|
|
619
|
+
had already put there.
|
|
620
|
+
"""
|
|
621
|
+
try:
|
|
622
|
+
await write_file_over_exec(
|
|
623
|
+
sandbox, path, content, working_directory=STORAGE_BASE, timeout=self._timeout
|
|
624
|
+
)
|
|
625
|
+
except SandboxFileRefused as refused:
|
|
626
|
+
logger.info("%s: shell write of %r refused: %s", self._id, path, refused.detail)
|
|
627
|
+
return _CODES[refused.refusal]
|
|
628
|
+
except SandboxShellTransferUnfinished as unfinished:
|
|
629
|
+
self._log_unfinished("shell write", path, unfinished)
|
|
630
|
+
self._condemn(call)
|
|
631
|
+
raise _BatchLost() from None
|
|
632
|
+
except SandboxShellTransferFailed as failed:
|
|
633
|
+
# The command ended and said something the road does not read as a refusal: a
|
|
634
|
+
# missing utility, an I/O error. The file did not land; the sandbox is whole.
|
|
635
|
+
logger.warning("%s: shell write of %r failed: %s", self._id, path, failed)
|
|
636
|
+
return _UPLOAD_FAILED
|
|
637
|
+
except asyncio.CancelledError:
|
|
638
|
+
self._condemn(call)
|
|
639
|
+
raise
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
|
643
|
+
return _SYNC.run(self.aupload_files(files))
|
|
644
|
+
|
|
645
|
+
# --- files out -------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
async def _download(
|
|
648
|
+
self, call: _Call, sandbox: Sandbox, path: str, *, room: int
|
|
649
|
+
) -> FileDownloadResponse:
|
|
650
|
+
"""One file, read under the smaller of the per-file cap and ``room``, the batch's rest."""
|
|
651
|
+
per_file = self._spec.files_out.max_bytes_per_file
|
|
652
|
+
cap = min(per_file, room)
|
|
653
|
+
# Which ceiling `cap` is: read off the cap handed down, never off a stat that a
|
|
654
|
+
# growing file has already made stale.
|
|
655
|
+
over_cap = (_OVER_FILE_CAP if cap == per_file else _OVER_TOTAL_CAP).format(
|
|
656
|
+
direction="files_out"
|
|
657
|
+
)
|
|
658
|
+
if not self._inside_base(path):
|
|
659
|
+
if not isinstance(sandbox, BoundedExec):
|
|
660
|
+
return FileDownloadResponse(path=path, error=_NO_SHELL_ROAD)
|
|
661
|
+
return await self._download_via_shell(call, sandbox, path, cap=cap, over_cap=over_cap)
|
|
662
|
+
try:
|
|
663
|
+
entry = await sandbox.stat_file(path, working_directory=STORAGE_BASE)
|
|
664
|
+
except TimeoutError:
|
|
665
|
+
# Not condemned: a read changes nothing in the sandbox, and the bound is the
|
|
666
|
+
# backend's own, its refusal of an entry it cannot serve (ACAS reads a FIFO the
|
|
667
|
+
# guest planted as a regular file until the bound). The shell road condemns
|
|
668
|
+
# because a command whose end is unknown may still be running.
|
|
669
|
+
logger.warning("%s: stat of %r timed out", self._id, path)
|
|
670
|
+
return FileDownloadResponse(path=path, error=_DOWNLOAD_FAILED)
|
|
671
|
+
except Exception as failed:
|
|
672
|
+
refusal = file_refusal(failed)
|
|
673
|
+
if refusal is None:
|
|
674
|
+
raise
|
|
675
|
+
logger.info("%s: download of %r refused: %s", self._id, path, failed)
|
|
676
|
+
return FileDownloadResponse(path=path, error=_CODES[refusal])
|
|
677
|
+
refusal = entry_refusal(entry)
|
|
678
|
+
if entry is None or refusal is not None:
|
|
679
|
+
return FileDownloadResponse(path=path, error=_CODES[refusal or FileRefusal.NOT_FOUND])
|
|
680
|
+
# `None` fails closed: an unknown size read as free is how a cap stops bounding.
|
|
681
|
+
if entry.size_bytes is None:
|
|
682
|
+
return FileDownloadResponse(path=path, error=_SIZE_UNKNOWN)
|
|
683
|
+
if entry.size_bytes > cap:
|
|
684
|
+
return FileDownloadResponse(path=path, error=over_cap)
|
|
685
|
+
# A batch that has spent its total leaves no room, and the plane's cap must be
|
|
686
|
+
# positive; one byte lets an empty file through and the re-count below refuse the rest.
|
|
687
|
+
try:
|
|
688
|
+
content = await sandbox.read_file(
|
|
689
|
+
path, working_directory=STORAGE_BASE, max_bytes=max(cap, 1)
|
|
690
|
+
)
|
|
691
|
+
except SandboxTransferCapExceeded:
|
|
692
|
+
return FileDownloadResponse(path=path, error=over_cap)
|
|
693
|
+
except TimeoutError:
|
|
694
|
+
# Not condemned, for the reason given at the stat: the file is failed alone.
|
|
695
|
+
logger.warning("%s: read of %r timed out", self._id, path)
|
|
696
|
+
return FileDownloadResponse(path=path, error=_DOWNLOAD_FAILED)
|
|
697
|
+
except Exception as failed:
|
|
698
|
+
refusal = file_refusal(failed)
|
|
699
|
+
if refusal is None:
|
|
700
|
+
raise
|
|
701
|
+
logger.info("%s: download of %r refused: %s", self._id, path, failed)
|
|
702
|
+
return FileDownloadResponse(path=path, error=_CODES[refusal])
|
|
703
|
+
if len(content) > cap:
|
|
704
|
+
# The protocol has the caller re-count: a file can grow after the stat, and a
|
|
705
|
+
# backend whose SDK buffers the whole response can only refuse after the fact.
|
|
706
|
+
return FileDownloadResponse(path=path, error=over_cap)
|
|
707
|
+
return FileDownloadResponse(path=path, content=content)
|
|
708
|
+
|
|
709
|
+
async def _download_via_shell(
|
|
710
|
+
self, call: _Call, sandbox: BoundedExec, path: str, *, cap: int, over_cap: str
|
|
711
|
+
) -> FileDownloadResponse:
|
|
712
|
+
"""Read outside the base through the shell, under ``cap``; see :meth:`_upload_via_shell`."""
|
|
713
|
+
try:
|
|
714
|
+
# A batch that has spent its total leaves no room; one byte keeps the probe's
|
|
715
|
+
# classification and lets an empty file through, and the re-count refuses the rest.
|
|
716
|
+
content = await read_file_over_exec(
|
|
717
|
+
sandbox,
|
|
718
|
+
path,
|
|
719
|
+
working_directory=STORAGE_BASE,
|
|
720
|
+
timeout=self._timeout,
|
|
721
|
+
max_bytes=max(cap, 1),
|
|
722
|
+
)
|
|
723
|
+
except SandboxFileRefused as refused:
|
|
724
|
+
logger.info("%s: shell read of %r refused: %s", self._id, path, refused.detail)
|
|
725
|
+
return FileDownloadResponse(path=path, error=_CODES[refused.refusal])
|
|
726
|
+
except SandboxTransferCapExceeded:
|
|
727
|
+
return FileDownloadResponse(path=path, error=over_cap)
|
|
728
|
+
except SandboxShellTransferUnfinished as unfinished:
|
|
729
|
+
# The command may still be running: the instance goes, and the rest of the batch
|
|
730
|
+
# is not attempted. A read its own budget ended is over the cap, and says so.
|
|
731
|
+
self._log_unfinished("shell read", path, unfinished)
|
|
732
|
+
self._condemn(call)
|
|
733
|
+
error = over_cap if unfinished.over_cap else _DOWNLOAD_BATCH_LOST
|
|
734
|
+
raise _BatchLost(FileDownloadResponse(path=path, error=error)) from None
|
|
735
|
+
except SandboxShellTransferFailed as failed:
|
|
736
|
+
logger.warning("%s: shell read of %r failed: %s", self._id, path, failed)
|
|
737
|
+
return FileDownloadResponse(path=path, error=_DOWNLOAD_FAILED)
|
|
738
|
+
except asyncio.CancelledError:
|
|
739
|
+
self._condemn(call)
|
|
740
|
+
raise
|
|
741
|
+
if len(content) > cap:
|
|
742
|
+
return FileDownloadResponse(path=path, error=over_cap)
|
|
743
|
+
return FileDownloadResponse(path=path, content=content)
|
|
744
|
+
|
|
745
|
+
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
746
|
+
if not paths:
|
|
747
|
+
return []
|
|
748
|
+
limits = self._spec.files_out
|
|
749
|
+
if len(paths) > limits.max_files:
|
|
750
|
+
refusal = _TOO_MANY_FILES.format(direction="files_out")
|
|
751
|
+
return [FileDownloadResponse(path=path, error=refusal) for path in paths]
|
|
752
|
+
try:
|
|
753
|
+
opened = await self._open(self._timeout)
|
|
754
|
+
except TimeoutError:
|
|
755
|
+
opened = None
|
|
756
|
+
if opened is None:
|
|
757
|
+
return [FileDownloadResponse(path=path, error=_DOWNLOAD_FAILED) for path in paths]
|
|
758
|
+
call, sandbox = opened
|
|
759
|
+
try:
|
|
760
|
+
responses: list[FileDownloadResponse] = []
|
|
761
|
+
room = limits.max_total_bytes
|
|
762
|
+
for path in paths:
|
|
763
|
+
try:
|
|
764
|
+
response = await self._download(call, sandbox, path, room=room)
|
|
765
|
+
except _BatchLost as lost:
|
|
766
|
+
responses.append(
|
|
767
|
+
lost.response or FileDownloadResponse(path=path, error=_DOWNLOAD_BATCH_LOST)
|
|
768
|
+
)
|
|
769
|
+
rest = paths[len(responses) :]
|
|
770
|
+
responses.extend(
|
|
771
|
+
FileDownloadResponse(path=p, error=_DOWNLOAD_BATCH_LOST) for p in rest
|
|
772
|
+
)
|
|
773
|
+
break
|
|
774
|
+
except Exception:
|
|
775
|
+
logger.exception("%s: download of %r failed", self._id, path)
|
|
776
|
+
response = FileDownloadResponse(path=path, error=_DOWNLOAD_FAILED)
|
|
777
|
+
if response.content is not None:
|
|
778
|
+
room -= len(response.content)
|
|
779
|
+
responses.append(response)
|
|
780
|
+
return responses
|
|
781
|
+
finally:
|
|
782
|
+
await self._leave(call)
|
|
783
|
+
|
|
784
|
+
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
|
785
|
+
return _SYNC.run(self.adownload_files(paths))
|
|
786
|
+
|
|
787
|
+
# --- lifecycle -------------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
async def aclose(self) -> bool:
|
|
790
|
+
"""Delete this conversation's sandbox; ``False`` when the delete did not land.
|
|
791
|
+
|
|
792
|
+
Admitted exclusively first, so every call over this key, from this adapter or another,
|
|
793
|
+
has left and a delete one of them queued has run. Then deletes the one instance this
|
|
794
|
+
adapter acquired, so a packaged kind serving the same conversation, or another adapter
|
|
795
|
+
over the same key with a different backend or egress, keeps its own; before any
|
|
796
|
+
acquire there is nothing of this adapter's to delete. An instance a sibling's queued
|
|
797
|
+
delete already took is named again here, which the protocol makes a no-op. The
|
|
798
|
+
router's ``dispose_scope`` on the host's conversation-delete path is the backstop for
|
|
799
|
+
a sandbox no ``aclose`` reached.
|
|
800
|
+
"""
|
|
801
|
+
owner = uuid.uuid4().hex
|
|
802
|
+
timeout = self._router.reclaim.timeout
|
|
803
|
+
try:
|
|
804
|
+
await self._router.enter_call(
|
|
805
|
+
self._key, self._spec, owner=owner, exclusive=True, timeout=timeout
|
|
806
|
+
)
|
|
807
|
+
except TimeoutError:
|
|
808
|
+
logger.warning("%s: close timed out waiting for the calls in flight", self._id)
|
|
809
|
+
return False
|
|
810
|
+
except Exception:
|
|
811
|
+
logger.exception("%s: close was not admitted", self._id)
|
|
812
|
+
return False
|
|
813
|
+
try:
|
|
814
|
+
instance_id = self._instance_id
|
|
815
|
+
if instance_id is None:
|
|
816
|
+
return True
|
|
817
|
+
disposed = await self._router.dispose_kind(
|
|
818
|
+
self._key, self._spec.kind, instance_id=instance_id, timeout=timeout
|
|
819
|
+
)
|
|
820
|
+
# Forgotten only once the delete landed, so a retry reaches the same instance;
|
|
821
|
+
# kept as is if an acquire replaced it meanwhile.
|
|
822
|
+
if disposed and self._instance_id == instance_id:
|
|
823
|
+
self._instance_id = None
|
|
824
|
+
return disposed
|
|
825
|
+
finally:
|
|
826
|
+
await self._router.release_call(self._key, self._spec.kind, owner=owner)
|
|
827
|
+
|
|
828
|
+
def close(self) -> bool:
|
|
829
|
+
"""Synchronous :meth:`aclose`."""
|
|
830
|
+
return _SYNC.run(self.aclose())
|
|
File without changes
|