Description
InMemoryCheckpointStorage returns its stored checkpoint objects directly, so a caller that mutates a checkpoint it loaded also mutates what the storage holds. The other two in-tree implementations of the same CheckpointStorage protocol do not behave that way: FileCheckpointStorage and the Azure Cosmos backend rebuild every returned checkpoint from its serialized form, so reads are isolated by construction.
All three read paths are affected, because the load, list_checkpoints and get_latest methods hand out the instances kept in the internal dictionary, while save already stores copy.deepcopy(checkpoint). Isolation on write is therefore deliberate, and isolation on read is missing.
This matters mostly because InMemoryCheckpointStorage is the backend tests and samples reach for first. A workflow that mutates restored state passes against the in-memory backend and behaves differently against a persistent one, or the reverse, and the two cannot both be right for the same protocol.
I could not find an existing issue or pull request covering this. It is unrelated to the per-step checkpoint save serialization in #7647 and #7676, which changes when saves happen rather than what a read returns.
I have a fix ready with tests and will open a pull request alongside this issue.
Code Sample
import asyncio
import tempfile
from agent_framework._workflows._checkpoint import (
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
async def probe(storage, label: str) -> None:
checkpoint = WorkflowCheckpoint(
workflow_name="probe-workflow",
graph_signature_hash="probe-signature",
state={"answer": "original"},
)
checkpoint_id = await storage.save(checkpoint)
first = await storage.load(checkpoint_id)
first.state["answer"] = "MUTATED"
second = await storage.load(checkpoint_id)
print(f"{label}: {second.state['answer']!r}")
async def main() -> None:
await probe(InMemoryCheckpointStorage(), "InMemoryCheckpointStorage")
with tempfile.TemporaryDirectory() as tmp:
await probe(FileCheckpointStorage(storage_path=tmp), "FileCheckpointStorage")
asyncio.run(main())
Output:
InMemoryCheckpointStorage: 'MUTATED'
FileCheckpointStorage: 'original'
Expected: both print `'original'`.
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.13.0
Python Version
Python 3.13.7
Additional Context
The protocol docstrings do not currently state whether a returned checkpoint belongs to the caller, which is why the two behaviors were able to drift apart. My pull request adds that sentence to the CheckpointStorage protocol and makes the in-memory backend copy on read, so all three implementations agree.
Description
InMemoryCheckpointStoragereturns its stored checkpoint objects directly, so a caller that mutates a checkpoint it loaded also mutates what the storage holds. The other two in-tree implementations of the sameCheckpointStorageprotocol do not behave that way:FileCheckpointStorageand the Azure Cosmos backend rebuild every returned checkpoint from its serialized form, so reads are isolated by construction.All three read paths are affected, because the
load,list_checkpointsandget_latestmethods hand out the instances kept in the internal dictionary, whilesavealready storescopy.deepcopy(checkpoint). Isolation on write is therefore deliberate, and isolation on read is missing.This matters mostly because
InMemoryCheckpointStorageis the backend tests and samples reach for first. A workflow that mutates restored state passes against the in-memory backend and behaves differently against a persistent one, or the reverse, and the two cannot both be right for the same protocol.I could not find an existing issue or pull request covering this. It is unrelated to the per-step checkpoint save serialization in #7647 and #7676, which changes when saves happen rather than what a read returns.
I have a fix ready with tests and will open a pull request alongside this issue.
Code Sample
Error Messages / Stack Traces
Package Versions
agent-framework-core: 1.13.0
Python Version
Python 3.13.7
Additional Context
The protocol docstrings do not currently state whether a returned checkpoint belongs to the caller, which is why the two behaviors were able to drift apart. My pull request adds that sentence to the
CheckpointStorageprotocol and makes the in-memory backend copy on read, so all three implementations agree.