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 018169c5489 Reject durable replay and HITL review when an agent holds
a SandboxToolset (#73529)
018169c5489 is described below
commit 018169c5489ab1d5ea51391248feb2ce9d289cef
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Sep 22 11:35:07 2026 +0100
Reject durable replay and HITL review when an agent holds a SandboxToolset
(#73529)
A sandbox is destroyed when the agent run ends, so two operator features
that
assume otherwise produced wrong answers instead of errors. durable=True
replayed
cached tool results such as 'wrote the file' against a sandbox that no
longer
existed, then ran the first cache miss against a fresh empty one.
enable_hitl_review=True regenerated after feedback in a second run whose
message history described files the first run's sandbox had held.
AgentOperator
now refuses both at construction, looking inside prefixed, filtered and
combined
toolsets and inside Toolset capabilities, and names the two ways out.
A backend raising a recoverable SandboxError from create() used to escape
the
toolset's error mapping and fail the task by accident. It now fails it on
purpose: the model has no input into provisioning, so nothing it retries can
help, and Airflow's own retry is the right one. The create() contract says
so.
Also covers the liveness probe branch where a non-Modal error keeps the
handle.
---
providers/common/ai/docs/sandbox/configuration.rst | 27 ++++--
providers/common/ai/docs/sandbox/index.rst | 4 +-
.../airflow/providers/common/ai/operators/agent.py | 53 ++++++++++-
.../airflow/providers/common/ai/sandbox/base.py | 10 +-
.../providers/common/ai/toolsets/sandbox.py | 10 ++
.../airflow/providers/common/ai/utils/toolsets.py | 64 +++++++++++++
.../tests/unit/common/ai/operators/test_agent.py | 105 +++++++++++++++++++++
.../ai/tests/unit/common/ai/sandbox/test_modal.py | 42 +++++++++
.../tests/unit/common/ai/toolsets/test_sandbox.py | 55 ++++++++++-
.../ai/tests/unit/common/ai/utils/test_toolsets.py | 66 +++++++++++++
10 files changed, 423 insertions(+), 13 deletions(-)
diff --git a/providers/common/ai/docs/sandbox/configuration.rst
b/providers/common/ai/docs/sandbox/configuration.rst
index 379fa064f45..fa375be32aa 100644
--- a/providers/common/ai/docs/sandbox/configuration.rst
+++ b/providers/common/ai/docs/sandbox/configuration.rst
@@ -239,20 +239,33 @@ lifetime. Human output review is not part of the run: it
starts after the agent
has finished and the sandbox has been destroyed, so a review pause costs no
sandbox time and keeps no files.
+**A failed provisioning fails the task.** The model has no input into
``create``:
+it takes only the spec, which is fixed in the Dag file. So whatever a backend
+raises while provisioning is treated as terminal, even a ``SandboxError`` that
+would have been a model retry from a tool call. The task fails and Airflow's
own
+retry attempts the provisioning again, instead of the model spending its retry
+budget on an image tag or a credential it cannot see.
+
**Nothing survives the run.** A task retry starts from an empty sandbox, and so
-does every other attempt. Two operator features assume otherwise and must not
be
-combined with a sandbox today, because neither is rejected:
+does every other attempt. Two operator features assume otherwise, and
+``AgentOperator`` refuses each of them at construction when any toolset,
including
+one nested inside ``.prefixed()``, a combined toolset or a ``Toolset``
capability,
+is a ``SandboxToolset``:
- ``durable=True`` caches each tool result and replays it on a retry without
- calling the backend, so a replayed ``write_file`` reports success while no
- sandbox exists, and the first call that misses the cache runs against a fresh
- empty one. The model is handed a filesystem that does not match what it was
just
- told, and nothing raises.
+ calling the backend, so a replayed ``write_file`` would report success while
no
+ sandbox exists, and the first call that misses the cache would run against a
+ fresh empty one.
- ``enable_hitl_review=True`` regenerates after reviewer feedback by starting a
second agent run, and the first run's sandbox was destroyed when that run
ended.
- The regenerated agent gets an empty sandbox while its own history describes
+ The regenerated agent would get an empty sandbox while its own history
describes
files it wrote earlier.
+The two ways out are dropping the flag, or moving the sandbox work into its own
+task and keeping the durable or reviewed agent free of sandbox tools. A toolset
+resolved per run from a callable cannot be inspected when the operator is
built,
+so it is the one composition the check does not see.
+
Cost and operations
-------------------
diff --git a/providers/common/ai/docs/sandbox/index.rst
b/providers/common/ai/docs/sandbox/index.rst
index 32908cfc61e..daa15497efd 100644
--- a/providers/common/ai/docs/sandbox/index.rst
+++ b/providers/common/ai/docs/sandbox/index.rst
@@ -385,8 +385,8 @@ is the list to read before designing a Dag around an agent
with a sandbox.
- **A credential cannot come from a connection or a secrets backend**; the
spec is
fixed at parse time, and anything injected is readable by the model.
:ref:`Credentials <sandbox-credentials>`.
-- **Do not combine with** ``durable=True`` **or** ``enable_hitl_review=True``.
- Neither is rejected today. :ref:`Lifecycle <sandbox-lifecycle>`.
+- **Cannot be combined with** ``durable=True`` **or**
``enable_hitl_review=True``;
+ ``AgentOperator`` raises. :ref:`Lifecycle <sandbox-lifecycle>`.
- **A run that outlives** ``sandbox_timeout`` **fails the task.**
:ref:`Lifecycle <sandbox-lifecycle>`.
- **The hostname allowlist is a weak control** and refused unless opted into.
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
index 6d3b1f71fa3..4038edf9f12 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
@@ -26,6 +26,7 @@ from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar
from pydantic import BaseModel
+from pydantic_ai.capabilities import Toolset
from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
from airflow.providers.common.ai.mixins.hitl_review import HITLReviewMixin
@@ -33,8 +34,10 @@ from airflow.providers.common.ai.observability import (
build_run_identity_attributes,
stamp_identity_on_agent_spans,
)
+from airflow.providers.common.ai.toolsets.sandbox import SandboxToolset
from airflow.providers.common.ai.utils.logging import log_run_summary,
wrap_toolsets_for_logging
from airflow.providers.common.ai.utils.output_type import
rehydrate_pydantic_output
+from airflow.providers.common.ai.utils.toolsets import find_toolset
from airflow.providers.common.ai.utils.usage import coerce_usage_limits
from airflow.providers.common.compat.sdk import (
AirflowOptionalProviderFeatureException,
@@ -199,6 +202,9 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
retry; put tools you need replayed in ``toolsets=``. Provider-native
capabilities such as ``WebSearch`` and ``Thinking`` execute inside the
model call and are covered by model-response caching.
+ Cannot be combined with a ``SandboxToolset`` (raises): a sandbox is
+ destroyed when the run ends, so replayed tool results would describe
+ files that no longer exist.
:param code_mode: When ``True``, wraps the agent's tools in a single
``run_code`` tool powered by the Monty sandbox (pydantic-ai-harness
``CodeMode``). Instead of one model round-trip per tool call, the model
@@ -234,7 +240,10 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
iterative review loop after the first generation. A human reviewer
can approve, reject, or request changes via the plugin's REST API
at ``/hitl-review`` or through the **HITL Review** extra link
- on the task instance. Default ``False``.
+ on the task instance. Default ``False``. Cannot be combined with a
+ ``SandboxToolset`` (raises): regeneration after feedback is a second
+ run, which starts from an empty sandbox while its history describes
+ the first run's files.
:param max_hitl_iterations: Maximum outputs shown to the reviewer (1 =
initial output). When the reviewer requests changes at
iteration >= this limit, the task fails with ``HITLMaxIterationsError``
@@ -350,11 +359,52 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
# surface the final message history.
raise ValueError("message_history and enable_hitl_review=True
cannot be used together.")
+ if durable or enable_hitl_review:
+ self._reject_sandbox_without_continuity(durable=durable,
enable_hitl_review=enable_hitl_review)
+
self.enable_hitl_review = enable_hitl_review
self.max_hitl_iterations = max_hitl_iterations
self.hitl_timeout = hitl_timeout
self.hitl_poll_interval = hitl_poll_interval
+ def _reject_sandbox_without_continuity(self, *, durable: bool,
enable_hitl_review: bool) -> None:
+ """
+ Refuse a ``SandboxToolset`` under a feature that assumes the sandbox
outlives the run.
+
+ A sandbox is provisioned on the first tool call and destroyed when the
run
+ ends, so nothing in it survives into a retry or a second run. Two
features
+ assume otherwise, and each produces a wrong answer rather than an
error:
+
+ * ``durable=True`` replays cached tool results on a retry without
calling the
+ backend, so a replayed ``write_file`` reports success while no
sandbox exists,
+ and the first call that misses the cache runs against a fresh, empty
one.
+ * ``enable_hitl_review=True`` regenerates after reviewer feedback by
starting a
+ second agent run, which gets an empty sandbox while its message
history still
+ describes the files the first run wrote.
+
+ The toolset is looked for inside wrappers and combinations
(``.prefixed()``,
+ ``.filtered()``, several toolsets passed together) and inside
``Toolset``
+ capabilities, since those are the compositions the documentation
recommends.
+ A toolset resolved per run from a callable cannot be inspected here.
+ """
+ candidates = list(self.toolsets or [])
+ for capability in self.agent_params.get("capabilities") or ():
+ if isinstance(capability, Toolset) and not
callable(capability.toolset):
+ candidates.append(capability.toolset)
+ if find_toolset(candidates, SandboxToolset) is None:
+ return
+ flag = "durable=True" if durable else "enable_hitl_review=True"
+ why = (
+ "cached tool results would be replayed against a sandbox that no
longer exists"
+ if durable
+ else "a regenerated run would start from an empty sandbox while
its history describes "
+ "files from the first run"
+ )
+ raise ValueError(
+ f"{flag} cannot be used with a SandboxToolset: {why}. "
+ f"Drop {flag}, or move the sandbox work into its own task."
+ )
+
@cached_property
def llm_hook(self) -> PydanticAIHook:
"""Return PydanticAIHook for the configured LLM connection."""
@@ -418,7 +468,6 @@ class AgentOperator(BaseOperator, HITLReviewMixin):
# pydantic-ai (and the pydantic-ai-importing CachingToolset) are
imported
# lazily to keep them out of DAG-parse-time imports, matching
# ``_build_durable_toolsets`` and the rest of this module.
- from pydantic_ai.capabilities import Toolset
from pydantic_ai.toolsets.abstract import AbstractToolset
from airflow.providers.common.ai.durable.caching_toolset import
CachingToolset
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 34a58ff1778..d43a0cdf3e8 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
@@ -36,7 +36,9 @@ class SandboxError(Exception):
A sandbox operation failed in a way the agent may be able to work around.
The toolset turns this into a ``ModelRetry`` so the model can adjust and
try
- again within the run (a bad path, a command the image cannot run).
+ again within the run (a bad path, a command the image cannot run). Raised
+ from :meth:`SandboxBackend.create` it is treated as terminal instead, since
+ the model cannot influence provisioning.
"""
@@ -171,6 +173,12 @@ class SandboxBackend(ABC):
backend cannot enforce, rather than provisioning something weaker than
was asked for. It is terminal rather than recoverable because it states
a configuration fact the model cannot see and cannot fix by retrying.
+
+ Every failure raised here is terminal, whichever class carries it. The
+ model has no input into provisioning, so a :class:`SandboxError` from
+ ``create`` is not something it can work around; the toolset re-raises
one
+ as :class:`SandboxTerminalError` and fails the task, so Airflow's retry
+ attempts the provisioning again.
"""
@abstractmethod
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 15870bb657c..d06288cd512 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
@@ -280,6 +280,16 @@ class SandboxToolset(AbstractToolset[Any]):
# __aexit__ can destroy a sandbox created during cancellation.
self._sandbox = await create_task
raise
+ except SandboxTerminalError:
+ raise
+ except SandboxError as e:
+ # Provisioning takes only the spec, which the model cannot see or
change,
+ # so no retry the model makes can turn a failed create into a
working
+ # sandbox. Fail the task and let Airflow's retry try the
provisioning
+ # again, rather than spending the model's retry budget on it.
+ raise SandboxTerminalError(
+ f"Could not provision a sandbox on backend
{self._backend.name!r}: {e}"
+ ) from e
else:
self._sandbox = sandbox
return sandbox
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/toolsets.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/toolsets.py
new file mode 100644
index 00000000000..3df255aa816
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/toolsets.py
@@ -0,0 +1,64 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Walk a pydantic-ai toolset and the toolsets it wraps or combines."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from pydantic_ai.toolsets.combined import CombinedToolset
+from pydantic_ai.toolsets.wrapper import WrapperToolset
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Iterator
+
+ from pydantic_ai.toolsets.abstract import AbstractToolset
+
+
+def iter_toolsets(toolset: AbstractToolset[Any]) ->
Iterator[AbstractToolset[Any]]:
+ """
+ Yield ``toolset`` and every toolset nested inside it, outermost first.
+
+ pydantic-ai composes toolsets by wrapping: ``.prefixed()``,
``.filtered()``,
+ ``.prepared()`` and ``.renamed()`` each return a :class:`WrapperToolset`
around
+ the original, and passing several toolsets together produces a
+ :class:`CombinedToolset`. A check that only looks at the top level
therefore
+ misses a toolset the moment an author composes it, which the documentation
+ recommends for running two sandboxes on one agent. This walks both shapes.
+
+ A toolset resolved per run from a callable (``DynamicToolset``) has nothing
+ to walk until the run starts, so it is yielded as itself and its contents
are
+ not inspected.
+ """
+ stack: list[AbstractToolset[Any]] = [toolset]
+ while stack:
+ current = stack.pop()
+ yield current
+ if isinstance(current, WrapperToolset):
+ stack.append(current.wrapped)
+ elif isinstance(current, CombinedToolset):
+ stack.extend(reversed(current.toolsets))
+
+
+def find_toolset(toolsets: Iterable[AbstractToolset[Any]], cls: type) ->
AbstractToolset[Any] | None:
+ """Return the first toolset of type ``cls`` in ``toolsets``, looking
inside wrappers, or ``None``."""
+ for toolset in toolsets:
+ for nested in iter_toolsets(toolset):
+ if isinstance(nested, cls):
+ return nested
+ return None
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 a5d49b11926..89411f55992 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
@@ -37,6 +37,7 @@ from pydantic_ai.messages import (
UserPromptPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel
+from pydantic_ai.toolsets.combined import CombinedToolset
from pydantic_ai.toolsets.function import FunctionToolset
from pydantic_ai.usage import RequestUsage, UsageLimits
@@ -45,7 +46,9 @@ from airflow.providers.common.ai.durable.caching_toolset
import CachingToolset
from airflow.providers.common.ai.durable.step_counter import DurableStepCounter
from airflow.providers.common.ai.durable.storage import DurableStorage
from airflow.providers.common.ai.operators.agent import AgentOperator,
HITLReviewLink, _build_code_mode
+from airflow.providers.common.ai.sandbox.base import SandboxBackend
from airflow.providers.common.ai.toolsets.logging import LoggingToolset
+from airflow.providers.common.ai.toolsets.sandbox import SandboxToolset
from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_PLUS,
AIRFLOW_V_3_3_PLUS
@@ -1212,6 +1215,108 @@ class TestAgentOperatorHITLArgumentChecks:
)
+class _NoopBackend(SandboxBackend):
+ """A backend that is never reached: these tests stop at the operator's
constructor."""
+
+ name = "noop"
+
+ def create(self, *, spec=None):
+ raise AssertionError("constructor guards must not provision")
+
+ def run_command(self, sandbox, command, *, timeout, max_output_bytes):
+ raise AssertionError("constructor guards must not run commands")
+
+ def destroy(self, sandbox):
+ pass
+
+
+def _sandbox_toolset():
+ return SandboxToolset(_NoopBackend())
+
+
+class TestAgentOperatorSandboxContinuityGuards:
+ """
+ A sandbox is destroyed when the run ends, so features that replay or rerun
against
+ it are refused up front rather than producing answers about files that are
gone.
+ """
+
+ @pytest.mark.parametrize(
+ "toolsets",
+ [
+ pytest.param([_sandbox_toolset()], id="direct"),
+ pytest.param([_sandbox_toolset().prefixed("box")], id="prefixed"),
+ pytest.param([_sandbox_toolset().filtered(lambda ctx, tool:
True)], id="filtered"),
+ pytest.param([CombinedToolset([FunctionToolset(),
_sandbox_toolset()])], id="combined"),
+ pytest.param(
+ [CombinedToolset([FunctionToolset(),
_sandbox_toolset().prefixed("a")]).prefixed("b")],
+ id="nested_twice",
+ ),
+ ],
+ )
+ def
test_durable_with_a_sandbox_toolset_is_rejected_wherever_it_hides(self,
toolsets):
+ with pytest.raises(ValueError, match="durable=True cannot be used with
a SandboxToolset"):
+ AgentOperator(task_id="t", prompt="p", llm_conn_id="c",
durable=True, toolsets=toolsets)
+
+ def test_durable_with_a_sandbox_in_a_toolset_capability_is_rejected(self):
+ """Tools reaching the agent through ``capabilities=[Toolset(...)]``
are durably cached
+ too, so the same replay-against-nothing applies to them."""
+ with pytest.raises(ValueError, match="durable=True cannot be used with
a SandboxToolset"):
+ AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ durable=True,
+ agent_params={"capabilities": [Toolset(_sandbox_toolset())]},
+ )
+
+ def
test_a_callable_toolset_capability_cannot_be_inspected_and_passes(self):
+ """A factory resolved per run has no concrete toolset to look inside
at parse time."""
+ op = AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ durable=True,
+ agent_params={"capabilities": [Toolset(lambda ctx:
_sandbox_toolset())]},
+ )
+ assert op.durable is True
+
+ @pytest.mark.skipif(
+ not AIRFLOW_V_3_1_PLUS, reason="Human in the loop is only compatible
with Airflow >= 3.1.0"
+ )
+ def test_hitl_review_with_a_sandbox_toolset_is_rejected(self):
+ with pytest.raises(ValueError, match="enable_hitl_review=True cannot
be used with a SandboxToolset"):
+ AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ enable_hitl_review=True,
+ toolsets=[_sandbox_toolset().prefixed("box")],
+ )
+
+ def test_the_message_names_the_ways_out(self):
+ with pytest.raises(ValueError, match="Drop durable=True, or move the
sandbox work into its own task"):
+ AgentOperator(
+ task_id="t", prompt="p", llm_conn_id="c", durable=True,
toolsets=[_sandbox_toolset()]
+ )
+
+ @pytest.mark.parametrize("flag", [{"durable": True},
{"enable_hitl_review": True}])
+ def test_the_flags_stay_usable_without_a_sandbox(self, flag):
+ if "enable_hitl_review" in flag and not AIRFLOW_V_3_1_PLUS:
+ pytest.skip("Human in the loop is only compatible with Airflow >=
3.1.0")
+ op = AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ toolsets=[FunctionToolset().prefixed("fn"),
CombinedToolset([FunctionToolset()])],
+ **flag,
+ )
+ assert op.toolsets is not None
+
+ def test_a_sandbox_toolset_without_either_flag_is_fine(self):
+ op = AgentOperator(task_id="t", prompt="p", llm_conn_id="c",
toolsets=[_sandbox_toolset()])
+ assert isinstance(op.toolsets[0], SandboxToolset)
+
+
class TestAgentOperatorRunIdentity:
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
def test_run_id_and_usage_pushed_to_xcom(self, mock_hook_cls,
make_mock_run_result):
diff --git a/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
b/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
index 11cea17ede3..41553d5b9a8 100644
--- a/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
+++ b/providers/common/ai/tests/unit/common/ai/sandbox/test_modal.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import builtins
import importlib
import json
+import logging
import sys
import threading
@@ -1052,6 +1053,47 @@ class TestMalformedHelperReply:
assert "run_command" not in str(caught.value)
+class TestLivenessProbe:
+ """
+ The probe after a deadline decides whether the handle is kept.
+
+ Only a Modal error is evidence that the sandbox is gone. Anything else
raised by the
+ probe says nothing about the sandbox, and the command it follows has
already produced
+ its output, so the handle is kept and the result is reported as a plain
timeout.
+ """
+
+ def test_a_non_modal_error_in_the_probe_keeps_the_handle(self, backend,
fake, caplog):
+ caplog.set_level(logging.DEBUG,
logger="airflow.providers.common.ai.sandbox.modal")
+ handle, sandbox = _created(backend, fake)
+ sandbox.process = FakeProcess(returncode=-1)
+ # Let the command through, then fail only the probe that follows it.
+ sandbox.exec_errors = [None, RuntimeError("client hiccup")]
+
+ result = backend.run_command(handle, "sleep 5", timeout=2,
max_output_bytes=1024)
+
+ assert result.timed_out is True
+ assert result.sandbox_terminated is False
+ assert "Could not check whether Modal sandbox" in caplog.text
+ # The handle is still cached, so the next command needs no lookup and
no create.
+ fake.Sandbox.from_id_error = AssertionError("a kept handle must not be
looked up again")
+ sandbox.process = FakeProcess(returncode=0)
+ assert backend.run_command(handle, "true", timeout=2,
max_output_bytes=1024).exit_code == 0
+ assert len(fake.Sandbox.by_id) == 1
+
+ def test_a_modal_error_in_the_probe_drops_the_handle(self, backend, fake):
+ handle, sandbox = _created(backend, fake)
+ sandbox.process = FakeProcess(returncode=-1)
+ sandbox.exec_errors = [None, fake.exception.NotFoundError("gone")]
+
+ result = backend.run_command(handle, "sleep 5", timeout=2,
max_output_bytes=1024)
+
+ assert result.sandbox_terminated is True
+ # The cached handle is gone, so a later call has to look the sandbox
up again.
+ fake.Sandbox.from_id_error = fake.exception.NotFoundError("gone")
+ with pytest.raises(SandboxTerminalError):
+ backend.run_command(handle, "true", timeout=2,
max_output_bytes=1024)
+
+
class TestDestroy:
def test_terminates_without_waiting(self, backend, fake):
"""``terminate(wait=True)`` took 31s when measured, and this runs in
every teardown."""
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 e248047e5c6..c05055c68dd 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
@@ -43,7 +43,14 @@ class _RecordingBackend(SandboxBackend):
name = "rec"
- def __init__(self, *, destroy_error: Exception | None = None,
run_result=None, run_error=None):
+ def __init__(
+ self,
+ *,
+ destroy_error: Exception | None = None,
+ run_result=None,
+ run_error=None,
+ create_error: Exception | None = None,
+ ):
self.created: list[SandboxSpec | None] = []
self.destroyed: list[str] = []
self.commands: list[tuple[str, str, float, int]] = []
@@ -52,11 +59,14 @@ class _RecordingBackend(SandboxBackend):
self.destroy_error = destroy_error
self.run_result = run_result
self.run_error = run_error
+ self.create_error = create_error
self.read_payload = b""
self.read_error: Exception | None = None
def create(self, *, spec: SandboxSpec | None = None) -> str:
self.created.append(spec)
+ if self.create_error is not None:
+ raise self.create_error
return f"box-{len(self.created)}"
def run_command(self, sandbox, command, *, timeout, max_output_bytes):
@@ -445,6 +455,49 @@ class TestErrorMapping:
with pytest.raises(SandboxTerminalError):
await _call(ts, "run_command", {"command": "x"})
+ @pytest.mark.asyncio
+ async def test_a_recoverable_error_from_create_is_terminal(self):
+ # The model has no say in provisioning, so a retry cannot fix a failed
+ # create. It must fail the task rather than reach the model as a
ModelRetry.
+ backend = _RecordingBackend(create_error=SandboxError("image pull
timed out"))
+ ts = SandboxToolset(backend)
+
+ async with ts:
+ with pytest.raises(
+ SandboxTerminalError, match="Could not provision.*image pull
timed out"
+ ) as caught:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert isinstance(caught.value.__cause__, SandboxError)
+ assert backend.destroyed == [], "nothing was provisioned, so nothing
is destroyed"
+
+ @pytest.mark.asyncio
+ async def test_a_terminal_error_from_create_propagates_unchanged(self):
+ error = SandboxTerminalError("credentials rejected")
+ ts = SandboxToolset(_RecordingBackend(create_error=error))
+
+ async with ts:
+ with pytest.raises(SandboxTerminalError) as caught:
+ await _call(ts, "run_command", {"command": "x"})
+
+ assert caught.value is error
+
+ @pytest.mark.asyncio
+ async def test_a_failed_create_is_retried_by_the_next_call(self):
+ # A create that raised left no sandbox behind, so the next tool call in
+ # the same run must try provisioning again rather than reuse a dead
task.
+ backend = _RecordingBackend(create_error=SandboxError("transient"))
+ ts = SandboxToolset(backend)
+
+ async with ts:
+ with pytest.raises(SandboxTerminalError):
+ await _call(ts, "run_command", {"command": "x"})
+ backend.create_error = None
+ assert await _call(ts, "run_command", {"command": "x"})
+
+ assert len(backend.created) == 2
+ assert backend.destroyed == ["box-2"]
+
@pytest.mark.asyncio
async def test_unknown_tool_raises(self):
ts = SandboxToolset(_RecordingBackend())
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_toolsets.py
b/providers/common/ai/tests/unit/common/ai/utils/test_toolsets.py
new file mode 100644
index 00000000000..e9a5bf2f8c7
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_toolsets.py
@@ -0,0 +1,66 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from pydantic_ai.toolsets.combined import CombinedToolset
+from pydantic_ai.toolsets.function import FunctionToolset
+
+from airflow.providers.common.ai.utils.toolsets import find_toolset,
iter_toolsets
+
+
+class _Marker(FunctionToolset):
+ """A toolset type worth finding."""
+
+
+class TestIterToolsets:
+ def test_a_plain_toolset_yields_itself(self):
+ ts = FunctionToolset()
+ assert list(iter_toolsets(ts)) == [ts]
+
+ def test_wrappers_are_unwrapped_outermost_first(self):
+ inner = FunctionToolset()
+ prefixed = inner.prefixed("a")
+ filtered = prefixed.filtered(lambda ctx, tool: True)
+
+ assert list(iter_toolsets(filtered)) == [filtered, prefixed, inner]
+
+ def test_a_combined_toolset_yields_its_members_in_order(self):
+ a, b = FunctionToolset(), FunctionToolset()
+ combined = CombinedToolset([a, b])
+
+ assert list(iter_toolsets(combined)) == [combined, a, b]
+
+ def
test_wrappers_inside_combinations_inside_wrappers_are_all_reached(self):
+ leaf = _Marker()
+ composed = CombinedToolset([FunctionToolset(),
leaf.prefixed("x")]).prefixed("y")
+
+ assert leaf in list(iter_toolsets(composed))
+
+
+class TestFindToolset:
+ def test_returns_the_nested_instance(self):
+ leaf = _Marker()
+ toolsets = [FunctionToolset(), CombinedToolset([leaf.prefixed("x")])]
+
+ assert find_toolset(toolsets, _Marker) is leaf
+
+ def test_returns_none_when_absent(self):
+ assert find_toolset([FunctionToolset().prefixed("x")], _Marker) is None
+
+ def test_empty_input(self):
+ assert find_toolset([], _Marker) is None