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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/maf-sandbox-deepagents)](https://pypi.org/project/maf-sandbox-deepagents/) [![Python](https://img.shields.io/pypi/pyversions/maf-sandbox-deepagents)](https://pypi.org/project/maf-sandbox-deepagents/) [![License](https://img.shields.io/badge/license-MIT-green)](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
+ [![PyPI](https://img.shields.io/pypi/v/maf-sandbox-deepagents)](https://pypi.org/project/maf-sandbox-deepagents/) [![Python](https://img.shields.io/pypi/pyversions/maf-sandbox-deepagents)](https://pypi.org/project/maf-sandbox-deepagents/) [![License](https://img.shields.io/badge/license-MIT-green)](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())