Source code for airflow.providers.common.ai.durable.strands_storage
# 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()
[docs]
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
[docs]
async def write(self, key: str, data: bytes) -> None:
await asyncio.to_thread(self._write, key, data)
[docs]
async def read(self, key: str) -> bytes | None:
return _decode(await asyncio.to_thread(self._store.get, _KEY_PREFIX + key))
[docs]
async def delete(self, key: str) -> None:
await asyncio.to_thread(self._delete, key)
[docs]
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