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 eee6e2a4a50 Resume Strands agents from their last checkpoint when a
task retries (#74404)
eee6e2a4a50 is described below
commit eee6e2a4a5005dad21f5422a9c26f83c33ab2a0a
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 01:38:31 2026 +0100
Resume Strands agents from their last checkpoint when a task retries
(#74404)
A task running a Strands Agents agent started the agent over from its
prompt on every retry. invoke_durably runs the agent with Strands'
checkpointing and a SnapshotSessionManager, saving the conversation and
the pause point together at each cycle boundary, so a retry resumes from
the last boundary instead. TaskStateStoreStorage keeps that state in the
task instance's task state store.
AirflowTools now keeps a tool's retry budget across checkpoint resumes,
which Strands runs as separate invocations.
---
docs/spelling_wordlist.txt | 1 +
providers/common/ai/docs/durable_execution.rst | 3 +-
providers/common/ai/docs/frameworks/strands.rst | 129 ++++++++-
.../airflow/providers/common/ai/durable/strands.py | 209 ++++++++++++++
.../providers/common/ai/durable/strands_storage.py | 137 +++++++++
.../ai/example_dags/example_strands_durable.py | 93 ++++++
.../airflow/providers/common/ai/tools/strands.py | 10 +-
.../tests/unit/common/ai/durable/test_strands.py | 314 +++++++++++++++++++++
.../unit/common/ai/durable/test_strands_storage.py | 156 ++++++++++
.../ai/tests/unit/common/ai/tools/test_strands.py | 54 +++-
scripts/in_container/run_agent_framework_tests.sh | 14 +-
11 files changed, 1106 insertions(+), 14 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 22ad6f3c217..8d0cd1dd1f1 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -29,6 +29,7 @@ afterall
agentcore
AgentDetails
agentic
+AgentResult
AgentVersionDetails
ai
aio
diff --git a/providers/common/ai/docs/durable_execution.rst
b/providers/common/ai/docs/durable_execution.rst
index ab6647f2492..0de990ce9c8 100644
--- a/providers/common/ai/docs/durable_execution.rst
+++ b/providers/common/ai/docs/durable_execution.rst
@@ -40,7 +40,8 @@ retries there is nothing to replay.
This page is about making an ``AgentOperator`` retry cheap. Deciding *whether*
a task
should retry at all is :doc:`retry_policies`; a retried ``LLMBatchOperator``
re-attaches
-to its running batch instead of resubmitting (:ref:`llm-batch-reattach`).
+to its running batch instead of resubmitting (:ref:`llm-batch-reattach`). To
resume a
+Strands Agents agent on a retry, see :ref:`howto/frameworks:strands-durable`.
**Configuration**
diff --git a/providers/common/ai/docs/frameworks/strands.rst
b/providers/common/ai/docs/frameworks/strands.rst
index 41fd9184512..75401a9a2e1 100644
--- a/providers/common/ai/docs/frameworks/strands.rst
+++ b/providers/common/ai/docs/frameworks/strands.rst
@@ -159,17 +159,138 @@ tracer, so an ``Agent`` created earlier in the process,
such as at module level,
content capture on. See
:doc:`../observability`.
+.. _howto/frameworks:strands-durable:
+
+Durable execution with Strands
+------------------------------
+
+When a task that calls ``agent(question)`` fails and Airflow retries it, the
agent
+starts again from the prompt: every model call is paid for again, and every
tool
+call happens again. On a task with ``retries``, run the agent with
+:func:`~airflow.providers.common.ai.durable.strands.invoke_durably` instead,
and a
+retry resumes it from the last cycle boundary it reached:
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_strands_durable.py
+ :language: python
+ :start-after: [START example_strands_durable]
+ :end-before: [END example_strands_durable]
+
+The example needs Airflow 3.3 or later, ``strands-agents``, a connection
holding an
+Anthropic API key, and a database connection; its module docstring lists the
details.
+
+``invoke_durably`` takes a function that builds the agent, here
+``functools.partial(Agent, ...)``, and calls it with two more arguments:
+``checkpointing=True`` and a ``SnapshotSessionManager``. Strands
+`pauses an agent built with checkpointing
+<https://github.com/strands-agents/harness-sdk/blob/main/strands-py/src/strands/experimental/checkpoint/checkpoint.py>`__
+at each boundary of a cycle that calls tools: once the model has asked for
tools,
+and once they have run. It leaves keeping the conversation to a session
manager.
+At each pause ``invoke_durably`` has the session manager save the conversation,
+with where the agent paused in ``agent.state["airflow_checkpoint"]``, as one
+snapshot in the task instance's
+:doc:`task state store <apache-airflow:core-concepts/task-state-store>`, and
the
+agent carries on. Strands saves the snapshot on its own only to redact or trim
the
+conversation it already holds, so a failed invocation leaves the last pause's
+snapshot as it was. On a retry, the agent is rebuilt from the snapshot and
resumed
+from the pause. The task log says where. This line comes from a test Dag whose
+model failed in its second cycle; ``cycle`` counts from 0:
+
+.. code-block:: text
+
+ [2026-10-07 11:03:38] INFO - Resuming the Strands agent from its last
checkpoint
session_id=f711ace605c22a218c598a0f9e9a19a3d392fbcf6551055d8af473bdff33dd72
position=after_tools cycle=0
+
+``invoke_durably`` returns at the agent's first stop that is not a pause: its
final
+answer, or an interrupt or a cancellation, which it does not resume. It
deletes the
+saved state then, so clearing the task later starts the agent over. If that
delete
+fails, the task still succeeds and logs ``Could not delete the Strands agent's
saved
+state``, and a later clear of the whole Dag run resumes from the last pause.
+
+A retry runs some work again:
+
+- It resumes from a cycle boundary. If the task fails while a cycle's tools
run,
+ the retry runs all of that cycle's tools again, the finished ones included,
so
+ tools that change data must still be safe to repeat.
+- A task that fails before the model's first request for tools starts over.
+- A retry whose prompt differs from the first try's starts over.
+- The state is deleted when ``invoke_durably`` returns, not when the task
succeeds.
+ If the task fails after that, for example in a second agent or while
returning
+ the answer, the retry runs the finished agent again from its prompt.
+- A ``SandboxToolset`` without ``attach_to`` is destroyed when the try ends,
so a
+ resumed agent works from a conversation that describes files the retry's new,
+ empty sandbox does not have. Attach the toolset to a sandbox another task
owns,
+ or keep sandbox work out of a durable agent.
+
+A retry resumes the saved conversation with the system prompt it was saved
with.
+So does clearing the whole Dag run, which keeps task state. To run a changed
system
+prompt on a failed task, clear the task itself without keeping its task state.
+
+An agent whose model keeps the conversation server-side (``model.stateful``)
raises
+``ValueError``: Strands' `session manager
+<https://github.com/strands-agents/harness-sdk/blob/main/strands-py/src/strands/session/snapshot_session_manager.py>`__
+drops a restored conversation for such a model, so it cannot be resumed.
+
+The snapshot holds the whole conversation (the prompt, the model's replies,
every
+tool call and result), the system prompt and ``agent.state``. It goes to the
+metadata database unless ``[workers] state_store_backend`` sends task state to
+external storage, and anyone who can read the task instance's task state can
read
+it. Each pause rewrites it whole, and once it is larger than
+``[state_store] max_value_storage_bytes`` (64 KB by default) every write logs a
+warning; the write still goes through. ``AirflowTools`` masks
+registered secrets in its tools' results; results of your own ``@tool``
functions
+are saved as returned. A task that never succeeds leaves its state in the task
+state store until the Dag run is deleted. A snapshot offloaded to a custom
+``[workers] state_store_backend`` stays in that storage after the rows are
gone,
+and clearing the task does not reach it.
+
+Within one try, a tool's retry limit in ``AirflowTools`` counts across the
agent's
+pauses; a task retry starts the count again. The ``AgentResult`` that
+``invoke_durably`` returns comes from the agent's last invocation. Its metrics
+cover every invocation of the current try, one per resume in
+``metrics.agent_invocations``, and none of the earlier tries.
+
+When a task runs more than one agent with ``invoke_durably``, give each a
+``session_id`` of its own that stays the same across tries.
+
+The task state store needs Airflow 3.3 or later. On an earlier version,
+``invoke_durably`` without ``storage`` raises
+``AirflowOptionalProviderFeatureException``. Pass a Strands storage instead,
such
+as ``S3Storage``, inside the task; ``AwsBaseHook`` comes from the Amazon
provider:
+
+.. code-block:: python
+
+ from strands.storage import S3Storage
+
+ from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook
+
+ storage = S3Storage(
+ "my-agent-state",
+ prefix="strands/",
+ boto_session=AwsBaseHook(aws_conn_id="aws_default").get_session(),
+ )
+ result = invoke_durably(agent, question, storage=storage)
+
+The default ``session_id`` is derived from the Dag id, run id, task id and map
+index, so it differs between Dag runs. If you pass your own ``session_id`` and
the
+storage is shared across Dag runs, as a bucket is, it must differ between runs
too. ``invoke_durably`` deletes the
+state when the agent finishes or a try starts over; a task that never succeeds
+leaves it in the bucket.
+
+Strands marks checkpointing experimental
(``strands.experimental.checkpoint``). It
+needs ``strands-agents`` 1.56 or later, as in
:ref:`howto/frameworks:strands-install`.
+For Pydantic AI agents, see :doc:`../durable_execution`.
+
Differences from ``AgentOperator``
----------------------------------
The agent is Strands', so features that ``AgentOperator`` implements on top of
-Pydantic AI do not apply:
+Pydantic AI work differently or do not apply:
-- A task retry runs the agent again from the start. There is no step-level
- replay as with ``AgentOperator(durable=True)``, so tools that change data
must
- be safe to repeat.
+- Durable execution resumes from a cycle boundary rather than replaying each
+ model call and tool call. See :ref:`howto/frameworks:strands-durable`.
- There is no human review step like
``AgentOperator(enable_hitl_review=True)``.
+.. _howto/frameworks:strands-install:
+
Installation
------------
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/durable/strands.py
b/providers/common/ai/src/airflow/providers/common/ai/durable/strands.py
new file mode 100644
index 00000000000..7050c4e6ba8
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/strands.py
@@ -0,0 +1,209 @@
+# 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.
+"""
+Resume a `Strands Agents <https://strandsagents.com/>`__ agent where it
stopped when Airflow retries its task.
+
+.. note:: Experimental, as Strands' checkpointing is; see
:mod:`strands.experimental.checkpoint`.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from typing import TYPE_CHECKING, Any, Protocol
+
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, get_current_context
+
+try:
+ from strands.session import SnapshotSessionManager
+ from strands.types.session import encode_bytes_values
+except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(e)
+
+from airflow.providers.common.ai.batch.state import compute_identity_key
+from airflow.providers.common.ai.utils.coroutines import run_coroutine_sync
+from airflow.providers.common.ai.utils.task_logger import get_task_logger
+from airflow.providers.common.compat.version_compat import AIRFLOW_V_3_3_PLUS
+
+if AIRFLOW_V_3_3_PLUS:
+ from airflow.providers.common.ai.durable.strands_storage import
TaskStateStoreStorage
+
+if TYPE_CHECKING:
+ from strands import Agent
+ from strands.agent import AgentResult
+ from strands.session import SessionManager
+ from strands.storage import Storage
+ from strands.types.agent import AgentInput
+
+__all__ = ["AgentFactory", "invoke_durably"]
+
+log = get_task_logger()
+
+# The key in ``agent.state`` that holds where the agent paused and for which
prompt.
+_CHECKPOINT_STATE_KEY = "airflow_checkpoint"
+
+
+class AgentFactory(Protocol):
+ """
+ Builds the agent :func:`invoke_durably` runs, wiring in the two arguments
it is called with.
+
+ ``functools.partial(Agent, model=model, tools=[...])`` is one.
+ """
+
+ def __call__(self, *, session_manager: SessionManager, checkpointing:
bool) -> Agent: ...
+
+
+def invoke_durably(
+ agent_factory: AgentFactory,
+ prompt: AgentInput,
+ *,
+ session_id: str | None = None,
+ storage: Storage | None = None,
+) -> AgentResult:
+ """
+ Run a Strands agent so that a retry of the task resumes it from its last
checkpoint.
+
+ Call it inside a task. The agent is built with Strands'
``checkpointing=True`` and a
+ ``SnapshotSessionManager``, so it pauses at every boundary of a cycle that
calls tools:
+ once the model has asked for tools, and once they have run. At each pause
the
+ conversation is saved, together with where the agent paused, before the
agent carries
+ on. When the task fails and Airflow retries it, the agent is rebuilt from
the last
+ pause, so the model calls and tool calls of the cycles before it do not
run again.
+ Once the agent stops for any other reason, such as its final answer, an
interrupt or
+ a cancellation, what was saved is deleted, so clearing the task later
starts the
+ agent over. :ref:`howto/frameworks:strands-durable` describes what a retry
runs again.
+
+ Pass a function that builds the agent, such as ``functools.partial(Agent,
...)``:
+
+ .. code-block:: python
+
+ from functools import partial
+
+ from strands import Agent
+
+ from airflow.providers.common.ai.durable.strands import invoke_durably
+ from airflow.providers.common.ai.tools.strands import AirflowTools
+ from airflow.sdk import task
+
+
+ @task(retries=3)
+ def research(question: str) -> str:
+ agent = partial(Agent, model=model,
plugins=[AirflowTools(warehouse)])
+ return str(invoke_durably(agent, question))
+
+ :param agent_factory: Builds the agent, given the ``session_manager`` and
+ ``checkpointing`` arguments to pass to ``Agent``. It is called once
per try, or
+ twice when the saved state is from a different prompt.
+ :param prompt: The prompt for the agent; ignored when resuming.
+ :param session_id: Names the agent's saved state. Defaults to an id
derived from the
+ task instance (Dag id, run id, task id and map index), the same on
every try. Pass
+ a distinct one for each agent when a task runs more than one; it must
stay the same
+ across tries, and when ``storage`` is shared across Dag runs it must
differ between
+ them.
+ :param storage: Where the agent's state is saved. Defaults to the task
instance's task
+ state store, on Airflow 3.3 and later; on older versions pass a
Strands storage such
+ as ``S3Storage``.
+ :return: The result of the agent's last invocation. Its metrics cover
every invocation
+ of this try, one per resume in ``metrics.agent_invocations``, and none
of the
+ earlier tries.
+ :raises ValueError: If the agent's model keeps the conversation server-side
+ (``model.stateful``), since Strands drops a restored conversation for
it.
+ """
+ context = get_current_context()
+ if storage is None:
+ if not AIRFLOW_V_3_3_PLUS:
+ raise AirflowOptionalProviderFeatureException(
+ "invoke_durably() saves to the task state store, which needs
Airflow 3.3 or later. "
+ "On older versions pass a Strands storage, such as
strands.storage.S3Storage, as storage=."
+ )
+ storage = TaskStateStoreStorage(context["task_state_store"])
+ if session_id is None:
+ ti = context["ti"]
+ # A hash: short (task state store keys are at most 512 characters) and
free of the
+ # characters Strands refuses in a session id, whatever the Dag, run
and task ids hold.
+ session_id = compute_identity_key(
+ dag_id=ti.dag_id,
+ task_id=ti.task_id,
+ run_id=ti.run_id,
+ map_index=ti.map_index if ti.map_index is not None else -1,
+ )
+ prompt_fingerprint = _build_fingerprint(prompt)
+
+ session_manager, agent = _build_agent(agent_factory, session_id, storage)
+ saved = agent.state.get(_CHECKPOINT_STATE_KEY)
+ request: AgentInput | dict[str, Any]
+ if isinstance(saved, dict) and saved.get("prompt") == prompt_fingerprint:
+ checkpoint = saved["checkpoint"]
+ log.info(
+ "Resuming the Strands agent from its last checkpoint",
+ session_id=session_id,
+ position=checkpoint["position"],
+ cycle=checkpoint["cycle_index"],
+ )
+ request = _build_checkpoint_resume(checkpoint)
+ else:
+ if saved is not None:
+ # The agent was restored from an earlier try with a different
prompt, so its
+ # conversation cannot be resumed. Start over from an empty one.
+ log.warning("The saved Strands agent state is for a different
prompt; starting over")
+ run_coroutine_sync(session_manager.delete_session())
+ session_manager, agent = _build_agent(agent_factory, session_id,
storage)
+ request = prompt
+
+ # Strands types the prompt as AgentInput, which leaves out its
checkpointResume block.
+ result = agent(request) # type: ignore[arg-type]
+ while result.checkpoint is not None:
+ checkpoint = result.checkpoint.to_dict()
+ # In the snapshot itself, so the conversation and the pause it belongs
to are saved
+ # in one write and can never disagree.
+ agent.state.set(_CHECKPOINT_STATE_KEY, {"checkpoint": checkpoint,
"prompt": prompt_fingerprint})
+ run_coroutine_sync(session_manager.save_snapshot(agent,
is_latest=True))
+ log.debug("Saved a Strands checkpoint", session_id=session_id,
**checkpoint)
+ result = agent(_build_checkpoint_resume(checkpoint)) # type:
ignore[arg-type]
+
+ try:
+ run_coroutine_sync(session_manager.delete_session())
+ except Exception:
+ # The agent has finished; a failed delete must not fail the task. What
is left is
+ # removed with the Dag run, and a retry resumes from the last pause.
+ log.warning("Could not delete the Strands agent's saved state",
session_id=session_id, exc_info=True)
+ return result
+
+
+def _build_agent(
+ agent_factory: AgentFactory, session_id: str, storage: Storage
+) -> tuple[SnapshotSessionManager, Agent]:
+ # "trigger" with no trigger: Strands saves on its own only to redact or
trim the
+ # conversation it holds, not when an invocation ends or fails, so the
snapshot stays
+ # at the last pause.
+ session_manager = SnapshotSessionManager(session_id, storage=storage,
save_latest_on="trigger")
+ agent = agent_factory(session_manager=session_manager, checkpointing=True)
+ if agent.model.stateful:
+ raise ValueError(
+ "invoke_durably() cannot resume an agent whose model keeps the
conversation "
+ "server-side (model.stateful): Strands drops a restored
conversation for it."
+ )
+ return session_manager, agent
+
+
+def _build_fingerprint(prompt: AgentInput) -> str:
+ # Bytes, such as an image in a content block, encoded the way Strands
saves them.
+ return hashlib.sha256(json.dumps(encode_bytes_values(prompt),
sort_keys=True).encode()).hexdigest()
+
+
+def _build_checkpoint_resume(checkpoint: dict[str, Any]) -> dict[str, Any]:
+ return {"checkpointResume": {"checkpoint": checkpoint}}
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/durable/strands_storage.py
b/providers/common/ai/src/airflow/providers/common/ai/durable/strands_storage.py
new file mode 100644
index 00000000000..3d6c9e9849a
--- /dev/null
+++
b/providers/common/ai/src/airflow/providers/common/ai/durable/strands_storage.py
@@ -0,0 +1,137 @@
+# 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.
+"""
+A `Strands Agents <https://strandsagents.com/>`__ ``Storage`` backed by the
task state store.
+
+Available on Airflow >= 3.3, where the task state store keeps key/value state
for a
+task instance across its tries within a Dag run; ``NEVER_EXPIRE`` does not
exist on older
+Airflow versions.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import bisect
+import builtins
+import threading
+from typing import TYPE_CHECKING
+
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+try:
+ from strands.storage import Storage
+except ImportError as e:
+ raise AirflowOptionalProviderFeatureException(e)
+
+from airflow.providers.common.ai.durable.base import DURABLE_KEY_PREFIX
+from airflow.sdk.execution_time.context import NEVER_EXPIRE
+
+if TYPE_CHECKING:
+ from pydantic import JsonValue
+
+ from airflow.sdk.execution_time.context import TaskStateStoreAccessor
+
+__all__ = ["TaskStateStoreStorage"]
+
+_KEY_PREFIX = f"{DURABLE_KEY_PREFIX}strands/"
+# The task state store cannot list keys by prefix, so the keys this storage
holds are kept
+# in a sorted list under one more key.
+_INDEX_KEY = f"{DURABLE_KEY_PREFIX}strands_index"
+# Every instance over the same task instance shares that list, so updates to
it are
+# serialized across the process, not per instance.
+_index_lock = threading.Lock()
+
+
+class TaskStateStoreStorage(Storage):
+ """
+ A Strands ``Storage`` that keeps its keys in the running task instance's
task state store.
+
+ The keys share the task instance's key namespace with anything user code
writes through
+ ``context["task_state_store"]``, under a reserved prefix that keeps them
apart. They are
+ written with ``NEVER_EXPIRE``, so a retry finds them whatever
``retry_delay`` or the
+ retention config is. Keys left behind by a task that never succeeds are
removed with
+ the Dag run.
+
+ A value that is valid UTF-8, such as a Strands session snapshot, is stored
as text, and
+ anything else as base64.
+
+ :param accessor: The task state store of the running task instance,
+ ``context["task_state_store"]``.
+ """
+
+ def __init__(self, accessor: TaskStateStoreAccessor) -> None:
+ self._store = accessor
+
+ async def write(self, key: str, data: bytes) -> None:
+ await asyncio.to_thread(self._write, key, data)
+
+ async def read(self, key: str) -> bytes | None:
+ return _decode(await asyncio.to_thread(self._store.get, _KEY_PREFIX +
key))
+
+ async def delete(self, key: str) -> None:
+ await asyncio.to_thread(self._delete, key)
+
+ async def list(self, query: str = "") -> builtins.list[str]:
+ keys = await asyncio.to_thread(self._load_index)
+ return [key for key in keys if key.startswith(query)]
+
+ def _write(self, key: str, data: bytes) -> None:
+ with _index_lock:
+ keys = self._load_index()
+ if key not in keys:
+ # Index first: a listed key with no value is harmless to
delete, while a value
+ # missing from the index would outlive a delete of everything
listed.
+ bisect.insort(keys, key)
+ self._save_index(keys)
+ self._store.set(_KEY_PREFIX + key, _encode(data),
retention=NEVER_EXPIRE)
+
+ def _delete(self, key: str) -> None:
+ # Value first, so a failure in between leaves the key listed for the
next delete.
+ self._store.delete(_KEY_PREFIX + key)
+ with _index_lock:
+ keys = self._load_index()
+ if key not in keys:
+ return
+ keys.remove(key)
+ self._save_index(keys)
+
+ def _save_index(self, keys: builtins.list[str]) -> None:
+ if not keys:
+ self._store.delete(_INDEX_KEY)
+ return
+ index: builtins.list[JsonValue] = [*keys]
+ self._store.set(_INDEX_KEY, index, retention=NEVER_EXPIRE)
+
+ def _load_index(self) -> builtins.list[str]:
+ keys = self._store.get(_INDEX_KEY)
+ return [key for key in keys if isinstance(key, str)] if
isinstance(keys, builtins.list) else []
+
+
+def _encode(data: bytes) -> JsonValue:
+ try:
+ return data.decode("utf-8")
+ except UnicodeDecodeError:
+ return {"base64": base64.b64encode(data).decode("ascii")}
+
+
+def _decode(value: JsonValue) -> bytes | None:
+ if isinstance(value, str):
+ return value.encode("utf-8")
+ if isinstance(value, dict) and isinstance(encoded := value.get("base64"),
str):
+ return base64.b64decode(encoded)
+ return None
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_durable.py
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_durable.py
new file mode 100644
index 00000000000..b1d1e6515bd
--- /dev/null
+++
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_durable.py
@@ -0,0 +1,93 @@
+# 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.
+"""
+Run a Strands Agents agent that a task retry resumes from its last checkpoint.
+
+The agent is the one in ``example_strands_agent``: an Anthropic model whose
API key
+comes from an Airflow connection, and a ``SQLToolset``'s tools through the
+:class:`~airflow.providers.common.ai.tools.strands.AirflowTools` plugin.
+:func:`~airflow.providers.common.ai.durable.strands.invoke_durably` runs it,
so when
+the task fails and Airflow retries it, the agent carries on from the last
cycle it
+finished instead of starting over.
+
+Before running:
+
+1. Use Airflow 3.3 or later, where the agent's state is kept in the task state
store.
+2. Install Strands: ``pip install "strands-agents>=1.56.0"``. The Anthropic
model
+ below also needs ``anthropic``, which the ``anthropic`` extra of this
provider
+ installs.
+3. Create a connection (``LLM_CONN_ID``, default ``anthropic_default``) whose
+ password is an Anthropic API key. Set its host only to route through a
gateway.
+4. Create a database connection (``DB_CONN_ID``, default ``sql_default``) whose
+ hook is a ``DbApiHook`` (e.g. SQLite, Postgres, MySQL).
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import timedelta
+from functools import partial
+
+from airflow.providers.common.compat.sdk import BaseHook, dag, task
+
+LLM_CONN_ID = os.environ.get("LLM_CONN_ID", "anthropic_default")
+LLM_MODEL = os.environ.get("LLM_MODEL", "claude-sonnet-5")
+DB_CONN_ID = os.environ.get("DB_CONN_ID", "sql_default")
+
+DEFAULT_QUESTION = "Which tables exist, and how many rows does each contain?"
+
+
+# [START example_strands_durable]
+@dag(tags=["example"])
+def example_strands_durable():
+ """Answer a question about a database with a Strands agent that survives
task retries."""
+
+ @task(retries=2, retry_delay=timedelta(minutes=1))
+ def run_durable_strands_agent(question: str = DEFAULT_QUESTION) -> str:
+ from strands import Agent
+ from strands.models.anthropic import AnthropicModel
+
+ from airflow.providers.common.ai.durable.strands import invoke_durably
+ from airflow.providers.common.ai.tools.strands import AirflowTools
+ from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+ llm = BaseHook.get_connection(LLM_CONN_ID)
+ model = AnthropicModel(
+ client_args={"api_key": llm.password, "base_url": llm.host or
None},
+ model_id=LLM_MODEL,
+ max_tokens=2048,
+ )
+ # invoke_durably builds the agent, adding the session manager and
checkpointing.
+ agent = partial(
+ Agent,
+ model=model,
+ plugins=[AirflowTools(SQLToolset(db_conn_id=DB_CONN_ID))],
+ system_prompt=(
+ "You are a SQL analyst. Use list_tables and get_schema to
explore "
+ "the database, then run read-only queries to answer the
question."
+ ),
+ callback_handler=None,
+ )
+ return str(invoke_durably(agent, question))
+
+ run_durable_strands_agent()
+
+
+# [END example_strands_durable]
+
+
+example_strands_durable()
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
b/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
index 3df6777269e..d9e80cf91c6 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/strands.py
@@ -87,7 +87,8 @@ class AirflowTools(Plugin):
result passes through Airflow's secret masker first. A failure the model
can
correct, such as a query naming a missing column, reaches it as a Strands
result
with ``status="error"``, counted against the tool's retry limit once per
model turn
- and afresh on every run of the agent. A tool that is still failing once
the limit is
+ and afresh each time the agent is given a new prompt; resuming from a
checkpoint
+ keeps the count. A tool that is still failing once the limit is
used up, or a failure the model cannot fix, such as a hook raising, fails
the agent
run, so the task fails and Airflow retries it. Strands on its own would
hand that
failure to the model and carry on. Tools a toolset marks sequential, such
as the
@@ -116,8 +117,11 @@ class AirflowTools(Plugin):
@hook
def _start_a_run(self, event: BeforeInvocationEvent) -> None:
- # Each call on the agent is a run of its own, with a fresh retry
budget per tool.
- self._run = object()
+ # Each call on the agent with a new prompt is a run of its own, with a
fresh retry
+ # budget per tool. A call that brings no new messages carries on the
run before it:
+ # resuming from a checkpoint or an interrupt, or calling the agent
with no prompt.
+ if event.messages != []:
+ self._run = object()
@hook
def _fail_the_run_on_tool_call_error(self, event: AfterToolCallEvent) ->
None:
diff --git a/providers/common/ai/tests/unit/common/ai/durable/test_strands.py
b/providers/common/ai/tests/unit/common/ai/durable/test_strands.py
new file mode 100644
index 00000000000..a5125bda811
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_strands.py
@@ -0,0 +1,314 @@
+# 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
+
+import asyncio
+import json
+import uuid
+from collections import Counter
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+from unittest import mock
+
+import pytest
+
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
+
+if not AIRFLOW_V_3_3_PLUS:
+ pytest.skip("task state store needs Airflow >= 3.3",
allow_module_level=True)
+
+pytest.importorskip("strands")
+
+from strands import Agent, tool
+from strands.storage import InMemoryStorage
+from strands.types.exceptions import MaxTokensReachedException
+
+from airflow.providers.common.ai.durable import strands as durable_strands
+from airflow.providers.common.ai.durable.strands import invoke_durably
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+from unit.common.ai.durable.test_task_state_store import FakeTaskStateStore
+from unit.common.ai.tools.test_strands import _OneToolCallModel
+
+if TYPE_CHECKING:
+ from strands.types.agent import AgentInput
+
+
+class _WorkerKilled(SystemExit):
+ """Stands in for the worker dying: Strands hands an exception from a tool
to the model."""
+
+
+class _ScriptedModel(_OneToolCallModel):
+ """
+ Turn 0 calls ``first``, turn 1 calls ``second`` and ``third`` together,
turn 2 answers.
+
+ The turn is the number of tool results in the conversation, so an agent
rebuilt from a
+ snapshot carries on from where the last one stopped.
+ """
+
+ def __init__(
+ self, turns: list[int], fail_at_turn: int | None, truncate_at_turn:
int | None = None
+ ) -> None:
+ super().__init__("first", {})
+ self._turns = turns
+ self._fail_at_turn = fail_at_turn
+ self._truncate_at_turn = truncate_at_turn
+
+ async def stream(self, messages, tool_specs=None, system_prompt=None,
**kwargs: Any):
+ turn = sum(1 for message in messages if any("toolResult" in block for
block in message["content"]))
+ self._turns.append(turn)
+ if turn == self._fail_at_turn:
+ raise RuntimeError(f"model unavailable at turn {turn}")
+ yield {"messageStart": {"role": "assistant"}}
+ if turn == self._truncate_at_turn:
+ # Strands keeps the cut-off answer in the conversation, then
raises.
+ yield {"contentBlockDelta": {"delta": {"text": "cut off"}}}
+ yield {"contentBlockStop": {}}
+ yield {"messageStop": {"stopReason": "max_tokens"}}
+ return
+ calls = {0: ["first"], 1: ["second", "third"]}.get(turn, [])
+ for name in calls:
+ start = {"toolUse": {"toolUseId": f"{name}-call", "name": name}}
+ yield {"contentBlockStart": {"start": start}}
+ yield {"contentBlockDelta": {"delta": {"toolUse": {"input":
json.dumps({})}}}}
+ yield {"contentBlockStop": {}}
+ if calls:
+ yield {"messageStop": {"stopReason": "tool_use"}}
+ return
+ yield {"contentBlockDelta": {"delta": {"text": "done"}}}
+ yield {"contentBlockStop": {}}
+ yield {"messageStop": {"stopReason": "end_turn"}}
+
+
+@dataclass
+class _TaskInstance:
+ dag_id: str = "research"
+ run_id: str = "manual__2026-10-07T00:00:00+00:00"
+ task_id: str = "answer"
+ map_index: int = -1
+ # Every try of a task instance gets a new id; the agent's state must not
be keyed on it.
+ id: uuid.UUID = field(default_factory=uuid.uuid4)
+
+
+class _Task:
+ """A task running a durable agent. Each ``run`` is one try; only stored
state carries over."""
+
+ def __init__(self) -> None:
+ self.store = FakeTaskStateStore()
+ self.tool_calls: Counter[str] = Counter()
+ self.model_turns: list[int] = []
+ self.agents: list[Agent] = []
+
+ def run(
+ self,
+ prompt: AgentInput = "go",
+ *,
+ fail_at_turn: int | None = None,
+ truncate_at_turn: int | None = None,
+ kill_in_tool: str | None = None,
+ map_index: int = -1,
+ **kwargs: Any,
+ ) -> str:
+ ti = _TaskInstance(map_index=map_index)
+ model = _ScriptedModel(self.model_turns, fail_at_turn,
truncate_at_turn)
+
+ def call(name: str) -> str:
+ self.tool_calls[name] += 1
+ if name == kill_in_tool:
+ raise _WorkerKilled
+ return f"{name} done"
+
+ @tool
+ def first() -> str:
+ """First tool."""
+ return call("first")
+
+ @tool
+ def second() -> str:
+ """Second tool."""
+ return call("second")
+
+ @tool
+ def third() -> str:
+ """Third tool."""
+ return call("third")
+
+ def factory(**durable_kwargs: Any) -> Agent:
+ agent = Agent(model=model, tools=[first, second, third],
callback_handler=None, **durable_kwargs)
+ self.agents.append(agent)
+ return agent
+
+ context = {"ti": ti, "task_state_store": self.store}
+ with mock.patch.object(durable_strands, "get_current_context",
autospec=True, return_value=context):
+ return str(invoke_durably(factory, prompt, **kwargs)).strip()
+
+
+def _get_texts(agent: Agent) -> list[str]:
+ """The text blocks of the agent's conversation: the prompts it was given
and its answers."""
+ return [block["text"] for message in agent.messages for block in
message["content"] if "text" in block]
+
+
[email protected]
+def task():
+ return _Task()
+
+
+class TestInvokeDurably:
+ def test_runs_the_agent_to_its_answer(self, task):
+ assert task.run() == "done"
+ assert task.tool_calls == {"first": 1, "second": 1, "third": 1}
+
+ def test_a_retry_resumes_after_the_last_finished_cycle(self, task):
+ with pytest.raises(RuntimeError, match="model unavailable at turn 1"):
+ task.run(fail_at_turn=1)
+
+ assert task.run() == "done"
+
+ # Cycle 1's model call and tool ran once; the retry started from cycle
2's model call.
+ assert task.tool_calls == {"first": 1, "second": 1, "third": 1}
+ assert task.model_turns == [0, 1, 1, 2]
+ assert _get_texts(task.agents[-1]) == ["go", "done"]
+
+ def test_a_retry_after_the_last_tool_cycle_only_asks_for_the_answer(self,
task):
+ with pytest.raises(RuntimeError):
+ task.run(fail_at_turn=2)
+
+ assert task.run() == "done"
+
+ assert task.tool_calls == {"first": 1, "second": 1, "third": 1}
+ assert task.model_turns == [0, 1, 2, 2]
+
+ def
test_a_failed_invocation_leaves_nothing_of_itself_in_the_saved_state(self,
task):
+ """Strands appends a cut-off answer before raising; the retry must not
inherit it."""
+ with pytest.raises(MaxTokensReachedException):
+ task.run(truncate_at_turn=1)
+
+ assert task.run() == "done"
+
+ assert _get_texts(task.agents[-1]) == ["go", "done"]
+ assert task.tool_calls == {"first": 1, "second": 1, "third": 1}
+
+ @pytest.mark.parametrize(
+ ("killed_in", "tool_calls"),
+ [
+ pytest.param("first", {"first": 2, "second": 1, "third": 1},
id="cycle 1"),
+ pytest.param("third", {"first": 1, "second": 2, "third": 2},
id="cycle 2"),
+ ],
+ )
+ def test_a_crash_inside_a_cycle_runs_all_of_its_tools_again(self, task,
killed_in, tool_calls):
+ """Strands resumes from a cycle boundary, so a cycle's tool batch is
all or nothing."""
+ with pytest.raises(_WorkerKilled):
+ task.run(kill_in_tool=killed_in)
+
+ assert task.run() == "done"
+
+ assert task.tool_calls == tool_calls
+ assert _get_texts(task.agents[-1]) == ["go", "done"]
+
+ def test_a_prompt_with_bytes_resumes(self, task):
+ """A document in the prompt is fingerprinted the way Strands saves
it."""
+ prompt = [
+ {"text": "go"},
+ {"document": {"format": "txt", "name": "orders", "source":
{"bytes": b"42"}}},
+ ]
+ with pytest.raises(RuntimeError):
+ task.run(prompt, fail_at_turn=1)
+
+ assert task.run(prompt) == "done"
+
+ assert task.tool_calls == {"first": 1, "second": 1, "third": 1}
+
+ def
test_a_failure_before_the_first_checkpoint_starts_over_with_one_prompt(self,
task):
+ with pytest.raises(RuntimeError):
+ task.run(fail_at_turn=0)
+
+ assert task.run() == "done"
+
+ assert _get_texts(task.agents[-1]) == ["go", "done"]
+
+ def test_a_retry_with_a_different_prompt_starts_over(self, task):
+ with pytest.raises(RuntimeError):
+ task.run(fail_at_turn=1)
+
+ assert task.run("go, but differently") == "done"
+
+ assert task.tool_calls == {"first": 2, "second": 1, "third": 1}
+ assert _get_texts(task.agents[-1]) == ["go, but differently", "done"]
+
+ @pytest.mark.parametrize("fail_at_turn", [None, 1], ids=["first try",
"after a retry"])
+ def test_the_saved_state_is_deleted_once_the_agent_finishes(self, task,
fail_at_turn):
+ if fail_at_turn is not None:
+ with pytest.raises(RuntimeError):
+ task.run(fail_at_turn=fail_at_turn)
+ assert task.store.store
+
+ task.run()
+
+ assert task.store.store == {}
+
+ def
test_a_failed_delete_after_the_agent_finishes_does_not_fail_the_task(self,
task):
+ class _DeleteFailsStore(FakeTaskStateStore):
+ def delete(self, key):
+ if key in self.store:
+ raise RuntimeError("api down")
+ super().delete(key)
+
+ task.store = _DeleteFailsStore()
+
+ assert task.run() == "done"
+
+ def test_a_strands_storage_can_replace_the_task_state_store(self, task):
+ storage = InMemoryStorage()
+ with pytest.raises(RuntimeError):
+ task.run(fail_at_turn=1, storage=storage)
+
+ assert task.run(storage=storage) == "done"
+
+ assert task.tool_calls == {"first": 1, "second": 1, "third": 1}
+ assert task.store.store == {}
+ assert asyncio.run(storage.list("")) == []
+
+ def
test_mapped_task_instances_sharing_a_storage_keep_their_own_state(self, task):
+ storage = InMemoryStorage()
+ with pytest.raises(RuntimeError):
+ task.run(fail_at_turn=1, map_index=0, storage=storage)
+
+ # Another mapped task instance starts its own agent rather than
resuming the first one's.
+ assert task.run(map_index=1, storage=storage) == "done"
+ assert task.tool_calls["first"] == 2
+
+ # And the first one still resumes where it stopped.
+ assert task.run(map_index=0, storage=storage) == "done"
+ assert task.tool_calls["first"] == 2
+
+ def test_refuses_a_stateful_model(self, task):
+ """Strands drops a restored conversation for a model that keeps it
server-side."""
+ with (
+ mock.patch.object(_ScriptedModel, "stateful",
new_callable=mock.PropertyMock, return_value=True),
+ pytest.raises(ValueError, match="stateful"),
+ ):
+ # The model fails at once if it is ever called, rather than loop
on lost history.
+ task.run(fail_at_turn=0)
+
+ assert task.model_turns == []
+
+ def test_needs_a_storage_below_airflow_3_3(self, task):
+ with (
+ mock.patch.object(durable_strands, "AIRFLOW_V_3_3_PLUS", False),
+ pytest.raises(AirflowOptionalProviderFeatureException,
match="Airflow 3.3"),
+ ):
+ task.run()
diff --git
a/providers/common/ai/tests/unit/common/ai/durable/test_strands_storage.py
b/providers/common/ai/tests/unit/common/ai/durable/test_strands_storage.py
new file mode 100644
index 00000000000..5fed3a69620
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/durable/test_strands_storage.py
@@ -0,0 +1,156 @@
+# 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
+
+import asyncio
+import time
+
+import pytest
+
+from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
+
+if not AIRFLOW_V_3_3_PLUS:
+ pytest.skip("task state store needs Airflow >= 3.3",
allow_module_level=True)
+
+pytest.importorskip("strands")
+
+from strands import Agent
+from strands.session import SnapshotSessionManager
+
+from airflow.providers.common.ai.durable.base import DURABLE_KEY_PREFIX
+from airflow.providers.common.ai.durable.strands_storage import
TaskStateStoreStorage
+from airflow.sdk.execution_time.context import NEVER_EXPIRE
+
+from unit.common.ai.durable.test_task_state_store import FakeTaskStateStore
+from unit.common.ai.tools.test_strands import _OneToolCallModel
+
+
[email protected]
+def accessor():
+ return FakeTaskStateStore()
+
+
[email protected]
+def storage(accessor):
+ return TaskStateStoreStorage(accessor)
+
+
+class TestTaskStateStoreStorage:
+ @pytest.mark.parametrize(
+ "data",
+ [
+ pytest.param(b'{"messages": ["\xe2\x82\xac 42"]}', id="utf-8"),
+ pytest.param(b"\x00\xff\xfe binary", id="binary"),
+ pytest.param(b"", id="empty"),
+ ],
+ )
+ def test_read_returns_the_bytes_written(self, storage, data):
+ asyncio.run(storage.write("session/a/snapshot.json", data))
+
+ assert asyncio.run(storage.read("session/a/snapshot.json")) == data
+
+ def test_read_of_a_missing_key_returns_none(self, storage):
+ assert asyncio.run(storage.read("session/missing.json")) is None
+
+ def test_writes_under_the_reserved_prefix_without_expiry(self, storage,
accessor):
+ asyncio.run(storage.write("session/a/snapshot.json", b"{}"))
+
+ assert set(accessor.store) == {
+ f"{DURABLE_KEY_PREFIX}strands/session/a/snapshot.json",
+ f"{DURABLE_KEY_PREFIX}strands_index",
+ }
+ assert set(accessor.set_retentions.values()) == {NEVER_EXPIRE}
+
+ def test_leaves_user_keys_alone(self, storage, accessor):
+ accessor.set("job_id", "abc")
+ asyncio.run(storage.write("job_id", b"agent"))
+ asyncio.run(storage.delete("job_id"))
+
+ assert accessor.store == {"job_id": "abc"}
+
+ def test_list_returns_matching_keys_sorted(self, storage):
+ for key in ("session/b/x.json", "context/a/y.json",
"session/a/z.json"):
+ asyncio.run(storage.write(key, b"{}"))
+
+ assert asyncio.run(storage.list("session/")) == ["session/a/z.json",
"session/b/x.json"]
+ assert asyncio.run(storage.list("")) == ["context/a/y.json",
"session/a/z.json", "session/b/x.json"]
+
+ def test_overwriting_a_key_lists_it_once(self, storage):
+ asyncio.run(storage.write("session/a.json", b"1"))
+ asyncio.run(storage.write("session/a.json", b"2"))
+
+ assert asyncio.run(storage.list("")) == ["session/a.json"]
+ assert asyncio.run(storage.read("session/a.json")) == b"2"
+
+ def test_deleting_every_key_leaves_nothing_in_the_store(self, storage,
accessor):
+ asyncio.run(storage.write("session/a.json", b"1"))
+ asyncio.run(storage.write("session/b.json", b"2"))
+
+ asyncio.run(storage.delete("session/a.json"))
+ assert asyncio.run(storage.list("")) == ["session/b.json"]
+ asyncio.run(storage.delete("session/b.json"))
+
+ assert accessor.store == {}
+
+ def test_deleting_a_missing_key_is_a_no_op(self, storage, accessor):
+ asyncio.run(storage.delete("session/missing.json"))
+
+ assert accessor.store == {}
+
+ def test_concurrent_writes_keep_every_key_listed(self):
+ """Strands deletes a session's keys concurrently; index updates must
not lose each other."""
+
+ class _SlowStore(FakeTaskStateStore):
+ def get(self, key, default=None):
+ value = super().get(key, default)
+ # Between this read of the index and the write that follows
it, so that
+ # unserialized updates would overwrite each other.
+ time.sleep(0.01)
+ return value
+
+ storage = TaskStateStoreStorage(_SlowStore())
+ keys = [f"session/{n}.json" for n in range(8)]
+
+ async def write_all():
+ await asyncio.gather(*(storage.write(key, b"{}") for key in keys))
+
+ asyncio.run(write_all())
+
+ assert asyncio.run(storage.list("")) == keys
+
+ def test_instances_over_one_task_instance_share_their_keys(self, accessor):
+ asyncio.run(TaskStateStoreStorage(accessor).write("session/a.json",
b"1"))
+ asyncio.run(TaskStateStoreStorage(accessor).write("session/b.json",
b"2"))
+
+ assert asyncio.run(TaskStateStoreStorage(accessor).list("")) ==
["session/a.json", "session/b.json"]
+
+ def test_backs_a_snapshot_session_manager(self, storage, accessor):
+ session_manager = SnapshotSessionManager("run-1", storage=storage)
+ agent = Agent(
+ model=_OneToolCallModel("lookup", {}),
session_manager=session_manager, callback_handler=None
+ )
+ agent.messages.append({"role": "user", "content": [{"text": "hello"}]})
+ session_manager.sync_agent(agent)
+
+ restored_manager = SnapshotSessionManager("run-1", storage=storage)
+ restored = Agent(
+ model=_OneToolCallModel("lookup", {}),
session_manager=restored_manager, callback_handler=None
+ )
+ assert restored.messages == [{"role": "user", "content": [{"text":
"hello"}]}]
+
+ asyncio.run(restored_manager.delete_session())
+ assert accessor.store == {}
diff --git a/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
b/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
index bc08c8b442d..2da97fb74aa 100644
--- a/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_strands.py
@@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio
import copy
import json
-from typing import Any
+from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
import pytest
@@ -40,6 +40,9 @@ from airflow.providers.common.ai.toolsets.sql import
SQLToolset
from unit.common.ai.toolsets.test_sql import _make_mock_db_hook
+if TYPE_CHECKING:
+ from strands.agent import AgentResult
+
class _OneToolCallModel(Model):
"""Asks for one call of ``tool_name``, then answers with the status and
text it got back."""
@@ -74,6 +77,37 @@ class _OneToolCallModel(Model):
yield {"messageStop": {"stopReason": "end_turn"}}
+class _RetryingModel(_OneToolCallModel):
+ """Calls ``tool_name`` again after every error result, up to ``attempts``
times, then answers."""
+
+ def __init__(self, tool_name: str, tool_input: dict[str, Any], *,
attempts: int) -> None:
+ super().__init__(tool_name, tool_input)
+ self._attempts = attempts
+
+ async def stream(self, messages, tool_specs=None, system_prompt=None,
**kwargs: Any):
+ calls = sum(1 for message in messages if any("toolResult" in block for
block in message["content"]))
+ yield {"messageStart": {"role": "assistant"}}
+ if calls < self._attempts:
+ start = {"toolUse": {"toolUseId": f"call-{calls}", "name":
self._tool_name}}
+ yield {"contentBlockStart": {"start": start}}
+ yield {"contentBlockDelta": {"delta": {"toolUse": {"input":
json.dumps(self._tool_input)}}}}
+ yield {"contentBlockStop": {}}
+ yield {"messageStop": {"stopReason": "tool_use"}}
+ return
+ yield {"contentBlockDelta": {"delta": {"text": "gave up"}}}
+ yield {"contentBlockStop": {}}
+ yield {"messageStop": {"stopReason": "end_turn"}}
+
+
+def _run_through_checkpoints(agent: Agent, prompt: str) -> AgentResult:
+ """Resume a checkpointing agent from each checkpoint it returns, until it
finishes."""
+ result = agent(prompt)
+ while result.checkpoint is not None:
+ # Strands types the prompt as AgentInput, which leaves out its
checkpointResume block.
+ result = agent({"checkpointResume": {"checkpoint":
result.checkpoint.to_dict()}}) # type: ignore[arg-type]
+ return result
+
+
def _tool(result: ToolResult | Exception) -> AirflowTool:
async def function(arguments: dict[str, Any]) -> ToolResult:
if isinstance(result, Exception):
@@ -188,6 +222,24 @@ class TestAgentRun:
assert str(agent("go")).startswith("error:")
assert str(agent("go again")).startswith("error:")
+ def test_resuming_from_a_checkpoint_keeps_the_retry_budget(self):
+ """With checkpointing every cycle is its own invocation; the budget
must still span the run."""
+
+ def lookup(key: str) -> str:
+ raise ModelRetry("no such key")
+
+ agent = Agent(
+ model=_RetryingModel("lookup", {"key": "a"}, attempts=3),
+
plugins=[AirflowTools(*airflow_tools_from_toolset(FunctionToolset([lookup],
max_retries=1)))],
+ callback_handler=None,
+ checkpointing=True,
+ )
+
+ with pytest.raises(EventLoopException) as caught:
+ _run_through_checkpoints(agent, "go")
+
+ assert isinstance(caught.value.original_exception, ToolCallError)
+
@pytest.mark.enable_redact
def test_a_secret_in_a_result_reaches_the_model_masked(self,
registered_secret):
answer = _run_agent(
diff --git a/scripts/in_container/run_agent_framework_tests.sh
b/scripts/in_container/run_agent_framework_tests.sh
index 2053c68fdc1..c2b584697f1 100755
--- a/scripts/in_container/run_agent_framework_tests.sh
+++ b/scripts/in_container/run_agent_framework_tests.sh
@@ -20,9 +20,9 @@
#
# Strands Agents caps mcp, and Google ADK caps opentelemetry and websockets,
below the versions
# uv.lock resolves, so their adapter tests are skipped everywhere else in CI.
This installs one
-# framework into the CI image and runs the tests of the framework-neutral
tools and their adapters;
-# the other framework's tests skip themselves. One framework per invocation,
so a bad release of
-# one cannot mask the other.
+# framework into the CI image and runs the tests of the framework-neutral
tools and their adapters,
+# and of durable execution for Strands; the other framework's tests skip
themselves. One framework
+# per invocation, so a bad release of one cannot mask the other.
#
# By default every package already in the image is held at its installed
version with uv's
# --override, so the framework is tested against the same dependencies as the
rest of Airflow and
@@ -32,7 +32,11 @@
# The newest framework release older than the repository's uv exclude-newer
window is installed.
set -euo pipefail
-TEST_PATH="providers/common/ai/tests/unit/common/ai/tools"
+TEST_PATHS=(
+ "providers/common/ai/tests/unit/common/ai/tools"
+ "providers/common/ai/tests/unit/common/ai/durable/test_strands.py"
+ "providers/common/ai/tests/unit/common/ai/durable/test_strands_storage.py"
+)
framework="${1:-}"
case "${framework}" in
@@ -87,4 +91,4 @@ uv pip freeze | grep -iE
'^(strands-agents|google-adk|mcp|opentelemetry-(api|sdk
python -c "${import_check}"
# --skip-db-tests: the job runs with backend "none", which has no database to
set up.
-pytest "${TEST_PATH}" --skip-db-tests -p no:cacheprovider --color=yes -ra
+pytest "${TEST_PATHS[@]}" --skip-db-tests -p no:cacheprovider --color=yes -ra