Key state storage by run id and namespace (#724)

This commit is contained in:
Adrian Lyjak
2026-07-07 18:48:04 -04:00
committed by GitHub
parent 126d40a66a
commit 26050d5803
30 changed files with 665 additions and 126 deletions
@@ -0,0 +1,7 @@
---
"llama-index-workflows": minor
"llama-agents-dbos": minor
"llama-agents-server": minor
---
State storage is now keyed by run id and namespace; existing rows migrate to the root namespace.
@@ -144,20 +144,24 @@ class DBOSWorkflowStore(AbstractWorkflowStore):
state_type: type[Any] | None = None,
serialized_state: dict[str, Any] | None = None,
serializer: BaseSerializer | None = None,
namespace: tuple[str, ...] = (),
) -> StateStore[Any]:
# Delegate the whole template method so memoization lives in the
# inner store's single cache (the proxy's own cache stays unused).
return self._resolve().create_state_store(
run_id, state_type, serialized_state, serializer
run_id, state_type, serialized_state, serializer, namespace
)
def _build_state_store(
self,
run_id: str,
namespace: tuple[str, ...],
state_type: type[Any] | None,
serializer: BaseSerializer | None,
) -> StateStoreFacade[Any]:
return self._resolve()._build_state_store(run_id, state_type, serializer)
return self._resolve()._build_state_store(
run_id, namespace, state_type, serializer
)
async def query(self, query: HandlerQuery) -> list[PersistentHandler]:
return await self._resolve().query(query)
@@ -1055,7 +1059,7 @@ class InternalDBOSAdapter(InternalRunAdapter):
self._db_path = db_path
self._closed = False
self._shutdown_event = asyncio.Event()
self._state_store: StateStore[Any] | None = None
self._state_stores: dict[tuple[str, ...], StateStore[Any]] = {}
# Journal for deterministic task ordering - lazily initialized
self._journal: TaskJournal | None = None
self._orphan_purge_done = False
@@ -1125,24 +1129,29 @@ class InternalDBOSAdapter(InternalRunAdapter):
self._resolved_pool = await self._pool_provider.get()
return self._resolved_pool
def _get_or_create_state_store(self) -> StateStore[Any]:
"""Get or lazily create the state store.
def _get_or_create_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any]:
"""Get or lazily create the per-namespace state store.
For PostgreSQL, the pool must be resolved first via _resolve_pool().
Call _ensure_resources() before accessing the state store.
"""
if self._state_store is None:
store = self._state_stores.get(namespace)
if store is None:
if self._resolved_pool is not None:
self._state_store = PostgresStateStore(
store = PostgresStateStore(
pool=self._resolved_pool,
run_id=self._run_id,
namespace=namespace,
state_type=cast(type[Any], self._state_type),
schema=self._schema,
)
elif self._db_path is not None:
self._state_store = SqliteStateStore(
store = SqliteStateStore(
db_path=self._db_path,
run_id=self._run_id,
namespace=namespace,
state_type=cast(type[Any], self._state_type),
)
else:
@@ -1150,10 +1159,13 @@ class InternalDBOSAdapter(InternalRunAdapter):
"No pool or db_path configured for state store. "
"Ensure the runtime pool is initialized before accessing state."
)
return self._state_store
self._state_stores[namespace] = store
return store
def get_state_store(self) -> StateStore[Any] | None:
return self._get_or_create_state_store()
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return self._get_or_create_state_store(namespace)
def is_replaying(self) -> bool:
if (
@@ -80,7 +80,9 @@ class StubInternalAdapter(InternalRunAdapter):
async def close(self) -> None:
self.closed = True
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -107,7 +109,9 @@ class StubExternalAdapter(ExternalRunAdapter):
async def get_result(self) -> StopEvent:
return self._result
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -820,7 +824,7 @@ async def test_do_resume_carries_over_serialized_state(
# Seed the state store with actual state data
state_store = InMemoryStateStore(MyState(counter=42))
store.state_stores["run-1"] = state_store
store.state_stores[("run-1", ())] = state_store
claim = await _claim_resume(lifecycle)
await decorator._do_resume("run-1", resume_claim=claim)
@@ -210,6 +210,7 @@ def create_agent_data_state_store(
monkeypatch: pytest.MonkeyPatch,
run_id: str,
state_type: type[Any] | None = None,
namespace: tuple[str, ...] = (),
) -> AgentDataStateStore[Any]:
"""Create an AgentDataStateStore with httpx patched to use the fake backend."""
client = AgentDataClient(
@@ -222,6 +223,7 @@ def create_agent_data_state_store(
store = AgentDataStateStore(
client=client,
run_id=run_id,
namespace=namespace,
state_type=state_type,
)
return store
@@ -116,12 +116,14 @@ async def _create_postgres_pool(dsn: str) -> asyncpg.Pool:
await conn.execute("CREATE SCHEMA IF NOT EXISTS dbos")
await conn.execute("""
CREATE TABLE IF NOT EXISTS dbos.workflow_state (
run_id VARCHAR(255) PRIMARY KEY,
run_id VARCHAR(255) NOT NULL,
namespace VARCHAR(255) NOT NULL DEFAULT '',
state_json TEXT NOT NULL,
state_type VARCHAR(255),
state_module VARCHAR(255),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
updated_at TIMESTAMPTZ,
PRIMARY KEY (run_id, namespace)
)
""")
return pool
@@ -73,22 +73,31 @@ class _ServerInternalRunAdapter(BaseInternalRunAdapterDecorator):
self._runtime = runtime
self._store = runtime._store
self._state_type = state_type
self._state_store: StateStore[Any] | None = None
self._state_stores: dict[tuple[str, ...], StateStore[Any]] = {}
self._write_lock: asyncio.Lock | None = None
@override
def get_state_store(self) -> StateStore[Any]:
if self._state_store is not None:
return self._state_store
initial = self._runtime._initial_state.pop(self.run_id, None)
def get_state_store(self, namespace: tuple[str, ...] = ()) -> StateStore[Any]:
cached = self._state_stores.get(namespace)
if cached is not None:
return cached
initial = (
None if namespace else self._runtime._initial_state.pop(self.run_id, None)
)
if initial is not None:
serialized_state, serializer = initial
store = self._store.create_state_store(
self.run_id, self._state_type, serialized_state, serializer
self.run_id,
self._state_type,
serialized_state,
serializer,
namespace=namespace,
)
else:
store = self._store.create_state_store(self.run_id, self._state_type)
self._state_store = store
store = self._store.create_state_store(
self.run_id, self._state_type, namespace=namespace
)
self._state_stores[namespace] = store
return store
@override
@@ -116,9 +116,9 @@ class AbstractWorkflowStore(ABC):
# Weak-valued by default so facades die with their last consumer.
# Backends needing a different lifecycle (strong refs + explicit
# eviction) assign a plain dict in their __init__.
self._state_store_cache: MutableMapping[str, StateStoreFacade[Any]] = (
weakref.WeakValueDictionary()
)
self._state_store_cache: MutableMapping[
tuple[str, tuple[str, ...]], StateStoreFacade[Any]
] = weakref.WeakValueDictionary()
async def start(self) -> None:
"""Initialize backend resources. Default is a no-op."""
@@ -129,18 +129,22 @@ class AbstractWorkflowStore(ABC):
state_type: type[Any] | None = None,
serialized_state: dict[str, Any] | None = None,
serializer: BaseSerializer | None = None,
namespace: tuple[str, ...] = (),
) -> StateStore[Any]:
"""Return the per-run state store, creating and caching it on first use.
"""Return the per-(run, namespace) state store, caching on first use.
One facade per run per process, so its write lock is a real guarantee.
If *serialized_state* is provided, it is staged as a seed on the
(possibly already handed-out) facade: validation is eager, the I/O to
materialize it stays lazy (first async state access or handoff).
Namespace is a key dimension alongside ``run_id``: the default ``()``
is the root namespace and reproduces the single-record behavior. One
facade per (run, namespace) per process, so its write lock is a real
guarantee. If *serialized_state* is provided, it is staged as a seed on
the (possibly already handed-out) facade: validation is eager, the I/O
to materialize it stays lazy (first async state access or handoff).
"""
store = self._state_store_cache.get(run_id)
cache_key = (run_id, namespace)
store = self._state_store_cache.get(cache_key)
if store is None:
store = self._build_state_store(run_id, state_type, serializer)
self._state_store_cache[run_id] = store
store = self._build_state_store(run_id, namespace, state_type, serializer)
self._state_store_cache[cache_key] = store
elif state_type is not None and store.state_type is DictState:
# An earlier type-less caller (e.g. handler continuation) must
# not shadow the workflow's concrete state type.
@@ -149,14 +153,20 @@ class AbstractWorkflowStore(ABC):
store.add_seed(serialized_state, serializer or JsonSerializer())
return store
def _evict_run_state_stores(self, run_id: str) -> None:
"""Drop every cached namespace facade for *run_id* (all namespaces)."""
for key in [k for k in self._state_store_cache if k[0] == run_id]:
self._state_store_cache.pop(key, None)
@abstractmethod
def _build_state_store(
self,
run_id: str,
namespace: tuple[str, ...],
state_type: type[Any] | None,
serializer: BaseSerializer | None,
) -> StateStoreFacade[Any]:
"""Construct the backend facade for a run. No caching, no seeding."""
"""Construct the backend facade for a (run, namespace). No caching."""
@abstractmethod
async def query(self, query: HandlerQuery) -> list[PersistentHandler]: ...
@@ -236,7 +246,6 @@ class AbstractWorkflowStore(ABC):
@staticmethod
def _is_terminal_event(event: StoredEvent) -> bool:
"""Check if a stored event is terminal (StopEvent or subclass, etc.)."""
types = (event.event.types or []) + [event.event.type]
return StopEvent.__name__ in types
@@ -23,12 +23,19 @@ from .agent_data_client import AgentDataClient
MODEL_T = TypeVar("MODEL_T", bound=BaseModel, default=DictState) # type: ignore[reportGeneralTypeIssues]
_FIELD_RUN_ID = "run_id"
_FIELD_NAMESPACE = "namespace"
_STATE_PAGE_SIZE = 100
class _AgentDataStateRecord(BaseModel):
"""Validates the shape persisted in the Agent Data API."""
"""Validates the shape persisted in the Agent Data API.
Root items carry no ``namespace`` field (matching pre-namespace items);
the ``""`` default reads both back as the root namespace.
"""
run_id: str
namespace: str = ""
data: str
state_type: str | None = None
state_module: str | None = None
@@ -55,10 +62,14 @@ class _AgentDataStateStorage:
*,
client: AgentDataClient,
run_id: str,
namespace: tuple[str, ...] = (),
collection: str = "workflow_state",
) -> None:
self._client = client
self._run_id = run_id
self._namespace = namespace
# Persisted key: () -> "" (today's single root item), ("child",) -> "child".
self._namespace_key = "/".join(namespace)
self._collection = collection
self._item_id: str | None = None
@@ -71,16 +82,37 @@ class _AgentDataStateStorage:
# HTTP-backed: no per-call connections, the storage scopes itself.
yield self
async def _load_record(self) -> _AgentDataStateRecord | None:
async def _matching_item(self) -> tuple[str, _AgentDataStateRecord] | None:
"""Find this namespace's item for the run.
Filtering is by ``run_id`` *and* ``namespace`` server-side, so the query
returns this namespace's single row directly rather than scanning the
run's first page and filtering in Python.
Root rows carry no ``namespace`` field (same shape as pre-namespace
rows), and the backend's ``eq: null`` matches a missing field, so the
root lookup is a single query covering legacy and new rows alike.
"""
namespace_filter = None if self._namespace_key == "" else self._namespace_key
items = await self._client.search(
self._collection,
{_FIELD_RUN_ID: {"eq": self._run_id}},
page_size=1,
{
_FIELD_RUN_ID: {"eq": self._run_id},
_FIELD_NAMESPACE: {"eq": namespace_filter},
},
)
if not items:
for item in items:
record = _AgentDataStateRecord.model_validate(item["data"])
if record.namespace == self._namespace_key:
return item["id"], record
return None
async def _load_record(self) -> _AgentDataStateRecord | None:
match = await self._matching_item()
if match is None:
return None
self._item_id = items[0]["id"]
return _AgentDataStateRecord.model_validate(items[0]["data"])
self._item_id, record = match
return record
async def load(self) -> StateRecord | None:
record = await self._load_record()
@@ -91,26 +123,26 @@ class _AgentDataStateStorage:
async def save(self, record: StateRecord) -> None:
stored = _AgentDataStateRecord(
run_id=self._run_id,
namespace=self._namespace_key,
data=record.data,
state_type=record.state_type,
state_module=record.state_module,
)
payload = stored.model_dump()
if self._namespace_key == "":
# Root rows keep the pre-namespace shape (no namespace field) so
# the root lookup stays a single eq-null query for all rows.
del payload[_FIELD_NAMESPACE]
if self._item_id is not None:
await self._client.update_item(self._item_id, payload)
return
match = await self._matching_item()
if match is not None:
self._item_id = match[0]
await self._client.update_item(self._item_id, payload)
else:
items = await self._client.search(
self._collection,
{_FIELD_RUN_ID: {"eq": self._run_id}},
page_size=1,
)
if items:
item_id = items[0]["id"]
self._item_id = item_id
await self._client.update_item(item_id, payload)
else:
result = await self._client.create(self._collection, payload)
self._item_id = result["id"]
result = await self._client.create(self._collection, payload)
self._item_id = result["id"]
def to_handle(self) -> dict[str, Any]:
payload = AgentDataSerializedState(
@@ -125,21 +157,69 @@ class _AgentDataStateStorage:
return None
return AgentDataSerializedState.model_validate(payload)
async def copy_from_handle(self, handle: AgentDataSerializedState) -> None:
"""Copy the source target's record into this one (no-op if absent).
async def _all_run_items(
self, collection: str, run_id: str
) -> AsyncIterator[dict[str, Any]]:
"""Yield every state item for a run, paginating in full.
Goes through ``save`` so ``_item_id`` stays consistent with the
copied row.
A run may hold more namespace rows than a single search page (one per
child invocation), so iterate with a keyset cursor over ``namespace``
(unique per run) rather than reading only the first page.
The root row has no ``namespace`` field, so comparison filters can
never match it — fetch it with its own eq-null query, then paginate
the namespaced rows (every child key sorts after ``""``).
"""
source = _AgentDataStateStorage(
client=self._client,
run_id=handle.run_id,
collection=handle.collection,
root_items = await self._client.search(
collection,
{
_FIELD_RUN_ID: {"eq": run_id},
_FIELD_NAMESPACE: {"eq": None},
},
)
record = await source.load()
if record is None:
return
await self.save(record)
for item in root_items:
yield item
cursor = ""
while True:
filters: dict[str, Any] = {
_FIELD_RUN_ID: {"eq": run_id},
_FIELD_NAMESPACE: {"gt": cursor},
}
page = await self._client.search(
collection,
filters,
page_size=_STATE_PAGE_SIZE,
order_by=_FIELD_NAMESPACE,
)
for item in page:
yield item
cursor = _AgentDataStateRecord.model_validate(item["data"]).namespace
if len(page) < _STATE_PAGE_SIZE:
return
async def copy_from_handle(self, handle: AgentDataSerializedState) -> None:
"""Copy every namespace item of the source run into this run.
The source's items are enumerated by ``run_id`` (paginated in full) and
each is saved under the same namespace in this run. Saves go through
per-namespace storages so each ``_item_id`` stays consistent.
"""
async for item in self._all_run_items(handle.collection, handle.run_id):
source = _AgentDataStateRecord.model_validate(item["data"])
namespace = tuple(source.namespace.split("/")) if source.namespace else ()
dest = _AgentDataStateStorage(
client=self._client,
run_id=self._run_id,
namespace=namespace,
collection=self._collection,
)
await dest.save(
StateRecord(
data=source.data,
state_type=source.state_type,
state_module=source.state_module,
)
)
class AgentDataStateStore(StateStoreFacade[MODEL_T], Generic[MODEL_T]):
@@ -156,12 +236,18 @@ class AgentDataStateStore(StateStoreFacade[MODEL_T], Generic[MODEL_T]):
*,
client: AgentDataClient,
run_id: str,
namespace: tuple[str, ...] = (),
state_type: type[MODEL_T] | None = None,
collection: str = "workflow_state",
serializer: BaseSerializer | None = None,
) -> None:
super().__init__(
_AgentDataStateStorage(client=client, run_id=run_id, collection=collection),
_AgentDataStateStorage(
client=client,
run_id=run_id,
namespace=namespace,
collection=collection,
),
state_type,
serializer,
)
@@ -169,7 +169,7 @@ class AgentDataStore(AbstractWorkflowStore):
# Clean up sequence counters and cached state store
self._event_sequences.pop(run_id, None)
self._tick_sequences.pop(run_id, None)
self._state_store_cache.pop(run_id, None)
self._evict_run_state_stores(run_id)
# ------------------------------------------------------------------
# Sequence helpers
@@ -491,12 +491,14 @@ class AgentDataStore(AbstractWorkflowStore):
def _build_state_store(
self,
run_id: str,
namespace: tuple[str, ...],
state_type: type[Any] | None,
serializer: BaseSerializer | None,
) -> AgentDataStateStore[Any]:
return AgentDataStateStore(
client=self._client,
run_id=run_id,
namespace=namespace,
state_type=state_type,
collection=f"{self._collection}_state",
serializer=serializer,
@@ -70,7 +70,7 @@ class MemoryWorkflowStore(AbstractWorkflowStore):
self.ticks: dict[str, list[StoredTick]] = {}
# Strong refs: facades live until eviction. Public alias kept for
# tests/plugins that inject stores; the ABC template reads the cache.
self.state_stores: dict[str, StateStoreFacade[Any]] = {}
self.state_stores: dict[tuple[str, tuple[str, ...]], StateStoreFacade[Any]] = {}
self._state_store_cache = self.state_stores
self._conditions: weakref.WeakValueDictionary[str, asyncio.Condition] = (
weakref.WeakValueDictionary()
@@ -81,6 +81,7 @@ class MemoryWorkflowStore(AbstractWorkflowStore):
def _build_state_store(
self,
run_id: str,
namespace: tuple[str, ...],
state_type: type[Any] | None,
serializer: BaseSerializer | None,
) -> InMemoryStateStore[Any]:
@@ -134,7 +135,7 @@ class MemoryWorkflowStore(AbstractWorkflowStore):
if run_id is not None:
self.events.pop(run_id, None)
self.ticks.pop(run_id, None)
self.state_stores.pop(run_id, None)
self._evict_run_state_stores(run_id)
def _get_or_create_condition(self, run_id: str) -> asyncio.Condition:
cond = self._conditions.get(run_id)
@@ -0,0 +1,8 @@
-- migration: 2
-- Per-namespace state records: namespace becomes a key dimension alongside
-- run_id. Existing single rows are root-namespace rows (namespace = '').
ALTER TABLE workflow_state ADD COLUMN IF NOT EXISTS namespace VARCHAR(255) NOT NULL DEFAULT '';
ALTER TABLE workflow_state DROP CONSTRAINT IF EXISTS workflow_state_pkey;
ALTER TABLE workflow_state ADD CONSTRAINT workflow_state_pkey PRIMARY KEY (run_id, namespace);
@@ -40,11 +40,15 @@ class _PostgresStateStorage:
self,
pool: asyncpg.Pool,
run_id: str,
namespace: tuple[str, ...] = (),
schema: str | None = None,
connection: asyncpg.Connection | asyncpg.pool.PoolConnectionProxy | None = None,
) -> None:
self._pool = pool
self._run_id = run_id
self._namespace = namespace
# Persisted key: () -> "" (today's single root row), ("child",) -> "child".
self._namespace_key = "/".join(namespace)
self._schema = schema
self._shared_conn = connection
@@ -81,15 +85,17 @@ class _PostgresStateStorage:
return
async with self._pool.acquire() as conn:
yield _PostgresStateStorage(
self._pool, self._run_id, self._schema, connection=conn
self._pool, self._run_id, self._namespace, self._schema, connection=conn
)
async def load(self) -> StateRecord | None:
"""Load raw state from the database."""
async with self._acquire() as conn:
row = await conn.fetchrow(
f"SELECT state_json FROM {self._table_ref} WHERE run_id = $1",
f"SELECT state_json FROM {self._table_ref} "
"WHERE run_id = $1 AND namespace = $2",
self._run_id,
self._namespace_key,
)
if row is None:
return None
@@ -101,15 +107,16 @@ class _PostgresStateStorage:
async with self._acquire() as conn:
await conn.execute(
f"""
INSERT INTO {self._table_ref} (run_id, state_json, state_type, state_module, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(run_id) DO UPDATE SET
INSERT INTO {self._table_ref} (run_id, namespace, state_json, state_type, state_module, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT(run_id, namespace) DO UPDATE SET
state_json = EXCLUDED.state_json,
state_type = EXCLUDED.state_type,
state_module = EXCLUDED.state_module,
updated_at = EXCLUDED.updated_at
""",
self._run_id,
self._namespace_key,
record.data,
record.state_type,
record.state_module,
@@ -129,15 +136,20 @@ class _PostgresStateStorage:
return PostgresSerializedState.model_validate(payload)
async def copy_from_handle(self, handle: PostgresSerializedState) -> None:
"""Copy state from another run's row using SQL INSERT...SELECT."""
"""Copy every namespace row from another run using INSERT...SELECT.
The ``run_id`` filter spans all namespaces, so a single statement
copies the source run's root and every child namespace; ``namespace``
is carried through unchanged.
"""
now = _utc_now()
async with self._acquire() as conn:
await conn.execute(
f"""
INSERT INTO {self._table_ref} (run_id, state_json, state_type, state_module, created_at, updated_at)
SELECT $1, state_json, state_type, state_module, $2, $3
INSERT INTO {self._table_ref} (run_id, namespace, state_json, state_type, state_module, created_at, updated_at)
SELECT $1, namespace, state_json, state_type, state_module, $2, $3
FROM {self._table_ref} WHERE run_id = $4
ON CONFLICT(run_id) DO UPDATE SET
ON CONFLICT(run_id, namespace) DO UPDATE SET
state_json = EXCLUDED.state_json,
state_type = EXCLUDED.state_type,
state_module = EXCLUDED.state_module,
@@ -157,12 +169,15 @@ class PostgresStateStore(StateStoreFacade[MODEL_T], Generic[MODEL_T]):
self,
pool: asyncpg.Pool,
run_id: str,
namespace: tuple[str, ...] = (),
state_type: type[MODEL_T] | None = None,
serializer: BaseSerializer | None = None,
schema: str | None = None,
) -> None:
super().__init__(
_PostgresStateStorage(pool, run_id, schema), state_type, serializer
_PostgresStateStorage(pool, run_id, namespace, schema),
state_type,
serializer,
)
@classmethod
@@ -298,6 +298,7 @@ class PostgresWorkflowStore(AbstractWorkflowStore):
def _build_state_store(
self,
run_id: str,
namespace: tuple[str, ...],
state_type: type[Any] | None,
serializer: BaseSerializer | None,
) -> PostgresStateStore[Any]:
@@ -308,6 +309,7 @@ class PostgresWorkflowStore(AbstractWorkflowStore):
return PostgresStateStore(
pool=self._pool,
run_id=run_id,
namespace=namespace,
state_type=state_type,
serializer=serializer,
schema=self._schema,
@@ -0,0 +1,23 @@
-- migration: 5
-- Per-namespace state records: namespace becomes a key dimension alongside
-- run_id. SQLite cannot ALTER a primary key, so rebuild the table with the
-- composite PK and migrate existing rows as root-namespace rows (namespace '').
CREATE TABLE workflow_state_new (
run_id TEXT NOT NULL,
namespace TEXT NOT NULL DEFAULT '',
state_json TEXT NOT NULL DEFAULT '{}',
state_type TEXT NOT NULL DEFAULT 'DictState',
state_module TEXT NOT NULL DEFAULT 'workflows.context.state_store',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (run_id, namespace)
);
INSERT INTO workflow_state_new (run_id, namespace, state_json, state_type, state_module, created_at, updated_at)
SELECT run_id, '', state_json, state_type, state_module, created_at, updated_at
FROM workflow_state;
DROP TABLE workflow_state;
ALTER TABLE workflow_state_new RENAME TO workflow_state;
@@ -40,10 +40,14 @@ class _SqliteStateStorage:
self,
db_path: str,
run_id: str,
namespace: tuple[str, ...] = (),
connection: sqlite3.Connection | None = None,
) -> None:
self._db_path = db_path
self._run_id = run_id
self._namespace = namespace
# Persisted key: () -> "" (today's single root row), ("child",) -> "child".
self._namespace_key = "/".join(namespace)
self._shared_conn = connection
@property
@@ -73,7 +77,9 @@ class _SqliteStateStorage:
return
conn = sqlite3.connect(self._db_path, timeout=30.0)
try:
yield _SqliteStateStorage(self._db_path, self._run_id, connection=conn)
yield _SqliteStateStorage(
self._db_path, self._run_id, self._namespace, connection=conn
)
finally:
conn.close()
@@ -82,8 +88,9 @@ class _SqliteStateStorage:
with self._connect() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT state_json FROM workflow_state WHERE run_id = ?",
(self._run_id,),
"SELECT state_json FROM workflow_state "
"WHERE run_id = ? AND namespace = ?",
(self._run_id, self._namespace_key),
)
row = cursor.fetchone()
if row is None:
@@ -96,9 +103,9 @@ class _SqliteStateStorage:
now = _utc_now().isoformat()
conn.execute(
"""
INSERT INTO workflow_state (run_id, state_json, state_type, state_module, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id) DO UPDATE SET
INSERT INTO workflow_state (run_id, namespace, state_json, state_type, state_module, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(run_id, namespace) DO UPDATE SET
state_json = excluded.state_json,
state_type = excluded.state_type,
state_module = excluded.state_module,
@@ -106,6 +113,7 @@ class _SqliteStateStorage:
""",
(
self._run_id,
self._namespace_key,
record.data,
record.state_type,
record.state_module,
@@ -125,13 +133,18 @@ class _SqliteStateStorage:
return SqliteSerializedState.model_validate(payload)
async def copy_from_handle(self, handle: SqliteSerializedState) -> None:
"""Copy state from another run's row using SQL INSERT...SELECT."""
"""Copy every namespace row from another run using INSERT...SELECT.
The ``run_id`` filter spans all namespaces, so a single statement
copies the source run's root and every child namespace; ``namespace``
is carried through unchanged.
"""
with self._connect() as conn:
now = _utc_now().isoformat()
conn.execute(
"""
INSERT OR REPLACE INTO workflow_state (run_id, state_json, state_type, state_module, created_at, updated_at)
SELECT ?, state_json, state_type, state_module, ?, ?
INSERT OR REPLACE INTO workflow_state (run_id, namespace, state_json, state_type, state_module, created_at, updated_at)
SELECT ?, namespace, state_json, state_type, state_module, ?, ?
FROM workflow_state WHERE run_id = ?
""",
(self._run_id, now, now, handle.run_id),
@@ -146,13 +159,16 @@ class SqliteStateStore(StateStoreFacade[MODEL_T], Generic[MODEL_T]):
self,
db_path: str,
run_id: str,
namespace: tuple[str, ...] = (),
state_type: type[MODEL_T] | None = None,
serializer: BaseSerializer | None = None,
connection: sqlite3.Connection | None = None,
) -> None:
self._db_path = db_path
super().__init__(
_SqliteStateStorage(db_path, run_id, connection), state_type, serializer
_SqliteStateStorage(db_path, run_id, namespace, connection),
state_type,
serializer,
)
@classmethod
@@ -93,12 +93,14 @@ class SqliteWorkflowStore(AbstractWorkflowStore):
def _build_state_store(
self,
run_id: str,
namespace: tuple[str, ...],
state_type: type[Any] | None,
serializer: BaseSerializer | None,
) -> SqliteStateStore[Any]:
return SqliteStateStore(
db_path=self.db_path,
run_id=run_id,
namespace=namespace,
state_type=state_type,
serializer=serializer,
connection=self._persistent_conn,
@@ -4,6 +4,7 @@
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from typing import Any
@@ -1028,3 +1029,136 @@ async def test_persist_error_does_not_block_in_memory_delivery(
await store.append_event("run-1", make_envelope(event=StopEvent(data="done")))
await asyncio.wait_for(task, timeout=2.0)
assert len(collected) == 3
# ---------------------------------------------------------------------------
# Per-namespace records
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_namespace_round_trip_and_isolation(
backend: FakeAgentDataBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Root and child namespaces persist as independent items under one run."""
root = create_agent_data_state_store(backend, monkeypatch, "run-ns")
child = create_agent_data_state_store(
backend, monkeypatch, "run-ns", namespace=("child",)
)
await root.set("k", "root-val")
await child.set("k", "child-val")
root2 = create_agent_data_state_store(backend, monkeypatch, "run-ns")
child2 = create_agent_data_state_store(
backend, monkeypatch, "run-ns", namespace=("child",)
)
assert await root2.get("k") == "root-val"
assert await child2.get("k") == "child-val"
@pytest.mark.asyncio
async def test_single_namespace_lookup_beyond_first_search_page(
backend: FakeAgentDataBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A namespace lookup filters by run_id AND namespace server-side, so it
finds a row even when the run holds more than one search page of rows."""
n = 150
for i in range(n):
s = create_agent_data_state_store(
backend, monkeypatch, "run-many", namespace=(f"child#{i}",)
)
await s.set("k", f"v{i}")
# A fresh store (no cache) for the last-written namespace must find its row.
late = create_agent_data_state_store(
backend, monkeypatch, "run-many", namespace=("child#149",)
)
assert await late.get("k") == "v149"
@pytest.mark.asyncio
async def test_copy_from_handle_copies_every_namespace_across_pages(
store: AgentDataStore,
) -> None:
"""Copying a run via its durable handle reproduces every namespace row,
paginating past the first search page."""
n = 150
root = store.create_state_store("run-src")
await root.set("k", "root")
for i in range(n):
child = store.create_state_store("run-src", namespace=(f"child#{i}",))
await child.set("k", f"v{i}")
handle = root.to_dict(JsonSerializer())
target_root = AgentDataStateStore.from_dict(
handle, JsonSerializer(), client=store._client, run_id="run-target"
)
await target_root.ensure_seeded()
assert await store.create_state_store("run-target").get("k") == "root"
for i in range(n):
tchild = store.create_state_store("run-target", namespace=(f"child#{i}",))
assert await tchild.get("k") == f"v{i}"
@pytest.mark.asyncio
async def test_root_lookup_is_single_query_matching_pre_namespace_rows(
backend: FakeAgentDataBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A row without a namespace field IS the root shape: the root lookup is a
single eq-null query that matches pre-namespace and new rows alike."""
serializer = JsonSerializer()
backend.create(
"test-deploy",
"workflow_state",
{
"run_id": "legacy-run",
"data": json.dumps({"_data": {"k": serializer.serialize("legacy")}}),
"state_type": "DictState",
"state_module": "workflows.context.state_store",
},
)
search_filters: list[dict[str, Any] | None] = []
original_search = backend.search
def counting_search(
deployment_name: str,
collection: str,
filters: dict[str, Any] | None = None,
page_size: int = 100,
order_by: str | None = None,
) -> list[dict[str, Any]]:
search_filters.append(filters)
return original_search(
deployment_name, collection, filters, page_size, order_by
)
monkeypatch.setattr(backend, "search", counting_search)
root = create_agent_data_state_store(backend, monkeypatch, "legacy-run")
assert await root.get("k") == "legacy"
assert search_filters == [
{"run_id": {"eq": "legacy-run"}, "namespace": {"eq": None}}
]
@pytest.mark.asyncio
async def test_root_write_omits_namespace_field(
backend: FakeAgentDataBackend, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Root rows persist without a namespace field; child rows carry theirs."""
root = create_agent_data_state_store(backend, monkeypatch, "run-shape")
child = create_agent_data_state_store(
backend, monkeypatch, "run-shape", namespace=("child",)
)
await root.set("k", "r")
await child.set("k", "c")
rows = [item["data"] for item in backend.search("test-deploy", "workflow_state")]
assert len(rows) == 2
root_rows = [row for row in rows if "namespace" not in row]
child_rows = [row for row in rows if row.get("namespace") == "child"]
assert len(root_rows) == 1
assert len(child_rows) == 1
@@ -51,7 +51,9 @@ class _RecordingInternalAdapter(InternalRunAdapter):
async def close(self) -> None:
self.closed = True
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -478,7 +478,7 @@ async def test_max_completed_cleans_up_events_ticks_and_state() -> None:
)
assert "run-old" in store.events
assert "run-old" in store.ticks
assert "run-old" in store.state_stores
assert ("run-old", ()) in store.state_stores
await _insert(
store,
@@ -490,12 +490,29 @@ async def test_max_completed_cleans_up_events_ticks_and_state() -> None:
assert "run-old" not in store.events
assert "run-old" not in store.ticks
assert "run-old" not in store.state_stores
assert ("run-old", ()) not in store.state_stores
remaining = await store.query(HandlerQuery())
assert len(remaining) == 1
assert remaining[0].handler_id == "h-new"
async def test_evict_run_state_stores_drops_every_namespace() -> None:
"""Run-scoped eviction removes all of a run's namespace facades at once."""
store = MemoryWorkflowStore()
store.create_state_store("run-x")
store.create_state_store("run-x", namespace=("child",))
store.create_state_store("run-other")
assert ("run-x", ()) in store.state_stores
assert ("run-x", ("child",)) in store.state_stores
store._evict_run_state_stores("run-x")
assert ("run-x", ()) not in store.state_stores
assert ("run-x", ("child",)) not in store.state_stores
# Other runs are untouched.
assert ("run-other", ()) in store.state_stores
@pytest.mark.asyncio
async def test_max_completed_eviction_via_update_handler_status() -> None:
"""Eviction triggers when status changes to terminal via update_handler_status."""
@@ -4,6 +4,7 @@
from __future__ import annotations
import json
from typing import AsyncGenerator
import asyncpg
@@ -35,12 +36,14 @@ async def pool(postgres_dsn: str) -> AsyncGenerator[asyncpg.Pool, None]:
await conn.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}")
await conn.execute(f"""
CREATE TABLE IF NOT EXISTS {SCHEMA}.workflow_state (
run_id VARCHAR(255) PRIMARY KEY,
run_id VARCHAR(255) NOT NULL,
namespace VARCHAR(255) NOT NULL DEFAULT '',
state_json TEXT NOT NULL,
state_type VARCHAR(255),
state_module VARCHAR(255),
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
updated_at TIMESTAMPTZ,
PRIMARY KEY (run_id, namespace)
)
""")
await conn.execute(f"DELETE FROM {SCHEMA}.workflow_state")
@@ -324,16 +327,18 @@ class FakeConnection:
def __init__(self, rows: dict[str, str]) -> None:
self._rows = rows
async def fetchrow(self, query: str, run_id: str) -> dict[str, str] | None:
state_json = self._rows.get(run_id)
async def fetchrow(
self, query: str, run_id: str, namespace: str
) -> dict[str, str] | None:
state_json = self._rows.get(f"{run_id}\x00{namespace}")
if state_json is None:
return None
return {"state_json": state_json}
async def execute(self, query: str, *args: object) -> None:
# Save upsert: (run_id, state_json, state_type, state_module, now, now)
run_id, state_json = str(args[0]), str(args[1])
self._rows[run_id] = state_json
# Save upsert: (run_id, namespace, state_json, state_type, state_module, now, now)
run_id, namespace, state_json = str(args[0]), str(args[1]), str(args[2])
self._rows[f"{run_id}\x00{namespace}"] = state_json
class FakePoolAcquire:
@@ -376,7 +381,7 @@ async def test_set_state_acquires_exactly_one_pool_connection() -> None:
assert pool.acquire_count == 1
assert pool.release_count == 1
assert "run-conn-count" in pool.rows
assert "run-conn-count\x00" in pool.rows
async def test_from_dict_empty_raises() -> None:
@@ -389,3 +394,48 @@ async def test_from_dict_no_pool_raises() -> None:
PostgresStateStore.from_dict(
{"store_type": "postgres", "run_id": "x"}, JsonSerializer()
)
# -- Per-namespace records --
@pytest.mark.docker
async def test_namespace_round_trip_and_isolation(pool: asyncpg.Pool) -> None:
"""Root and child namespaces persist as independent rows under one run."""
root: PostgresStateStore[DictState] = PostgresStateStore(
pool=pool, run_id="run-ns", schema=SCHEMA
)
child: PostgresStateStore[DictState] = PostgresStateStore(
pool=pool, run_id="run-ns", namespace=("child",), schema=SCHEMA
)
await root.set("k", "root-val")
await child.set("k", "child-val")
root2: PostgresStateStore[DictState] = PostgresStateStore(
pool=pool, run_id="run-ns", schema=SCHEMA
)
child2: PostgresStateStore[DictState] = PostgresStateStore(
pool=pool, run_id="run-ns", namespace=("child",), schema=SCHEMA
)
assert await root2.get("k") == "root-val"
assert await child2.get("k") == "child-val"
@pytest.mark.docker
async def test_pre_migration_root_row_reads_as_root(pool: asyncpg.Pool) -> None:
"""A row written without a namespace (additive default '') reads as root."""
state_json = json.dumps({"_data": {"k": JsonSerializer().serialize("legacy")}})
async with pool.acquire() as conn:
await conn.execute(
f"INSERT INTO {SCHEMA}.workflow_state "
"(run_id, state_json, state_type, state_module, created_at, updated_at) "
"VALUES ($1, $2, 'DictState', 'workflows.context.state_store', now(), now())",
"legacy-run",
state_json,
)
store: PostgresStateStore[DictState] = PostgresStateStore(
pool=pool, run_id="legacy-run", schema=SCHEMA
)
assert await store.get("k") == "legacy"
@@ -53,7 +53,9 @@ class StubInternalAdapter(InternalRunAdapter):
async def close(self) -> None:
self.closed = True
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -77,7 +79,9 @@ class StubExternalAdapter(ExternalRunAdapter):
async def get_result(self) -> StopEvent:
return StopEvent(result="done")
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -67,7 +67,9 @@ class StubInternalAdapter(InternalRunAdapter):
async def close(self) -> None:
self.closed = True
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -91,7 +93,9 @@ class StubExternalAdapter(ExternalRunAdapter):
async def get_result(self) -> StopEvent:
return StopEvent(result="done")
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return None
@@ -5,6 +5,7 @@
from __future__ import annotations
import asyncio
import json
import sqlite3
from pathlib import Path
from typing import Any
@@ -13,6 +14,7 @@ import pytest
from llama_agents.server import HandlerQuery, SqliteWorkflowStore
from llama_agents.server._store.sqlite.migrate import run_migrations
from llama_agents.server._store.sqlite.sqlite_state_store import (
SqliteSerializedState,
SqliteStateStore,
)
from pydantic import BaseModel
@@ -491,3 +493,110 @@ async def test_sqlite_workflow_store_single_connection_opens_existing_regular_db
await state_store.set("x", 1)
assert await state_store.get("x") == 1
# -- Per-namespace records --
@pytest.mark.asyncio
async def test_namespace_round_trip_and_isolation(db_path: str) -> None:
"""Root and child namespaces persist as independent rows under one run."""
root: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-ns"
)
child: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-ns", namespace=("child",)
)
await root.set("k", "root-val")
await child.set("k", "child-val")
# Fresh facades read straight from their own row — no cross-namespace bleed.
root2: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-ns"
)
child2: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-ns", namespace=("child",)
)
assert await root2.get("k") == "root-val"
assert await child2.get("k") == "child-val"
@pytest.mark.asyncio
async def test_pre_migration_root_row_reads_as_root(tmp_path: Path) -> None:
"""A row written under the pre-namespace schema reads back as root."""
path = str(tmp_path / "legacy.db")
serializer = JsonSerializer()
legacy_state_json = json.dumps({"_data": {"k": serializer.serialize("legacy")}})
conn = sqlite3.connect(path)
try:
# Recreate the schema as it stood at migration 4 (single-column PK,
# no namespace) and mark migrations 1-4 applied.
conn.executescript(
"""
CREATE TABLE schema_migrations (
package TEXT NOT NULL,
version INTEGER NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (package, version)
);
CREATE TABLE workflow_state (
run_id TEXT PRIMARY KEY,
state_json TEXT NOT NULL DEFAULT '{}',
state_type TEXT NOT NULL DEFAULT 'DictState',
state_module TEXT NOT NULL DEFAULT 'workflows.context.state_store',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
for version in range(1, 5):
conn.execute(
"INSERT INTO schema_migrations (package, version) VALUES ('server', ?)",
(version,),
)
conn.execute(
"INSERT INTO workflow_state "
"(run_id, state_json, state_type, state_module, created_at, updated_at) "
"VALUES (?, ?, 'DictState', 'workflows.context.state_store', '', '')",
("legacy-run", legacy_state_json),
)
conn.commit()
# Apply the pending namespace migration (table rebuild).
run_migrations(conn)
conn.commit()
finally:
conn.close()
store: SqliteStateStore[DictState] = SqliteStateStore(
db_path=path, run_id="legacy-run"
)
assert await store.get("k") == "legacy"
@pytest.mark.asyncio
async def test_copy_reproduces_every_namespace(db_path: str) -> None:
"""Copying a run via its handle reproduces root and child namespaces."""
src_root: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-src"
)
src_child: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-src", namespace=("child",)
)
await src_root.set("k", "root-val")
await src_child.set("k", "child-val")
# Seed the destination root facade from the source handle; ensure_seeded
# drives copy_from_handle, which spans every namespace of the source run.
serializer = JsonSerializer()
dst_root: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-dst"
)
dst_root.add_seed(SqliteSerializedState(run_id="run-src").model_dump(), serializer)
assert await dst_root.get("k") == "root-val"
dst_child: SqliteStateStore[DictState] = SqliteStateStore(
db_path=db_path, run_id="run-dst", namespace=("child",)
)
assert await dst_child.get("k") == "child-val"
@@ -329,8 +329,8 @@ async def test_context_from_handler_id_falls_back_to_legacy_state_snapshot(
started_at=datetime.now(timezone.utc),
)
)
state_stores = cast(dict[str, Any], memory_store.state_stores)
state_stores["plugin-run"] = ToDictOnlyStateStore()
state_stores = cast(dict[Any, Any], memory_store.state_stores)
state_stores[("plugin-run", ())] = ToDictOnlyStateStore()
async with server.contextmanager():
ctx = await server._service._context_from_handler_id(
@@ -109,7 +109,7 @@ class ExternalContext(Generic[MODEL_T, RunResultT]):
@property
def store(self) -> StateStore[MODEL_T]:
"""Access workflow state store."""
state_store = self._external_adapter.get_state_store()
state_store = self._external_adapter.get_state_store(())
if state_store is None:
raise RuntimeError("State store not available from adapter")
return state_store # type: ignore[return-value]
@@ -162,7 +162,7 @@ class ExternalContext(Generic[MODEL_T, RunResultT]):
# Fetch state store from adapter and serialize
state_data = {}
state_store = self._external_adapter.get_state_store()
state_store = self._external_adapter.get_state_store(())
if state_store is not None:
state_data = state_store.to_dict(active_serializer)
@@ -142,7 +142,7 @@ class InternalContext(Generic[MODEL_T]):
@property
def store(self) -> StateStore[MODEL_T]:
"""Access workflow state store."""
state_store = self._internal_adapter.get_state_store()
state_store = self._internal_adapter.get_state_store(())
if state_store is None:
raise RuntimeError("State store not available from adapter")
return state_store # type: ignore[return-value]
@@ -137,7 +137,11 @@ class InternalAsyncioAdapter(InternalRunAdapter, SnapshottableAdapter):
def replay(self) -> list[WorkflowTick]:
return self._queues.ticks
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
if namespace:
return None
return self._queues.state_store
@@ -178,7 +182,11 @@ class ExternalAsyncioAdapter(
def replay(self) -> list[WorkflowTick]:
return self._queues.ticks
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
if namespace:
return None
return self._queues.state_store
async def get_result(self) -> StopEvent:
@@ -135,8 +135,10 @@ class BaseInternalRunAdapterDecorator(InternalRunAdapter):
async def close(self) -> None:
await self._decorated.close()
def get_state_store(self) -> StateStore[Any] | None:
return self._decorated.get_state_store()
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return self._decorated.get_state_store(namespace)
async def finalize_step(self) -> None:
await self._decorated.finalize_step()
@@ -188,5 +190,7 @@ class BaseExternalRunAdapterDecorator(ExternalRunAdapter):
async def cancel(self) -> None:
await self._decorated.cancel()
def get_state_store(self) -> StateStore[Any] | None:
return self._decorated.get_state_store()
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
return self._decorated.get_state_store(namespace)
@@ -181,11 +181,14 @@ class InternalRunAdapter(ABC):
"""
pass
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
"""
Get the state store for this workflow run.
Get the per-namespace state store for this workflow run.
Returns the state store from the runtime, or None if not initialized.
``namespace`` selects the namespace's own record (``()`` is the root);
each namespace owns an isolated store. Returns None if not initialized.
Default implementation returns None.
"""
return None
@@ -334,12 +337,14 @@ class ExternalRunAdapter(ABC):
"""
await self.send_event(TickCancelRun())
def get_state_store(self) -> StateStore[Any] | None:
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> StateStore[Any] | None:
"""
Get the state store for this workflow run.
Get the per-namespace state store for this workflow run.
Returns the state store if this adapter owns it, or None if state
is managed externally. Default implementation returns None.
``namespace`` selects the namespace's own record (``()`` is the root).
Returns None if this adapter doesn't own state. Default returns None.
"""
return None
@@ -192,7 +192,9 @@ class MockRunAdapter(
def has_stream_events(self) -> bool:
return not self._event_stream.empty()
def get_state_store(self) -> "InMemoryStateStore[Any] | None":
def get_state_store(
self, namespace: tuple[str, ...] = ()
) -> "InMemoryStateStore[Any] | None":
return self._state_store
def set_state_store(self, state_store: "InMemoryStateStore[Any]") -> None: