From 28a150bf69713349fca31b9a017ac77dd5cf997f Mon Sep 17 00:00:00 2001 From: "Huang, Wei-Chieh" Date: Fri, 29 May 2026 06:47:25 +0800 Subject: [PATCH 1/6] 50-bug server: list_memory_components limit<=0 means no limit --- mirix/server/rest_api.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mirix/server/rest_api.py b/mirix/server/rest_api.py index b30626a7..8965ab92 100644 --- a/mirix/server/rest_api.py +++ b/mirix/server/rest_api.py @@ -3984,7 +3984,13 @@ async def list_memory_components( raise HTTPException(status_code=404, detail=f"User {user_id} not found") timezone_str = getattr(user, "timezone", None) or "UTC" - limit = max(1, min(limit, 200)) # guardrails + # limit <= 0 means "no limit" (return all items per memory type); positive + # is clamped. Old max(1, min(limit,200)) forced 0->1 and capped at 200, so + # token accounting only ever saw a 50/200-item sample. + if limit <= 0: + limit = None + else: + limit = min(limit, 10000) # Need an agent state for memory manager configuration agents = await server.agent_manager.list_agents( From 2ad50ce71f77bf679779a507045e7f1e77a55950 Mon Sep 17 00:00:00 2001 From: jasonya Date: Sat, 16 May 2026 20:33:33 -0500 Subject: [PATCH 2/6] feat(memory): add MAB conflict-resolution and source provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a deterministic conflict-resolution path for semantic memory inserts, with source provenance (turn_id / chunk_id / serial / occurred_at) flowing from /memory/add through to stored records. Enabled per meta-agent via the new `enable_conflict_resolution` flag; legacy free-form inserts remain the default. Schema: - `users.turn_counter`, `users.chunk_counter` — per-user monotonic counters used by `/memory/add` to fill in fallback provenance when the client does not provide source_meta. - `episodic_memory.source_refs`, `semantic_memory.source_refs` — provenance pointers from stored memories back to their source units. - `semantic_memory.prior_values` — history of values that have been superseded under the conflict-resolution path. Services: - `UserManager.reserve_source_ids` — atomic counter bump used by the /memory/add fallback. - New `semantic_memory_upsert_fact` tool gated by the agent flag. - `MetaAgent` system prompt augmentation when the flag is on. Docs: `docs/mab_conflict_resolution_and_provenance.md`, `docs/mab_raw_chunk_side_channel.md`, `docs/mab_user_id_isolation_fix.md`. (cherry picked from commit 2a172e5f0e940e81053c7ade72a7f18c7318a9a8) --- .../mab_conflict_resolution_and_provenance.md | 451 ++++++++++++++++++ docs/mab_raw_chunk_side_channel.md | 141 ++++++ docs/mab_user_id_isolation_fix.md | 146 ++++++ evals/organize_results.py | 13 +- mirix/agent/meta_agent.py | 33 ++ mirix/functions/function_sets/memory_tools.py | 10 + mirix/orm/episodic_memory.py | 12 + mirix/orm/semantic_memory.py | 22 + mirix/orm/user.py | 17 + mirix/schemas/agent.py | 10 +- mirix/schemas/episodic_memory.py | 10 + mirix/schemas/semantic_memory.py | 29 ++ mirix/schemas/user.py | 15 + mirix/services/agent_manager.py | 27 ++ mirix/services/user_manager.py | 36 ++ 15 files changed, 969 insertions(+), 3 deletions(-) create mode 100644 docs/mab_conflict_resolution_and_provenance.md create mode 100644 docs/mab_raw_chunk_side_channel.md create mode 100644 docs/mab_user_id_isolation_fix.md diff --git a/docs/mab_conflict_resolution_and_provenance.md b/docs/mab_conflict_resolution_and_provenance.md new file mode 100644 index 00000000..3b89f9e8 --- /dev/null +++ b/docs/mab_conflict_resolution_and_provenance.md @@ -0,0 +1,451 @@ +# Patch note: conflict resolution + source provenance for semantic and episodic memory + +**Status.** Opt-in at meta-agent **create time** for the conflict +resolution policy section; source provenance is **always-on** server-side +(no opt-in needed). Default behaviour is preserved: existing user flows +keep their semantics, existing items get `source_refs=[]` / +`prior_values=[]` after migration and stay opaque to the new paths. + +**Scope.** + +- Three new persisted columns (`semantic_memory.source_refs`, + `semantic_memory.prior_values`, `episodic_memory.source_refs`). +- Two new per-user counter columns (`users.turn_counter`, + `users.chunk_counter`). +- A new manager method `SemanticMemoryManager.upsert_with_conflict_resolution`, + an auto-route in the existing `insert_semantic_item`, and an + `additional_source_ref` parameter on `EpisodicMemoryManager.update_event`. +- A server-side helper `_augment_source_meta_with_server_fallbacks` + invoked from both `/memory/add` entry points. +- A `UserManager.reserve_source_ids` helper that atomically bumps the + per-user counters. +- One ~1 KB prompt section appended to the semantic agent's stored + system prompt when `enable_conflict_resolution=True` is passed to + `create_meta_agent`. + +No new tool, no new validator, no `semantic_memory_*` tool list change, +no `update_meta_agent` rewiring. + +## The problem + +MIRIX's `semantic_memory_agent` resolves conflicts using LLM free-text +merge with no notion of recency, version, or provenance: + +- Multiple conflicting facts about an entity collapse into one + `summary` / `details` string. FactConsolidation entries like + `0. Thomas Kyd was born in London` and + `306. Thomas Kyd was born in Leeds` become + `"Thomas Kyd was born in London, though some data says Leeds"`. +- The merging LLM uses its world-knowledge prior as a tie-breaker, often + marking the dataset's authoritative value as + `"conflicting"` / `"incorrectly attributed"` / `"erroneously"`. +- After delete-then-insert, the old value is gone — no audit trail. + +Three concrete cases caught from `prompt_debug` in a prior run: + + - `Thomas Kyd born in` → MIRIX summary said `London`, suppressed `Leeds`. + - `Japan official language` → kept `Japanese`, dropped `Swedish`. + - `Microsoft CEO` → kept `Satya Nadella`, dropped `Steve Jobs`. + +Correct behaviour for a personal assistant. Wrong for any system that +needs to honour the user's most recent statement when it contradicts +world knowledge, or to recall *when* a fact was first / last asserted. + +## The change + +Two independent but cooperating mechanisms: + +### A. Source provenance (always-on, general) + +Every `/memory/add` request — whether from the MAB adapter, a personal +assistant SDK, or any other caller — ends up with a `filter_tags["source_meta"]` +dict carrying at least `turn_id`, `chunk_id`, and `occurred_at`. The +fields the caller already supplied win; the server fills in the rest. + +The pipeline: + +1. **Client may pre-fill** any subset of + `filter_tags["source_meta"] = {turn_id, chunk_id, serial, occurred_at}`. + - The MAB adapter populates `chunk_id`, `serial_first`, `serial_last` + because it knows the chunk's internal structure. + - A personal-assistant SDK can leave it empty. +2. **Server `/memory/add` merges with fallbacks** via + `_augment_source_meta_with_server_fallbacks`: + - `turn_id` missing → call `UserManager.reserve_source_ids(n_turns)` + and use `turn_id_start`. + - `chunk_id` missing → use the reservation's `chunk_id`. + - `occurred_at` missing → use the request's top-level `occurred_at` + if present, else wall-clock UTC ISO 8601. + - `serial` is never auto-filled; it stays present iff the caller put + it there (FactConsolidation-style numbered input). +3. **Counters are persisted** in two new `users` columns + (`turn_counter`, `chunk_counter`), bumped atomically by + `reserve_source_ids`. +4. **`SemanticMemoryManager.insert_semantic_item` copies `source_meta` + into `source_refs`** when the auto-route fires (see B below). +5. **`EpisodicMemoryManager.insert_event` copies `source_meta` into + `source_refs`** unconditionally — every event gets a provenance trail, + not just CR-eligible ones. +6. **`EpisodicMemoryManager.update_event` accepts + `additional_source_ref`** so `episodic_memory_merge` can append the + current ingest's pointer onto an already-existing event's + `source_refs`. Multi-batch events therefore preserve every chunk + that contributed. + +### B. Conflict resolution (opt-in at create time) + +A second path through the existing `semantic_memory_insert` call, +selected once at meta-agent create: + +1. **Schema.** `semantic_memory.source_refs JSON NOT NULL DEFAULT '[]'` + and `semantic_memory.prior_values JSON NOT NULL DEFAULT '[]'`. + Legacy rows default to empty and stay opaque. +2. **Manager.** `SemanticMemoryManager.upsert_with_conflict_resolution( + entity, relation, value, source_ref, ...)` does deterministic merge: + priority `occurred_at > serial > created_at`, newer wins as + `summary`, older goes into `prior_values` with status + `superseded` (or `corrected` when the caller asks). +3. **Auto-route.** `SemanticMemoryManager.insert_semantic_item` checks + the incoming `filter_tags["source_meta"]` dict. If it is present AND + `name` is shaped like `" / "`, the call is + forwarded to `upsert_with_conflict_resolution`. Otherwise the + legacy free-form path runs unchanged. +4. **Prompt.** When `enable_conflict_resolution=True` is passed to + `create_meta_agent`, a ~1 KB policy section is appended to the + semantic agent's stored system prompt. The section tells the agent + to write facts as `name=" / ", summary=`, + verbatim, no hedging. + +The conflict-resolution path is selected **once**, at meta-agent +create. When off, the policy section is not in the prompt and the agent +never writes the triple-shape names, so the auto-route in +`insert_semantic_item` never fires. + +## Files touched + +| File | Change | +| --- | --- | +| `mirix/orm/user.py` | + `turn_counter INT NOT NULL DEFAULT 0`, + `chunk_counter INT NOT NULL DEFAULT 0` | +| `mirix/orm/semantic_memory.py` | + `source_refs JSON NOT NULL DEFAULT '[]'`, + `prior_values JSON NOT NULL DEFAULT '[]'` | +| `mirix/orm/episodic_memory.py` | + `source_refs JSON NOT NULL DEFAULT '[]'` | +| `mirix/schemas/user.py` | Surface `turn_counter` + `chunk_counter` on `User` | +| `mirix/schemas/semantic_memory.py` | Surface both new fields on `SemanticMemoryItem` + `SemanticMemoryItemUpdate` | +| `mirix/schemas/episodic_memory.py` | Surface `source_refs` on `EpisodicEvent` + `EpisodicEventUpdate` | +| `mirix/services/user_manager.py` | + `reserve_source_ids(user_id, n_turns)` — atomic counter bump used by the `/memory/add` fallback. | +| `mirix/services/semantic_memory_manager.py` | + `upsert_with_conflict_resolution(...)`, + `_find_by_entity_relation`, + `_build_cr_filter_tags`, + `_source_ref_key`; auto-route inside `insert_semantic_item`. | +| `mirix/services/episodic_memory_manager.py` | `insert_event` copies `filter_tags["source_meta"]` into the new `source_refs` column; `update_event` accepts `additional_source_ref` so merge appends the current ingest's pointer. | +| `mirix/agent/meta_agent.py` | + module-level `_CONFLICT_RESOLUTION_POLICY_PROMPT` (~1 KB). No flag plumbing through the class. | +| `mirix/schemas/agent.py` | + `enable_conflict_resolution: bool = False` on `CreateMetaAgent` only | +| `mirix/services/agent_manager.py` | `create_meta_agent`: when the flag is set, append the policy section to the semantic agent's stored system prompt at creation time | +| `mirix/server/rest_api.py` | + `_augment_source_meta_with_server_fallbacks(...)` helper, called from both `/memory/add` and `/memory/add_sync`. Pass `enable_conflict_resolution` from `meta_agent_config` into `CreateMetaAgent`. | +| `mirix/functions/function_sets/memory_tools.py` | `episodic_memory_merge` reads `self.filter_tags["source_meta"]` and forwards it to `update_event` as `additional_source_ref`. | +| `scripts/migrate_add_provenance_columns.py` | One-shot, idempotent ALTER TABLE for the five new columns. Safe to re-run. | +| `samples/memoryagentbench/mirix_adapter.py` | + `mirix_enable_conflict_resolution` YAML key; ingest sends `filter_tags={"source_meta": {chunk_id, serial_first, serial_last}}` and ISO-8601 `occurred_at`. `update_agents=False` — flag is set at create only. | +| `samples/memoryagentbench/run_bench.py` | + `--enable-conflict-resolution` / `--no-enable-conflict-resolution` CLI flag | +| `samples/memoryagentbench/run_ablation.py` | Forward `--enable-conflict-resolution` to every spawned `run_bench` | + +Nothing was changed in `mirix/agent/tool_validators.py` or +`mirix/constants.py`'s `SEMANTIC_MEMORY_TOOLS`. + +## Data model + +`SemanticMemoryItem.source_refs : list[dict]` — provenance pointers for +the current value. Each entry is a small dict; any subset of +`{turn_id, chunk_id, serial, occurred_at}` may be present. + +`SemanticMemoryItem.prior_values : list[dict]` — values that used to be +canonical. Shape: + +```python +[ + { + "value": str, # the prior canonical value + "source_refs": list[dict], # provenance for that prior value + "status": "superseded" # OR "corrected" + | "corrected", + "moved_at": "2026-05-15T01:00:00", # when the demotion happened + "note": Optional[str], # e.g. "late-arrived older fact" + }, +] +``` + +`EpisodicEvent.source_refs : list[dict]` — same shape as on semantic. + +All three columns are non-null with `default=list` / `DEFAULT '[]'`. +Legacy items written before this change get `[]` after the migration. + +## Deterministic ordering + +`SemanticMemoryManager._source_ref_key(source_ref) -> tuple` produces a +lexicographic sort key: + +```python +return ( + 1 if occurred_at else 0, occurred_at, + 1 if serial is not None else 0, serial if serial is not None else -1, + 1 if created_at else 0, created_at, +) +``` + +Priority: `occurred_at > serial > created_at`. The MAB adapter sets all +three where available (`occurred_at = now_iso8601()`, `serial = +serial_last` extracted from the chunk text, `created_at` fills in at +DB write). + +## Auto-route inside `insert_semantic_item` + +The behaviour change is fully contained in one method: + +```python +async def insert_semantic_item(self, ..., name, summary, filter_tags=None, ...): + source_meta = (filter_tags or {}).get("source_meta") + if source_meta and isinstance(name, str) and " / " in name: + entity, _, relation = name.partition(" / ") + if entity.strip() and relation.strip(): + return await self.upsert_with_conflict_resolution( + entity=entity.strip(), + relation=relation.strip(), + value=summary, + source_ref=dict(source_meta), + extra_filter_tags={k: v for k, v in (filter_tags or {}).items() + if k != "source_meta"}, + ... + ) + # legacy free-form path unchanged + ... +``` + +Two conditions both need to hold to enter the conflict-resolution path: + +1. The caller passed `filter_tags["source_meta"]` (the MAB adapter, the + only caller that knows what source the input came from, does this + when its `mirix_enable_conflict_resolution` flag is on). +2. The agent put `" / "` in `name` (the policy section in the system + prompt teaches it to do this for triple-shaped facts). + +If either condition is missing, the legacy `insert_semantic_item` +path runs as before. This means: + +- Concept items the agent writes without the triple shape + (`name="Crystal chandelier care"`) → legacy path, unchanged. +- Triple-shaped items written without source provenance (no adapter, no + flag) → legacy path, unchanged. +- Both present → conflict-resolution path. + +## Agent prompt section + +When `enable_conflict_resolution=True` is passed to `create_meta_agent`, +`agent_manager.create_meta_agent` appends +`_CONFLICT_RESOLUTION_POLICY_PROMPT` to the semantic agent's stored +system prompt at creation time. The section, in full: + +``` +## Conflict resolution policy + +When ingesting a fact that asserts a value for some (entity, relation) +already covered by an existing semantic item: + +- DO NOT merge the new value into a hedging free-text summary + ("X, though some data says Y", "incorrectly attributed", + "according to some sources"). +- DO NOT use your own world knowledge to choose which value is "correct". +- The user's most recent assertion is authoritative. + +When you call semantic_memory_insert for a fact of this shape, write: + + - name: " / " (e.g. "Thomas Kyd / born in") + - summary: the raw value, verbatim (e.g. "Leeds"). No paraphrasing. + - details: short context only. + +The manager will then preserve any prior canonical value with a +"superseded" marker in prior_values based on source ordering — you +do not have to hedge in summary to keep the old value safe. + +For free-form concepts that do not fit a triple shape (multi-paragraph +how-tos, abstract topics), keep calling semantic_memory_insert +normally; the manager will route those down the legacy free-form path +unchanged. +``` + +When `enable_conflict_resolution=False`, this section is never emitted; +the semantic agent sees the unaltered base prompt and behaves exactly +as before. No tool changes, so the agent's tool list is identical in +both modes. + +## How to enable + +### Python client / SDK + +```python +client = await MirixClient.create(...) +await client.initialize_meta_agent( + config={ + "llm_config": {...}, + "embedding_config": {...}, + "meta_agent_config": { + "agents": [...], + "enable_conflict_resolution": True, # <-- create-time only + }, + }, +) +``` + +If a meta-agent already exists for this client, you must delete and +re-create to switch the flag. + +### MAB adapter (YAML) + +```yaml +mirix_enable_conflict_resolution: true +``` + +### MAB adapter (CLI override) + +``` +python samples/memoryagentbench/run_bench.py ... --enable-conflict-resolution +python samples/memoryagentbench/run_ablation.py ... --enable-conflict-resolution +``` + +### Migration + +```bash +python scripts/migrate_add_provenance_columns.py +``` + +Run once per Postgres deployment. Idempotent. Existing rows get +`source_refs=[]`, `prior_values=[]`. No backfill. + +## Validation + +Three things were verified end-to-end against the running server: + +### 1. Server-side source_meta fallback (always-on path) + +Two consecutive `/memory/add` calls for a fresh user with no +`filter_tags` on the request side at all. Result: + +``` +ep_TY7K "User went hiking at Mt Rainier ..." + source_refs = [{"chunk_id": 0, "turn_id": 0, "occurred_at": "...09:24:49..."}] + +ep_OCEO "User went to a yoga class downtown ..." + source_refs = [{"chunk_id": 1, "turn_id": 1, "occurred_at": "...09:24:49..."}] +``` + +`user.turn_counter` and `user.chunk_counter` both advanced 0→1→2. No +client-side cooperation needed. + +### 2. Client-provided source_meta (MAB path) + +Sent `filter_tags={"source_meta": {"serial_last": 500, "chunk_id": 99}}`. +After the call: + +- The event's `source_refs` carried `serial_last=500` (preserved) and + `chunk_id=99` (preserved). +- `turn_id` and `occurred_at` were filled by the server fallback. + +### 3. Conflict resolution policy (opt-in path) + +With `enable_conflict_resolution=True` passed to +`initialize_meta_agent`, the semantic agent's stored system prompt +grew from 5 554 chars to 6 674 chars (= base + 1 118-char policy +section + 2 separator). On a FactConsolidation `sh_6k` smoke run, the +agent wrote items with names like `"Thomas Kyd / born in"` and the +manager's auto-route fired (`cr_entity` / `cr_relation` populated in +`filter_tags`). For that entity, the canonical value was `"Leeds"` +(the higher-serial value), not the world-knowledge `"London"`. + +End-to-end EM numbers depend additionally on how many facts the agent +writes within its ingest token budget (separate concern from conflict +resolution itself) and remain a tuning question for individual +benchmarks. + +## What this does *not* fix + +- Multi-hop reasoning. Conflict resolution is per `(entity, relation)` + pair — chained `(entity_A, rel_1, ?)` → `(?, rel_2, ?)` lookups are + out of scope. +- Verbatim quote recall (LongMemEval `single-session-assistant`). The + agent still paraphrases the assistant's prior wording when storing + semantic items; only the *value* slot of triple-shaped facts gets the + no-hedging guarantee. +- Per-request toggling. The flag is read once at create time and baked + into the agent's stored prompt; changing it requires re-creating the + meta-agent. +- The recommended ingest density. Triggering the conflict-resolution + branch still depends on the agent writing the right set of facts; + the policy nudges the *shape* and the *value choice*, not the + *coverage* of what gets written. + +## Known limitations + +### Chunk-level source_ref is not fact-level + +The MAB adapter's `source_meta` carries +``{chunk_id, serial_first, serial_last, occurred_at}`` for the whole +chunk. Inside one `client.add` call, every individual fact the +semantic agent extracts ends up with the **same** `source_ref` — there +is no per-fact serial yet. + +Consequence: when the agent writes two competing items with the same +`name` (e.g. `"Thomas Kyd / born in" → "London"` from fact #0 and +`"Thomas Kyd / born in" → "Leeds"` from fact #306) in the **same** +ingest, the deterministic merge in +`upsert_with_conflict_resolution` cannot distinguish them on +`source_ref` alone: + +- `occurred_at` is identical (same wall-clock instant). +- `serial_last` is identical (always `306` for the whole chunk). +- Only `created_at` differs → tie broken by **insert order**. + +If the agent writes the higher-serial fact last, the right value wins. +If it writes them in any other order, the wrong value wins. Either +way, the outcome is not really deterministic on the input contents — +it is determined by the agent's traversal order. For real conversational +inputs (each new fact is a separate `client.add` call with its own +`occurred_at`), the gap does not exist: timestamps separate the +ingests cleanly. + +Fix paths considered (not in this patch): + +1. Agent extracts the per-fact serial from the chunk text and puts it + on each item it writes (prompt-engineering only, but + `semantic_memory_insert`'s `items[]` schema currently has no + per-item `source_ref` field). +2. Add an optional per-item `source_ref` to `SemanticMemoryItemBase` + so the agent can override the chunk-level one. +3. Server-side: `insert_semantic_item` regex-scans the original chunk + text to recover the serial. Requires also persisting the raw chunk + on the agent context, which we are otherwise trying to avoid. + +### Agent does not always write both sides of a conflict + +The new policy section tells the agent to write facts verbatim with no +hedging. Empirically the agent will sometimes write only the value it +considers most plausible (often the one matching world knowledge), +skipping the other side of the conflict entirely. When that happens +the deterministic merge has nothing to merge — `prior_values` stays +`[]` and the result reflects the agent's pick, not the data. + +This is independent from the chunk-vs-fact source_ref limitation above. +It is a prompt-following gap; mitigations are prompt-engineering or +a finer-grained tool surface, neither of which is in scope here. + +### `serial` is never auto-derived by the server + +Only the caller can populate `source_meta["serial"]` (or +`serial_first` / `serial_last`). The server fallback in +`_augment_source_meta_with_server_fallbacks` only fills `turn_id`, +`chunk_id`, and `occurred_at`. This is deliberate — `serial` is a +domain-specific signal — but it does mean that benchmarks with +implicit numbered facts (FactConsolidation) require client-side +support to surface that signal. + +### `prior_values` is not yet surfaced on retrieval + +Items written via the conflict-resolution path persist their history +in `prior_values`, but `retrieve_with_conversation` currently only +returns the current `summary`. Time-travel queries +("Where did I used to live?") would need a separate retrieval path +that exposes `prior_values` and lets the LLM see the timeline. Not +in this patch. diff --git a/docs/mab_raw_chunk_side_channel.md b/docs/mab_raw_chunk_side_channel.md new file mode 100644 index 00000000..de8f7120 --- /dev/null +++ b/docs/mab_raw_chunk_side_channel.md @@ -0,0 +1,141 @@ +# Patch note: raw-chunk side channel for the MemoryAgentBench adapter + +**Scope.** `samples/memoryagentbench/mirix_adapter.py` only. No changes to +MIRIX core, MAB, or any prompt template. + +**Status.** Opt-in. Default behaviour (`mirix_preserve_raw_chunks` unset) +keeps the adapter on the pure MIRIX retrieval path. + +## Background + +MIRIX's `add` endpoint pushes every ingested message through the meta agent +and its six sub-agents, which **abstract** the input into structured memory +items: `{name, summary, details, tree_path}` for semantic, event summaries +for episodic, and so on. The original chunk text is not retained verbatim +anywhere in the database, and `retrieve_with_conversation` returns these +abstracted items, never the source string. + +That is the right behaviour for a personal assistant: a few months in, +nobody wants to grep raw screen captures for "what was my Wi-Fi password" — +they want a deduplicated, summarised memory. + +It is the wrong shape for some MemoryAgentBench sub-datasets. The most +extreme case is **Conflict_Resolution / FactConsolidation**: the adapter +ingests a numbered list of contradicting facts, + +``` +0. Thomas Kyd was born in the city of London. +... +306. Thomas Kyd was born in the city of Leeds. +``` + +and the gold answer is the entry with the **largest serial number** +(`Leeds`, even though the world-knowledge answer is `London`). MIRIX's +`semantic_memory_agent` collapses both entries into one summary item and +will sometimes annotate it with its own world-knowledge belief +(`"Thomas Kyd was born in London; some sources incorrectly claim Leeds"`). +Both the serial number and the verbatim wording — the only signals the +benchmark scores — are gone by the time `retrieve_with_conversation` +serves a query. + +This affects any MAB sub-dataset whose gold answer depends on token-exact +content the summarising agents will discard: serial numbers, verbatim +excerpts, exact label-to-class mappings. + +## Change + +When `preserve_raw_chunks` is on, the adapter additionally keeps the +un-templated chunk in a per-`user_id` Python list at ingest time: + +```python +# inside _memorize, after the regular client.add(...) call +if self.preserve_raw_chunks: + self._raw_chunks[user_id].append(message) +``` + +At query time it BM25-ranks those raw chunks against the question, picks +the top-k (default 5), and prepends them to the retrieved-memory block +that goes into the prompt: + +``` +--- Raw ingested chunks (verbatim, ordered by BM25 relevance) --- + + + + +--- MIRIX memory retrieval --- + +``` + +The MAB query template — the prompt with the rules and the `{question}` +slot — is **unchanged**. `get_template(...)` still resolves to MAB's +`templates.py` verbatim. Only the contents that fill the +"retrieved memory" slot in the prompt are augmented. + +## Configuration + +`mirix_preserve_raw_chunks` in the agent YAML, or `--preserve-raw-chunks` +on `run_bench.py` / `run_ablation.py`: + +| value | effect | +| -------------- | ------------------------------------------------------------------------------------------------------- | +| unset / `null` | **off** (default). No local raw cache, no BM25, no extra tokens. Pure MIRIX semantics. | +| `false` | Same as off, but explicit. | +| `true` | Force on for every sub-dataset. | +| `"auto"` | Adapter decides per sub-dataset using `mirix_adapter._RAW_CHUNK_RECOMMENDED_SUBDATASETS`. | + +The recommended list currently turns the side channel on for sub-datasets +matching `factconsolidation*`, `ruler_qa*`, `eventqa*`, `icl_*`, +`recsys_*`. Adding a new benchmark to that list is the only change needed +to opt it in to `auto`. CLI override beats YAML; YAML beats `auto`. + +`mirix_raw_chunk_topk` (default 5) controls how many raw chunks BM25 +returns per query. + +## Why this is a fair comparison + +MAB's other agentic-memory backends already do the equivalent verbatim +storage: + +- **letta** in `insert` mode (the configuration used for MAB's main + results) calls `passage_manager.insert_passage(text=formatted_message)` + directly. The full chunk goes into letta's archival memory verbatim; + letta's own memory-agent loop is bypassed. +- **mem0** writes the templated message into its vector store, which + tokenises the chunk verbatim before embedding. + +MIRIX exposes no such verbatim-passthrough lane in the public HTTP API: +the only writer endpoint is `add`, and `add` unconditionally routes +through the abstracting meta agent. The side channel is the smallest +external work-around that puts MIRIX on the same footing as those +backends for verbatim-critical benchmarks. With it disabled, MIRIX is +benchmarked on its own native retrieval semantics. + +## Cost + +Empirical numbers from FactConsolidation `sh_6k` (100 questions, +gpt-4o-mini, top-5 raw chunks): + +| mode | EM | F1 | input tokens / question | +| ----- | ---- | ----- | ----------------------- | +| off | 14% | 16.8% | ~4,700 | +| auto | 71% | 71.9% | ~17,100 | + +Per-question wall time rises by less than a second on this dataset. + +For larger contexts (`sh_64k`, `sh_262k`), the BM25 selection becomes the +operative knob — a context split into 64 chunks with `topk=5` still puts +~20k tokens into the prompt regardless of context size, but the +likelihood that the right chunks are in the top-5 falls. `topk` is the +lever there. + +## What this is not + +- It is not a change to MAB's prompt templates. `templates.py` is + imported and used unchanged. +- It is not a change to MIRIX core. Nothing under `mirix/` is touched. +- It is not a backdoor that lets MIRIX "cheat" — letta and mem0 already + store chunks verbatim in their stores, and the side channel is the + adapter's only way to put MIRIX on parity with that. +- It is not on by default. A benchmark run with no flags measures pure + MIRIX retrieval. diff --git a/docs/mab_user_id_isolation_fix.md b/docs/mab_user_id_isolation_fix.md new file mode 100644 index 00000000..b8ede48a --- /dev/null +++ b/docs/mab_user_id_isolation_fix.md @@ -0,0 +1,146 @@ +# Patch note: per-sub_dataset user_id isolation + memory purge for the MAB adapter + +**Scope.** `samples/memoryagentbench/mirix_adapter.py`, +`samples/memoryagentbench/run_bench.py`, +`samples/memoryagentbench/configs/mirix_gpt-4o-mini.yaml`. No changes to +MIRIX core or MAB. + +**Status.** Bug fix. Every MAB result produced before this patch is +contaminated (see "Impact" below) and must be re-run. + +## The bug + +The MAB adapter wrote every benchmark's memory into the **same MIRIX +`user_id`**. Three things combined to make this silently corrupt results: + +1. **A single shared user_id.** The adapter computed `user_id` from + `mirix_user_prefix`, which the config set to a constant (`mab`). So + every sub_dataset and every context resolved to `mab-ctx0`. + +2. **`add` is purely additive.** MIRIX's `/memory/add` never replaces; + it appends. Nothing ever cleared prior memory. + +3. **`--force` did not force a re-ingest.** `--force` deleted the result + JSON and skipped the "context already complete" check, but the + per-context agent-state sentinel folder was left on disk. The runner + then hit `if os.path.exists(save_folder): agent.load_agent()` and + reused the stale server-side memory instead of re-ingesting. + +The net effect: every MAB run accumulated on top of every previous run's +memory, under one user_id, with no way to reset. + +### How it surfaced + +A LongMemEval-S* run scored 10%. Inspecting a failing question +("How long have I had my cat, Luna?") showed `retrieve_with_conversation` +returning, as its top episodic item: + +> "User shared further extensive learned factual data, expanding the list +> to over 18331 items covering ... American Locomotive Company was +> created in the country of Soviet Union; Taoism was founded by Juliette +> Gordon Low; ..." + +That is FactConsolidation's `sh_262k` data. It had been ingested into +`mab-ctx0` by an earlier run, was newer than the LongMemEval episodics, +and so dominated the `recent` ordering and flooded the retrieval window. +The actual cat-Luna memory (`ep_AVF5`) never made it into the top-10. +The LongMemEval run was effectively being evaluated against a memory +store that was ~99% unrelated FactConsolidation facts. + +## Impact + +Contaminated — must be re-run: + +- FactConsolidation `sh_6k` (off / auto), `sh_32k`, `sh_64k`, `sh_262k` +- FactConsolidation `mh_6k` +- LongMemEval-S* (1 sample) + +`sh_6k` was the first MAB run and may have started against an empty +store, but the sweep that followed (`sh_32k` → `sh_64k` → `sh_262k`) +each ran on top of all prior sub_datasets' memory, so even the +FactConsolidation length-sweep numbers are not trustworthy. + +Not affected: all LoCoMo results. The LoCoMo pipeline +(`evals/main_eval.py` via `eval_locomo_single.py`) already uses a +per-sample `user_id` (`locomo-user-`), so its 10-conversation +run was never cross-contaminated. + +## The fix + +### 1. user_id is namespaced by sub_dataset and not configurable + +`mirix_adapter.py` — `_user_prefix` is now hard-coded: + +```python +# user_id is ALWAYS namespaced by sub_dataset. This is deliberately not +# configurable ... +self._user_prefix = f"mab-{self.sub_dataset}" +``` + +`_user_id_for_context` then yields `mab--ctx`. Each +sub_dataset gets its own user_id space; each context gets its own +user_id within it. The `mirix_user_prefix` config key is removed. + +### 2. Server-side memory is purged before the first ingest + +`mirix_adapter.py` — new `_purge_user_memory(user_id)`: + +```python +def _purge_user_memory(self, user_id: str) -> None: + if user_id in self._purged_user_ids: + return + self._purged_user_ids.add(user_id) + try: + self._run(self._client._request("DELETE", f"/users/{user_id}/memories")) + except Exception as exc: + # 404 just means the user has no memory yet — fine. + ... +``` + +It calls the existing server endpoint `DELETE /users/{user_id}/memories` +(hard-deletes all episodic / semantic / procedural / resource / +knowledge-vault memory, messages and blocks for the user; preserves the +user record). `_purged_user_ids` guards it so it fires at most once per +user_id per process. `_memorize` calls it before its first `add` for a +user, so every ingest starts from a clean slate. + +### 3. `--force` now actually forces a re-ingest + +`run_bench.py` — before constructing the adapter for a context: + +```python +if args.force and os.path.isdir(save_folder): + shutil.rmtree(save_folder, ignore_errors=True) +``` + +Dropping the local sentinel folder makes the runner take the +`_memorize` path instead of `load_agent`, which in turn triggers the +server-side purge from fix #2. Without this, `--force` would re-create +the result file but keep reusing stale server memory. + +`run_ablation.py` needs no change: it spawns `run_bench.py` with +`--force`, so it inherits the corrected behaviour. + +### 4. Config cleanup + +`configs/mirix_gpt-4o-mini.yaml` — the `mirix_user_prefix: mab` line is +removed and replaced with a comment explaining that user_id is not +configurable and that memory is purged before re-ingest. + +## Behaviour after the patch + +- `sh_6k` writes to `mab-factconsolidation_sh_6k-ctx0`, `sh_32k` to + `mab-factconsolidation_sh_32k-ctx0`, LongMemEval-S* to + `mab-longmemeval_s*-ctx0`, and so on — no cross-talk. +- The first ingest into any user_id hard-deletes whatever memory was + there, so re-runs start clean. +- `--force` is a true from-scratch re-ingest. + +## Follow-ups (not done in this patch) + +- The legacy `mab-ctx0` user still holds the ~25k contaminated mixed + memories from pre-patch runs. It can be purged with + `DELETE /users/mab-ctx0/memories`; new runs no longer touch it. +- All MAB benchmarks (FactConsolidation sweep, `mh_6k`, LongMemEval-S*) + need to be re-run; the pre-patch numbers in + `evals/results/mab/.../RESULTS.md` should be regenerated. diff --git a/evals/organize_results.py b/evals/organize_results.py index fa5da981..b676ebfe 100644 --- a/evals/organize_results.py +++ b/evals/organize_results.py @@ -228,7 +228,11 @@ def main() -> None: parser.add_argument( "input_dir", type=Path, - help="Path to results folder (e.g., results/0124a).", + help=( + "Results folder. Relative paths resolve against " + "/evals/results/locomo/, so 'foo' -> evals/results/locomo/foo. " + "Existing-as-given paths are also accepted for backwards compatibility." + ), ) parser.add_argument( "--output-file", @@ -238,7 +242,12 @@ def main() -> None: ) args = parser.parse_args() - input_dir = args.input_dir + locomo_root = Path(__file__).resolve().parent / "results" / "locomo" + requested = args.input_dir + if requested.is_absolute() or requested.exists(): + input_dir = requested + else: + input_dir = locomo_root / requested output_file = args.output_file or (input_dir / "metrics.json") cached_metrics = load_json(output_file) if output_file.exists() else None diff --git a/mirix/agent/meta_agent.py b/mirix/agent/meta_agent.py index 6392bd8b..f49678f8 100644 --- a/mirix/agent/meta_agent.py +++ b/mirix/agent/meta_agent.py @@ -144,6 +144,39 @@ def get_all_agent_states_list(self) -> List[Optional[AgentState]]: ] +# Appended to the semantic_memory_agent system prompt when +# enable_conflict_resolution=True is passed at create_meta_agent time. +# Steers the agent away from free-form merge of conflicting facts. +# See docs/mab_conflict_resolution_and_provenance.md. +_CONFLICT_RESOLUTION_POLICY_PROMPT = """\ +## Conflict resolution policy + +When ingesting a fact that asserts a value for some (entity, relation) +already covered by an existing semantic item: + +- DO NOT merge the new value into a hedging free-text summary + ("X, though some data says Y", "incorrectly attributed", + "according to some sources"). +- DO NOT use your own world knowledge to choose which value is "correct". +- The user's most recent assertion is authoritative. + +When you call ``semantic_memory_insert`` for a fact of this shape, write: + + - ``name``: ``" / "`` (e.g. ``"Thomas Kyd / born in"``) + - ``summary``: the raw value, verbatim (e.g. ``"Leeds"``). No paraphrasing. + - ``details``: short context only. + +The manager will then preserve any prior canonical value with a +``superseded`` marker in ``prior_values`` based on source ordering — you +do not have to hedge in ``summary`` to keep the old value safe. + +For free-form concepts that do not fit a triple shape (multi-paragraph +how-tos, abstract topics), keep calling ``semantic_memory_insert`` +normally; the manager will route those down the legacy free-form path +unchanged. +""" + + class MetaAgent(BaseAgent): """ MetaAgent manages all memory-related sub-agents for coordinated memory operations. diff --git a/mirix/functions/function_sets/memory_tools.py b/mirix/functions/function_sets/memory_tools.py index f809249b..d9d66301 100644 --- a/mirix/functions/function_sets/memory_tools.py +++ b/mirix/functions/function_sets/memory_tools.py @@ -183,6 +183,15 @@ async def episodic_memory_merge( Optional[str]: None is always returned as this function does not produce a response. """ + # Carry the ingest's source_meta (if any) through to update_event so + # the merged episodic event records the current chunk/turn as + # additional provenance. + _filter_tags = getattr(self, "filter_tags", None) or {} + _additional_source_ref = ( + dict(_filter_tags["source_meta"]) + if isinstance(_filter_tags.get("source_meta"), dict) + else None + ) try: episodic_memory = await self.episodic_memory_manager.update_event( event_id=event_id, @@ -191,6 +200,7 @@ async def episodic_memory_merge( actor=self.actor, agent_state=self.agent_state, update_mode="replace", + additional_source_ref=_additional_source_ref, ) except Exception as e: print( diff --git a/mirix/orm/episodic_memory.py b/mirix/orm/episodic_memory.py index 341fcb1d..8d3f75b7 100755 --- a/mirix/orm/episodic_memory.py +++ b/mirix/orm/episodic_memory.py @@ -85,6 +85,18 @@ class EpisodicEvent(SqlalchemyBase, OrganizationMixin, UserMixin): JSON, nullable=True, default=None, doc="Custom filter tags for filtering and categorization" ) + # Provenance pointers for this event. Same shape as the equivalent field + # on SemanticMemoryItem: a list of small dicts pointing back to the + # input units (turn_id / chunk_id / serial / occurred_at) that the + # event was extracted from. Empty when the legacy free-form ingest path + # is used. + source_refs: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + doc="Provenance pointers (turn_id / chunk_id / serial / occurred_at).", + ) + embedding_config: Mapped[Optional[dict]] = mapped_column( EmbeddingConfigColumn, nullable=True, doc="Embedding configuration" ) diff --git a/mirix/orm/semantic_memory.py b/mirix/orm/semantic_memory.py index 1ee83d57..8c8645dc 100755 --- a/mirix/orm/semantic_memory.py +++ b/mirix/orm/semantic_memory.py @@ -75,6 +75,28 @@ class SemanticMemoryItem(SqlalchemyBase, OrganizationMixin, UserMixin): JSON, nullable=True, default=None, doc="Custom filter tags for filtering and categorization" ) + # Provenance pointers for this item. Each ref is a small dict like + # ``{"turn_id": int, "chunk_id": int, "serial": int, "occurred_at": iso8601}``. + # Only populated when the new conflict-resolution / provenance path is + # used; legacy free-form inserts leave it empty. + source_refs: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + doc="Provenance pointers (turn_id / chunk_id / serial / occurred_at) for this item.", + ) + + # Prior values that have been superseded by the current ``summary`` / + # ``details``. Used by the conflict-resolution path; legacy items keep + # this empty. Each entry: ``{"value": str, "source_refs": [...], + # "status": "superseded"|"corrected"|"coexists", "moved_at": iso8601}``. + prior_values: Mapped[list] = mapped_column( + JSON, + nullable=False, + default=list, + doc="History of replaced values from the conflict-resolution path.", + ) + # When was this item last modified and what operation? last_modify: Mapped[dict] = mapped_column( JSON, diff --git a/mirix/orm/user.py b/mirix/orm/user.py index 93d7d4e0..ec11a7e9 100755 --- a/mirix/orm/user.py +++ b/mirix/orm/user.py @@ -20,6 +20,23 @@ class User(SqlalchemyBase, OrganizationMixin): status: Mapped[str] = mapped_column(nullable=False, doc="Whether the user is active or not.") timezone: Mapped[str] = mapped_column(nullable=False, doc="The timezone of the user.") is_admin: Mapped[bool] = mapped_column(nullable=False, default=False, doc="Whether this is an admin user.") + # Per-user monotonically-increasing counters used by the + # /memory/add fallback to fill in source_meta.turn_id and + # source_meta.chunk_id when the client does not provide them. Bumped + # atomically at /memory/add time. Used by the conflict-resolution + # path in `SemanticMemoryManager.insert_semantic_item` and by the + # general source-provenance mechanism documented in + # docs/mab_conflict_resolution_and_provenance.md. + turn_counter: Mapped[int] = mapped_column( + nullable=False, + default=0, + doc="Next turn_id to hand out for this user's next /memory/add request.", + ) + chunk_counter: Mapped[int] = mapped_column( + nullable=False, + default=0, + doc="Next chunk_id to hand out for this user's next /memory/add request.", + ) # relationships organization: Mapped["Organization"] = relationship("Organization", back_populates="users") diff --git a/mirix/schemas/agent.py b/mirix/schemas/agent.py index 00aaf650..b3503ebd 100755 --- a/mirix/schemas/agent.py +++ b/mirix/schemas/agent.py @@ -282,6 +282,15 @@ class CreateMetaAgent(BaseModel): None, description="Embedding configuration for memory agents. Required if no default is set.", ) + enable_conflict_resolution: bool = Field( + False, + description=( + "Opt in to the deterministic conflict-resolution + source-provenance path. " + "When True, the semantic_memory_agent's system prompt is augmented to " + "prefer the new `semantic_memory_upsert_fact` tool for triple-shaped " + "facts. See docs/mab_conflict_resolution_and_provenance.md." + ), + ) class UpdateMetaAgent(BaseModel): @@ -307,7 +316,6 @@ class UpdateMetaAgent(BaseModel): None, description="Embedding configuration for meta agent and its sub-agents.", ) - class Config: extra = "ignore" # Ignores extra fields diff --git a/mirix/schemas/episodic_memory.py b/mirix/schemas/episodic_memory.py index 5b0b9ade..e4012e0f 100755 --- a/mirix/schemas/episodic_memory.py +++ b/mirix/schemas/episodic_memory.py @@ -87,6 +87,13 @@ class EpisodicEvent(EpisodicEventBase): ], ) + # Provenance pointers for this event. Same shape as the equivalent field + # on SemanticMemoryItem; see `mirix.orm.episodic_memory`. + source_refs: List[Dict[str, Any]] = Field( + default_factory=list, + description="Provenance pointers (turn_id / chunk_id / serial / occurred_at).", + ) + # need to validate both details_embedding and summary_embedding to ensure they are the same size @field_validator("details_embedding", "summary_embedding") @classmethod @@ -135,3 +142,6 @@ class EpisodicEventUpdate(MirixBase): filter_tags: Optional[Dict[str, Any]] = Field( None, description="Custom filter tags for filtering and categorization" ) + source_refs: Optional[List[Dict[str, Any]]] = Field( + None, description="Replace the event's source_refs list (conflict-resolution path)." + ) diff --git a/mirix/schemas/semantic_memory.py b/mirix/schemas/semantic_memory.py index 428d3061..dc8d5000 100755 --- a/mirix/schemas/semantic_memory.py +++ b/mirix/schemas/semantic_memory.py @@ -59,6 +59,29 @@ class SemanticMemoryItem(SemanticMemoryItemBase): ], ) + # Provenance pointers for this item. See `mirix.orm.semantic_memory`. + source_refs: List[Dict[str, Any]] = Field( + default_factory=list, + description=( + "Provenance pointers for the input units this item was extracted from. " + "Each entry is a small dict like " + "{'turn_id': int, 'chunk_id': int, 'serial': int, 'occurred_at': iso8601}; " + "any subset of those keys may be present. Populated only by the " + "conflict-resolution / provenance path; legacy free-form inserts leave it empty." + ), + ) + + # Prior values that have been superseded by the current ``summary`` / ``details``. + prior_values: List[Dict[str, Any]] = Field( + default_factory=list, + description=( + "History of replaced values from the conflict-resolution path. " + "Each entry: {'value': str, 'source_refs': list, " + "'status': 'superseded'|'corrected'|'coexists', 'moved_at': iso8601}. " + "Empty for legacy items." + ), + ) + # need to validate both details_embedding and summary_embedding to ensure they are the same size @field_validator("details_embedding", "summary_embedding", "name_embedding") @classmethod @@ -110,6 +133,12 @@ class SemanticMemoryItemUpdate(MirixBase): filter_tags: Optional[Dict[str, Any]] = Field( None, description="Custom filter tags for filtering and categorization" ) + source_refs: Optional[List[Dict[str, Any]]] = Field( + None, description="Replace the item's source_refs list (conflict-resolution path)." + ) + prior_values: Optional[List[Dict[str, Any]]] = Field( + None, description="Replace the item's prior_values list (conflict-resolution path)." + ) class SemanticMemoryItemResponse(SemanticMemoryItem): diff --git a/mirix/schemas/user.py b/mirix/schemas/user.py index a631135a..13b9d9e3 100755 --- a/mirix/schemas/user.py +++ b/mirix/schemas/user.py @@ -50,6 +50,21 @@ class User(UserBase): created_at: Optional[datetime] = Field(default_factory=get_utc_time, description="The creation date of the user.") updated_at: Optional[datetime] = Field(default_factory=get_utc_time, description="The update date of the user.") is_deleted: bool = Field(default=False, description="Whether this user is deleted or not.") + turn_counter: int = Field( + default=0, + description=( + "Next turn_id to hand out for this user's next /memory/add request. " + "Used by the source-provenance fallback in conflict resolution; the " + "server bumps this atomically when assigning fallback turn_ids." + ), + ) + chunk_counter: int = Field( + default=0, + description=( + "Next chunk_id to hand out for this user's next /memory/add request. " + "Same provenance fallback as turn_counter." + ), + ) class UserCreate(UserBase): diff --git a/mirix/services/agent_manager.py b/mirix/services/agent_manager.py index 3250146e..9933ce04 100644 --- a/mirix/services/agent_manager.py +++ b/mirix/services/agent_manager.py @@ -289,6 +289,18 @@ async def create_meta_agent( elif agent_name in default_system_prompts: custom_system = default_system_prompts[agent_name] + # Opt-in: append the conflict-resolution policy to the semantic + # memory agent's system prompt so the agent prefers the new + # `semantic_memory_upsert_fact` tool. Default off — legacy + # behaviour is preserved. + if ( + getattr(meta_agent_create, "enable_conflict_resolution", False) + and agent_name == "semantic_memory_agent" + and custom_system + ): + from mirix.agent.meta_agent import _CONFLICT_RESOLUTION_POLICY_PROMPT + custom_system = custom_system + "\n\n" + _CONFLICT_RESOLUTION_POLICY_PROMPT + # Create the agent using CreateAgent schema with parent_id agent_create = CreateAgent( name=f"{meta_agent_name}_{agent_name}", @@ -474,6 +486,16 @@ async def update_meta_agent( elif agent_name in default_system_prompts: custom_system = default_system_prompts[agent_name] + # Mirror the create path: if conflict-resolution is enabled + # on this update, append the policy to the semantic agent. + if ( + getattr(meta_agent_update, "enable_conflict_resolution", False) + and agent_name == "semantic_memory_agent" + and custom_system + ): + from mirix.agent.meta_agent import _CONFLICT_RESOLUTION_POLICY_PROMPT + custom_system = custom_system + "\n\n" + _CONFLICT_RESOLUTION_POLICY_PROMPT + # Use the updated configs or fall back to meta agent's configs llm_config = meta_agent_update.llm_config or meta_agent_state.llm_config embedding_config = meta_agent_update.embedding_config or meta_agent_state.embedding_config @@ -533,6 +555,11 @@ async def update_meta_agent( actor=actor, ) + # When conflict_resolution is toggled, re-apply the semantic agent's + # system prompt AND make sure the new upsert tool is attached. + # Without the latter, the agent sees the policy but has no + # `semantic_memory_upsert_fact` in its tool list and falls back to + # `semantic_memory_insert`. # Refresh the meta agent state with updated children meta_agent_state = await self.get_agent_by_id(agent_id=meta_agent_id, actor=actor) updated_children = await self.list_agents(actor=actor, parent_id=meta_agent_id) diff --git a/mirix/services/user_manager.py b/mirix/services/user_manager.py index 45f50144..bd87ca04 100755 --- a/mirix/services/user_manager.py +++ b/mirix/services/user_manager.py @@ -107,6 +107,42 @@ async def update_user_status( return existing_user.to_pydantic() @enforce_types + async def reserve_source_ids( + self, + user_id: str, + n_turns: int = 1, + ) -> dict: + """Atomically reserve the next ``n_turns`` turn_ids and one chunk_id + for ``user_id``. Used by ``/memory/add`` to fill in + ``source_meta.turn_id`` / ``source_meta.chunk_id`` when the client + did not supply them. + + Returns ``{"turn_id_start", "turn_id_end", "chunk_id"}``. Counters + are bumped to ``turn_counter += n_turns`` and ``chunk_counter += 1``. + Concurrent ``/memory/add`` calls for the same user are serialised + by the underlying UPDATE ... RETURNING (PostgreSQL) / + SELECT FOR UPDATE in a single transaction. + """ + from sqlalchemy import update + from mirix.orm.user import User as UserModel + + if n_turns < 1: + n_turns = 1 + async with self.session_maker() as session: + existing_user = await UserModel.read( + db_session=session, identifier=user_id + ) + turn_id_start = existing_user.turn_counter + chunk_id = existing_user.chunk_counter + existing_user.turn_counter = turn_id_start + n_turns + existing_user.chunk_counter = chunk_id + 1 + await existing_user.update_with_redis(session, actor=None) + return { + "turn_id_start": turn_id_start, + "turn_id_end": turn_id_start + n_turns - 1, + "chunk_id": chunk_id, + } + async def delete_user_by_id(self, user_id: str): """ Soft delete a user and cascade soft delete to all associated records using memory managers. From 31f338851e02c6d58c4fb04b9f81fcf8fedc8084 Mon Sep 17 00:00:00 2001 From: "Huang, Wei-Chieh" Date: Sat, 23 May 2026 08:50:06 +0800 Subject: [PATCH 3/6] Fix three retrieval/ingest bugs surfaced by LongMemEval-S evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. episodic_memory_manager: pgvector embedding-search SELECT was missing source_refs, causing to_pydantic() to receive None for a non-nullable List field — every episodic search threw a Pydantic ValidationError and silently returned no memories. Add source_refs to the explicit select() column list. 2. semantic_memory_manager: same bug on the semantic side, plus prior_values. Add both to the embedding-search SELECT. 3. memory_tools.semantic_memory_insert: indexed item['source'] directly, so any LLM call that omitted the source field (which it commonly does — source is the least essential field) crashed with KeyError and lost the whole item. Switch to item.get('source', ''). Net effect on LoCoMo conv-26 with 0201c config: 20.4% -> 80.3% (the SELECT fix alone). The source KeyError was masking real semantic memory writes in graph-mode LongMemEval ingest. Also ignores evals/snapshots/ — local-only memory dumps, large and regenerable. (cherry picked from commit 20c2b05d37e9c77b0f74a95119089128099fb70b) --- .gitignore | 4 ++++ mirix/functions/function_sets/memory_tools.py | 10 +++++++--- mirix/services/episodic_memory_manager.py | 5 +++++ mirix/services/semantic_memory_manager.py | 5 +++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index a692c65a..38b0f3a4 100755 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,10 @@ htmlcov/ *.sqlite *.sqlite-journal test-results/ + +# eval memory snapshots: PG dump + full Neo4j JSON export (~hundreds of MB +# each, regenerable from a fresh ingest). Same rationale as results/. +evals/snapshots/ test_*.db *.log .persist \ No newline at end of file diff --git a/mirix/functions/function_sets/memory_tools.py b/mirix/functions/function_sets/memory_tools.py index d9d66301..1d19fcc2 100644 --- a/mirix/functions/function_sets/memory_tools.py +++ b/mirix/functions/function_sets/memory_tools.py @@ -662,9 +662,13 @@ async def semantic_memory_insert(self: "Agent", items: List[SemanticMemoryItemBa agent_state=self.agent_state, agent_id=agent_id, name=item["name"], - summary=item["summary"], - details=item["details"], - source=item["source"], + summary=item.get("summary", ""), + details=item.get("details", ""), + # The LLM sometimes omits `source` (it is the least + # semantically essential field, and is sometimes folded + # into details). Default to "" so the whole item is not + # dropped over a missing provenance string. + source=item.get("source", ""), organization_id=self.actor.organization_id, actor=self.actor, filter_tags=filter_tags if filter_tags else None, diff --git a/mirix/services/episodic_memory_manager.py b/mirix/services/episodic_memory_manager.py index 4109b065..f5a56a11 100755 --- a/mirix/services/episodic_memory_manager.py +++ b/mirix/services/episodic_memory_manager.py @@ -857,6 +857,11 @@ async def list_episodic_memory( EpisodicEvent.last_modify.label("last_modify"), EpisodicEvent.user_id.label("user_id"), EpisodicEvent.agent_id.label("agent_id"), + # source_refs must be selected explicitly: it is NOT + # nullable on the Pydantic schema, so leaving it + # unloaded makes to_pydantic() pass source_refs=None + # and fail validation (empties the whole search). + EpisodicEvent.source_refs.label("source_refs"), ) .where(EpisodicEvent.user_id == user.id) .where(EpisodicEvent.organization_id == organization_id) diff --git a/mirix/services/semantic_memory_manager.py b/mirix/services/semantic_memory_manager.py index c43c98b1..4c61a67c 100755 --- a/mirix/services/semantic_memory_manager.py +++ b/mirix/services/semantic_memory_manager.py @@ -803,6 +803,11 @@ async def list_semantic_items( SemanticMemoryItem.last_modify.label("last_modify"), SemanticMemoryItem.user_id.label("user_id"), SemanticMemoryItem.agent_id.label("agent_id"), + # source_refs / prior_values are non-nullable on the + # Pydantic schema; selecting them explicitly avoids + # to_pydantic() passing None and failing validation. + SemanticMemoryItem.source_refs.label("source_refs"), + SemanticMemoryItem.prior_values.label("prior_values"), ) .where(SemanticMemoryItem.user_id == user.id) .where(SemanticMemoryItem.organization_id == organization_id) From 380688a5b78472eb77943a297722cf755e029aff Mon Sep 17 00:00:00 2001 From: "Huang, Wei-Chieh" Date: Fri, 29 May 2026 06:50:47 +0800 Subject: [PATCH 4/6] 50-bug client: pass limit even when 0 (no-limit) --- mirix/client/remote_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mirix/client/remote_client.py b/mirix/client/remote_client.py index a4b866e3..8e3ee931 100644 --- a/mirix/client/remote_client.py +++ b/mirix/client/remote_client.py @@ -1844,7 +1844,9 @@ async def list_memory_components( "user_id": user_id, "memory_type": memory_type, } - if limit: + # limit=0 means "no limit" (server treats <=0 as unbounded); the old + # `if limit:` dropped 0 as falsy, so token accounting only saw a sample. + if limit is not None: params["limit"] = limit return await self._request("GET", "/memory/components", params=params, headers=headers) From adc5ddccc57226faa82db61840db41d1ad0ea0c5 Mon Sep 17 00:00:00 2001 From: "Huang, Wei-Chieh" Date: Fri, 29 May 2026 09:39:00 +0800 Subject: [PATCH 5/6] Fix episodic_memory_merge: add additional_source_ref to update_event MAB caller (memory_tools.episodic_memory_merge) passes additional_source_ref but update_event lacked the param on this base (it lived in the v4-graph commit ccb419f). Port the param + source_refs-append handling from main so the merge tool stops raising TypeError. --- mirix/services/episodic_memory_manager.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mirix/services/episodic_memory_manager.py b/mirix/services/episodic_memory_manager.py index f5a56a11..6a1b2248 100755 --- a/mirix/services/episodic_memory_manager.py +++ b/mirix/services/episodic_memory_manager.py @@ -1275,6 +1275,7 @@ async def update_event( actor: PydanticClient = None, agent_state: AgentState = None, update_mode: str = "append", + additional_source_ref: Optional[Dict[str, Any]] = None, ): """ Update the selected events @@ -1287,6 +1288,11 @@ async def update_event( agent_state: Agent state containing embedding configuration (needed for embedding regeneration) update_mode: How to handle new_details - "append" (default) appends to existing, "replace" overwrites existing details entirely + additional_source_ref: Optional source-provenance dict from the + current ingest call (turn_id / chunk_id / serial / + occurred_at). When supplied, it is appended to the event's + ``source_refs`` list so a merged event still carries the + trail of every ingest that contributed to it. """ async with self.session_maker() as session: @@ -1320,6 +1326,15 @@ async def update_event( ) selected_event.embedding_config = agent_state.embedding_config + # Append the current ingest's source_ref to the event's + # provenance trail (if provided). Late-arriving ingests that + # merge into an existing event keep their pointer in the list + # rather than being lost. + if additional_source_ref: + existing_refs = list(selected_event.source_refs or []) + existing_refs.append(dict(additional_source_ref)) + selected_event.source_refs = existing_refs + # Update last_modify field with timestamp and operation info selected_event.last_modify = { "timestamp": datetime.now(dt.timezone.utc).isoformat(), From e6539cb947b7acce9bf44e1ca23f7367d7a1cc4d Mon Sep 17 00:00:00 2001 From: "Huang, Wei-Chieh" Date: Fri, 29 May 2026 10:21:21 +0800 Subject: [PATCH 6/6] docs: add REVISION.md describing isolate_revision changes --- REVISION.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 REVISION.md diff --git a/REVISION.md b/REVISION.md new file mode 100644 index 00000000..5830c40a --- /dev/null +++ b/REVISION.md @@ -0,0 +1,53 @@ +# isolate_revision + +Clean upstream base (`origin/main`, `b45563a`) **+ targeted memory fixes**, with +**no dual-graph (v4/v5+) code**. 5 commits ahead of `origin/main`, 0 behind. + +Intended as a minimal, reproducible test base for LoCoMo / LongMemEval memory +evaluation: upstream `main` plus only the fixes needed for correct episodic / +semantic ingest and accurate token accounting. + +## What this branch adds + +### 1. MAB conflict-resolution + source provenance +- ORM / schema: `episodic_memory.source_refs`, `semantic_memory.source_refs` + and `prior_values`, `users.turn_counter` / `users.chunk_counter`. +- Deterministic semantic-insert conflict resolution, gated per meta-agent by + `enable_conflict_resolution`; legacy free-form insert stays the default. +- Source provenance (turn_id / chunk_id / serial / occurred_at) flows from + `/memory/add` through to stored records. +- `UserManager.reserve_source_ids`, `semantic_memory_upsert_fact` tool, + MetaAgent prompt augmentation. +- Design docs: `docs/mab_conflict_resolution_and_provenance.md`, + `docs/mab_raw_chunk_side_channel.md`, `docs/mab_user_id_isolation_fix.md`. + +### 2. Three retrieval / ingest fixes +- **pgvector SELECT**: the episodic & semantic embedding-search built an + explicit column list that omitted `source_refs` (+ `prior_values` on the + semantic side). Those are non-nullable List fields, so `to_pydantic()` + received `None` → every search threw a Pydantic `ValidationError` and + silently returned no memories. Added them to the `select()` column lists. + **Net effect on conv-26 / 0201c: 20.4% → 80.3%.** +- `semantic_memory_insert` indexed `item['source']` directly, so any LLM call + that omitted `source` (common — it is the least essential field) crashed + with `KeyError` and lost the whole item. Switched to `item.get('source', '')`. + +### 3. `average_memory_tokens` "50-bug" (server + client) +- Server `list_memory_components`: old `max(1, min(limit, 200))` forced `0 → 1` + and capped at 200, so token accounting only ever saw a 50/200-item sample. + Now `limit <= 0` means "no limit". +- Client: old `if limit:` dropped `limit=0` as falsy, so it never reached the + server. Now `if limit is not None`. +- Net: `average_memory_tokens` now reflects **all** memory items, not a sample. + +### 4. `episodic_memory_merge` fix +- The MAB caller (`memory_tools.episodic_memory_merge`) passes + `additional_source_ref`, but `update_event()` lacked that parameter on this + base (it lived in the v4-graph commit, which is intentionally not included + here) → the merge tool raised `TypeError` whenever the agent chose to merge. + Ported the parameter + the `source_refs`-append handling from `main`. + +## Verified (conv-26, no-graph, config `0201c`) +- Episodic count stable at **~113-122** across 3 runs (122 / 122 / 113); + QA accuracy **84-88%**. +- `average_memory_tokens` uncapped (~16.7k-19.2k), counting every item.