This is an automated email from the ASF dual-hosted git repository.
kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 4d304d0876c Export files an agent built in a sandbox to object storage
(#73990)
4d304d0876c is described below
commit 4d304d0876cbe7d615502d5c90575a24b9fb95e4
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Oct 1 11:09:48 2026 +0100
Export files an agent built in a sandbox to object storage (#73990)
SandboxToolset(exports={sandbox_path: storage_url}) copies each file out
when the run ends and before the sandbox is destroyed, streaming through the
worker without passing the model's context or XCom. A promised file that
cannot be exported fails the task; a failed run exports nothing; a teardown
failure after a good export only logs.
Backends gain SandboxBackend.export_file, a concrete default that reads the
file in bounded slices through run_command, overridden to stream natively on
sbx and OpenSandbox.
* Leave export destinations untouched when an export fails
Each file is now copied to a sibling staging key and moved into place only
after every file has been copied, so a failed export no longer deletes or
truncates what was already at a destination. Exports are also bounded by a
total deadline that scales with max_export_bytes, so a guest trickling bytes
cannot hold the task open past the stall timeout.
---
providers/common/ai/docs/sandbox/backends.rst | 11 +-
providers/common/ai/docs/sandbox/configuration.rst | 59 +++-
providers/common/ai/docs/sandbox/index.rst | 5 +-
.../ai/example_dags/example_sandbox_toolset.py | 70 +++-
.../airflow/providers/common/ai/sandbox/base.py | 167 +++++++++-
.../providers/common/ai/sandbox/opensandbox.py | 71 +++-
.../src/airflow/providers/common/ai/sandbox/sbx.py | 90 +++++-
.../providers/common/ai/toolsets/sandbox.py | 209 +++++++++++-
.../tests/unit/common/ai/operators/test_agent.py | 15 +
.../ai/tests/unit/common/ai/sandbox/test_base.py | 163 +++++++++-
.../unit/common/ai/sandbox/test_opensandbox.py | 137 ++++++++
.../ai/tests/unit/common/ai/sandbox/test_sbx.py | 106 ++++++
.../tests/unit/common/ai/toolsets/test_sandbox.py | 356 +++++++++++++++++++++
13 files changed, 1414 insertions(+), 45 deletions(-)
diff --git a/providers/common/ai/docs/sandbox/backends.rst
b/providers/common/ai/docs/sandbox/backends.rst
index 1934ecd742a..a5a7b40472d 100644
--- a/providers/common/ai/docs/sandbox/backends.rst
+++ b/providers/common/ai/docs/sandbox/backends.rst
@@ -321,9 +321,14 @@ in. Subclass
:class:`~airflow.providers.common.ai.sandbox.SandboxBackend` in you
own package and pass an instance to ``SandboxToolset``.
**Three methods are required**: ``create``, ``run_command`` and ``destroy``.
The
-three file operations ship as defaults implemented over ``run_command``,
because
-reading, writing and listing a file are all expressible as shell commands.
-Override them only when the vendor has a native file API:
+file operations ship as defaults implemented over ``run_command``, because
+reading, writing, listing and exporting a file are all expressible as shell
+commands. The default ``export_file``, behind ``SandboxToolset(exports=...)``,
+copies a file in 4 MiB slices, one command each, and needs ``stat``, ``tail``,
+``head`` and ``base64`` in the guest. It relies on ``run_command`` returning
each
+slice's output intact, or setting ``stdout_truncated`` when it could not.
Override
+it when the vendor can stream a download, as the ``sbx`` and OpenSandbox
backends do. Override the others
+only when the vendor has a native file API:
.. code-block:: python
diff --git a/providers/common/ai/docs/sandbox/configuration.rst
b/providers/common/ai/docs/sandbox/configuration.rst
index d4e347f27cb..2647defde51 100644
--- a/providers/common/ai/docs/sandbox/configuration.rst
+++ b/providers/common/ai/docs/sandbox/configuration.rst
@@ -203,9 +203,49 @@ capped, 50 KiB per stream for ``run_command`` and
``max_read_bytes`` (5 MiB) per
``read_file``. Those caps are a budget rather than a transport limit: a 200 MB
file
reads out of a live sandbox in under ten seconds on the same path, but raising
the
cap costs roughly three times the file size in worker memory to show the model
50
-KiB of it. A file leaves through a task instead: give the agent a sandbox a
task
-owns, and read the file out through the backend after the run
-(:ref:`sandbox-attach`).
+KiB of it.
+
+A file leaves through ``exports`` instead. Name the paths the agent will write
and
+where each should land, and when the run ends the toolset copies them out
before
+it destroys the sandbox. The copy streams through the worker in bounded
pieces, so
+a file far over ``max_read_bytes`` is never held in worker memory whole, and
none
+of it passes through the model's context or XCom. The destinations are
templated
+when the toolset is passed through ``AgentOperator``, so each run writes its
own,
+and the ``run_command`` tool tells the model which files will be collected so
it
+writes them where they are expected. A local destination's missing directories
+are created; on object storage a key needs none:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_sandbox_toolset.py
+ :language: python
+ :start-after: [START howto_sandbox_agent_export]
+ :end-before: [END howto_sandbox_agent_export]
+
+A destination is anything :class:`~airflow.sdk.ObjectStoragePath` can open,
with
+credentials from ``export_conn_id``. Only a regular file is exported, up to
+``max_export_bytes`` (1 GiB by default). Two outcomes are kept apart:
+
+* **A promised file that cannot be exported fails the task.** The file is
missing,
+ is a directory, is over the limit, changed size while it was copied, took
longer
+ than the limit allows at 1 MiB/s, or the storage refused the write. Every
file is
+ first copied to a staging key beside its destination, named after it with a
+ ``.partial`` suffix, and the files are moved into place only once all of them
+ have been copied, so a failed export leaves every destination as it was. The
+ staging keys are removed, and one that cannot be is logged and named in the
+ error. The sandbox is still destroyed.
+* **A sandbox that cannot be destroyed afterwards does not.** The file is
delivered,
+ the failure is logged with the sandbox's name, and the backend's lifetime or
an
+ operator's sweep reclaims it.
+
+A run that fails exports nothing, and a run that never called a tool has no
+sandbox to export from, which fails the task the same way a missing file does.
+None of these touches the destinations, so a file an earlier try exported is
+still there: a consumer that runs whatever the outcome, such as one with
+``trigger_rule=TriggerRule.ALL_DONE``, should check that the task succeeded
before
+it trusts a file. A destination that renders to something other than a storage
+URL fails the task before the model runs.
+``exports`` cannot be combined with ``attach_to``: a sandbox another task owns
is
+read out by that task (:ref:`sandbox-attach`), which is also the shape to use
when
+the files must survive a failed run.
When the deliverable is a file and the Dag already knows the job, do not use an
agent for it at all. Drive a backend from a ``@task``: the input goes in
through
@@ -225,12 +265,13 @@ A sandbox another task owns
---------------------------
The toolset's own sandbox is provisioned from a spec fixed in the Dag file, on
the
-model's first tool call, and destroyed when the run ends. Three things cannot
be
-done inside that shape: a credential cannot come from a connection, a file the
-agent built cannot leave, and a second run against the same agent, which is
what
-:ref:`HITL review <howto:hitl_review>` does when a reviewer asks for changes,
cannot
-find the first run's files. All three have the same answer. Let a task create
the
-sandbox and hand the agent only the handle.
+model's first tool call, and destroyed when the run ends. Two things cannot be
+done inside that shape: a credential cannot come from a connection, and a
second
+run against the same agent, which is what :ref:`HITL review
<howto:hitl_review>`
+does when a reviewer asks for changes, cannot find the first run's files. A
file
+the agent built can leave through ``exports`` (:ref:`sandbox-results`), but
only
+from a run that succeeded. All of these have the same answer. Let a task create
+the sandbox and hand the agent only the handle.
.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_sandbox_toolset.py
:language: python
diff --git a/providers/common/ai/docs/sandbox/index.rst
b/providers/common/ai/docs/sandbox/index.rst
index e21f8112897..1d87a27f20e 100644
--- a/providers/common/ai/docs/sandbox/index.rst
+++ b/providers/common/ai/docs/sandbox/index.rst
@@ -229,9 +229,8 @@ actual isolation. Choose the smallest boundary that fits,
then configure it.
- ``SandboxToolset``
* - Produce a large artifact for a downstream task
- A ``@task`` driving a backend directly when the Dag knows the job. When
- the agent has to produce it, a ``@task`` provisions the sandbox, the
agent
- attaches, and a ``@task`` reads the file out; see
- :ref:`A sandbox another task owns <sandbox-attach>`.
+ the agent has to produce it, ``SandboxToolset(exports=...)`` copies the
file
+ to object storage when the run ends; see :ref:`sandbox-results`.
* - A whole task's worth of untrusted work isolated, with no agent involved
- ``KubernetesPodOperator``
* - Airflow's own credentials kept away from the agent
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_sandbox_toolset.py
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_sandbox_toolset.py
index 15ab35396c1..456287c23aa 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_sandbox_toolset.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_sandbox_toolset.py
@@ -18,7 +18,7 @@
"""
Example Dags for ``SandboxToolset``.
-Three shapes, each a job a data team actually runs:
+Five shapes, each a job a data team actually runs:
1. An agent investigates a revenue anomaly. It queries the warehouse through a
``SQLToolset`` (the credential stays in the task, the model only sees rows)
and
@@ -34,6 +34,9 @@ Three shapes, each a job a data team actually runs:
``@task`` reads the report the agent wrote and destroys the sandbox. The
task
that creates the sandbox decides what goes in, in ordinary Python at run
time,
and a file the agent built comes out without crossing the model's context.
+5. An agent writes a file and the toolset exports it to object storage when the
+ run ends, for a downstream task to load. No task has to own the sandbox, so
+ this is the shorter shape whenever the file is all that must come out.
"""
from __future__ import annotations
@@ -229,10 +232,10 @@ def example_sandbox_task_artifact():
Convert a CSV to parquet inside a sandbox and land the result in object
storage.
No agent is involved. The Dag knows exactly what to run, so a model would
add
- nothing, and a file produced inside an *agent's* sandbox could only come
back
- through the model's context, which is text-only and capped. Driving the
- backend from a task has neither limit: the bytes move through the worker,
and
- the caller owns the sandbox's lifetime.
+ nothing. Driving the backend from a task also gives the caller the
sandbox's
+ whole lifetime: it puts the input in, runs the conversion, and reads the
result
+ out, with the bytes moving through the worker. When an agent has to
produce the
+ file instead, see example 5.
"""
# [START howto_sandbox_task_artifact]
@@ -372,3 +375,60 @@ if modal is not None:
# [END howto_sandbox_attach]
example_sandbox_attach()
+
+
+# ---------------------------------------------------------------------------
+# 5. An agent builds a file, and the toolset exports it when the run ends.
+# ---------------------------------------------------------------------------
+
+# [START howto_sandbox_agent_export]
+# Templated per run. Point it at ``s3://`` or ``gs://`` and pass
``export_conn_id``
+# in a real deployment; ``file://`` keeps the example runnable on a laptop.
+STAGING_URI = "file:///tmp/airflow-sandbox-example/{{ run_id }}/staging.csv"
+
+
+@dag(
+ schedule=None,
+ start_date=datetime(2024, 1, 1, tzinfo=timezone.utc),
+ catchup=False,
+ tags=["example", "sandbox"],
+)
+def example_sandbox_agent_export():
+ """Have an agent normalize a vendor file, export the result, and load it
downstream."""
+ normalize = AgentOperator(
+ task_id="normalize",
+ prompt=(
+ "Here is a sample of this month's vendor export:\n\n"
+ f"{VENDOR_SAMPLE}\n"
+ "Write it to a file, then write a Python script that turns it into
staging.csv with "
+ "the columns order_id, customer_name, ordered_at (ISO date),
amount_usd (decimal) "
+ "and country_code. Run the script and fix it until every row
parses, then report "
+ "the column mapping you settled on."
+ ),
+ system_prompt=(
+ "You have a sandbox with Python 3.12 and the standard library, no
network, and "
+ "an empty working directory. Read tracebacks and fix the code
rather than guessing."
+ ),
+ llm_conn_id="pydanticai_default",
+ output_type=ColumnMapping,
+ toolsets=[
+ SandboxToolset(
+ SbxSandboxBackend(host_network_policy="deny-all"),
+ # Copied out before the sandbox is destroyed. If the agent
never wrote
+ # staging.csv, the task fails rather than leaving ``load`` to
find nothing.
+ exports={"staging.csv": STAGING_URI},
+ ),
+ ],
+ )
+
+ @task
+ def load(staging_uri: str) -> int:
+ with ObjectStoragePath(staging_uri).open() as staging:
+ return sum(1 for _ in staging) - 1
+
+ normalize >> load(STAGING_URI)
+
+
+# [END howto_sandbox_agent_export]
+
+example_sandbox_agent_export()
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
index 0a69c54c48c..09f91b4cb24 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/base.py
@@ -23,6 +23,7 @@ import binascii
import json
import logging
import math
+import re
import shlex
import time
import uuid
@@ -32,6 +33,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypeGuard
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
+ from typing import BinaryIO
log = logging.getLogger(__name__)
@@ -90,7 +92,7 @@ class SandboxFileTooLargeError(SandboxError):
self.path = path
self.size_bytes = size_bytes
self.max_bytes = max_bytes
- super().__init__(f"{path!r} is {size_bytes} bytes, over the
{max_bytes} byte read limit.")
+ super().__init__(f"{path!r} is {size_bytes} bytes, over the
{max_bytes} byte limit.")
# Bounded budget for the shell helpers behind the default file operations.
@@ -98,6 +100,34 @@ _FILE_OP_TIMEOUT = 120.0
# They return a status or a listing, never bulk content, so a small cap bounds
# what a hostile guest can push into worker memory.
_FILE_OP_OUTPUT_CAP = 1024 * 1024
+# Slice the default export reads per command. Each slice is held in worker
memory
+# about three times over (base64 text, then decoded bytes), so this bounds the
+# export's footprint whatever the file's size.
+_EXPORT_CHUNK_BYTES = 4 * 1024 * 1024
+# Prefix on the line an export check prints the file's size on.
+_EXPORT_SIZE_TAG = "airflow-export-size:"
+# The slowest an export may run on average before it is ended, so a guest that
trickles
+# a byte at a time cannot hold the task open: 1 GiB, the toolset's default
budget, gets
+# about 17 minutes.
+_EXPORT_MIN_RATE = 1024 * 1024
+
+
+def _export_allowance(max_bytes: int) -> float:
+ """Seconds a whole export of a file of up to ``max_bytes`` may take."""
+ return max(_FILE_OP_TIMEOUT, max_bytes / _EXPORT_MIN_RATE)
+
+
+def _export_deadline(max_bytes: int) -> float:
+ """When an export starting now must have finished, on the
``time.monotonic`` clock."""
+ return time.monotonic() + _export_allowance(max_bytes)
+
+
+def _check_export_deadline(path: str, deadline: float, max_bytes: int) -> None:
+ if time.monotonic() > deadline:
+ raise SandboxError(
+ f"Exporting {path!r} took longer than the
{_export_allowance(max_bytes):g}s allowed for a "
+ f"file of up to {max_bytes} bytes; the sandbox is sending it too
slowly."
+ )
def _validate_positive_finite(value: float, name: str) -> None:
@@ -273,7 +303,8 @@ class SandboxBackend(ABC):
:class:`~airflow.providers.common.ai.toolsets.sandbox.SandboxToolset`.
The four operation methods are named after the four tools the toolset
exposes, so the mapping from a model-facing tool to the backend call behind
- it is literal; ``create`` and ``destroy`` are lifecycle and have no tool. A
+ it is literal; ``create`` and ``destroy`` are lifecycle and have no tool,
and
+ neither has ``export_file``, which hands a finished file to the Dag
author. A
backend whose sandboxes can be found again from another process implements
:class:`AttachableSandboxBackend` instead, which adds the ownership rules a
task-provisioned sandbox needs.
@@ -336,15 +367,18 @@ class SandboxBackend(ABC):
#
# Concrete, not abstract: every one of these is expressible as a shell
# command, so a backend only has to implement ``run_command`` to get all
- # three. Override them when the vendor exposes a native file API, which
+ # four. Override them when the vendor exposes a native file API, which
# avoids base64 expansion, the command-line length ceiling, and the guest
# needing coreutils at all.
# ------------------------------------------------------------------
- # Reserved exit statuses for "the path is not readable" and "the path is a
- # directory", distinct from any status the guest's own command might
return.
+ # Reserved exit statuses for "the path is not readable", "the path is a
+ # directory", "the path is not a regular file" and "the file is over the
+ # export budget", distinct from any status the guest's own command might
return.
_MISSING_PATH_STATUS = 66
_IS_DIRECTORY_STATUS = 67
+ _NOT_REGULAR_FILE_STATUS = 68
+ _TOO_LARGE_STATUS = 69
def read_file(self, sandbox: str, path: str, *, max_bytes: int) -> bytes:
"""
@@ -440,6 +474,129 @@ class SandboxBackend(ABC):
entries.append((name, kind == "d"))
return entries
+ def export_file(self, sandbox: str, path: str, dest: BinaryIO, *,
max_bytes: int) -> int:
+ """
+ Copy a regular file out of the sandbox into ``dest`` and return the
bytes written.
+
+ ``dest`` is a writable binary stream, typically an object-storage
file, and the
+ copy goes through it without the whole file ever being held in worker
memory,
+ so a file far over :meth:`read_file`'s budget can leave the sandbox.
Only a
+ regular file is exported: a directory, a device, or a FIFO is refused,
since
+ none of them has a size to promise a caller. Raise
+ :class:`SandboxFileTooLargeError` instead of copying a file larger than
+ ``max_bytes``, and :class:`SandboxError` when the file changed size
while it
+ was being copied, which means a process in the sandbox is still
writing it.
+
+ This default reads the file in slices through :meth:`run_command`, one
+ command per slice, and needs ``stat``, ``tail``, ``head`` and
``base64`` in the
+ guest. It relies on ``run_command`` returning each slice's output
intact, or
+ setting ``stdout_truncated`` when it could not, and on nothing but the
command's
+ own output reaching stdout. Override it when the vendor can stream a
file out,
+ and bound the whole copy by :func:`_export_deadline` as this one does,
since a
+ guest that keeps sending a byte now and then never trips a stall
timeout.
+ """
+ deadline = _export_deadline(max_bytes)
+ quoted = shlex.quote(path)
+ check = self.run_command(
+ sandbox,
+ f"{self._export_checks(quoted, max_bytes)}
{self._print_export_size()}",
+ timeout=_FILE_OP_TIMEOUT,
+ max_output_bytes=_FILE_OP_OUTPUT_CAP,
+ )
+ self._raise_for_export_status(path, check, max_bytes)
+ size = self._parse_export_size(check.stdout)
+ written = 0
+ while True:
+ _check_export_deadline(path, deadline, max_bytes)
+ # ``tail -c +N`` seeks on a regular file, so each slice costs its
own
+ # length rather than a read from the start.
+ result = self.run_command(
+ sandbox,
+ f"tail -c +{written + 1} -- {quoted} | head -c
{_EXPORT_CHUNK_BYTES} | base64",
+ timeout=max(1.0, min(_FILE_OP_TIMEOUT, deadline -
time.monotonic())),
+ max_output_bytes=_EXPORT_CHUNK_BYTES * 2 + 4096,
+ )
+ if result.sandbox_terminated:
+ raise SandboxTerminalError(f"The sandbox ended while {path!r}
was being exported.")
+ if result.exit_code or result.stdout_truncated:
+ # A truncated slice decodes cleanly into the wrong bytes, so
it is an
+ # error, not a short read.
+ raise SandboxError(result.stderr.strip() or f"Could not export
{path!r}.")
+ try:
+ data = base64.b64decode(result.stdout, validate=False)
+ except (binascii.Error, ValueError) as e:
+ raise SandboxError(f"Could not decode {path!r} from the
sandbox.") from e
+ if written + len(data) > max_bytes:
+ raise SandboxFileTooLargeError(path, written + len(data),
max_bytes)
+ dest.write(data)
+ written += len(data)
+ if len(data) < _EXPORT_CHUNK_BYTES:
+ break
+ self._check_export_size(path, expected=size, written=written)
+ return written
+
+ @classmethod
+ def _export_checks(cls, quoted: str, max_bytes: int) -> str:
+ """
+ Shell that refuses what :meth:`export_file` will not copy, leaving the
size in ``$sz``.
+
+ For a backend overriding :meth:`export_file` that still drives the
guest
+ through a shell, so its refusals mean what the default's do.
+ """
+ return (
+ f"sz=$(stat -Lc %s -- {quoted} 2>/dev/null) || exit
{cls._MISSING_PATH_STATUS}; "
+ f"[ -d {quoted} ] && exit {cls._IS_DIRECTORY_STATUS}; "
+ f"[ -f {quoted} ] || exit {cls._NOT_REGULAR_FILE_STATUS}; "
+ f'[ "$sz" -gt {max_bytes} ] && {{
{cls._print_export_size(to_stderr=True)} exit {cls._TOO_LARGE_STATUS}; }};'
+ )
+
+ @staticmethod
+ def _print_export_size(*, to_stderr: bool = False) -> str:
+ """
+ Shell that prints ``$sz`` on a line of its own, tagged.
+
+ Tagged because the stream it lands on may also carry the vendor CLI's
own notices.
+ """
+ redirect = " >&2" if to_stderr else ""
+ return f'printf "\\n{_EXPORT_SIZE_TAG}%s\\n" "$sz"{redirect};'
+
+ @staticmethod
+ def _parse_export_size(text: str) -> int:
+ sizes = re.findall(rf"^{_EXPORT_SIZE_TAG}(\d+)$", text,
flags=re.MULTILINE)
+ if not sizes:
+ raise SandboxError("The sandbox did not report the size of the
file being exported.")
+ return int(sizes[-1])
+
+ @classmethod
+ def _raise_for_export_status(cls, path: str, result: SandboxExecResult,
max_bytes: int) -> None:
+ """Turn a failed :meth:`_export_checks` into the error it stands
for."""
+ if result.sandbox_terminated:
+ raise SandboxTerminalError(f"The sandbox ended while {path!r} was
being exported.")
+ if result.exit_code == cls._MISSING_PATH_STATUS:
+ raise SandboxError(f"{path!r} does not exist in the sandbox, or is
not readable.")
+ if result.exit_code == cls._IS_DIRECTORY_STATUS:
+ raise SandboxError(f"{path!r} is a directory; only a regular file
can be exported.")
+ if result.exit_code == cls._NOT_REGULAR_FILE_STATUS:
+ raise SandboxError(f"{path!r} is not a regular file; only a
regular file can be exported.")
+ if result.exit_code == cls._TOO_LARGE_STATUS:
+ try:
+ size = cls._parse_export_size(result.stderr)
+ except SandboxError:
+ size = max_bytes + 1
+ raise SandboxFileTooLargeError(path, size, max_bytes)
+ if result.exit_code:
+ raise SandboxError(result.stderr.strip() or f"Could not export
{path!r}.")
+
+ @staticmethod
+ def _check_export_size(path: str, *, expected: int, written: int) -> None:
+ if written != expected:
+ # The run is over, so something the agent started in the
background is
+ # still writing it. What was copied is not the file the run
produced.
+ raise SandboxError(
+ f"{path!r} changed while it was exported: it was {expected}
bytes when the export "
+ f"began and {written} bytes were copied. A process in the
sandbox may still be writing it."
+ )
+
@abstractmethod
def destroy(self, sandbox: str) -> None:
"""Tear down the sandbox. Must be idempotent."""
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/sandbox/opensandbox.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/opensandbox.py
index 07aaaed61d7..f8a36d5496e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/opensandbox.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/opensandbox.py
@@ -22,7 +22,7 @@ import logging
import posixpath
import threading
import time
-from contextlib import contextmanager, suppress
+from contextlib import closing, contextmanager, suppress
from datetime import timedelta
from typing import TYPE_CHECKING, Any
@@ -32,13 +32,16 @@ from airflow.providers.common.ai.sandbox.base import (
SandboxExecResult,
SandboxFileTooLargeError,
SandboxTerminalError,
+ _check_export_deadline,
+ _export_deadline,
_new_sandbox_name,
_validate_positive_finite,
)
from airflow.providers.common.compat.sdk import BaseHook
if TYPE_CHECKING:
- from collections.abc import Callable, Iterator
+ from collections.abc import Callable, Generator, Iterator
+ from typing import BinaryIO
from opensandbox import SandboxSync
from opensandbox.config import ConnectionConfigSync
@@ -459,21 +462,68 @@ class OpenSandboxBackend(SandboxBackend):
def read_file(self, sandbox: str, path: str, *, max_bytes: int) -> bytes:
_validate_positive_finite(max_bytes, "max_bytes")
sandbox_client = self._get_sandbox(sandbox)
- chunks = None
data = bytearray()
+ with closing(self._download(sandbox_client, path,
max_bytes=max_bytes)) as chunks:
+ for chunk in chunks:
+ data.extend(chunk[: max_bytes + 1 - len(data)])
+ if len(data) > max_bytes:
+ size = self._get_file_size(sandbox_client, path,
at_least=len(data))
+ raise SandboxFileTooLargeError(path, size, max_bytes)
+ return bytes(data)
+
+ def export_file(self, sandbox: str, path: str, dest: BinaryIO, *,
max_bytes: int) -> int:
+ """Override: stream the file through the SDK's ranged download, one
chunk at a time."""
+ _validate_positive_finite(max_bytes, "max_bytes")
+ sandbox_client = self._get_sandbox(sandbox)
+ try:
+ info = sandbox_client.files.get_file_info([path])
+ entry = info.get(path) or next(iter(info.values()))
+ except Exception as e:
+ if _get_status_code(e) == 404 or isinstance(e, StopIteration):
+ self._confirm_sandbox_exists(sandbox_client)
+ raise SandboxError(f"{path!r} does not exist in the sandbox,
or is not readable.") from e
+ with _translate_opensandbox_errors(
+ "inspect a sandbox file", recoverable_statuses=frozenset({400})
+ ):
+ raise
+ if entry.entry_type == "directory":
+ raise SandboxError(f"{path!r} is a directory; only a regular file
can be exported.")
+ if entry.entry_type == "other":
+ raise SandboxError(f"{path!r} is not a regular file; only a
regular file can be exported.")
+ if entry.size > max_bytes:
+ raise SandboxFileTooLargeError(path, entry.size, max_bytes)
+ deadline = _export_deadline(max_bytes)
+ written = 0
+ with closing(self._download(sandbox_client, path,
max_bytes=max_bytes)) as chunks:
+ for chunk in chunks:
+ _check_export_deadline(path, deadline, max_bytes)
+ written += len(chunk)
+ if written > max_bytes:
+ raise SandboxFileTooLargeError(path, written, max_bytes)
+ dest.write(chunk)
+ if entry.entry_type == "file":
+ # A symlink reports the link's own size, not its target's, so
there is
+ # nothing to compare against for one.
+ self._check_export_size(path, expected=entry.size, written=written)
+ return written
+
+ def _download(
+ self, sandbox_client: SandboxSync, path: str, *, max_bytes: int
+ ) -> Generator[bytes, None, None]:
+ """
+ Yield the first ``max_bytes + 1`` bytes of a sandbox file, with the
SDK's errors translated.
+
+ A generator, so a failure in whatever the caller does with a chunk is
raised in
+ the caller and never mistaken for a failed download.
+ """
+ chunks = None
try:
chunks = sandbox_client.files.read_bytes_stream(
path,
chunk_size=min(65536, max_bytes + 1),
range_header=f"bytes=0-{max_bytes}",
)
- for chunk in chunks:
- data.extend(chunk[: max_bytes + 1 - len(data)])
- if len(data) > max_bytes:
- size = self._get_file_size(sandbox_client, path,
at_least=len(data))
- raise SandboxFileTooLargeError(path, size, max_bytes)
- except SandboxFileTooLargeError:
- raise
+ yield from chunks
except Exception as e:
if _get_status_code(e) == 404:
self._confirm_sandbox_exists(sandbox_client)
@@ -485,7 +535,6 @@ class OpenSandboxBackend(SandboxBackend):
if close is not None:
with suppress(Exception):
close()
- return bytes(data)
def write_file(self, sandbox: str, path: str, content: bytes) -> None:
sandbox_client = self._get_sandbox(sandbox)
diff --git a/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
b/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
index 02e39430a7f..dc11059ef2d 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/sandbox/sbx.py
@@ -36,12 +36,17 @@ from airflow.providers.common.ai.sandbox.base import (
SandboxBackend,
SandboxError,
SandboxExecResult,
+ SandboxFileTooLargeError,
SandboxTerminalError,
+ _check_export_deadline,
+ _export_deadline,
_new_sandbox_name,
_validate_positive_finite,
)
if TYPE_CHECKING:
+ from typing import BinaryIO
+
from airflow.providers.common.ai.sandbox.base import SandboxSpec
# Extra wall-clock beyond the per-command budget to absorb CLI and microVM
@@ -55,6 +60,10 @@ _FILE_OP_TIMEOUT = 120.0
# Helpers return a status or a directory listing, never bulk file content, so a
# small cap is enough to bound what a hostile guest can push into worker
memory.
_HELPER_OUTPUT_CAP = 1024 * 1024
+# Seconds an export may go without a byte arriving before it is ended. The
whole copy
+# is bounded separately, by ``_export_deadline``; measured, 200 MB streams out
of a
+# local microVM in under three seconds.
+_EXPORT_STALL_TIMEOUT = 120.0
log = logging.getLogger(__name__)
@@ -108,7 +117,7 @@ class SbxSandboxBackend(SandboxBackend):
can find and remove them; budget for that sweep before running this at
scale.
The template image must provide GNU coreutils ``timeout``, ``base64``,
``stat``,
- ``find``, ``mkdir`` and ``dirname``, which the command and file tools use.
Any Debian or Ubuntu based
+ ``head``, ``find``, ``mkdir`` and ``dirname``, which the command and file
tools use. Any Debian or Ubuntu based
image, including ``python:*-slim``, does.
:param image: Container image for the sandbox (``sbx --template``).
@@ -458,6 +467,85 @@ class SbxSandboxBackend(SandboxBackend):
if code:
raise SandboxError(stderr.decode(errors="replace").strip() or
f"Could not write {path!r}.")
+ def export_file(self, sandbox: str, path: str, dest: BinaryIO, *,
max_bytes: int) -> int:
+ """
+ Override: stream the file out of one ``sbx exec`` straight into
``dest``.
+
+ ``sbx exec`` carries the guest's stdout byte for byte, so the default's
+ base64 round trip per slice buys nothing here. The guest reports the
size it
+ is about to send on stderr, which is how a file still being written is
caught.
+ """
+ quoted = shlex.quote(path)
+ script = (
+ f"{self._export_checks(quoted, max_bytes)} "
+ f"{self._print_export_size(to_stderr=True)} exec head -c
{max_bytes + 1} -- {quoted}"
+ )
+ deadline = _export_deadline(max_bytes)
+ written = 0
+ stderr = bytearray()
+ stalled = threading.Event()
+ expired = threading.Event()
+ finished = threading.Event()
+ last_progress = [time.monotonic()]
+ with subprocess.Popen(
+ [self._sbx_path, "exec", sandbox, "sh", "-c", script],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ start_new_session=True,
+ ) as proc:
+
+ def kill() -> None:
+ with suppress(OSError):
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+ with suppress(OSError):
+ proc.kill()
+
+ def watch() -> None:
+ # Two clocks: the stall one restarts with every chunk, so a
large file that
+ # keeps moving is not cut off; the deadline does not, so a
guest that
+ # trickles a byte now and then is ended too.
+ while not finished.wait(min(1.0, _EXPORT_STALL_TIMEOUT / 4)):
+ now = time.monotonic()
+ if now - last_progress[0] > _EXPORT_STALL_TIMEOUT or now >
deadline:
+ (stalled if now <= deadline else expired).set()
+ kill()
+ return
+
+ def drain_stderr() -> None:
+ with suppress(ValueError, OSError):
+ for chunk in iter(lambda: proc.stderr.read(4096), b""): #
type: ignore[union-attr]
+ stderr.extend(chunk[: _HELPER_OUTPUT_CAP -
len(stderr)])
+
+ drainer = threading.Thread(target=drain_stderr, daemon=True)
+ watchdog = threading.Thread(target=watch, daemon=True)
+ drainer.start()
+ watchdog.start()
+ try:
+ for chunk in iter(lambda: proc.stdout.read(65536), b""): #
type: ignore[union-attr]
+ written += len(chunk)
+ if written > max_bytes:
+ raise SandboxFileTooLargeError(path, written,
max_bytes)
+ dest.write(chunk)
+ last_progress[0] = time.monotonic()
+ proc.wait()
+ finally:
+ finished.set()
+ if proc.poll() is None:
+ kill()
+ proc.wait()
+ drainer.join(timeout=5.0)
+ if expired.is_set():
+ _check_export_deadline(path, deadline, max_bytes)
+ if stalled.is_set():
+ raise SandboxError(f"Exporting {path!r} stalled: no data arrived
for {_EXPORT_STALL_TIMEOUT:g}s.")
+ report = stderr.decode(errors="replace")
+ self._raise_for_export_status(
+ path, SandboxExecResult(exit_code=proc.returncode, stdout="",
stderr=report), max_bytes
+ )
+ self._check_export_size(path,
expected=self._parse_export_size(report), written=written)
+ return written
+
def destroy(self, sandbox: str) -> None:
# Already-gone is fine -- 'sbx rm -f' exits nonzero for a missing
sandbox,
# which we ignore so destroy stays idempotent.
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
index 3ae301b8584..75762aa1a6a 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
@@ -23,10 +23,13 @@ import concurrent.futures
import contextvars
import logging
import math
+import sys
import threading
import time
+import uuid
from typing import TYPE_CHECKING, Any, NamedTuple
+from fsspec.implementations.local import LocalFileSystem
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.tools import ToolDefinition
from pydantic_ai.toolsets.abstract import AbstractToolset, ToolsetTool
@@ -54,10 +57,10 @@ from airflow.providers.common.ai.utils.tool_definition
import (
return_schema_kwargs,
)
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset
-from airflow.providers.common.compat.sdk import get_current_context
+from airflow.providers.common.compat.sdk import ObjectStoragePath,
get_current_context
if TYPE_CHECKING:
- from collections.abc import Sequence
+ from collections.abc import Mapping, Sequence
from pydantic_ai._run_context import RunContext
@@ -76,6 +79,16 @@ _provisioning =
concurrent.futures.ThreadPoolExecutor(thread_name_prefix="sandbo
RUN_COMMAND = "run_command"
+def _sentence(error: BaseException) -> str:
+ """Return an exception's message ending in exactly one full stop, to join
into a longer one."""
+ return f"{str(error).rstrip('.')}."
+
+
+# Ends the name of the staging key an export is copied to before it is moved
into place,
+# so a consumer globbing its destination's own extension never matches one.
+_PARTIAL_SUFFIX = ".partial"
+
+
class _Identity(NamedTuple):
"""Who an attaching run presents as: the owner it expects on the sandbox,
and the holder it claims to be."""
@@ -186,6 +199,15 @@ class SandboxToolset(AirflowToolset):
the handle travels from the provisioning task:
``attach_to="{{ ti.xcom_pull('provision') }}"``.
+ **Files the agent builds can leave.** ``exports`` maps a path in the
sandbox to
+ an object-storage destination, and when the run ends the toolset copies
each
+ file there before it destroys the sandbox. The copy streams through the
worker
+ in bounded pieces and never passes through the model's context or XCom, so
a
+ parquet file, a chart, or a trained model is as easy to hand downstream as
a
+ line of text. The destinations are templated the same way ``attach_to``
is. A
+ promised file that cannot be exported fails the task, and a failed run
exports
+ nothing; a sandbox that cannot be destroyed afterwards does not fail the
task.
+
A non-zero exit or a timeout is normal tool output -- the model reads it
and
corrects itself. A recoverable sandbox failure becomes a bounded retry.
Only
a terminal failure (credentials rejected, daemon unreachable) fails the
task,
@@ -213,6 +235,17 @@ class SandboxToolset(AirflowToolset):
which ``ModalSandboxBackend`` is and ``SbxSandboxBackend`` is not), and
cannot be combined with ``spec``, since the sandbox is already
provisioned.
The toolset never destroys an attached sandbox.
+ :param exports: Files to copy out of the sandbox when the run ends, as a
+ mapping from a path in the sandbox (relative paths resolve the way the
+ ``read_file`` tool resolves them) to an object-storage URL such as
+ ``"s3://bucket/{{ run_id }}/report.parquet"``, anything
+ :class:`~airflow.sdk.ObjectStoragePath` can open. Only a regular file
is
+ exported. Cannot be combined with ``attach_to``: the task that owns an
+ attached sandbox collects its files itself.
+ :param export_conn_id: Airflow connection for the export destinations, or
+ ``None`` for the storage's default credentials. Only meaningful with
+ ``exports``.
+ :param max_export_bytes: Largest file an export will copy. Default 1 GiB.
:param owner: The owner the attached sandbox must carry. Defaults to the
Dag
run the task is part of, which is what a provisioning task in the same
run
stamps with ``SandboxSpec(owner=dag_run_owner(context))``. Set it only
when
@@ -222,7 +255,7 @@ class SandboxToolset(AirflowToolset):
# Rendered, on a copy, by AgentOperator. Deliberately not
``template_fields``, which
# Airflow's templater would render in place wherever the toolset is nested.
- agent_template_fields: Sequence[str] = ("attach_to",)
+ agent_template_fields: Sequence[str] = ("attach_to", "_exports",
"_export_conn_id")
def __init__(
self,
@@ -237,12 +270,16 @@ class SandboxToolset(AirflowToolset):
tool_prefix: str = "",
attach_to: str | None = None,
owner: str | None = None,
+ exports: Mapping[str, str] | None = None,
+ export_conn_id: str | None = None,
+ max_export_bytes: int = 1024 * 1024 * 1024,
) -> None:
_validate_positive_finite(default_command_timeout,
"default_command_timeout")
_validate_positive_finite(max_command_timeout, "max_command_timeout")
_validate_positive_finite(max_output_lines, "max_output_lines")
_validate_positive_finite(max_output_bytes, "max_output_bytes")
_validate_positive_finite(max_read_bytes, "max_read_bytes")
+ _validate_positive_finite(max_export_bytes, "max_export_bytes")
if default_command_timeout > max_command_timeout:
raise ValueError(
f"default_command_timeout ({default_command_timeout}) must not
exceed "
@@ -267,6 +304,11 @@ class SandboxToolset(AirflowToolset):
)
if not attach_to:
raise ValueError("attach_to must be a sandbox handle, not an
empty string.")
+ if exports:
+ raise ValueError(
+ "exports cannot be combined with attach_to: the sandbox
belongs to the task that "
+ "created it, and that task reads out whatever the agent
left behind."
+ )
elif owner is not None:
raise ValueError("owner only applies together with attach_to.")
elif spec is not None and spec.owner is not None:
@@ -277,6 +319,14 @@ class SandboxToolset(AirflowToolset):
"sandbox it provisions itself when the run ends, so an owner
on it would mean nothing; "
"provision the sandbox in a task and pass its handle as
attach_to instead."
)
+ for path, destination in (exports or {}).items():
+ if not path or not destination:
+ raise ValueError(
+ f"exports maps a sandbox path to a destination, and
neither may be empty; got "
+ f"{path!r} -> {destination!r}."
+ )
+ if export_conn_id is not None and not exports:
+ raise ValueError("export_conn_id only applies together with
exports.")
self._backend = backend
self.attach_to = attach_to
# Fixed here, not re-derived from ``attach_to`` later: the templater
rewrites
@@ -296,6 +346,9 @@ class SandboxToolset(AirflowToolset):
self._max_output_lines = int(max_output_lines)
self._max_output_bytes = int(max_output_bytes)
self._max_read_bytes = int(max_read_bytes)
+ self._exports = dict(exports or {})
+ self._export_conn_id = export_conn_id
+ self._max_export_bytes = int(max_export_bytes)
self._tool_prefix = tool_prefix
self._sandbox: str | None = None
self._create_lock = threading.Lock()
@@ -310,6 +363,13 @@ class SandboxToolset(AirflowToolset):
# Tools run only between enter and exit, so nothing outside a run can
provision a
# sandbox that no exit will destroy, or use one it has not claimed.
self._open = False
+ # The exception already being handled when a run entered, if any. The
run was
+ # started from inside that handler, so finding it again at exit says
nothing
+ # about how the run went (see ``_run_failed``).
+ self._handling_at_enter: BaseException | None = None
+ # Set on an instance that has handed its runs to copies (see
``for_run``). It
+ # never holds a sandbox itself, so it has nothing to export or to fail
over.
+ self._forked = False
@property
def id(self) -> str:
@@ -335,6 +395,7 @@ class SandboxToolset(AirflowToolset):
# other's. The backend keys all state by unique sandbox handle, so
# sharing the backend itself is safe. ``type(self)`` so a subclass does
# not silently degrade to this class on every run.
+ self._forked = True
return type(self)(
self._backend,
# Attach mode refuses a spec, and the default one filled in above
is
@@ -348,6 +409,9 @@ class SandboxToolset(AirflowToolset):
tool_prefix=self._tool_prefix,
attach_to=self._attached_handle if self._attach_mode else None,
owner=self._owner,
+ exports=self._exports or None,
+ export_conn_id=self._export_conn_id,
+ max_export_bytes=self._max_export_bytes,
)
@property
@@ -375,14 +439,33 @@ class SandboxToolset(AirflowToolset):
# nothing leaks if the run fails before any tool executes. An attached
one is
# claimed now, so a wrong handle or a held sandbox fails the run
before the
# model has spent anything, and the tool descriptions can state the
lifetime.
+ # Read here, not in the thread: the exception being handled belongs to
this frame.
+ self._handling_at_enter = sys.exc_info()[1]
await asyncio.to_thread(self._open_run)
return self
def _open_run(self) -> None:
if self._attach_mode:
self._attach(self._attached_handle)
+ else:
+ self._check_export_destinations()
self._open = True
+ def _check_export_destinations(self) -> None:
+ """
+ Refuse a rendered destination that is not a URL, before the model
spends anything.
+
+ The destinations are templated, and a value that rendered to nothing
reads as a
+ path relative to the worker's working directory, where the file would
land and
+ the export report success. The same failure shape ``_attached_handle``
guards.
+ """
+ for path, destination in self._exports.items():
+ if not isinstance(destination, str) or "://" not in destination:
+ raise SandboxTerminalError(
+ f"exports[{path!r}] rendered to {destination!r}, which is
not a storage URL such as "
+ "s3://bucket/key or file:///path. Check the template or
the value it reads."
+ )
+
def _attach(self, handle: str) -> None:
owner, holder = self._identity()
backend = self._attachable_backend
@@ -469,19 +552,36 @@ class SandboxToolset(AirflowToolset):
return _Identity(owner=self._owner if self._owner is not None else
run, holder=holder)
async def __aexit__(self, *args: Any) -> bool | None:
- await asyncio.to_thread(self._close)
+ # Read here, not in the thread: the exception being handled belongs to
this frame.
+ run_failed = args[0] is not None or self._run_failed
+ await asyncio.to_thread(self._close, run_failed=run_failed)
return None
+ @property
+ def _run_failed(self) -> bool:
+ """
+ Whether the run this toolset served ended in an exception.
+
+ pydantic-ai exits its toolsets through an exit stack that passes no
exception to
+ them, whether the run succeeded or not, so the exception being handled
at exit is
+ the only record of a failure. One that was already being handled when
the run
+ entered is not it: that run was started from inside an ``except``
block, and it
+ may well have succeeded.
+ """
+ handling = sys.exc_info()[1]
+ return handling is not None and handling is not self._handling_at_enter
+
def __enter__(self) -> Self:
"""Own the sandbox's lifetime from synchronous code, such as a task
running a native agent."""
self._open_run()
return self
def __exit__(self, *args: Any) -> bool | None:
- self._close()
+ # A with statement passes the exception that ended the block, so it is
the answer.
+ self._close(run_failed=args[0] is not None)
return None
- def _close(self) -> None:
+ def _close(self, *, run_failed: bool) -> None:
with self._create_lock:
self._open = False
pending = self._create_future
@@ -506,8 +606,92 @@ class SandboxToolset(AirflowToolset):
if holder is not None:
self._release(self._attached_handle, holder)
return
+ export = bool(self._exports) and not run_failed
if sandbox is None:
+ if export and not self._forked:
+ # The run never called a tool, so it never provisioned a
sandbox, or the
+ # sandbox ended under its last command and nothing replaced
it. Either
+ # way the files it was to leave behind do not exist.
+ raise SandboxTerminalError(
+ "The run ended with no sandbox standing, so none of the
files it was to export "
+ f"exist: {', '.join(repr(path) for path in
self._exports)}."
+ )
return
+ try:
+ if export:
+ self._export(sandbox)
+ finally:
+ self._destroy(sandbox)
+
+ def _export(self, sandbox: str) -> None:
+ """
+ Copy every file in ``exports`` out of the sandbox, failing the task on
the first that cannot be.
+
+ Before teardown, and never best effort: a task that promised a file
and did not
+ deliver it must fail, or its downstream task finds nothing and cannot
tell why.
+
+ Each file is copied to a staging key next to its destination, and only
once every
+ file has been copied are they moved into place. A failed export
therefore leaves
+ every destination as it was, including one an earlier run filled,
rather than
+ holding a truncated copy or a part of this run's set.
+ """
+ staged: list[tuple[ObjectStoragePath, ObjectStoragePath]] = []
+ for path, destination in self._exports.items():
+ target = self._export_target(destination)
+ partial =
target.with_name(f"{target.name}.{uuid.uuid4().hex[:12]}{_PARTIAL_SUFFIX}")
+ try:
+ if isinstance(target.fs, LocalFileSystem):
+ # A per-run destination names a directory nothing has
created yet. Only
+ # locally: on object storage a key needs no parent, and an
s3fs mkdir
+ # can create a bucket.
+ target.parent.mkdir(parents=True, exist_ok=True)
+ staged.append((partial, target))
+ with partial.open("wb") as stream:
+ size = self._backend.export_file(sandbox, path, stream,
max_bytes=self._max_export_bytes)
+ except Exception as e:
+ raise self._export_failed(
+ staged,
+ f"Could not export {path!r} from sandbox {sandbox} "
+ f"on backend {self._backend.name!r} to {destination}:
{_sentence(e)}",
+ ) from e
+ log.info("Exported %s from sandbox %s (%s)", path, sandbox,
format_size(size))
+ published: list[str] = []
+ for partial, target in staged:
+ try:
+ # Within one store: a rename on a local disk, a server-side
copy on object
+ # storage, so the bytes do not pass through the worker a
second time.
+ partial.move(target)
+ except Exception as e:
+ done = f" Already in place: {', '.join(published)}." if
published else ""
+ raise self._export_failed(
+ staged, f"Could not move the export into {target}:
{_sentence(e)}{done}"
+ ) from e
+ published.append(str(target))
+ log.info("Published %s", target)
+
+ @staticmethod
+ def _export_failed(
+ staged: list[tuple[ObjectStoragePath, ObjectStoragePath]], message: str
+ ) -> SandboxTerminalError:
+ """Remove the staging keys of a failed export, and build the error
that names any left behind."""
+ left_behind = []
+ for partial, _ in staged:
+ try:
+ partial.unlink(missing_ok=True)
+ except Exception:
+ log.warning("Could not remove %s after a failed export",
partial, exc_info=True)
+ left_behind.append(str(partial))
+ note = f" Left behind, to delete by hand: {', '.join(left_behind)}."
if left_behind else ""
+ return SandboxTerminalError(f"{message}{note}")
+
+ def _export_target(self, destination: str) -> ObjectStoragePath:
+ # ``conn_id`` only when one is set: on Airflow 3.0 and 3.1 an explicit
None
+ # discards a connection named in the URL itself
(``s3://conn@bucket/key``).
+ if self._export_conn_id is None:
+ return ObjectStoragePath(destination)
+ return ObjectStoragePath(destination, conn_id=self._export_conn_id)
+
+ def _destroy(self, sandbox: str) -> None:
try:
self._backend.destroy(sandbox)
except Exception:
@@ -622,6 +806,17 @@ class SandboxToolset(AirflowToolset):
return self._attach_note or "This sandbox was set up by an earlier
task."
return self._describe_network(self._spec)
+ @property
+ def _export_note(self) -> str:
+ """Which files leave the sandbox when the run ends, so the model
writes them where they are collected."""
+ if not self._exports:
+ return ""
+ paths = ", ".join(self._exports)
+ return (
+ f" When this run ends, these files are copied out of the sandbox
and everything else is "
+ f"discarded, so write your results to them before you finish:
{paths}."
+ )
+
@staticmethod
def _describe_network(spec: SandboxSpec) -> str:
if not spec.block_network:
@@ -663,7 +858,7 @@ class SandboxToolset(AirflowToolset):
name = self._tool_name(base)
description = _DESCRIPTIONS[base]
if base == RUN_COMMAND:
- description = f"{description} {self._network_note}"
+ description = f"{description}
{self._network_note}{self._export_note}"
tool_def = ToolDefinition(
name=name,
description=description,
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
index b49bcaf1a30..7d7baedbcb2 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
@@ -414,6 +414,21 @@ class TestAgentOperatorToolsetTemplating:
assert op.capabilities[0] is capability
+ def test_sandbox_export_destinations_are_rendered_on_a_copy(self):
+ toolset = SandboxToolset(
+ _NoopBackend(),
+ exports={"out/report.csv": "s3://reports/{{ params.customer
}}/report.csv"},
+ export_conn_id="s3_{{ params.customer }}",
+ )
+ op = AgentOperator(task_id="t", prompt="p", llm_conn_id="llm",
toolsets=[toolset])
+
+ op.render_template_fields(self.CONTEXT)
+
+ (rendered,) = op.toolsets
+ assert rendered._exports == {"out/report.csv":
"s3://reports/acme/report.csv"}
+ assert rendered._export_conn_id == "s3_acme"
+ assert toolset._exports == {"out/report.csv": "s3://reports/{{
params.customer }}/report.csv"}
+
def test_only_connection_ids_are_templated(self):
"""allowed_tables is validated and canonicalised in __init__, so
rendering it later
would bypass the fail-closed empty-list check."""
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
index 43dac4b5b06..82e43e34c2a 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_base.py
@@ -17,12 +17,15 @@
from __future__ import annotations
import inspect
+import io
+import os
import subprocess
from types import SimpleNamespace
import pytest
import time_machine
+from airflow.providers.common.ai.sandbox import base
from airflow.providers.common.ai.sandbox.base import (
EXPIRES_AT_TAG,
HOLDER_TAG,
@@ -124,7 +127,7 @@ class TestBackendContract:
assert required == {"create", "run_command", "destroy"}
def test_file_operations_are_overridable_defaults(self):
- for name in ("read_file", "write_file", "list_directory"):
+ for name in ("read_file", "write_file", "list_directory",
"export_file"):
method = getattr(SandboxBackend, name)
assert not getattr(method, "__isabstractmethod__", False)
@@ -253,6 +256,164 @@ class TestDefaultFileOperations:
local.list_directory("s", str(tmp_path / "nope"))
+class TestDefaultExport:
+ """The inherited export_file, against a real shell, with slices small
enough to need several."""
+
+ @pytest.fixture(autouse=True)
+ def _small_slices(self, monkeypatch):
+ monkeypatch.setattr(base, "_EXPORT_CHUNK_BYTES", 64)
+
+ @pytest.mark.parametrize("size", [0, 1, 63, 64, 128, 1000], ids=lambda n:
f"{n}-bytes")
+ def test_copies_the_file_byte_for_byte_across_slices(self, local,
tmp_path, size):
+ blob = os.urandom(size)
+ (tmp_path / "out.bin").write_bytes(blob)
+ dest = io.BytesIO()
+
+ written = local.export_file("s", str(tmp_path / "out.bin"), dest,
max_bytes=10_000)
+
+ assert written == size
+ assert dest.getvalue() == blob
+
+ def test_a_relative_path_resolves_like_read_file(self, local, tmp_path):
+ (tmp_path / "report.csv").write_bytes(b"a,b\n1,2\n")
+ dest = io.BytesIO()
+
+ local.export_file("s", "report.csv", dest, max_bytes=100)
+
+ assert dest.getvalue() == local.read_file("s", "report.csv",
max_bytes=100)
+
+ @pytest.mark.parametrize("name", ["with space.bin", "with'quote.bin",
"semi;colon.bin", "$dollar.bin"])
+ def test_hostile_filenames_are_quoted(self, local, tmp_path, name):
+ (tmp_path / name).write_bytes(b"payload")
+ dest = io.BytesIO()
+
+ local.export_file("s", str(tmp_path / name), dest, max_bytes=100)
+
+ assert dest.getvalue() == b"payload"
+
+ def test_a_missing_file_is_an_error(self, local, tmp_path):
+ with pytest.raises(SandboxError, match="does not exist"):
+ local.export_file("s", str(tmp_path / "nope.bin"), io.BytesIO(),
max_bytes=100)
+
+ def test_a_directory_is_refused(self, local, tmp_path):
+ (tmp_path / "sub").mkdir()
+
+ with pytest.raises(SandboxError, match="is a directory"):
+ local.export_file("s", str(tmp_path / "sub"), io.BytesIO(),
max_bytes=100)
+
+ def
test_a_stream_with_no_size_is_refused_rather_than_read_without_end(self, local):
+ with pytest.raises(SandboxError, match="not a regular file"):
+ local.export_file("s", "/dev/zero", io.BytesIO(), max_bytes=100)
+
+ def
test_a_file_over_the_budget_is_refused_before_anything_is_written(self, local,
tmp_path):
+ (tmp_path / "big.bin").write_bytes(b"x" * 500)
+ dest = io.BytesIO()
+
+ with pytest.raises(SandboxFileTooLargeError) as error:
+ local.export_file("s", str(tmp_path / "big.bin"), dest,
max_bytes=100)
+
+ assert error.value.size_bytes == 500
+ assert dest.getvalue() == b""
+
+ def test_a_file_that_grows_while_it_is_exported_is_an_error(self, local,
tmp_path):
+ target = tmp_path / "growing.bin"
+ target.write_bytes(b"x" * 100)
+ run_command = local.run_command
+
+ def grow_after_the_check(sandbox, command, **kwargs):
+ result = run_command(sandbox, command, **kwargs)
+ if "stat" in command:
+ with target.open("ab") as f:
+ f.write(b"y" * 10)
+ return result
+
+ local.run_command = grow_after_the_check
+
+ with pytest.raises(SandboxError, match="changed while it was
exported"):
+ local.export_file("s", str(target), io.BytesIO(), max_bytes=1000)
+
+ def test_a_file_that_grows_past_the_budget_mid_copy_is_refused(self,
local, tmp_path):
+ target = tmp_path / "growing.bin"
+ target.write_bytes(b"x" * 100)
+ run_command = local.run_command
+
+ def grow_after_the_check(sandbox, command, **kwargs):
+ result = run_command(sandbox, command, **kwargs)
+ if "stat" in command:
+ with target.open("ab") as f:
+ f.write(b"y" * 500)
+ return result
+
+ local.run_command = grow_after_the_check
+
+ with pytest.raises(SandboxFileTooLargeError):
+ local.export_file("s", str(target), io.BytesIO(), max_bytes=200)
+
+ def test_an_export_slower_than_its_deadline_is_ended(self, local,
tmp_path, monkeypatch):
+ # Each slice has its own command timeout, so only the whole-copy
deadline stops a
+ # guest that sends every slice just inside it.
+ (tmp_path / "out.bin").write_bytes(b"x" * 100)
+ monkeypatch.setattr(base, "_export_allowance", lambda max_bytes: 0.0)
+
+ with pytest.raises(SandboxError, match="took longer than"):
+ local.export_file("s", str(tmp_path / "out.bin"), io.BytesIO(),
max_bytes=1000)
+
+ def test_the_deadline_scales_with_the_budget(self):
+ assert base._export_allowance(1024**3) == 1024
+ assert base._export_allowance(1024) == base._FILE_OP_TIMEOUT
+
+ def test_a_truncated_slice_is_an_error_not_a_short_file(self, local,
tmp_path):
+ # A slice cut short decodes cleanly into the wrong bytes, so it must
not be
+ # mistaken for the end of the file.
+ (tmp_path / "out.bin").write_bytes(b"x" * 100)
+ run_command = local.run_command
+
+ def truncate_slices(sandbox, command, **kwargs):
+ result = run_command(sandbox, command, **kwargs)
+ if "tail" in command:
+ return SandboxExecResult(
+ exit_code=0, stdout=result.stdout[:8], stderr="",
stdout_truncated=True
+ )
+ return result
+
+ local.run_command = truncate_slices
+
+ with pytest.raises(SandboxError, match="Could not export"):
+ local.export_file("s", str(tmp_path / "out.bin"), io.BytesIO(),
max_bytes=1000)
+
+ def test_a_sandbox_that_ends_mid_export_is_terminal(self, local, tmp_path):
+ (tmp_path / "out.bin").write_bytes(b"x" * 100)
+ run_command = local.run_command
+
+ def end_on_first_slice(sandbox, command, **kwargs):
+ if "tail" in command:
+ return SandboxExecResult(exit_code=-1, stdout="", stderr="",
sandbox_terminated=True)
+ return run_command(sandbox, command, **kwargs)
+
+ local.run_command = end_on_first_slice
+
+ with pytest.raises(SandboxTerminalError, match="ended"):
+ local.export_file("s", str(tmp_path / "out.bin"), io.BytesIO(),
max_bytes=1000)
+
+ def test_the_size_is_found_among_other_output(self, local, tmp_path):
+ # A vendor CLI can print notices on the same stream as the guest.
+ (tmp_path / "out.bin").write_bytes(b"x" * 10)
+ run_command = local.run_command
+
+ def noisy(sandbox, command, **kwargs):
+ result = run_command(sandbox, command, **kwargs)
+ if "stat" in command:
+ return SandboxExecResult(
+ exit_code=0, stdout=f"Starting
daemon...\n{result.stdout}", stderr=""
+ )
+ return result
+
+ local.run_command = noisy
+ dest = io.BytesIO()
+
+ assert local.export_file("s", str(tmp_path / "out.bin"), dest,
max_bytes=100) == 10
+
+
class TestDagRunOwner:
def test_names_the_dag_and_the_run(self):
context = {"ti": SimpleNamespace(dag_id="my_dag",
run_id="manual__2026-01-01T00:00:00+00:00")}
diff --git
a/providers/common/ai/tests/unit/common/ai/sandbox/test_opensandbox.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_opensandbox.py
index 0b2dcdc6910..3eaad62682a 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_opensandbox.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_opensandbox.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import builtins
+import io
import threading
import time
from types import SimpleNamespace
@@ -29,6 +30,7 @@ pytest.importorskip("opensandbox")
from opensandbox.exceptions import SandboxApiException
from opensandbox.models.sandboxes import NetworkPolicy, NetworkRule
+from airflow.providers.common.ai.sandbox import base
from airflow.providers.common.ai.sandbox.base import (
SandboxError,
SandboxFileTooLargeError,
@@ -670,6 +672,141 @@ class TestFileOperations:
backend.read_file("box-1", "/w/a", max_bytes=0)
+def _stream(*chunks: bytes) -> mock.MagicMock:
+ stream = mock.MagicMock(spec=["__iter__", "close"])
+ stream.__iter__.return_value = iter(chunks)
+ return stream
+
+
+def _entry(entry_type: str | None, size: int) -> SimpleNamespace:
+ return SimpleNamespace(entry_type=entry_type, size=size)
+
+
+class TestExportFile:
+ def test_streams_the_ranged_download_into_the_destination(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/out.bin":
_entry("file", 5)}
+ stream = _stream(b"he", b"llo")
+ sandbox.files.read_bytes_stream.return_value = stream
+ dest = io.BytesIO()
+
+ written = backend.export_file("box-1", "/w/out.bin", dest,
max_bytes=100)
+
+ assert written == 5
+ assert dest.getvalue() == b"hello"
+ sandbox.files.read_bytes_stream.assert_called_once_with(
+ "/w/out.bin", chunk_size=101, range_header="bytes=0-100"
+ )
+ stream.close.assert_called_once()
+
+ @pytest.mark.parametrize(
+ ("entry_type", "match"),
+ [("directory", "is a directory"), ("other", "not a regular file")],
+ )
+ def test_refuses_what_is_not_a_regular_file_without_downloading(self,
entry_type, match):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/x": _entry(entry_type,
0)}
+
+ with pytest.raises(SandboxError, match=match):
+ backend.export_file("box-1", "/w/x", io.BytesIO(), max_bytes=100)
+
+ sandbox.files.read_bytes_stream.assert_not_called()
+
+ def test_a_file_over_the_budget_is_refused_without_downloading(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/big": _entry("file",
1_000)}
+
+ with pytest.raises(SandboxFileTooLargeError) as error:
+ backend.export_file("box-1", "/w/big", io.BytesIO(), max_bytes=100)
+
+ assert error.value.size_bytes == 1_000
+ sandbox.files.read_bytes_stream.assert_not_called()
+
+ def test_a_missing_file_is_an_error_when_the_sandbox_exists(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.side_effect = _api_error(404)
+
+ with pytest.raises(SandboxError, match="does not exist"):
+ backend.export_file("box-1", "/w/missing", io.BytesIO(),
max_bytes=100)
+
+ sandbox.get_info.assert_called_once()
+
+ def test_an_empty_lookup_means_the_file_does_not_exist(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {}
+
+ with pytest.raises(SandboxError, match="does not exist"):
+ backend.export_file("box-1", "/w/missing", io.BytesIO(),
max_bytes=100)
+
+ def test_a_lookup_that_fails_on_the_server_is_terminal(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.side_effect = _api_error(500)
+
+ with pytest.raises(SandboxTerminalError, match="inspect a sandbox
file"):
+ backend.export_file("box-1", "/w/out.bin", io.BytesIO(),
max_bytes=100)
+
+ def
test_a_relative_path_uses_the_entry_the_server_keyed_by_absolute_path(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/workspace/out.bin":
_entry("file", 5)}
+ sandbox.files.read_bytes_stream.return_value = _stream(b"hello")
+ dest = io.BytesIO()
+
+ backend.export_file("box-1", "out.bin", dest, max_bytes=100)
+
+ assert dest.getvalue() == b"hello"
+
+ def test_a_file_that_grows_past_the_budget_mid_copy_is_refused(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/out.bin":
_entry("file", 5)}
+ sandbox.files.read_bytes_stream.return_value = _stream(b"x" * 8, b"x"
* 8)
+
+ with pytest.raises(SandboxFileTooLargeError):
+ backend.export_file("box-1", "/w/out.bin", io.BytesIO(),
max_bytes=10)
+
+ def test_a_download_slower_than_its_deadline_is_ended(self, monkeypatch):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/out.bin":
_entry("file", 5)}
+ stream = _stream(b"he", b"llo")
+ sandbox.files.read_bytes_stream.return_value = stream
+ monkeypatch.setattr(base, "_export_allowance", lambda max_bytes: 0.0)
+
+ with pytest.raises(SandboxError, match="took longer than"):
+ backend.export_file("box-1", "/w/out.bin", io.BytesIO(),
max_bytes=100)
+
+ stream.close.assert_called_once()
+
+ def test_a_file_that_changed_size_is_an_error(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/out.bin":
_entry("file", 5)}
+ sandbox.files.read_bytes_stream.return_value = _stream(b"hello", b"!")
+
+ with pytest.raises(SandboxError, match="changed while it was
exported"):
+ backend.export_file("box-1", "/w/out.bin", io.BytesIO(),
max_bytes=100)
+
+ def test_a_symlink_is_exported_without_comparing_the_links_own_size(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/link":
_entry("symlink", 9)}
+ sandbox.files.read_bytes_stream.return_value = _stream(b"target
contents")
+ dest = io.BytesIO()
+
+ backend.export_file("box-1", "/w/link", dest, max_bytes=100)
+
+ assert dest.getvalue() == b"target contents"
+
+ def test_a_destination_failure_is_not_reported_as_a_sandbox_error(self):
+ backend, sandbox = _backend_with_sandbox()
+ sandbox.files.get_file_info.return_value = {"/w/out.bin":
_entry("file", 5)}
+ stream = _stream(b"hello")
+ sandbox.files.read_bytes_stream.return_value = stream
+ dest = mock.MagicMock(spec=["write"])
+ dest.write.side_effect = OSError("No space left on device")
+
+ with pytest.raises(OSError, match="No space left"):
+ backend.export_file("box-1", "/w/out.bin", dest, max_bytes=100)
+
+ stream.close.assert_called_once()
+
+
class TestGetSandbox:
@mock.patch("opensandbox.SandboxSync.connect", autospec=True)
def test_uncached_sandbox_is_reconnected_and_then_cached(self, connect):
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
index a34bd6d098b..9cc04b40e48 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_sbx.py
@@ -17,13 +17,19 @@
from __future__ import annotations
import base64
+import io
+import os
import subprocess
+import textwrap
+import time
from unittest.mock import patch
import pytest
+from airflow.providers.common.ai.sandbox import base, sbx
from airflow.providers.common.ai.sandbox.base import (
SandboxError,
+ SandboxFileTooLargeError,
SandboxSpec,
SandboxTerminalError,
)
@@ -327,6 +333,106 @@ class TestWriteFileOverride:
backend.write_file("box", "/w/a", b"x")
+class TestExportFileOverride:
+ """
+ sbx overrides export_file to stream one ``exec`` into the destination.
+
+ Driven through a stand-in ``sbx`` binary that runs the guest command on
this host,
+ so the real subprocess, the stream and the watchdog are what is under
test. It
+ prints a daemon notice first, as the real CLI does when it starts its
daemon.
+ """
+
+ @pytest.fixture
+ def local_sbx(self, tmp_path):
+ fake = tmp_path / "sbx"
+ fake.write_text(
+ textwrap.dedent(
+ """\
+ #!/bin/sh
+ [ "$1" = exec ] || exit 2
+ shift 2
+ echo "Starting sandboxd daemon..." >&2
+ [ -n "$FAKE_SBX_STALL" ] && sleep "$FAKE_SBX_STALL"
+ "$@"
+ rc=$?
+ # A later size report stands in for a file that changed
mid-copy.
+ [ -n "$FAKE_SBX_SIZE" ] && printf
'\\nairflow-export-size:%s\\n' "$FAKE_SBX_SIZE" >&2
+ exit $rc
+ """
+ )
+ )
+ fake.chmod(0o755)
+ return SbxSandboxBackend(sbx_path=str(fake),
host_network_policy="deny-all")
+
+ def test_streams_the_file_byte_for_byte(self, local_sbx, tmp_path):
+ blob = os.urandom(3 * 1024 * 1024 + 7)
+ (tmp_path / "model.bin").write_bytes(blob)
+ dest = io.BytesIO()
+
+ written = local_sbx.export_file("box", str(tmp_path / "model.bin"),
dest, max_bytes=10 * 1024 * 1024)
+
+ assert written == len(blob)
+ assert dest.getvalue() == blob
+
+ def test_a_missing_file_is_an_error(self, local_sbx, tmp_path):
+ with pytest.raises(SandboxError, match="does not exist"):
+ local_sbx.export_file("box", str(tmp_path / "nope.bin"),
io.BytesIO(), max_bytes=100)
+
+ def test_a_file_over_the_budget_reports_its_size_despite_the_notice(self,
local_sbx, tmp_path):
+ (tmp_path / "big.bin").write_bytes(b"x" * 500)
+ dest = io.BytesIO()
+
+ with pytest.raises(SandboxFileTooLargeError) as error:
+ local_sbx.export_file("box", str(tmp_path / "big.bin"), dest,
max_bytes=100)
+
+ assert error.value.size_bytes == 500
+ assert dest.getvalue() == b""
+
+ def test_a_destination_that_fails_stops_the_copy(self, local_sbx,
tmp_path):
+ (tmp_path / "out.bin").write_bytes(os.urandom(1024 * 1024))
+
+ class FullDisk(io.RawIOBase):
+ def writable(self):
+ return True
+
+ def write(self, b):
+ raise OSError("No space left on device")
+
+ with pytest.raises(OSError, match="No space left"):
+ local_sbx.export_file("box", str(tmp_path / "out.bin"),
FullDisk(), max_bytes=10 * 1024 * 1024)
+
+ def test_a_file_that_changed_size_while_copied_is_an_error(self,
local_sbx, tmp_path, monkeypatch):
+ (tmp_path / "out.bin").write_bytes(b"x" * 10)
+ monkeypatch.setenv("FAKE_SBX_SIZE", "11")
+
+ with pytest.raises(SandboxError, match="changed while it was
exported"):
+ local_sbx.export_file("box", str(tmp_path / "out.bin"),
io.BytesIO(), max_bytes=100)
+
+ def test_a_transfer_that_keeps_trickling_is_ended_by_the_deadline(self,
local_sbx, tmp_path, monkeypatch):
+ # The stall clock is far off, so only the whole-copy deadline can end
this one.
+ (tmp_path / "out.bin").write_bytes(b"x")
+ monkeypatch.setenv("FAKE_SBX_STALL", "30")
+ monkeypatch.setattr(sbx, "_EXPORT_STALL_TIMEOUT", 3600.0)
+ monkeypatch.setattr(base, "_export_allowance", lambda max_bytes: 0.5)
+ start = time.monotonic()
+
+ with pytest.raises(SandboxError, match="took longer than"):
+ local_sbx.export_file("box", str(tmp_path / "out.bin"),
io.BytesIO(), max_bytes=100)
+
+ assert time.monotonic() - start < 10
+
+ def test_a_stalled_transfer_is_ended(self, local_sbx, tmp_path,
monkeypatch):
+ (tmp_path / "out.bin").write_bytes(b"x")
+ monkeypatch.setenv("FAKE_SBX_STALL", "30")
+ monkeypatch.setattr(sbx, "_EXPORT_STALL_TIMEOUT", 0.5)
+ start = time.monotonic()
+
+ with pytest.raises(SandboxError, match="stalled"):
+ local_sbx.export_file("box", str(tmp_path / "out.bin"),
io.BytesIO(), max_bytes=100)
+
+ assert time.monotonic() - start < 10
+
+
class TestDestroy:
def test_is_idempotent_when_the_sandbox_is_already_gone(self, backend):
with patch.object(backend, "_run_cli", autospec=True,
return_value=_completed(returncode=1)):
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
index 9759b10ec96..48d9f7edc5c 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
@@ -49,6 +49,7 @@ from airflow.providers.common.ai.sandbox.base import (
from airflow.providers.common.ai.tools import ToolCallError
from airflow.providers.common.ai.toolsets import sandbox as sandbox_module
from airflow.providers.common.ai.toolsets.sandbox import SandboxToolset
+from airflow.providers.common.compat.sdk import ObjectStoragePath
from unit.common.ai.sandbox.fake_tags import InMemoryTagStore
@@ -79,6 +80,12 @@ class _RecordingBackend(SandboxBackend):
self.create_error = create_error
self.read_payload = b""
self.read_error: Exception | None = None
+ # What the agent left in each sandbox, for export_file to hand back.
+ self.files: dict[str, bytes] = {}
+ # (sandbox, path, max_bytes, whether the sandbox was already destroyed)
+ self.exported: list[tuple[str, str, int, bool]] = []
+ # Paths whose copy writes some bytes and then fails, as a copy cut off
partway does.
+ self.fail_partway: set[str] = set()
def create(self, *, spec: SandboxSpec | None = None) -> str:
self.created.append(spec)
@@ -105,6 +112,16 @@ class _RecordingBackend(SandboxBackend):
def list_directory(self, sandbox, path):
return list(self.entries)
+ def export_file(self, sandbox, path, dest, *, max_bytes):
+ self.exported.append((sandbox, path, max_bytes, sandbox in
self.destroyed))
+ if path not in self.files:
+ raise SandboxError(f"{path!r} does not exist in the sandbox, or is
not readable.")
+ if path in self.fail_partway:
+ dest.write(self.files[path][:1])
+ raise SandboxError(f"the sandbox stopped sending {path!r}")
+ dest.write(self.files[path])
+ return len(self.files[path])
+
def destroy(self, sandbox):
self.destroyed.append(sandbox)
if self.destroy_error is not None:
@@ -157,6 +174,7 @@ class TestInit:
"max_output_lines",
"max_output_bytes",
"max_read_bytes",
+ "max_export_bytes",
],
)
def test_rejects_non_positive_bounds(self, field, bad):
@@ -779,6 +797,280 @@ class TestForRun:
assert isinstance(forked, CustomToolset)
+class TestExports:
+ @pytest.mark.parametrize(
+ ("kwargs", "match"),
+ [
+ ({"exports": {"": "file:///tmp/x"}}, "neither may be empty"),
+ ({"exports": {"out.bin": ""}}, "neither may be empty"),
+ ({"export_conn_id": "aws"}, "export_conn_id only applies together
with exports"),
+ ],
+ )
+ def test_constructor_refuses_a_shape_that_cannot_work(self, kwargs, match):
+ with pytest.raises(ValueError, match=match):
+ SandboxToolset(_RecordingBackend(), **kwargs)
+
+ def test_an_attached_sandbox_cannot_export(self):
+ # The task that owns an attached sandbox reads out what the agent left.
+ with pytest.raises(ValueError, match="exports cannot be combined with
attach_to"):
+ SandboxToolset(_AttachableRecordingBackend(), attach_to="sb-1",
exports={"a": "file:///tmp/a"})
+
+ @pytest.mark.asyncio
+ async def
test_a_finished_run_copies_each_file_out_before_the_sandbox_is_destroyed(self,
tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out/report.parquet": b"PAR1...", "chart.png":
b"\x89PNG"}
+ ts = SandboxToolset(
+ backend,
+ exports={
+ "out/report.parquet": f"file://{tmp_path}/report.parquet",
+ "chart.png": f"file://{tmp_path}/chart.png",
+ },
+ max_export_bytes=1234,
+ )
+
+ async with ts:
+ await _call(ts, "run_command", {"command": "make report"})
+
+ assert backend.exported == [
+ ("box-1", "out/report.parquet", 1234, False),
+ ("box-1", "chart.png", 1234, False),
+ ]
+ assert backend.destroyed == ["box-1"]
+ assert (tmp_path / "report.parquet").read_bytes() == b"PAR1..."
+ assert (tmp_path / "chart.png").read_bytes() == b"\x89PNG"
+ assert sorted(f.name for f in tmp_path.iterdir()) == ["chart.png",
"report.parquet"]
+
+ @pytest.mark.asyncio
+ async def
test_a_failed_run_exports_nothing_and_still_destroys_the_sandbox(self,
tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"half done"}
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ async def run_that_fails():
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+ raise RuntimeError("agent blew up")
+
+ with pytest.raises(RuntimeError, match="agent blew up"):
+ await run_that_fails()
+
+ assert backend.exported == []
+ assert backend.destroyed == ["box-1"]
+ assert not (tmp_path / "out.bin").exists()
+
+ @pytest.mark.asyncio
+ async def test_a_file_that_cannot_be_exported_fails_the_task(self,
tmp_path):
+ backend = _RecordingBackend()
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ with pytest.raises(SandboxTerminalError, match="Could not export
'out.bin' from sandbox box-1"):
+ async with ts:
+ await _call(ts, "run_command", {"command": "forgot to write
it"})
+
+ assert backend.destroyed == ["box-1"]
+ assert list(tmp_path.iterdir()) == []
+
+ @pytest.mark.asyncio
+ async def
test_a_copy_that_fails_partway_leaves_the_destination_as_it_was(self, tmp_path):
+ (tmp_path / "out.bin").write_bytes(b"ORIGINAL")
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"new contents"}
+ backend.fail_partway = {"out.bin"}
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ with pytest.raises(SandboxTerminalError, match="stopped sending"):
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert (tmp_path / "out.bin").read_bytes() == b"ORIGINAL"
+ assert [f.name for f in tmp_path.iterdir()] == ["out.bin"]
+
+ @pytest.mark.asyncio
+ async def test_for_run_carries_the_export_connection_across(self):
+ base = SandboxToolset(
+ _RecordingBackend(), exports={"out.bin": "s3://b/out.bin"},
export_conn_id="reports"
+ )
+
+ forked = await base.for_run(_ctx())
+
+ with patch.object(sandbox_module, "ObjectStoragePath", autospec=True)
as path_cls:
+ forked._export_target("s3://b/out.bin")
+
+ path_cls.assert_called_once_with("s3://b/out.bin", conn_id="reports")
+
+ @pytest.mark.asyncio
+ async def test_a_failed_export_changes_none_of_the_destinations(self,
tmp_path):
+ # Nothing is moved into place until every file has been copied, so a
consumer
+ # that runs whatever the outcome never finds half of a set.
+ (tmp_path / "first.bin").write_bytes(b"ORIGINAL")
+ backend = _RecordingBackend()
+ backend.files = {"first.bin": b"one"}
+ ts = SandboxToolset(
+ backend,
+ exports={
+ "first.bin": f"file://{tmp_path}/first.bin",
+ "second.bin": f"file://{tmp_path}/second.bin",
+ },
+ )
+
+ with pytest.raises(SandboxTerminalError, match="Could not export
'second.bin'"):
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert (tmp_path / "first.bin").read_bytes() == b"ORIGINAL"
+ assert [f.name for f in tmp_path.iterdir()] == ["first.bin"]
+
+ @pytest.mark.asyncio
+ async def test_a_publish_that_fails_names_what_is_already_in_place(self,
tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"first.bin": b"one", "second.bin": b"two"}
+ ts = SandboxToolset(
+ backend,
+ exports={
+ "first.bin": f"file://{tmp_path}/first.bin",
+ "second.bin": f"file://{tmp_path}/second.bin",
+ },
+ )
+ move = ObjectStoragePath.move
+
+ def fail_the_second(self, path, **kwargs):
+ if str(path).endswith("second.bin"):
+ raise OSError("permission denied")
+ return move(self, path, **kwargs)
+
+ with patch.object(ObjectStoragePath, "move", autospec=True,
side_effect=fail_the_second):
+ with pytest.raises(
+ SandboxTerminalError, match=r"second\.bin: permission
denied.*Already in place: .*first\.bin"
+ ):
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert sorted(f.name for f in tmp_path.iterdir()) == ["first.bin"]
+
+ @pytest.mark.asyncio
+ async def test_a_copy_that_cannot_be_removed_is_named_in_the_error(self,
tmp_path, caplog):
+ backend = _RecordingBackend()
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ with patch.object(ObjectStoragePath, "unlink", autospec=True,
side_effect=OSError("denied")):
+ with pytest.raises(
+ SandboxTerminalError, match=r"Left behind, to delete by hand:
.*out\.bin\.\w+\.partial"
+ ):
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert "Could not remove" in caplog.text
+
+ @pytest.mark.asyncio
+ async def test_a_local_destination_gets_its_directories(self, tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/run-1/nested/out.bin"})
+
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert (tmp_path / "run-1" / "nested" / "out.bin").read_bytes() ==
b"payload"
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("rendered", [None, "", "None",
"relative/out.bin"])
+ async def test_a_destination_that_is_not_a_url_fails_before_the_run(self,
rendered):
+ backend = _RecordingBackend()
+ ts = SandboxToolset(backend, exports={"out.bin": "{{ var.value.dest
}}"})
+ # What the templater writes after construction.
+ ts._exports = {"out.bin": rendered}
+
+ with pytest.raises(SandboxTerminalError, match="not a storage URL"):
+ async with ts:
+ pass
+
+ assert backend.created == []
+
+ def test_the_connection_is_not_passed_when_none_is_set(self):
+ # An explicit conn_id=None discards a connection named in the URL on
Airflow 3.0/3.1.
+ ts = SandboxToolset(_RecordingBackend(), exports={"out.bin":
"s3://conn@bucket/out.bin"})
+
+ with patch.object(sandbox_module, "ObjectStoragePath", autospec=True)
as path_cls:
+ ts._export_target("s3://conn@bucket/out.bin")
+
+ path_cls.assert_called_once_with("s3://conn@bucket/out.bin")
+
+ @pytest.mark.asyncio
+ async def
test_a_teardown_failure_after_a_good_export_does_not_fail_the_task(self,
tmp_path, caplog):
+ backend = _RecordingBackend(destroy_error=RuntimeError("daemon went
away"))
+ backend.files = {"out.bin": b"payload"}
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ async with ts:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert (tmp_path / "out.bin").read_bytes() == b"payload"
+ assert "Failed to destroy sandbox box-1" in caplog.text
+
+ @pytest.mark.asyncio
+ async def
test_a_run_that_never_used_its_sandbox_cannot_deliver_its_files(self, tmp_path):
+ backend = _RecordingBackend()
+ ts = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ with pytest.raises(SandboxTerminalError, match="no sandbox
standing.*'out.bin'"):
+ async with ts:
+ pass
+
+ assert backend.created == []
+
+ @pytest.mark.asyncio
+ async def test_the_model_is_told_which_files_are_collected(self):
+ with_exports = SandboxToolset(_RecordingBackend(),
exports={"out/report.csv": "file:///tmp/r.csv"})
+ without = SandboxToolset(_RecordingBackend())
+
+ exported = (await
with_exports.get_tools(_ctx()))["run_command"].tool_def.description
+ plain = (await
without.get_tools(_ctx()))["run_command"].tool_def.description
+
+ assert "copied out of the sandbox" in exported
+ assert "out/report.csv" in exported
+ assert "copied out" not in plain
+
+ @pytest.mark.asyncio
+ async def test_for_run_carries_the_exports_across(self, tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+ base = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"}, max_export_bytes=99)
+
+ forked = await base.for_run(_ctx())
+ async with forked:
+ await _call(forked, "run_command", {"command": "x"})
+
+ assert backend.exported == [("box-1", "out.bin", 99, False)]
+ assert (tmp_path / "out.bin").read_bytes() == b"payload"
+
+ def test_a_with_block_exports_for_a_native_agent(self, tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+
+ with SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"}) as sandbox:
+ run_command = {tool.name: tool for tool in
sandbox.airflow_tools()}["run_command"]
+ asyncio.run(run_command.call({"command": "ls"}))
+
+ assert (tmp_path / "out.bin").read_bytes() == b"payload"
+ assert backend.destroyed == ["box-1"]
+
+ def test_a_native_agent_that_raises_exports_nothing(self, tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+
+ def native_run_that_fails():
+ with SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"}) as sandbox:
+ run_command = {tool.name: tool for tool in
sandbox.airflow_tools()}["run_command"]
+ asyncio.run(run_command.call({"command": "ls"}))
+ raise RuntimeError("native agent failed")
+
+ with pytest.raises(RuntimeError, match="native agent failed"):
+ native_run_that_fails()
+
+ assert backend.exported == []
+ assert backend.destroyed == ["box-1"]
+
+
class TestAttachMode:
"""
``attach_to``: use a sandbox another task provisioned, and leave it
standing.
@@ -1205,6 +1497,70 @@ class TestOutsideAnAgentRun:
class TestInsideAPydanticAIRun:
+ @staticmethod
+ def _model_calling(tool: str):
+ def model(messages, info):
+ if any(isinstance(p, ToolReturnPart) for m in messages for p in
m.parts):
+ return ModelResponse(parts=[TextPart("done")])
+ return ModelResponse(parts=[ToolCallPart(tool, {"command": "ls"},
tool_call_id="c")])
+
+ return FunctionModel(model)
+
+ def test_a_successful_run_exports_its_files(self, tmp_path):
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+ sandbox = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ Agent(self._model_calling("box_run_command"),
toolsets=[sandbox.prefixed("box")]).run_sync("go")
+
+ assert backend.exported == [("box-1", "out.bin", 1024**3, False)]
+ assert (tmp_path / "out.bin").read_bytes() == b"payload"
+
+ def test_a_run_started_inside_an_except_block_still_exports(self,
tmp_path):
+ # The exception already being handled when the run began is not the
run failing.
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+ sandbox = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+ agent = Agent(self._model_calling("run_command"), toolsets=[sandbox])
+
+ try:
+ raise KeyError("cache miss")
+ except KeyError:
+ result = agent.run_sync("go")
+
+ assert result.output == "done"
+ assert (tmp_path / "out.bin").read_bytes() == b"payload"
+
+ def
test_an_agent_held_open_exports_from_its_run_and_not_again_at_exit(self,
tmp_path):
+ # ``async with agent`` enters the toolset the author built; runs use
copies of it.
+ backend = _RecordingBackend()
+ backend.files = {"out.bin": b"payload"}
+ sandbox = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+ agent = Agent(self._model_calling("run_command"), toolsets=[sandbox])
+
+ async def held_open():
+ async with agent:
+ await agent.run("go")
+
+ asyncio.run(held_open())
+
+ assert backend.exported == [("box-1", "out.bin", 1024**3, False)]
+ assert (tmp_path / "out.bin").read_bytes() == b"payload"
+
+ def test_a_failed_run_exports_nothing(self, tmp_path):
+ # pydantic-ai closes its toolsets through an exit stack that passes
them no
+ # exception, so the toolset has to find the failure itself.
+ backend =
_RecordingBackend(run_error=SandboxTerminalError("credentials rejected"))
+ backend.files = {"out.bin": b"payload"}
+ sandbox = SandboxToolset(backend, exports={"out.bin":
f"file://{tmp_path}/out.bin"})
+
+ with pytest.raises(SandboxTerminalError, match="credentials rejected"):
+ Agent(self._model_calling("run_command"),
toolsets=[sandbox]).run_sync("go")
+
+ assert backend.exported == []
+ assert backend.destroyed == ["box-1"]
+ assert not (tmp_path / "out.bin").exists()
+
def test_the_run_opens_the_sandbox_and_destroys_it_when_it_ends(self):
backend = _RecordingBackend()