feat(memory): add consolidate() method to FileMemoryStore - #3429
maisieyanz merged 31 commits into
Conversation
Adds consolidate() for deduplication, contradiction resolution, insight derivation, pruning, and reorganization, with a plan-then-execute flow and hardened input bounds and merge-target validation.
|
@strandly-the-agent Review my pr |
|
Superseded by the formal review on the latest head. My earlier review at |
|
@strandly-the-agent review |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Changes requested — six reproduced integrity/resource failures remain
✅ Reviewed clean exact head 6623fff12406ac845af28aa609e0d6c943bf89bb; GitHub status rollup is SUCCESS.
✅ Focused suites: 101/101 passed; type-check, lint, and diff-check passed.
✅ Every adversarial scenario was rerun cold twice with byte-identical output.
✅ Same-instance overlap, post-return action limits, and changelog-error masking are fixed.
🔴 Path identity, non-atomic/stale apply, read-only mutation, empty output, prompt framing, and unbounded invocation still block merge.
b46e008, especially its incorrect claim that data integrity was guaranteed.
API gate: needs-api-review is present, but this is a substantial public primitive and needs an API meeting before completion. Two blocking questions: should omitted operations authorize destructive prune/overwrite, and should planning/application be separated with explicit authorization plus a structured result instead of Promise<void>?
Per-pass breakdown
- Correctness/adversarial: reproduced Windows namespace escape, case-alias deletion, partial writes without audit, stale-snapshot data loss, mutation under
writable: false, empty-content erasure, prompt-induced victim deletion, and an unbounded 25-call planner loop. - Test quality: the new tests cover the three latest hardenings, but none of the six blocker groups above.
- LLM context: fixed triple-fence framing lets stored text become planner instructions; deterministic validation cannot prove semantic provenance.
- API/DevX: substantial primitive needs an API meeting. Main decisions are destructive defaults, plan/apply separation, consistency guarantees, and structured outcomes.
- Docs: lines 490–492 incorrectly claim an aborted write pass leaves the store unchanged.
- Suppressed from inline review: searchable changelog, derive-insights wording mismatch,
NaN/large-output edge cases,maxDirectorieswording, and broader API alternatives. These are lower-value or subsumed by the blockers/API questions above. - Not repeated: the existing human enum question at line 49.
Aggregation: 5 specialist passes, 14 raw items → 6 inline blocker groups + 2 summary-only API questions.
| * @returns The paths whose deletes failed, each with the underlying error (empty when all succeed) | ||
| */ | ||
| private async _executePlan( | ||
| plan: ConsolidationPlan, |
There was a problem hiding this comment.
🔴 Block — applying the plan is neither atomic nor protected from stale snapshots
Writes execute sequentially here, and moves later write data captured by _readAllFiles() before deleting the live source. The same-instance flag does not cover add(), another store instance, or an external writer.
Cold repros, each repeated 2/2 with byte-identical output: when the second write failed, the store remained NEW-A / OLD-B with no changelog; a concurrent update after snapshotting was overwritten by the stale move and then deleted. The first result also contradicts lines 490–492, which say a failed write leaves the store unchanged.
This needs explicit apply semantics before merge: transaction/CAS support, or snapshot preconditions plus rollback/journaling and a structured partial result. A per-instance mutex cannot provide the documented integrity guarantee.
There was a problem hiding this comment.
🟡 Downgrading this from a blocker. At 49dcce1e the @remarks at :357-361 now state the boundary explicitly — "concurrent writes captured before the snapshot may be silently overwritten … Cross-instance/external-writer protection is not provided in this version" — and :571-574 no longer claims an unchanged store on write failure. A disclosed v1 scope boundary for a local-file store is a defensible call, and I verified there's no content loss on an injected write failure (orphan target, nothing destroyed).
Two things I'd still weigh, both cheap:
- The failure mode is silent success, not an error. Cold repro, 2/2: an
add()landing during planning is discarded, the live file is deleted, and the changelog records the run as fully successful. Re-reading eachmovesource at execution (:597-603) and skipping + reporting when its bytes differ from the snapshot turns silent loss into a reported conflict without any locking or CAS. - A mid-plan write failure still exits before
_recordChangelog(:379-382), so the one state change that did land is unaudited and the error doesn't name it. Recording the changelog in afinallywould cover it.
Neither blocks merge as long as the documented contract stands.
There was a problem hiding this comment.
🔴 Still open at 3621800, and sharper than last round: the guard the @remarks point to as the mitigation doesn't run at all for the plan shape that loses everything.
newTargets (execute.ts:205-209) only collects merge.target and move.to, so an update+delete plan creates no new path, newTargets.size === 0, and assertNewTargetsUnclaimed returns at :211 before reading anything.
Cold repro, 2/2 byte-identical. Two instances over one backend (_consolidating is instance-scoped, file-memory-store.ts:109), each folding the other's file into its own survivor:
run1 = fulfilled run2 = fulfilled
surviving knowledge files: 0 []
TOTAL KNOWLEDGE LOSS with both runs reporting success: YES
My correctness pass independently reproduced the mirrored-merge variant of the same thing (merge([a,b])→a racing merge([a,b])→b, both files deleted) — there the guard does run but skips the target because it's already in the snapshot.
I know there's no lock and the docstring says so. What I'd push back on is the framing: file-memory-store.ts:262-267 presents the re-read as the protection against concurrent writers, and a reader will reasonably assume it covers the worst case. It covers the case where a new key gets claimed, and not the case where everything disappears. Either the guard needs to cover in-snapshot targets too (re-read every path the plan will delete, and abort if its bytes moved), or the @remarks should say plainly that two overlapping consolidations can empty the store and both report success — which is a very different sentence to write than the current one, and probably the argument for a namespace-scoped guard.
Related, same root cause — a concurrent delete gets undone. The write pass sources content from the start-of-run snapshot (execute.ts:121-130), so a memory the user deleted mid-run is re-materialized at the move target and the run reports success (archive/secret.md now holds the deleted content: YES). The @remarks only describe concurrent writes being lost, never a removal being reversed — worth a line, since "forget this" silently failing is its own category of problem for a memory store.
There was a problem hiding this comment.
Unchanged at 664fe60 — execute.ts:200-233 and :124 are byte-for-byte untouched by this commit, and the mirrored-merge race still leaves zero surviving files with both runs reporting success (re-reproduced at this head).
I've raised this three rounds running, so I'm going to stop re-escalating and put it down as a decision instead. Two defensible outcomes, and I don't think it's my call which:
- Fix it — extend the claim check to cover in-snapshot targets and the paths a plan will delete (re-read, compare bytes to the snapshot, abort on drift), which also closes the resurrection case at
:124. - Document it precisely — keep the current scope, but change
file-memory-store.ts:262-267so it doesn't present the re-read as the mitigation for concurrent writers. The sentence that needs to be there is roughly: two overlapping consolidations can empty the store, and both will report success. That's a much stronger warning than the text carries today, and it's the one a reader deciding "can I run this from two workers?" actually needs.
Either is fine by me; what I'd push back on is the current middle ground, where the docs imply a guard that doesn't run for the worst-case plan shape. Happy to leave this thread to you and lizradway from here.
Replace the hand-maintained ALL_OPERATIONS array in file-memory-store.ts with a CONSOLIDATE_OPERATIONS const tuple in types.ts, and derive the ConsolidateOperation union from it
|
can you add the file directory layout to the pr description? |
Address review feedback on the consolidation guardrails.
… framing Cap the planning and plan-revision agent invocations at a turn limit and throw on instead of extracting a plan from an unfinished run.
|
@strandly-the-agent Re-review this pr please. |
strandly-the-agent
left a comment
There was a problem hiding this comment.
Re-review at 49dcce1e — changes requested (3 blockers; most of the prior round is genuinely fixed)
✅ Verified fixed at this head: writable: false rejection (:386-391, throws before any await or model call) · evidence-tag containment (serializeEvidence :1139-1141, held against 6 payload shapes incl. storage-key injection) · turn bound (limits.turns + explicit limitTurns throw, :505-517/:543-556) · changelog excluded from search() and planner input (:270, :472) · backslash and ASCII case-alias path vectors (:1008, :174-176, :895-968).
🔴 Still blocking — three bypasses of the new guards, each reproduced cold twice: zero-width characters defeat the non-empty-content check (reopens the erasure class); duplicate merge sources launder an unauthorized update past the operations allow-list; the audit changelog is forgeable and exceeded maxGeneratedBytes by ~32×.
🟡 5 should-fix: reserved-key write via add(), NaN disabling all five caps, planner budgets never told to the model + deriveInsights promising semantics merge can't honor, unconstrained filename segment, optional stale-move mitigation.
Heads-up — the API gate is currently inert: the PR carries needs-api-review, but .github/workflows/api-review-label.yml:19-25 recognizes only api/needs-review / api/review-complete, so the check takes the "no API review labels present — skipping" branch and passes vacuously. The python label also doesn't match the diff (strands-ts/** only).
Verification ledger
- Exact head
49dcce1e481cb5d925a957d6c0a9332875db6e61, merge-basee4189c10, clean detached checkout, no tracked edits. - Focused suites: 124/124 passed (74 consolidate + 50 store),
Type Errors no errors. tsc --noEmit -p src/tsconfig.jsonpassed ·npm run lintpassed ·git diff --checkclean.- Every adversarial scenario re-run cold twice with byte-identical output.
- Not verified here:
format:check(Prettier isn't installed in this sandbox; the GitHub status rollup at this head isSUCCESS, which covers it) and the macOS NFC/NFD case (no APFS volume — flagged as speculative, not a blocker). The repo-widenpm run type-checkfailed only withTS6305becausedist/was never built here — environmental, not PR-caused. - Two specialist passes failed transiently and were retried successfully; no pass is missing.
API / process notes (summary-level, not inline)
- Substantial new primitive. Model-planned destructive mutation of customer data is a category
MemoryStorehas never had (src/memory/types.ts:101-164has no maintenance method), and there's no Python sibling to reconcile against, so this shape sets the cross-SDK contract. Perteam/API_BAR_RAISING.md:24-26that reads as the explicit-meeting tier rather than a solo approve. operationsomitted still authorizes deletion (:411defaults to all;prune→delete:773,resolveContradictions→delete/update:769-772), with no dry-run and no report of what was removed. Would it be better for the smallest call —consolidate({ model })— not to be the most destructive one?CONSOLIDATE_OPERATIONSis unreachable by consumers: documented as the customer-facing source of truth (types.ts:24-26,:43-44) butindex.ts:2exports it type-only, and package subpath exports block a deep import — sooperations: CONSOLIDATE_OPERATIONS.filter(op => op !== 'prune')can't be written today.Promise<void>can't express the outcome: no-op (:420) is indistinguishable from "ran, changed nothing"; partial success throws after mutating (:452-458); audit-write failure is swallowed tologger.warn(:699-701). Both sibling stores return a typed result (bedrock-knowledge-base/store.ts:376,test-memory-store/store.ts:194) and this store's ownadd()returns a receipt (:310).- The reserved changelog key has no exported constant and no reader — the PR's own tests reach around the store with
storage.read('consolidation-changelog.md')(consolidate.test.node.ts:95,107,120). Is the changelog meant to be a customer-readable artifact or an internal aid? That answer changes both the key's visibility and whether aConsolidateResultsubsumes it. - Smaller: no
cancelSignal(settled convention attypes/agent.ts:135,multiagent.ts:58,graph.ts:331) ·maxInputBytes/maxGeneratedBytesare the only byte-unit public config fields instrands-ts/src, vs.team/DECISIONS.md:185-191preferring token units ·operations: []is silently accepted and costs a planner call ·DEFAULT_MAX_CONSOLIDATION_TURNS(:58) is named "default" but nothing can override it, and:363reads as a single-call bound while the worst case is two invokes ·maxDirectories(types.ts:59-63) promises a store-wide invariant whileadd()validates paths not at all (:317-319). - PR body is the customer-facing doc of record (no
site/page mentionsFileMemoryStore) and is stale: the prose still says it excludes aconsolidation/directory while the diagram shows the flat key from32d455c6;maxActionsPerPlanandmaxGeneratedBytesare missing from the config example; the read-only and turn-limit throws aren't listed; no module-exports section (API_BAR_RAISING.md:56-64).
Per-pass breakdown
- Correctness: of the 6 prior blockers, 3 verified fixed, 1 (path identity) fixed for the named vectors via a documented heuristic, 1 (turns) fixed, 1 (non-atomic/stale snapshot) unchanged but now explicitly scoped out at
:357-361. New nit:parseFrontmatter(:117) can match the opening delimiter's newline as the close, so'---\n---\nbody'passesvalidateActionContentbut parses with an empty description and raw---as body — folded in here, not posted separately. - Adversarial: 11 repro scripts, each cold-run twice byte-identically. Broke: zero-width bodies, duplicate merge sources, changelog forgery + byte-cap bypass,
add()reserved key,NaNcaps, filename charset. Could not break: evidence-tag containment,.././backslash/nesting/directory rules, changelog exclusion from search and planner input, write-before-delete ordering (no content loss on injected write failure — the:571-574tradeoff claim is accurate), read-only rejection, action cap, same-instance overlap guard. - Test quality: the 124 tests are genuinely behavioral — they assert storage state and model-call arguments rather than internals. Gaps proven by repro: no coverage for
Cfbodies, duplicate sources,reason/summarysize or sanitization,NaN/Infinitylimits, NFC/NFD aliases. - LLM-context: the evidence framing is sound and states untrusted-data provenance in both system and user messages. Gaps:
deriveInsightspromises retentionmergecan't honor;maxActionsPerPlan/maxGeneratedBytes/maxDirectories, merge cardinality and the directory charset rule are enforced but never stated to the planner, and two of them hard-throw with no revise attempt; the revise-retry message (:543-546) re-injects the model's prior plan without the untrusted-data framing used for evidence. - Routed to existing threads instead of new comments: the zero-width bypass (on the empty-content thread), U+2028/U+2029 escaping (evidence thread), NFC/NFD + filename charset (path-identity thread), stale-move mitigation (atomicity thread), turn-bound confirmation (turn thread).
Aggregation: 5 passes → 13 raw findings + 10 API sub-items → 3 🔴 and 5 🟡 posted inline or as thread replies, the rest summary-level.
| * well-formed schema should complete in a single turn; 3 allows for model hesitation without | ||
| * permitting a runaway loop. | ||
| */ | ||
| const DEFAULT_MAX_CONSOLIDATION_TURNS = 3 |
There was a problem hiding this comment.
we should definitely benchmark this later on
| if (files.size === 0) return | ||
|
|
||
| if (files.size > maxFiles) { | ||
| throw new Error(`Knowledge store exceeds consolidation file limit: ${files.size} files (maxFiles: ${maxFiles})`) |
There was a problem hiding this comment.
can be addressed as follow up by the team, but im wondering if you have any ideas for a more intelligent route to take in this scenario (ie. only consolidate on some fraction of the files, etc)
There was a problem hiding this comment.
no need imo to implement this now, just want some ideas for the future
There was a problem hiding this comment.
Two ideas from what this round's testing turned up, both squarely follow-up material rather than anything for this PR:
-
A bounded slice per run would also bound blast radius. A plan can currently push the store past its own admission limits (see the
moveamplification blocker — 1000 files / 117 MB generated from one 120 KiB source), after which no futureconsolidate()can run at all. Processing a bounded slice per run — oldest-N, one directory, or a byte budget — handles "store exceeds limits" gracefully and means a single bad plan can only ever affect that slice. -
Tell the planner its budget. The prompt never states
maxActionsPerPlan,maxGeneratedBytes,maxDirectories, the ≥2-distinct-sources rule, or the directory charset (planner.ts:131-190, unchanged by the refactor), yet the first two hard-throw with no revise cycle (plan.ts:88-91,file-memory-store.ts:366-370). A model told "at most N actions, at most X bytes generated" will usually stay inside it; today it can only discover the limit by being rejected, and for those two limits it doesn't even get the retry. That likely removes most of the need for smarter post-hoc handling.
A third option if the corpus genuinely outgrows a single planner call: hierarchical consolidation — consolidate within each directory first, then a second pass over the per-directory summaries — which keeps each model call small and makes the work naturally resumable.
| const assertPositiveFinite = (name: string, value: number): void => { | ||
| if (!Number.isFinite(value) || value <= 0) { | ||
| throw new TypeError(`${name} must be a positive finite number, got ${value}`) | ||
| } | ||
| } | ||
| assertPositiveFinite('maxFiles', maxFiles) | ||
| assertPositiveFinite('maxActionsPerPlan', maxActionsPerPlan) | ||
| assertPositiveFinite('maxDirectories', maxDirectories) |
There was a problem hiding this comment.
can we do these assertions at initialization rather than in consolidate so these are caught earlier?
There was a problem hiding this comment.
moved assertions to the top of consolidate() instead. I can't move them to initialization unless I added them to the constructor
| maxDirectories?: number | ||
|
|
||
| /** | ||
| * Maximum number of knowledge files allowed as planner input. Defaults to 100. |
There was a problem hiding this comment.
how did you chose these defaults?
There was a problem hiding this comment.
They're pretty conservative default values that I thought would be safe for general use case. They're subject to change if they're ever tested or benchmarked
…place typed errors, and made planner messages more concise
…w feedback Lowercase keys at write time (add() and extractPlan) so a path is its own canonical storage key, removing the case-insensitive resolution layer: resolveWriteTarget, validateTargetsUnambiguous, resolveCanonicalKey, and pathsResolveSame are gone and path identity is plain string equality. Also: trim the planner prompt's injection framing and drop escapeEvidence, hoist consolidate() limit validation ahead of any I/O, and replace the allowed-action if-chain with an OPERATION_ACTIONS lookup map.
Also fold in review cleanups:
- drop isConsolidationChangelog in favor of a direct key comparison
- have validate.ts helpers return string[] instead of string | undefined,
and replace planOverwritesSelf with a safeOverwrites set accumulated in
one pass
- make ConsolidateConfig.model optional, falling back to the Agent default
Add co-located unit tests for the consolidation modules
…y listing inside readAllFiles, before any content is read, so an oversized store is rejected without first pulling its whole corpus into memory, Extract mapWithConcurrency and STORAGE_READ_CONCURRENCY into a neutral concurrency module so search() no longer imports from the consolidation-specific execute module, and make comments more concise
| * | ||
| * @internal | ||
| */ | ||
| export interface DeleteFailure { |
There was a problem hiding this comment.
non-blocking: should we define this in errors.ts?
b3f913a
into
strands-agents:feature/memory-store
Description
A
FileMemoryStoreaccumulates knowledge over time: duplicate facts pile up, later entries contradict earlier ones, and files drift into the wrong place. Nothing today reconciles that — the store only grows.consolidate()gives it an offline maintenance pass that an operator or scheduled job can run to keep the corpus coherent.The consolidation strategy is plan-then-execute. A single structured-output call produces a full action plan over every file; deterministic code, not the model, then validates and applies it. The model never touches storage directly. Validation is the gate: it accumulates all violations at once (unknown paths, disallowed actions for the requested operations, and any write/delete collision that would destroy content), and throws with every violation named at once, so a rejected plan is diagnosable in one pass rather than one error at a time. Execution runs all writes before any deletes, so merged content lands before its sources are removed. A crash mid-run leaves duplicates rather than dropping content that had nowhere else to live.
This protects sources, not overwrite targets: an update rewrites in place by design. Consolidation reserves consolidation-changelog.md and excludes it from the working set, search(), and add(), so it can't ingest or rewrite its own audit log.
Public API Changes
FileMemoryStoregains aconsolidate()method, plus aConsolidateConfigandConsolidateOperationtype:ConsolidateOperation is one of 'deduplicate' | 'resolveContradictions' | 'deriveInsights' | 'prune' | 'reorganize'.
consolidate() throws when the store exceeds maxFiles, when the plan exceeds maxActionsPerPlan, when the model returns no structured output, when the plan fails validation, or when a concurrent run is already in flight on the same store instance.
a store named
agent-memorylays out like this:reorganizemay group files into additional top-level directories within thestore's namespace, subject to the layout rules below.
Every knowledge file is markdown with YAML frontmatter carrying a
description:Layout rules enforced by
validatePath()— the planner is untrusted, so a planviolating any of these is rejected before a single storage mutation:
.mdmaxDirectoriesdirs (default 8)^[a-z0-9-]{1,30}$./..segments or backslashesconsolidation-changelog.mdis reservedRelated Issues
Documentation PR
Type of Change
Bug fix
New feature
Breaking change
Documentation update
Other (please describe):
Testing
How have you tested the change? Verify that the changes do not break functionality or introduce new warnings.
hatch run prepareChecklist
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.