Skip to content

refactor: back persistent memory with pydantic-ai-harness - #187

Open
OchnikBartek wants to merge 3 commits into
mainfrom
refactor/memory-harness
Open

refactor: back persistent memory with pydantic-ai-harness#187
OchnikBartek wants to merge 3 commits into
mainfrom
refactor/memory-harness

Conversation

@OchnikBartek

Copy link
Copy Markdown
Member

Summary

Migrates persistent memory from pydantic-deep's own backend-backed implementation
to the upstream pydantic-ai-harness Memory capability, and restores the two
isolation guarantees that move silently broke: per-tenant scoping and fork/branch
memory.

Added

  • Added pydantic_deep/features/memory/store.py — the single place mapping our
    options onto a harness store: build_memory_store, resolve_memory_dir,
    sanitize_agent_name, deps_store_resolver, and the shared
    build_memory_capability used by both the main agent and subagents.
  • Added pydantic_deep/features/forking/memory.py with BranchMemoryStore and
    MemoryFlushReport — a copy-on-write MemoryStore overlay, the memory
    counterpart of BranchOverlay, plus flush_to() for merge-time replay.
  • Added memory_store to DeepAgentDeps (deps.py), so a per-run store
    overrides the one the agent was built with.
  • Added memory_namespace= and memory_base_dir= to create_deep_agent (all
    three signatures) and as DeepAgentSpec fields in spec.py.
  • Added pydantic-ai-harness>=0.10.0 to base dependencies in pyproject.toml.
    There is no [memory] extra — harness 0.10.0 ships memory in the base package.
  • Added memory_dir to ImproveAnalyzer.__init__ with memory_path_for() and
    DEFAULT_IMPROVE_MEMORY_DIR in features/improve/analyzer.py.

Changed

  • Registered the harness Memory capability from create_deep_agent(include_memory=True)
    instead of AgentMemoryToolset. Memory is now injected as user-role context
    rather than a system-instruction block; update_memory is gone, replaced by
    write_memory(old_text=...), and search_memory is new.
  • Deprecated the old API (MemoryCapability, AgentMemoryToolset, MemoryFile,
    load_memory, get_memory_path, format_memory_prompt, the
    DEFAULT_MEMORY_* / DEFAULT_PIN_END_MARKER constants) — still importable,
    now emitting DeprecationWarning, following the capabilities/ pattern.
  • Reinterpreted memory_dir as a host filesystem path, not a path inside
    deps.backend. Backend-only values like /.deep/memory now raise a ValueError
    naming the move instead of failing at run time with
    OSError: [Errno 30] Read-only file system.
  • Anchored a relative memory_dir against memory_base_dir (default: process
    CWD), and made apps/cli/agent.py pass memory_base_dir=str(root) so memory
    follows --working-dir and stays where /remember writes.
  • Warned on the two silent-data-loss paths in agent.py: include_memory=True
    with no memory_dir/memory_store (ephemeral store), and a host FileStore
    paired with a non-local backend.
  • Built Memory with a store_resolver (deps_store_resolver) that prefers
    deps.memory_store and otherwise seeds it with the agent's store. Seeding is
    what gives clone_for_branch a parent store to wrap, since the fork machinery
    clones deps, not the agent.
  • Made clone_for_branch read BranchIsolation.memory, which was previously only
    recorded: "copy" wraps the parent store in a BranchMemoryStore, "share"
    passes it through. ForkCoordinator._merge now flushes the winning branch's
    staged memory and logs skipped conflicts; losers' writes are dropped with their
    deps.
  • Sanitized subagent agent_name values before they reach the store. The harness
    enforces [A-Za-z0-9_.-]{1,200} via validate_store_path on every run, so a
    name like "code reviewer" previously built fine and raised at first delegation.
  • Restored _inject_subagent_memory_toolset in agent.py for subagents supplying
    their own agent_factory or a prebuilt agent — those bypass the default
    factory and had been left with toolsets: []. extra={"memory": False} is
    honoured there.
  • Passed defer_loading=tool_search on the memory capability. Riding the
    capability list bypassed defer_situational_toolsets, which only wraps
    all_toolsets, so all four memory tool schemas were sent upfront.
  • Repointed the improve pipeline from the abandoned .deep/memory/main/MEMORY.md
    to .pydantic-deep/main/MEMORY.md — the file the agent's FileStore actually
    reads.
  • Turned extra={"memory_pin_marker": ...} into a DeprecationWarning. The
    harness has no pin concept and its truncation keeps the tail of MEMORY.md,
    so a pinned header would be the first thing dropped.
  • Updated CLAUDE.md and 13 docs pages, including the
    memory_dir="/.deep/memory" example in docs/concepts/agents.md that crashed
    as written, and the docs/advanced/multi-user.md advice to use a namespace
    parameter that did not exist.

Testing

  • Added tests/test_forking_memory.py (23 tests): resolver precedence and
    seeding, clone_for_branch per isolation flag, read-through, staged writes,
    discard leaving the parent untouched, flush apply/create/delete, conflict
    detection, branch-local CAS surviving a concurrent parent write, merged
    list_paths, search over not-yet-read parent content, branch-local operation
    receipts, fork-of-fork propagation, and per-tenant non-leakage.

  • Added TestMemoryStoreWiring to tests/test_memory.py (12 tests): backend-style
    memory_dir raising, base-dir anchoring, name sanitization, the ephemeral
    warning, namespace propagation, parametrized tool_search deferral, the
    pin-marker warning, and custom-factory subagents keeping all four memory tools.

  • Added TestImproveMemoryAlignment to tests/test_improve.py (4 tests) pinning
    improve's target to what the agent's store reads on disk — nothing guarded this,
    which is why it regressed silently.

  • Updated tests/test_agent.py, tests/test_improve.py, tests/test_spec.py,
    tests/test_eviction.py and tests/test_processors.py for the new factory
    signature, spec fields and memory paths.

  • Ran pytest tests/ — 2759 passed. ruff check clean, mypy pydantic_deep/
    clean across 132 source files.

  • Drove the wiring end-to-end on FunctionModel rather than only through unit
    tests, confirming the resolver seeds deps at run start (with tool_search=True
    too), the branch wraps the parent, a discarded branch write never reaches the
    parent, and a flush applies it:

    before run: deps.memory_store = None
    after run:  deps.memory_store = InMemoryStore
    branch store: BranchMemoryStore | wraps parent: True
    parent after discarded branch write: None
    parent after flush: 'branch-only\n' | applied: ['main/MEMORY.md']
    

Notes for Reviewers

  • deps_store_resolver mutates ctx.deps.memory_store when it is unset. This
    is deliberate — the fork machinery clones deps, so a branch needs a parent store
    reachable from deps — and it mirrors what LiveForkCapability already does with
    ctx.deps.fork_coordinator. Worth a look if you disagree with the precedent.
  • BranchMemoryStore seeds a path copy-on-first-touch into a private
    InMemoryStore so CAS versions are branch-local. Without that, a concurrent
    parent write would fail a branch write mid-run. The trade-off: a branch sees the
    parent as of first touch, not later, and divergence surfaces once at flush as a
    conflict rather than overwriting the newer parent.
  • Existing on-disk MEMORY.md files are not migrated. Combined with
    memory_dir changing meaning, an app upgrading across this patch bump needs to
    move its memory directory or pass memory_store=. The new warning covers the
    no-config case but cannot cover a caller who already passed a backend-style
    memory_dir — that raises.
  • memory="share" on BranchIsolation keeps the old write-through behaviour as
    an explicit opt-in. Worth checking no TUI/CLI preset sets it by default.
  • The new ephemeral-store UserWarning fires in existing tests (438 warnings in
    the suite). Tests pass, but if the noise is unwanted the fix is either explicit
    include_memory=False/memory_dir= in those tests or a filterwarnings entry
    — it touches many test files, so it is left out here.
  • Repointing pydantic-ai-todo onto pydantic-ai-harness/planning/ is out of
    scope; the upstream contribution there is still in flight.

@OchnikBartek
OchnikBartek requested a review from DEENUU1 July 27, 2026 09:58
@OchnikBartek OchnikBartek self-assigned this Jul 27, 2026
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 30259077581

Coverage remained the same at 100.0%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: 183 of 183 lines across 11 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 7220
Covered Lines: 7220
Line Coverage: 100.0%
Coverage Strength: 1.0 hits per line

💛 - Coveralls

@DEENUU1 DEENUU1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a strong piece of work. Moving memory onto the harness store was the easy half; noticing that the move silently dropped per-tenant scoping and fork isolation, and then rebuilding both, is the half most migrations skip. BranchMemoryStore is the part I like most — seeding a path into a private overlay so CAS versions stay branch-local, and surfacing divergence once at flush time instead of failing a branch write mid-run, is exactly the right trade-off, and the docstring explains why rather than what. The eager mkdir with an actionable error message is also a good call: injection_errors="ignore" would otherwise have turned a bad memory_dir into an agent that quietly has no memory. And thank you for the "Notes for Reviewers" section — it made this review much faster.

Two things I'd like fixed before merge, both verified against harness 0.10.0 on this branch:

  1. sanitize_agent_name doesn't mirror the harness rule it says it mirrors — it misses the '..' not in segment half, so a subagent named v1..v2 still passes the sanitizer and then raises ValueError: invalid memory path at the first delegation. That's the exact run-time failure the function exists to prevent.
  2. BranchMemoryStore._seed is a check-then-act across an await with no lock. Two memory tool calls in one model turn run concurrently in pydantic-ai (_tool_execution.py only serialises sequential=True tools, and the harness memory tools aren't marked), so inside a memory="copy" branch the second one gets MemoryConflictError. I reproduced it with two concurrent read() calls.

The rest are non-blocking. Three more things that aren't anchored to diff lines:

  • No CHANGELOG entry. Every recent PR here touches CHANGELOG.md, and this one changes what memory_dir means — old values now raise, and existing on-disk MEMORY.md files aren't migrated. That needs to be written down where upgraders will see it, not only in the PR body.
  • docs/c4/4b-deep-dive-toolsets-and-capabilities.md is stale. Lines 296-315 and 470-479 still document update_memory, memory_dir = /.deep/memory as the default, and system-prompt injection. You swept c4 pages 1 and 2 but not 4b, so a reader lands on a documented default that now raises.
  • pydantic_deep/agent.py:1303 builds ImproveToolset(working_dir=Path(".")) while the CLI passes memory_base_dir=str(root). Even with the memory_dir fix below, improve and the agent only agree when the process CWD happens to equal the backend root. See the analyzer comment.

Comment on lines +97 to +100
if _VALID_AGENT_NAME_RE.fullmatch(agent_name):
return agent_name
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", agent_name).strip("-")[:200]
return cleaned or "agent"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says this mirrors the harness' _VALID_SEGMENT_RE, but the harness check is two conditions, not one:

# pydantic_ai_harness/memory/_store.py:83
if not all(_VALID_SEGMENT_RE.fullmatch(segment) and '..' not in segment for segment in path.split('/')):

.. is missing here, and . is inside the character class, so a name containing .. sails through the fast path unchanged and then blows up exactly where this function was written to stop it. I ran it on this branch:

'..'      -> sanitized '..'      ValueError: invalid memory path: '..'
'a..b'    -> sanitized 'a..b'    ValueError: invalid memory path: 'a..b'
'v1..v2'  -> sanitized 'v1..v2'  ValueError: invalid memory path: 'v1..v2'

So a subagent named v1..v2 builds fine and dies on first delegation — the same run-time failure as code reviewer, just a different character. With a FileStore it's also the path-escape case, which is why the harness rejects it.

Suggested change
if _VALID_AGENT_NAME_RE.fullmatch(agent_name):
return agent_name
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", agent_name).strip("-")[:200]
return cleaned or "agent"
if _VALID_AGENT_NAME_RE.fullmatch(agent_name) and ".." not in agent_name:
return agent_name
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", agent_name)
cleaned = re.sub(r"\.{2,}", ".", cleaned).strip("-.")[:200]
return cleaned or "agent"

I checked this against validate_store_path for .., a..b, v1..v2, ..., ..a.., code reviewer, ???, a 250-char name and the ordinary main/reviewer cases — all pass, and already-valid names are still returned unchanged.

One leftover I left out of the suggestion because it's marginal: a bare "." still survives the fast path, and under FileStore _resolve("./MEMORY.md") realpaths to the store root, so that agent's memory lands beside the other agents' directories instead of inside its own. Your call whether that's worth a third condition.

Comment on lines +76 to +90
async def _seed(self, path: str) -> None:
"""Copy `path` from the parent into the overlay, once."""
if path in self._seeded:
return
parent_file = await self._parent.read(path, max_chars=_SEED_MAX_CHARS)
if parent_file is None:
self._seeded[path] = None
return
if parent_file.truncated: # pragma: no cover - guards against a silent partial copy
raise RuntimeError(
f"branch memory seeding read a truncated {path!r}; refusing to stage a "
"partial copy that flush_to would write back over the parent"
)
self._seeded[path] = parent_file.version
await self._overlay.write(path, parent_file.content, expected_version=None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a check-then-act across an await with nothing guarding it. Two coroutines both find path not in self._seeded, both await self._parent.read(...), and the second self._overlay.write(..., expected_version=None) hits an overlay where the path now exists — InMemoryStore.write raises MemoryConflictError.

It's reachable: pydantic-ai runs tool calls from one model response in parallel (_tool_execution.py only segments on sequential=True tools, and the harness MemoryToolset registers its four functions with the default), so any turn inside a memory="copy" branch that emits e.g. search_memory + read_memorysearch seeds every parent path under the prefix, read seeds one of them — races. Reproduced on this branch with the simplest version:

b = BranchMemoryStore(parent)
await asyncio.gather(b.read(p, max_chars=1000), b.read(p, max_chars=1000), return_exceptions=True)
# -> [MemoryFile, MemoryConflictError: memory path 'main/MEMORY.md' changed before it could be written]

A single lock is enough — seeding is short, and InMemoryStore already does the same thing. Add import anyio at the top and self._seed_lock = anyio.Lock() in __init__ next to self._overlay, then:

Suggested change
async def _seed(self, path: str) -> None:
"""Copy `path` from the parent into the overlay, once."""
if path in self._seeded:
return
parent_file = await self._parent.read(path, max_chars=_SEED_MAX_CHARS)
if parent_file is None:
self._seeded[path] = None
return
if parent_file.truncated: # pragma: no cover - guards against a silent partial copy
raise RuntimeError(
f"branch memory seeding read a truncated {path!r}; refusing to stage a "
"partial copy that flush_to would write back over the parent"
)
self._seeded[path] = parent_file.version
await self._overlay.write(path, parent_file.content, expected_version=None)
async def _seed(self, path: str) -> None:
"""Copy `path` from the parent into the overlay, once."""
async with self._seed_lock:
if path in self._seeded:
return
parent_file = await self._parent.read(path, max_chars=_SEED_MAX_CHARS)
if parent_file is None:
self._seeded[path] = None
return
if parent_file.truncated: # pragma: no cover - guards against a silent partial copy
raise RuntimeError(
f"branch memory seeding read a truncated {path!r}; refusing to stage a "
"partial copy that flush_to would write back over the parent"
)
self._seeded[path] = parent_file.version
await self._overlay.write(path, parent_file.content, expected_version=None)

Worth a test that gathers two concurrent branch reads of the same path — tests/test_forking_memory.py covers the sequential paths thoroughly but nothing exercises two at once.

winner_memory = getattr(winner.deps, "memory_store", None)
if isinstance(winner_memory, BranchMemoryStore):
memory_report = await winner_memory.flush_to()
memory_conflicts = list(memory_report.conflicts)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file overlay's conflicts go into MergeResult.conflicts and its errors into MergeResult.errors, but a memory conflict only reaches a log line. Same concept, two places, and only one of them is visible to the caller — so an app that checks if result.conflicts: ... to decide whether the merge was clean will report a clean merge while the winner's write_memory was silently dropped.

I'd fold them into the existing fields so there's one thing to check:

winner_memory = getattr(winner.deps, "memory_store", None)
memory_conflicts: list[str] = []
if isinstance(winner_memory, BranchMemoryStore):
    memory_report = await winner_memory.flush_to()
    memory_conflicts = [f"memory:{path}" for path in memory_report.conflicts]
    if memory_conflicts:
        logger.warning(...)

and then conflicts=[*conflicts, *memory_conflicts] in the MergeResult. The memory: prefix keeps them distinguishable from backend paths without needing a new field. If you'd rather keep the two surfaces separate, a memory_conflicts: list[str] field on MergeResult works too — the thing I don't want is the caller having to read logs to find out.

working_dir: Path | None = None,
on_progress: ProgressCallback | None = None,
context_files: dict[str, str] | None = None,
memory_dir: str | None = None,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parameter is never passed by anything in the library. ImproveToolset.__init__ doesn't take a memory_dir, and create_deep_agent builds it at pydantic_deep/agent.py:1303 without one, so the analyzer always falls back to DEFAULT_IMPROVE_MEMORY_DIR.

That means the alignment fix holds only for the default. create_deep_agent(memory_dir="~/.myapp/memory", include_improve=True) puts the agent's FileStore under ~/.myapp/memory/main/MEMORY.md while improve still writes {working_dir}/.pydantic-deep/main/MEMORY.md — which is the same desync TestImproveMemoryAlignment says it's guarding, just moved from the directory name to the directory the caller chose. The four new tests all construct ImprovementAnalyzer directly, so none of them would catch it.

Threading it through is small:

# improve/toolset.py
class ImproveToolset(FunctionToolset[Any]):
    def __init__(self, ..., memory_dir: str | None = None) -> None:
        self._memory_dir = memory_dir
        # ...and pass memory_dir=self._memory_dir to both ImprovementAnalyzer(...) calls

# agent.py:1303
improve_toolset = ImproveToolset(
    sessions_dir=_improve_sessions,
    working_dir=Path("."),
    model=model if isinstance(model, str) else DEFAULT_IMPROVE_MODEL,
    memory_dir=memory_dir,
)

While you're in there: working_dir=Path(".") is the other half of the same alignment. The CLI now passes memory_base_dir=str(root), so improve and the agent agree only when the process CWD equals the backend root — start the CLI with --working-dir pointing elsewhere and they diverge again. _wd is already computed a few lines above for sessions_dir; using it for working_dir too would close it.

Smaller thing in this constructor: self._context_files = context_files or dict(DEFAULT_CONTEXT_FILES) treats {} as "use defaults", but the guard below is if context_files is None, so context_files={} gets the defaults and ignores memory_dir. if context_files is None in both places would make it consistent.

Comment thread pydantic_deep/agent.py
else:
# The default factory attaches memory as a capability; a caller-
# supplied factory/agent never runs it, so wire the memory tools
# in through `toolsets` the way the pre-capability code did.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the way the pre-capability code did" isn't quite true, and the gap is worth spelling out because it's a silent behaviour change.

AgentMemoryToolset had a get_instructions() that loaded the first N lines of MEMORY.md into the prompt, so the old toolset-injection path gave a subagent both the tools and its memory. The harness splits those: MemoryToolset is tools only, and the loading happens in Memory.before_model_request plus the guidance in Memory.get_instructions() — neither of which runs for a capability that was never registered on the agent.

So a subagent with its own agent_factory now gets four memory tools, no memory in its context, and no guidance telling it the tools exist. It'll only ever see its memory if it independently decides to call read_memory.

I don't think there's a clean fix — SubAgentConfig has no capabilities field, and agent_kwargs isn't consulted on the custom-factory path — so the toolset fallback is the right call. I'd just make the docstring say what it actually delivers, something like:

    Subagents built by the default factory receive memory as a `Memory`
    capability. Those carrying their own `agent_factory` (or a prebuilt
    `agent`) never reach it, so the tools are wired in through `toolsets`
    instead. Note this gives tools only: `Memory.before_model_request` never
    runs for an unregistered capability, so memory is not auto-injected into
    the subagent's context and the usage guidance is not added — a caller who
    wants that must register the capability on the agent they build.

Worth a line in docs/learn/memory.md too, since it's the kind of thing someone debugs for an hour.

Comment thread pydantic_deep/agent.py
for sa_config in effective_subagents:
if (
sa_config.get("agent") is None and sa_config.get("agent_factory") is None
): # pragma: no branch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this # pragma: no branch is stale now. It says the false branch is never taken, but you added an else and test_custom_agent_factory_subagent_keeps_memory_tools exercises it, so the pragma is both a no-op and misleading to the next reader.

Suggested change
): # pragma: no branch
):

@DEENUU1 DEENUU1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, this time on the parts I skimmed the first time round — the merge critical section, and the docs that tell people how to use the new per-tenant story. Everything below is reproduced on this branch against harness 0.10.0.

One more blocking bug and one blocking docs problem, both in the area the PR is about:

  1. BranchMemoryStore.flush_to can raise, and the coordinator doesn't catch it. BranchOverlay.flush_to was deliberately built the other way — its own docstring says "flush_to never aborts on the first failure" and per-write failures land in FlushReport.errors. The memory flush runs right after it in the same critical section with no such guard, so one bad path takes the whole merge down after the backend already landed and every loser was already cancelled.
  2. docs/advanced/multi-user.md — the prose is right, but both runnable examples on the page leak memory between users. The page's own table says "If you skip it: Users see each other's memory", and then both code blocks skip it.

Three non-blocking, plus a couple of answers:

Your question — "worth checking no TUI/CLI preset sets memory=\"share\" by default." Nothing does. The only construction sites are coordinator.py:528 (isolation or BranchIsolation()) and forking/__init__.py:81-82, both landing on the copy default. The one way share gets set is the model itself, through the fork tool's isolation: dict[str, Any] argument.

Which surfaces a small related thing: _coerce_isolation does BranchIsolation(**raw) on that model-supplied dict, and a plain dataclass doesn't validate its Literals. An unknown key raises loudly, but a known key with a junk value is silent — and since every flag is tested with == "copy", {"memory": "isolated"} from a confused model quietly means share-with-parent, so branch writes hit the parent and survive a discarded branch. That's a pre-existing pattern (backend and todos have the same shape), but memory is the flag this PR just made load-bearing, so it's worth a BranchIsolation __post_init__ check or a Pydantic model at the tool boundary.

One more unwired cell for memory_path_for (on top of the memory_dir comment from my last pass): it doesn't know about memory_namespace either. With a namespace set, the agent reads {memory_dir}/{namespace}/main/MEMORY.mdMemory._resolve_scope builds f'{namespace}/{agent_name}' — while improve writes {memory_dir}/main/MEMORY.md. Whatever shape the memory_dir fix takes, the namespace needs to ride along with it.

# the losers' are dropped with their deps.
winner_memory = getattr(winner.deps, "memory_store", None)
if isinstance(winner_memory, BranchMemoryStore):
memory_report = await winner_memory.flush_to()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This await can raise, and nothing here catches it. destination.read(...) and destination.write(...) inside flush_to can both throw — OSError from a FileStore, or MemoryConflictError if a third actor writes between the read and the CAS write a few lines later (the same check-then-act window the conflict detection is built around, just at the flush site instead).

The asymmetry is what makes it bite. BranchOverlay.flush_to right above collects per-write failures into report.errors and its docstring is explicit that it "never aborts on the first failure". The memory flush has no equivalent, so a single failing path escapes merge_or_select — and by then the backend overlay has already flushed to the parent and every loser has already been cancelled and dropped.

Repro on this branch (parent store whose write raises OSError, one branch, pick: merge):

  winner.overlay still attached: True
  fork resolved: False

So: winner.overlay = None never runs, MergeResult is never built, materializer.cleanup() never runs, _cached_outcome is never reset — and the losers are gone while the winner's files are on disk. The fork is stuck open in a state no caller can recover from, over a memory write.

Two halves to the fix. In BranchMemoryStore.flush_to, make the loop tolerant the way BranchOverlay is — add an errors: list[tuple[str, str]] field to MemoryFlushReport and wrap the per-path body:

for path in self._touched:
    try:
        ...  # existing read / conflict / delete / write
    except Exception as exc:
        report.errors.append((path, f"{type(exc).__name__}: {exc}"))

And here, surface them next to the conflicts rather than letting anything escape the critical section. Worth a test with a parent store that raises, asserting the merge still completes and the failure is reported — the two TestMergeFlush cases cover the happy path and the conflict path, but nothing covers a store that misbehaves.

Comment on lines +13 to +20
!!! note "Memory has its own store"
Persistent memory no longer lives in the backend — it uses a pluggable
`MemoryStore`. It still follows `deps`, though: pass
`DeepAgentDeps(memory_store=...)` per request and that store wins over the one
the agent was built with, so a single shared agent stays isolated. Alternatively
give the agent a `memory_dir=`/`memory_store=` per user, or pass a per-tenant
`memory_namespace=` to partition one shared store. Checkpoints likewise use a
separate `checkpoint_store`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note is correct, and the table you added at line 131 is correct too ("Memory | DeepAgentDeps(memory_store=…) per run | If you skip it: Users see each other's memory"). But neither runnable example on the page does it, so the code a reader copies demonstrates exactly the leak the table warns about.

I checked, on a page titled "One agent, many users — without their state ever touching":

agent = create_deep_agent(model=..., memory_dir=d)     # built once, as the page says
async def run_as(user):
    deps = DeepAgentDeps()                              # exactly lines 30-32
    await agent.run("hi", deps=deps)
    return deps.memory_store
# alice store is bob store: True

That's deps_store_resolver doing its job — deps carry nothing, so it seeds them with the agent's shared store, and alice and bob end up in the same main/MEMORY.md.

Both examples need the extra line. Lines 30-32:

deps = DeepAgentDeps(
    backend=LocalBackend(root_dir=f"/workspaces/{user_id}"),  # (2)!
    memory_store=FileStore(f"/memory/{user_id}"),             # (3)!
)

And the FastAPI one at lines 155-158, which matters more because it's the production-shaped one and it already scopes checkpoint_store per user — memory is the one thing it misses:

return DeepAgentDeps(
    backend=LocalBackend(root_dir=f"/workspaces/{user_id}"),
    memory_store=FileStore(f"/memory/{user_id}"),
    checkpoint_store=FileCheckpointStore(f"/checkpoints/{user_id}"),
)

Given restoring per-tenant scoping is half the point of this PR, I'd rather the page's headline example show it than describe it.

Comment thread pydantic_deep/agent.py
Comment on lines +880 to +883
memory_namespace: Per-tenant namespace for the harness `Memory`
capability, isolating users that share one store. Use this for the
build-agent-once / deps-per-user pattern, where a single store is
shared across requests. Defaults to "" (no namespace).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This advice can't be followed. memory_namespace: str is bound at agent construction, so "build the agent once" gives you exactly one namespace for every request — the opposite of per-tenant. To actually vary it per user you'd have to build one agent per user, which is the pattern this paragraph is telling you to avoid.

It's the same shape as the bug you fixed on this page's docs counterpart: pointing people at a namespace knob that doesn't do the job. Here the knob exists, it just isn't per-run.

The harness already supports it — Memory.namespace is str | Callable[[RunContext[AgentDepsT]], str] and _resolve_scope does self.namespace(ctx) if callable(self.namespace) else self.namespace. build_memory_capability narrows it to str on the way through, which is where it gets lost. Widening it is small:

# features/memory/store.py
def build_memory_capability(
    *,
    namespace: str | Callable[[RunContext[Any]], str] = "",
    ...

plus the same union on memory_namespace in the three create_deep_agent signatures and in _make_default_deep_agent_factory (it already forwards the value to subagents, so they'd follow the tenant too). DeepAgentSpec.memory_namespace stays str — a callable isn't serializable, and the YAML case is genuinely the static one.

Then memory_namespace=lambda ctx: ctx.deps.user_id becomes the answer for build-once/deps-per-user, and the docstring is true as written. If you'd rather not widen it, the fix is the other direction: say plainly that memory_namespace is for one-tenant-per-agent, and point the build-once pattern at DeepAgentDeps(memory_store=...) instead.

Comment thread pydantic_deep/agent.py
# Memory used to always persist (backend `/.deep/memory`). It now
# persists only when the caller says where, so an unconfigured agent
# would silently drop everything it "remembered" at process exit.
if not memory_dir:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 438 warnings you flagged in the suite aren't only a test-noise problem — this fires on the default constructor, so it fires for essentially every user and every documented example. include_memory=True and memory_dir=None are both defaults, so the README's headline quickstart

agent = create_deep_agent(model="anthropic:claude-sonnet-4-6")

now prints a paragraph of UserWarning (I confirmed it). There are ~271 create_deep_agent( call sites across docs/ and the README and only a handful pass anything memory-related, so "the library warns you on the happy path" is the new default experience.

The warning itself is the right instinct — silently losing memory at process exit is worth shouting about. The problem is it can't distinguish "I asked for memory and forgot to say where" from "I never thought about memory and got it switched on". A sentinel separates them, and it's a pattern already used in this codebase (apps/cli/agent.py:162 does include_improve: bool | None = None):

include_memory: bool | None = None,   # None = default-on, quietly ephemeral
...
_include_memory = True if include_memory is None else include_memory
if _include_memory and memory_store is None and not memory_dir and include_memory is True:
    warnings.warn(...)   # only when the caller explicitly opted in

That keeps the shout for the case that deserves it and takes it off the quickstart. Whichever way you go, the ~438-warning test suite should end up quiet too — a suite that warns 438 times is a suite where nobody reads warnings, which is how the next one gets missed.

new_todos = list(deps.todos) if isolation.todos == "copy" else deps.todos

# "copy" → staged, discardable branch memory; "share" → the parent's store.
new_memory_store: Any = deps.memory_store

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the Any isn't needed here. BranchMemoryStore structurally satisfies the MemoryStore protocol — I checked both checkers:

mypy --strict : Success: no issues found in 1 source file
pyright       : 0 errors, 0 warnings, 0 informations

and swapping the annotation in this file passes mypy --strict on the module as-is. The Any on raw_backend two lines up has a real reason (unwrap_backend returns Any); this one just turns off type checking on the value being assigned to a typed field, which is the thing the standards ask us not to do.

Suggested change
new_memory_store: Any = deps.memory_store
new_memory_store: MemoryStore | None = deps.memory_store

Needs from pydantic_ai_harness.memory import MemoryStore at the top — the module already imports BranchMemoryStore from the sibling, so it's one more line next to it.

@DEENUU1 DEENUU1 moved this to In review in Vstorm OSS Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

3 participants