agharsallah Codex commited on
Commit Β·
f487b74
1
Parent(s): 24b5ffb
feat: mem0 as a layered, derived semantic memory index
Browse filesAdd a MemoryIndex abstraction and a mem0-backed implementation that upgrades the
salience relevance term with semantic search. Layered + derived: the index is
populated from ledger events (keyed by event.id, idempotent and rebuildable), so
the append-only ledger remains the single source of truth (ADR-0005 preserved).
Env-gated by MEMORY_INDEX; offline keeps the keyword path. 258 passed, 2 skipped.
ADR-0018.
Co-Authored-By: Codex <codex@openai.com>
- docs/adr/0018-layered-semantic-memory-index.md +128 -0
- docs/architecture/memory-stack.md +47 -6
- pyproject.toml +10 -0
- src/agents/base.py +17 -4
- src/core/memory.py +91 -19
- src/core/memory_index.py +227 -0
- src/core/registry.py +11 -3
- tests/test_memory_index.py +232 -0
docs/adr/0018-layered-semantic-memory-index.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0018: Semantic Memory Index as a Layered, Derived Ledger View
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
Accepted
|
| 6 |
+
|
| 7 |
+
## Context
|
| 8 |
+
|
| 9 |
+
Agent memory is a **filtered view over the append-only event ledger**, not a
|
| 10 |
+
separate store (ADR-0005). `EpisodicMemory` returns the most-recent visible
|
| 11 |
+
events; `SalienceMemory` (ADR-0005 consequences, `docs/architecture/memory-stack.md`)
|
| 12 |
+
ranks visible events by a composite score:
|
| 13 |
+
|
| 14 |
+
```
|
| 15 |
+
salience(e) = w_relΒ·relevance(e, query) + w_recΒ·recency(e, turn) + w_impΒ·importance(e.kind)
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
The `relevance` term is **keyword (Jaccard) overlap** between the event text and
|
| 19 |
+
the current scene. That is cheap and deterministic but lexical: it misses an
|
| 20 |
+
event that is *about* the same thing in different words, exactly the case where
|
| 21 |
+
recall matters most over a long run. The documented Phase-3 upgrade is to replace
|
| 22 |
+
keyword relevance with semantic similarity over an embedding model, with vectors
|
| 23 |
+
optionally living in the durable Postgres/pgvector store from ADR-0014.
|
| 24 |
+
|
| 25 |
+
The constraint is the same one that has held since ADR-0001/0005: the ledger is
|
| 26 |
+
the **single source of truth**. A vector store must not become a second, parallel
|
| 27 |
+
store on a write path the ledger does not own. If it is wiped, the ledger must be
|
| 28 |
+
able to rebuild it. And, like every other optional integration here
|
| 29 |
+
(ADR-0014/0015/0016/0017), the offline path must stay the default the suite
|
| 30 |
+
exercises β no network, no credentials, and no new package required to
|
| 31 |
+
`import src.*` or `import app`.
|
| 32 |
+
|
| 33 |
+
## Decision
|
| 34 |
+
|
| 35 |
+
Add a **semantic retrieval index as a derived, rebuildable lens over the ledger**,
|
| 36 |
+
used only to compute the `relevance` term. The index changes *how* relevance is
|
| 37 |
+
scored, never *which* events are eligible nor the recency/importance terms.
|
| 38 |
+
|
| 39 |
+
**A small protocol.** `MemoryIndex` (`src/core/memory_index.py`) is two methods:
|
| 40 |
+
`index(events) -> None` derives/refreshes entries from ledger events, and
|
| 41 |
+
`search(query, k) -> list[Event]` returns the most relevant indexed events. It is
|
| 42 |
+
a `runtime_checkable` `Protocol`, so any backend β a vector service, a local
|
| 43 |
+
embedding store, or a test fake β supplies semantic relevance without the salience
|
| 44 |
+
layer knowing which.
|
| 45 |
+
|
| 46 |
+
**Derived, not authoritative.** `index()` upserts each event under its
|
| 47 |
+
`event.id`, so re-indexing the same ledger slice each turn is idempotent (no
|
| 48 |
+
duplicates) and the index is rebuildable from the ledger at any time. The salience
|
| 49 |
+
layer always **derives then reads**: it indexes the visible candidates first, then
|
| 50 |
+
queries, so the index can never report an event the ledger has not produced. The
|
| 51 |
+
event is reconstructed from metadata stored on the entry, so a hit needs no second
|
| 52 |
+
lookup. This is what keeps the index a *faster lens on the same events* (ADR-0005)
|
| 53 |
+
rather than a competing store.
|
| 54 |
+
|
| 55 |
+
**Layered into salience, visibility intact.** `SalienceMemory` gains an optional
|
| 56 |
+
`index` field. With an index attached, the relevance term is derived from the
|
| 57 |
+
semantic search rank (normalised to `[0,1]` by descending rank); with `index=None`
|
| 58 |
+
(the default) it is the keyword-Jaccard path, unchanged byte-for-byte. In both
|
| 59 |
+
cases the candidate set is the *same* ledger-derived visibility filter
|
| 60 |
+
(`actor == self.agent_name or kind in _GLOBALLY_VISIBLE`) and the recency and
|
| 61 |
+
importance terms are untouched β an agent never recalls another agent's private
|
| 62 |
+
thoughts, with or without the index. `_recall` in `src/agents/base.py` threads the
|
| 63 |
+
agent's optional `memory_index` into `SalienceMemory`; `format_for_prompt` output
|
| 64 |
+
shape is unchanged (`[turn][kind][sal=β¦] text`).
|
| 65 |
+
|
| 66 |
+
**The concrete backend.** `Mem0MemoryIndex` wraps a vector-memory library, lazily
|
| 67 |
+
imported inside the backend so the package is touched only when the index is
|
| 68 |
+
exercised. Each event is stored as one raw memory with **inference disabled** β
|
| 69 |
+
the event text is embedded verbatim, with **no model-driven fact extraction** β so
|
| 70 |
+
indexing is deterministic and the ledger, not a model, remains the source of
|
| 71 |
+
truth. The full event rides along in the entry metadata for reconstruction.
|
| 72 |
+
|
| 73 |
+
**Env-gated, offline by default.** `memory_index_from_env()` returns `None` unless
|
| 74 |
+
`MEMORY_INDEX` is truthy, in which case it builds the backend (still not importing
|
| 75 |
+
the library until first use). An embedding model is required when the index is
|
| 76 |
+
active; by default embeddings route via `OPENAI_API_KEY` β the same credential the
|
| 77 |
+
live model path already uses β and `MEMORY_INDEX_CONFIG` (a JSON blob forwarded to
|
| 78 |
+
the library's `from_config`) can pin a local embedder or persist vectors in the
|
| 79 |
+
project's own Postgres/pgvector (ADR-0014), so the index can live beside the ledger
|
| 80 |
+
it derives from. With the gate unset the system stays on the keyword path and never
|
| 81 |
+
imports the package.
|
| 82 |
+
|
| 83 |
+
**Dependency.** `mem0ai` is a new optional `memory` extra in `pyproject.toml`. It
|
| 84 |
+
is lazy-imported, so `import src.*` and `import app` work with it not installed and
|
| 85 |
+
the gate unset β the offline default the test-suite exercises.
|
| 86 |
+
|
| 87 |
+
## Reconciliation with ADR-0005
|
| 88 |
+
|
| 89 |
+
ADR-0005 makes memory a pure function of the ledger and explicitly anticipates this
|
| 90 |
+
step: *"Richer retrieval (semantic search, salience scoring) can be added later as
|
| 91 |
+
an upgraded `EpisodicMemory` implementation without changing the agent protocol."*
|
| 92 |
+
This ADR honours that literally. The index is **derived** (populated from ledger
|
| 93 |
+
events, keyed by `event.id`), **rebuildable** (wipe it and re-index from the
|
| 94 |
+
ledger), and **non-authoritative** (it only re-ranks the relevance term over events
|
| 95 |
+
the visibility filter already admits). No event originates in the index; nothing is
|
| 96 |
+
written there that the ledger does not own first. The four ADR-0005 properties hold:
|
| 97 |
+
consistency (the index trails the ledger and is rebuilt from it), crash recovery
|
| 98 |
+
(reload the ledger, re-index), testability (a fake `MemoryIndex` makes the semantic
|
| 99 |
+
path deterministic and offline), and privacy (the candidate filter is unchanged, so
|
| 100 |
+
an agent still cannot recall another's private thoughts).
|
| 101 |
+
|
| 102 |
+
## Consequences
|
| 103 |
+
|
| 104 |
+
- With `MEMORY_INDEX` unset the relevance term is keyword-Jaccard exactly as before:
|
| 105 |
+
`tests/test_memory.py` and `tests/test_salience_memory.py` are unchanged and green,
|
| 106 |
+
and the suite stays β₯243 green offline. With the package absent the one real-backend
|
| 107 |
+
test skips (`pytest.importorskip`); the fake-index tests exercise the layering,
|
| 108 |
+
idempotency, env gate, and `_recall` wiring with nothing installed.
|
| 109 |
+
- The index is a derived view, not a second source of truth: it is keyed by
|
| 110 |
+
`event.id` (idempotent re-index), populated from the ledger before each query, and
|
| 111 |
+
can be dropped and rebuilt from the ledger. It never sits on a write path the ledger
|
| 112 |
+
does not own.
|
| 113 |
+
- Only the relevance term changes. Recency, importance, the top-K cut, chronological
|
| 114 |
+
ordering, and the `format_for_prompt` shape are identical across both paths, so a
|
| 115 |
+
scenario that enables the index sees better recall without other behavioural drift.
|
| 116 |
+
- Persisting vectors in the ADR-0014 Postgres/pgvector store is an optional
|
| 117 |
+
`MEMORY_INDEX_CONFIG` path, not a requirement; the default in-process vector store
|
| 118 |
+
works offline-with-embedder for a single process.
|
| 119 |
+
- **Alternative backends behind the same protocol.** `MemoryIndex` is deliberately
|
| 120 |
+
two methods, so a stateful agent-memory service (e.g. a Letta-style memory server)
|
| 121 |
+
could be wrapped as another `MemoryIndex` implementation β `index()` writing through
|
| 122 |
+
to it, `search()` reading back β without touching `SalienceMemory` or the agent
|
| 123 |
+
protocol, as long as it too treats the ledger as authoritative and stays rebuildable.
|
| 124 |
+
- Follow-ups: surface the active relevance mode (keyword vs semantic) on the stats
|
| 125 |
+
panel; add a one-shot "rebuild index from ledger" path for cold start / after a wipe;
|
| 126 |
+
evaluate scoping vector entries by `run_id` for multi-run isolation (mirrors the
|
| 127 |
+
ADR-0014 single-store caveat); blend semantic and lexical relevance rather than
|
| 128 |
+
switching between them.
|
docs/architecture/memory-stack.md
CHANGED
|
@@ -48,7 +48,7 @@ salience(e) = w_relΒ·relevance(e, query) + w_recΒ·recency(e, turn) + w_impΒ·impo
|
|
| 48 |
|
| 49 |
| Component | How computed | Default weight |
|
| 50 |
|---|---|---|
|
| 51 |
-
| relevance | Jaccard similarity between event text and current scene | 0.30 |
|
| 52 |
| recency | exp(βλ·Ξturn), Ξ»=0.1 β half-life β7 turns | 0.40 |
|
| 53 |
| importance | Kind-based weight table | 0.30 |
|
| 54 |
|
|
@@ -73,9 +73,10 @@ prompt reads naturally (not by importance descending).
|
|
| 73 |
important but older memories over irrelevant recent ones.
|
| 74 |
First enable point: when the agent window fills up (>30 turns).
|
| 75 |
|
| 76 |
-
**
|
| 77 |
-
|
| 78 |
-
|
|
|
|
| 79 |
|
| 80 |
---
|
| 81 |
|
|
@@ -105,6 +106,46 @@ already present in `src/core/memory.py` β it just needs the agent to check
|
|
| 105 |
|
| 106 |
---
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
## Context Builder Layering
|
| 109 |
|
| 110 |
The ContextBuilder assembles layers in this order (permanent cost β variable cost):
|
|
@@ -132,6 +173,6 @@ The layering order is deliberate:
|
|
| 132 |
|---|---|---|
|
| 133 |
| Keyword salience | 2 | `SalienceMemory` with Jaccard relevance |
|
| 134 |
| Reflection events | 2 | `ReflectionTracker` + `agent.reflected` kind |
|
| 135 |
-
| Embedding relevance |
|
| 136 |
-
| pgvector retrieval |
|
| 137 |
| Belief graph | 4 | Structured belief store derived from reflection events |
|
|
|
|
| 48 |
|
| 49 |
| Component | How computed | Default weight |
|
| 50 |
|---|---|---|
|
| 51 |
+
| relevance | Semantic similarity when a `MemoryIndex` is attached (ADR-0018); else Jaccard similarity between event text and current scene | 0.30 |
|
| 52 |
| recency | exp(βλ·Ξturn), Ξ»=0.1 β half-life β7 turns | 0.40 |
|
| 53 |
| importance | Kind-based weight table | 0.30 |
|
| 54 |
|
|
|
|
| 73 |
important but older memories over irrelevant recent ones.
|
| 74 |
First enable point: when the agent window fills up (>30 turns).
|
| 75 |
|
| 76 |
+
**Semantic relevance (ADR-0018, implemented)**: the keyword-Jaccard relevance is
|
| 77 |
+
the offline default; attaching a `MemoryIndex` upgrades only that term to
|
| 78 |
+
semantic search (see "Semantic Relevance Index" below). Recency, importance, the
|
| 79 |
+
visibility filter, and the `format_for_prompt` shape are unchanged.
|
| 80 |
|
| 81 |
---
|
| 82 |
|
|
|
|
| 106 |
|
| 107 |
---
|
| 108 |
|
| 109 |
+
## Semantic Relevance Index (ADR-0018, optional)
|
| 110 |
+
|
| 111 |
+
The `relevance` term in Layer 2 can be computed by **semantic search** instead of
|
| 112 |
+
keyword overlap. This is a *derived, rebuildable lens over the ledger* β it
|
| 113 |
+
changes how relevance is scored, never which events are eligible (the visibility
|
| 114 |
+
filter and the recency/importance terms are untouched). The ledger stays the
|
| 115 |
+
single source of truth (ADR-0005): the index is keyed by `event.id` (re-indexing
|
| 116 |
+
is idempotent) and can be wiped and rebuilt from the ledger.
|
| 117 |
+
|
| 118 |
+
```python
|
| 119 |
+
@runtime_checkable
|
| 120 |
+
class MemoryIndex(Protocol):
|
| 121 |
+
def index(self, events: tuple[Event, ...]) -> None: ... # derive, idempotent by id
|
| 122 |
+
def search(self, query: str, k: int) -> list[Event]: ... # read back by relevance
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
`SalienceMemory(..., index=...)` derives, then reads: it indexes the visible
|
| 126 |
+
candidates first, then queries, so a hit can never be an event the ledger has not
|
| 127 |
+
produced. With `index=None` (the offline default) the relevance term is keyword
|
| 128 |
+
Jaccard, byte-for-byte unchanged.
|
| 129 |
+
|
| 130 |
+
**Backend (`Mem0MemoryIndex`)**: stores each event as one raw memory with
|
| 131 |
+
inference disabled (the text is embedded verbatim β no model-driven fact
|
| 132 |
+
extraction), carrying the full event in metadata so a hit reconstructs the
|
| 133 |
+
`Event`. Lazy-imported, so `import src.*` / `import app` work with the package not
|
| 134 |
+
installed.
|
| 135 |
+
|
| 136 |
+
**Gate**: `memory_index_from_env()` returns `None` unless `MEMORY_INDEX` is
|
| 137 |
+
truthy. When active, an embedding model is required β routed via `OPENAI_API_KEY`
|
| 138 |
+
by default, or pinned to a local embedder / the project's Postgres+pgvector
|
| 139 |
+
(ADR-0014) via `MEMORY_INDEX_CONFIG` (a JSON blob forwarded to the backend's
|
| 140 |
+
`from_config`). Install the `memory` extra (`mem0ai`).
|
| 141 |
+
|
| 142 |
+
**Alternative backends**: the two-method protocol can wrap any retrieval store β
|
| 143 |
+
a stateful agent-memory service (e.g. a Letta-style memory server) could be a
|
| 144 |
+
`MemoryIndex` too, as long as it stays derived from and rebuildable from the
|
| 145 |
+
ledger.
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
## Context Builder Layering
|
| 150 |
|
| 151 |
The ContextBuilder assembles layers in this order (permanent cost β variable cost):
|
|
|
|
| 173 |
|---|---|---|
|
| 174 |
| Keyword salience | 2 | `SalienceMemory` with Jaccard relevance |
|
| 175 |
| Reflection events | 2 | `ReflectionTracker` + `agent.reflected` kind |
|
| 176 |
+
| Embedding relevance | done | `MemoryIndex` semantic search for the relevance term (ADR-0018) |
|
| 177 |
+
| pgvector retrieval | done | `MEMORY_INDEX_CONFIG` persists vectors in the ADR-0014 Postgres/pgvector store |
|
| 178 |
| Belief graph | 4 | Structured belief store derived from reflection events |
|
pyproject.toml
CHANGED
|
@@ -47,6 +47,16 @@ instructor = [
|
|
| 47 |
mcp = [
|
| 48 |
"mcp>=1.0",
|
| 49 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
[tool.ruff]
|
| 52 |
line-length = 120
|
|
|
|
| 47 |
mcp = [
|
| 48 |
"mcp>=1.0",
|
| 49 |
]
|
| 50 |
+
# Semantic memory index (ADR-0018). Optional: a derived, rebuildable vector lens
|
| 51 |
+
# over the event ledger that upgrades the salience relevance term from keyword
|
| 52 |
+
# overlap to semantic search when the MEMORY_INDEX gate is set. The ledger stays
|
| 53 |
+
# the single source of truth; the index is repopulated from it. The system runs
|
| 54 |
+
# fully offline on the keyword path without this; mem0 is imported lazily so
|
| 55 |
+
# importing src.* and app never requires it. An embedding model is needed when
|
| 56 |
+
# active (routed via OPENAI_API_KEY by default, or MEMORY_INDEX_CONFIG).
|
| 57 |
+
memory = [
|
| 58 |
+
"mem0ai>=0.1",
|
| 59 |
+
]
|
| 60 |
|
| 61 |
[tool.ruff]
|
| 62 |
line-length = 120
|
src/agents/base.py
CHANGED
|
@@ -30,6 +30,7 @@ from src.core.structured import build_output_model, json_instruction, parse_agen
|
|
| 30 |
from src.models.router import ModelRouter
|
| 31 |
|
| 32 |
if TYPE_CHECKING:
|
|
|
|
| 33 |
from src.tools.registry import ToolRegistry
|
| 34 |
|
| 35 |
_ctx = ContextBuilder()
|
|
@@ -69,9 +70,18 @@ class ManifestAgent(Agent):
|
|
| 69 |
|
| 70 |
manifest: AgentManifest
|
| 71 |
|
| 72 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
self.router = router
|
| 74 |
self.tools = tools
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
self._reflection_tracker: ReflectionTracker | None = None
|
| 76 |
self.last_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 77 |
|
|
@@ -176,9 +186,12 @@ class ManifestAgent(Agent):
|
|
| 176 |
def _recall(self, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
|
| 177 |
cfg = self.manifest.memory
|
| 178 |
if cfg.use_salience:
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
| 182 |
return EpisodicMemory(self.manifest.name, max_recent=cfg.window).format_for_prompt(recent_events)
|
| 183 |
|
| 184 |
def _tracker(self, threshold: int) -> ReflectionTracker:
|
|
|
|
| 30 |
from src.models.router import ModelRouter
|
| 31 |
|
| 32 |
if TYPE_CHECKING:
|
| 33 |
+
from src.core.memory_index import MemoryIndex
|
| 34 |
from src.tools.registry import ToolRegistry
|
| 35 |
|
| 36 |
_ctx = ContextBuilder()
|
|
|
|
| 70 |
|
| 71 |
manifest: AgentManifest
|
| 72 |
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
router: ModelRouter,
|
| 76 |
+
tools: "ToolRegistry | None" = None,
|
| 77 |
+
memory_index: "MemoryIndex | None" = None,
|
| 78 |
+
) -> None:
|
| 79 |
self.router = router
|
| 80 |
self.tools = tools
|
| 81 |
+
# Optional semantic relevance index β a derived, rebuildable lens over the
|
| 82 |
+
# ledger (ADR-0018). ``None`` (offline default) keeps salience on the
|
| 83 |
+
# keyword path.
|
| 84 |
+
self.memory_index = memory_index
|
| 85 |
self._reflection_tracker: ReflectionTracker | None = None
|
| 86 |
self.last_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
| 87 |
|
|
|
|
| 186 |
def _recall(self, turn: int, projection: StageProjection, recent_events: tuple[Event, ...]) -> str:
|
| 187 |
cfg = self.manifest.memory
|
| 188 |
if cfg.use_salience:
|
| 189 |
+
# The index (when attached) upgrades only the relevance term to
|
| 190 |
+
# semantic search; recency/importance and the visibility filter are
|
| 191 |
+
# unchanged. With no index this is the keyword-Jaccard path.
|
| 192 |
+
return SalienceMemory(
|
| 193 |
+
self.manifest.name, top_k=cfg.salience_top_k, index=self.memory_index
|
| 194 |
+
).format_for_prompt(recent_events, current_turn=turn, query=projection.current_scene)
|
| 195 |
return EpisodicMemory(self.manifest.name, max_recent=cfg.window).format_for_prompt(recent_events)
|
| 196 |
|
| 197 |
def _tracker(self, threshold: int) -> ReflectionTracker:
|
src/core/memory.py
CHANGED
|
@@ -15,17 +15,29 @@ Memory architecture (three layers):
|
|
| 15 |
a high-level belief. Reflection events are themselves visible to
|
| 16 |
the agent, so beliefs accumulate over time without blowing the window.
|
| 17 |
|
| 18 |
-
None of these layers maintain separate
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
"""
|
| 22 |
from __future__ import annotations
|
| 23 |
|
| 24 |
import math
|
| 25 |
from dataclasses import dataclass, field
|
|
|
|
| 26 |
|
| 27 |
from src.core.events import Event
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
# ββ importance weights by event kind βββββββββββββββββββββββββββββββββββββββββ
|
| 30 |
|
| 31 |
_KIND_IMPORTANCE: dict[str, float] = {
|
|
@@ -84,10 +96,18 @@ class SalienceMemory:
|
|
| 84 |
|
| 85 |
salience(e) = w_relΒ·relevance + w_recΒ·recency + w_impΒ·importance
|
| 86 |
|
| 87 |
-
relevance:
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
recency: exponential decay β exp(βλ·Ξturn). Ξ»=0.1 gives half-life β7 turns.
|
| 90 |
importance: event-kind weight from _KIND_IMPORTANCE table.
|
|
|
|
|
|
|
|
|
|
| 91 |
"""
|
| 92 |
|
| 93 |
agent_name: str
|
|
@@ -96,31 +116,76 @@ class SalienceMemory:
|
|
| 96 |
w_recency: float = 0.4
|
| 97 |
w_importance: float = 0.3
|
| 98 |
decay_lambda: float = 0.1
|
|
|
|
| 99 |
|
| 100 |
-
def
|
| 101 |
-
|
| 102 |
-
importance = _KIND_IMPORTANCE.get(event.kind, 0.5)
|
| 103 |
-
event_text = str(event.payload.get("text", "")).lower()
|
| 104 |
query_words = set(query.lower().split())
|
| 105 |
-
event_words = set(event_text.split())
|
| 106 |
if not query_words or not event_words:
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return (
|
| 111 |
self.w_relevance * relevance
|
| 112 |
+ self.w_recency * recency
|
| 113 |
+ self.w_importance * importance
|
| 114 |
)
|
| 115 |
|
| 116 |
-
def
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
e for e in events
|
| 119 |
if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE
|
| 120 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
scored = sorted(
|
| 122 |
candidates,
|
| 123 |
-
key=lambda e: self.score(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
reverse=True,
|
| 125 |
)
|
| 126 |
# Return in chronological order so prompts read naturally
|
|
@@ -130,14 +195,21 @@ class SalienceMemory:
|
|
| 130 |
def format_for_prompt(
|
| 131 |
self, events: tuple[Event, ...], current_turn: int, query: str
|
| 132 |
) -> str:
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
if not recalled:
|
| 135 |
return "(no salient memories)"
|
| 136 |
lines = []
|
| 137 |
for e in recalled:
|
| 138 |
text = e.payload.get("text") or e.payload.get("summary") or str(e.payload)
|
| 139 |
-
|
| 140 |
-
lines.append(f"[turn {e.turn:03d}][{e.kind}][sal={s:.2f}] {text}")
|
| 141 |
return "\n".join(lines)
|
| 142 |
|
| 143 |
|
|
|
|
| 15 |
a high-level belief. Reflection events are themselves visible to
|
| 16 |
the agent, so beliefs accumulate over time without blowing the window.
|
| 17 |
|
| 18 |
+
None of these layers maintain a separate *source of truth* β they are functions
|
| 19 |
+
over the shared append-only ledger. Memory is always consistent with the ledger
|
| 20 |
+
because it *is* the ledger.
|
| 21 |
+
|
| 22 |
+
The one optional accelerator is a semantic relevance index
|
| 23 |
+
(:class:`~src.core.memory_index.MemoryIndex`, attached via ``SalienceMemory.index``
|
| 24 |
+
and gated by ``MEMORY_INDEX`` β see ADR-0018). It is a *derived, rebuildable*
|
| 25 |
+
view: populated FROM ledger events, keyed by ``event.id`` (idempotent re-index),
|
| 26 |
+
and used only to score the relevance term over events the visibility filter
|
| 27 |
+
already admits. Wipe it and it rebuilds from the ledger; with it unattached
|
| 28 |
+
(the offline default) relevance is keyword overlap, exactly as below.
|
| 29 |
"""
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
import math
|
| 33 |
from dataclasses import dataclass, field
|
| 34 |
+
from typing import TYPE_CHECKING
|
| 35 |
|
| 36 |
from src.core.events import Event
|
| 37 |
|
| 38 |
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
| 39 |
+
from src.core.memory_index import MemoryIndex
|
| 40 |
+
|
| 41 |
# ββ importance weights by event kind βββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
|
| 43 |
_KIND_IMPORTANCE: dict[str, float] = {
|
|
|
|
| 96 |
|
| 97 |
salience(e) = w_relΒ·relevance + w_recΒ·recency + w_impΒ·importance
|
| 98 |
|
| 99 |
+
relevance: semantic similarity between the event and the current scene when
|
| 100 |
+
a :class:`~src.core.memory_index.MemoryIndex` is attached
|
| 101 |
+
(``index`` set), else keyword (Jaccard) overlap between the event
|
| 102 |
+
text and the scene. The index is a *derived* lens over the same
|
| 103 |
+
ledger events β it changes only how the relevance term is scored,
|
| 104 |
+
never which events are eligible (see ``visible``) nor the recency
|
| 105 |
+
or importance terms.
|
| 106 |
recency: exponential decay β exp(βλ·Ξturn). Ξ»=0.1 gives half-life β7 turns.
|
| 107 |
importance: event-kind weight from _KIND_IMPORTANCE table.
|
| 108 |
+
|
| 109 |
+
Attach an index via ``index=...`` to use semantic relevance; with ``index``
|
| 110 |
+
left ``None`` (the default) the scoring is exactly the offline keyword path.
|
| 111 |
"""
|
| 112 |
|
| 113 |
agent_name: str
|
|
|
|
| 116 |
w_recency: float = 0.4
|
| 117 |
w_importance: float = 0.3
|
| 118 |
decay_lambda: float = 0.1
|
| 119 |
+
index: "MemoryIndex | None" = None
|
| 120 |
|
| 121 |
+
def _keyword_relevance(self, event: Event, query: str) -> float:
|
| 122 |
+
event_words = set(str(event.payload.get("text", "")).lower().split())
|
|
|
|
|
|
|
| 123 |
query_words = set(query.lower().split())
|
|
|
|
| 124 |
if not query_words or not event_words:
|
| 125 |
+
return 0.0
|
| 126 |
+
return len(query_words & event_words) / len(query_words | event_words)
|
| 127 |
+
|
| 128 |
+
def score(
|
| 129 |
+
self,
|
| 130 |
+
event: Event,
|
| 131 |
+
current_turn: int,
|
| 132 |
+
query: str,
|
| 133 |
+
relevance: float | None = None,
|
| 134 |
+
) -> float:
|
| 135 |
+
"""Composite salience. *relevance* may be supplied (e.g. a semantic rank);
|
| 136 |
+
when ``None`` it is computed from keyword overlap as before."""
|
| 137 |
+
recency = math.exp(-self.decay_lambda * max(0, current_turn - event.turn))
|
| 138 |
+
importance = _KIND_IMPORTANCE.get(event.kind, 0.5)
|
| 139 |
+
if relevance is None:
|
| 140 |
+
relevance = self._keyword_relevance(event, query)
|
| 141 |
return (
|
| 142 |
self.w_relevance * relevance
|
| 143 |
+ self.w_recency * recency
|
| 144 |
+ self.w_importance * importance
|
| 145 |
)
|
| 146 |
|
| 147 |
+
def _candidates(self, events: tuple[Event, ...]) -> list[Event]:
|
| 148 |
+
"""Ledger-derived visibility filter β unchanged whether or not an index
|
| 149 |
+
is attached: an agent only ever recalls its own events plus globally
|
| 150 |
+
visible kinds."""
|
| 151 |
+
return [
|
| 152 |
e for e in events
|
| 153 |
if e.actor == self.agent_name or e.kind in _GLOBALLY_VISIBLE
|
| 154 |
]
|
| 155 |
+
|
| 156 |
+
def _relevance_map(
|
| 157 |
+
self, candidates: list[Event], query: str
|
| 158 |
+
) -> dict[str, float] | None:
|
| 159 |
+
"""When an index is attached, derive a semantic relevance score per
|
| 160 |
+
candidate event (id β score in [0,1] by descending rank); else ``None``
|
| 161 |
+
so :meth:`score` uses keyword overlap.
|
| 162 |
+
|
| 163 |
+
The index is populated from the candidate events first, then queried β
|
| 164 |
+
derive, then read β so it never reports events the ledger has not
|
| 165 |
+
produced, and re-indexing is idempotent (keyed by ``event.id``).
|
| 166 |
+
"""
|
| 167 |
+
if self.index is None or not query or not candidates:
|
| 168 |
+
return None
|
| 169 |
+
self.index.index(tuple(candidates))
|
| 170 |
+
hits = self.index.search(query, k=len(candidates))
|
| 171 |
+
eligible = {e.id for e in candidates}
|
| 172 |
+
ranked = [h.id for h in hits if h.id in eligible]
|
| 173 |
+
if not ranked:
|
| 174 |
+
return {}
|
| 175 |
+
n = len(ranked)
|
| 176 |
+
return {eid: (n - i) / n for i, eid in enumerate(ranked)}
|
| 177 |
+
|
| 178 |
+
def visible(self, events: tuple[Event, ...], current_turn: int, query: str) -> list[Event]:
|
| 179 |
+
candidates = self._candidates(events)
|
| 180 |
+
relevance = self._relevance_map(candidates, query)
|
| 181 |
scored = sorted(
|
| 182 |
candidates,
|
| 183 |
+
key=lambda e: self.score(
|
| 184 |
+
e,
|
| 185 |
+
current_turn,
|
| 186 |
+
query,
|
| 187 |
+
relevance=None if relevance is None else relevance.get(e.id, 0.0),
|
| 188 |
+
),
|
| 189 |
reverse=True,
|
| 190 |
)
|
| 191 |
# Return in chronological order so prompts read naturally
|
|
|
|
| 195 |
def format_for_prompt(
|
| 196 |
self, events: tuple[Event, ...], current_turn: int, query: str
|
| 197 |
) -> str:
|
| 198 |
+
candidates = self._candidates(events)
|
| 199 |
+
relevance = self._relevance_map(candidates, query)
|
| 200 |
+
|
| 201 |
+
def _score(e: Event) -> float:
|
| 202 |
+
rel = None if relevance is None else relevance.get(e.id, 0.0)
|
| 203 |
+
return self.score(e, current_turn, query, relevance=rel)
|
| 204 |
+
|
| 205 |
+
top = sorted(candidates, key=_score, reverse=True)[: self.top_k]
|
| 206 |
+
recalled = sorted(top, key=lambda e: e.turn)
|
| 207 |
if not recalled:
|
| 208 |
return "(no salient memories)"
|
| 209 |
lines = []
|
| 210 |
for e in recalled:
|
| 211 |
text = e.payload.get("text") or e.payload.get("summary") or str(e.payload)
|
| 212 |
+
lines.append(f"[turn {e.turn:03d}][{e.kind}][sal={_score(e):.2f}] {text}")
|
|
|
|
| 213 |
return "\n".join(lines)
|
| 214 |
|
| 215 |
|
src/core/memory_index.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval index for the salience *relevance* term β a derived ledger lens.
|
| 2 |
+
|
| 3 |
+
The append-only event ledger is the single source of truth (ADR-0005). This
|
| 4 |
+
module adds an optional **semantic** retrieval index *over* that ledger: it does
|
| 5 |
+
not store anything the ledger does not already own, and it can be wiped and
|
| 6 |
+
rebuilt from the ledger at any time. It is a faster lens on the same events, not
|
| 7 |
+
a second store (ADR-0018).
|
| 8 |
+
|
| 9 |
+
Two pieces:
|
| 10 |
+
|
| 11 |
+
* :class:`MemoryIndex` β a tiny protocol the salience layer can lean on:
|
| 12 |
+
``index(events)`` derives vector entries from ledger events (idempotent,
|
| 13 |
+
keyed by ``event.id``) and ``search(query, k)`` returns the most relevant
|
| 14 |
+
events back. Any backend that satisfies this protocol can supply semantic
|
| 15 |
+
relevance β a vector service, a local embedding store, or a fake in tests.
|
| 16 |
+
|
| 17 |
+
* :class:`Mem0MemoryIndex` β a concrete backend. It is **lazy-imported and
|
| 18 |
+
env-gated**: with the backend not installed or not configured, nothing here
|
| 19 |
+
is imported and :class:`~src.core.memory.SalienceMemory` falls back to its
|
| 20 |
+
keyword-Jaccard relevance exactly as before. The backend activates only when
|
| 21 |
+
:func:`memory_index_from_env` finds it configured.
|
| 22 |
+
|
| 23 |
+
Because the index is derived, ``index()`` upserts each event under its
|
| 24 |
+
``event.id`` so re-indexing the same events is a no-op (no duplicates) β this is
|
| 25 |
+
what makes the index rebuildable rather than authoritative.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
| 31 |
+
|
| 32 |
+
from src.core.events import Event
|
| 33 |
+
|
| 34 |
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
| 35 |
+
from mem0 import Memory
|
| 36 |
+
|
| 37 |
+
#: Env gate. Set to a truthy value to activate the semantic index; unset (the
|
| 38 |
+
#: default) keeps memory on the offline keyword path with nothing imported.
|
| 39 |
+
INDEX_ENV = "MEMORY_INDEX"
|
| 40 |
+
|
| 41 |
+
#: Truthy spellings accepted for the gate and boolean sub-options.
|
| 42 |
+
_TRUTHY: frozenset[str] = frozenset({"1", "true", "yes", "on", "mem0"})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ββ protocol ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
|
| 47 |
+
@runtime_checkable
|
| 48 |
+
class MemoryIndex(Protocol):
|
| 49 |
+
"""A derived, rebuildable semantic index over ledger events.
|
| 50 |
+
|
| 51 |
+
Implementations MUST treat the ledger as authoritative: ``index`` is an
|
| 52 |
+
idempotent upsert keyed by ``event.id`` (re-indexing never duplicates), and
|
| 53 |
+
``search`` only ever returns events that were previously indexed.
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def index(self, events: tuple[Event, ...]) -> None:
|
| 57 |
+
"""Derive/refresh index entries for *events* (idempotent by ``event.id``)."""
|
| 58 |
+
...
|
| 59 |
+
|
| 60 |
+
def search(self, query: str, k: int) -> list[Event]:
|
| 61 |
+
"""Return up to *k* indexed events most semantically relevant to *query*."""
|
| 62 |
+
...
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ββ mem0 backend ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
|
| 67 |
+
def _event_text(event: Event) -> str:
|
| 68 |
+
"""The natural-language surface of an event used for embedding/recall."""
|
| 69 |
+
return str(event.payload.get("text") or event.payload.get("summary") or event.payload)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class Mem0MemoryIndex:
|
| 73 |
+
"""Semantic :class:`MemoryIndex` backed by the ``mem0`` vector memory.
|
| 74 |
+
|
| 75 |
+
Derived, not authoritative. Each ledger event is upserted as one raw memory
|
| 76 |
+
(``infer=False`` β text is stored verbatim, **no model extraction**, so
|
| 77 |
+
indexing is deterministic and the ledger stays the source of truth) carrying
|
| 78 |
+
the full event in ``metadata`` so a search hit reconstructs the original
|
| 79 |
+
:class:`Event` without a second lookup. The entry id is the ``event.id``, so
|
| 80 |
+
re-indexing the same event updates in place rather than duplicating β the
|
| 81 |
+
index is rebuildable from the ledger.
|
| 82 |
+
|
| 83 |
+
Configuration (env, read by :func:`memory_index_from_env`):
|
| 84 |
+
|
| 85 |
+
* ``MEMORY_INDEX`` β gate; truthy activates the index, unset disables it.
|
| 86 |
+
* Embedder credentials β an embedding model is required to vectorise event
|
| 87 |
+
text. By default ``mem0`` routes embeddings via ``OPENAI_API_KEY`` (the
|
| 88 |
+
same key the live model path already uses); point it elsewhere with a
|
| 89 |
+
``MEMORY_INDEX_CONFIG`` JSON blob (passed verbatim to ``mem0`` as its
|
| 90 |
+
config β see its docs for ``embedder`` / ``vector_store`` keys).
|
| 91 |
+
* ``MEMORY_INDEX_CONFIG`` β optional JSON config forwarded to
|
| 92 |
+
``mem0.Memory.from_config``. Use it to pin a local embedder or to persist
|
| 93 |
+
vectors in the project's own Postgres/pgvector (the durable store from
|
| 94 |
+
ADR-0014) instead of the default in-process vector store, so the index
|
| 95 |
+
lives beside the ledger it derives from.
|
| 96 |
+
|
| 97 |
+
``mem0`` is imported lazily inside :meth:`_memory` so ``import src.*`` and
|
| 98 |
+
``import app`` work with the package not installed.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
#: mem0 scopes memories to a session id; the index is engine-wide, so a fixed
|
| 102 |
+
#: namespace keeps every event in one searchable space.
|
| 103 |
+
_NAMESPACE = "ledger"
|
| 104 |
+
|
| 105 |
+
def __init__(self, config: dict | None = None) -> None:
|
| 106 |
+
self._config = config
|
| 107 |
+
self._mem: "Memory | None" = None
|
| 108 |
+
self._indexed: set[str] = set()
|
| 109 |
+
|
| 110 |
+
# ββ lazy construction βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 111 |
+
|
| 112 |
+
def _memory(self) -> "Memory":
|
| 113 |
+
"""Construct (once) and return the underlying ``mem0`` memory."""
|
| 114 |
+
if self._mem is None:
|
| 115 |
+
from mem0 import Memory # lazy: offline import must not require mem0
|
| 116 |
+
|
| 117 |
+
self._mem = (
|
| 118 |
+
Memory.from_config(self._config) if self._config else Memory()
|
| 119 |
+
)
|
| 120 |
+
return self._mem
|
| 121 |
+
|
| 122 |
+
# ββ MemoryIndex protocol ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 123 |
+
|
| 124 |
+
def index(self, events: tuple[Event, ...]) -> None:
|
| 125 |
+
"""Upsert *events* into the vector store, keyed by ``event.id``.
|
| 126 |
+
|
| 127 |
+
Idempotent: an ``event.id`` already indexed in this process is skipped, so
|
| 128 |
+
re-indexing the same ledger slice each turn does not duplicate entries.
|
| 129 |
+
"""
|
| 130 |
+
fresh = [e for e in events if e.id not in self._indexed]
|
| 131 |
+
if not fresh:
|
| 132 |
+
return
|
| 133 |
+
mem = self._memory()
|
| 134 |
+
for event in fresh:
|
| 135 |
+
mem.add(
|
| 136 |
+
_event_text(event),
|
| 137 |
+
user_id=self._NAMESPACE,
|
| 138 |
+
metadata=_event_metadata(event),
|
| 139 |
+
infer=False, # store verbatim; the ledger, not a model, is truth
|
| 140 |
+
)
|
| 141 |
+
self._indexed.add(event.id)
|
| 142 |
+
|
| 143 |
+
def search(self, query: str, k: int) -> list[Event]:
|
| 144 |
+
"""Semantic search; map hits back to :class:`Event` via stored metadata."""
|
| 145 |
+
if not query or k <= 0:
|
| 146 |
+
return []
|
| 147 |
+
mem = self._memory()
|
| 148 |
+
hits = mem.search(query, top_k=k, filters={"user_id": self._NAMESPACE})
|
| 149 |
+
events: list[Event] = []
|
| 150 |
+
for hit in _result_items(hits):
|
| 151 |
+
event = _event_from_metadata(hit.get("metadata"))
|
| 152 |
+
if event is not None:
|
| 153 |
+
events.append(event)
|
| 154 |
+
return events
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ββ metadata round-trip (event β vector entry) ββββββββββββββββββββββββββββββββ
|
| 158 |
+
|
| 159 |
+
def _event_metadata(event: Event) -> dict:
|
| 160 |
+
"""Flatten an event into JSON-safe metadata for the vector entry."""
|
| 161 |
+
return {
|
| 162 |
+
"event_id": event.id,
|
| 163 |
+
"run_id": event.run_id,
|
| 164 |
+
"turn": event.turn,
|
| 165 |
+
"kind": event.kind,
|
| 166 |
+
"actor": event.actor,
|
| 167 |
+
"payload": event.payload,
|
| 168 |
+
"created_at": event.created_at.isoformat(),
|
| 169 |
+
"schema_version": event.schema_version,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _event_from_metadata(metadata: dict | None) -> Event | None:
|
| 174 |
+
"""Reconstruct an :class:`Event` from stored metadata, or ``None`` if absent."""
|
| 175 |
+
if not metadata or "event_id" not in metadata:
|
| 176 |
+
return None
|
| 177 |
+
try:
|
| 178 |
+
return Event(
|
| 179 |
+
id=str(metadata["event_id"]),
|
| 180 |
+
run_id=str(metadata.get("run_id", "")),
|
| 181 |
+
turn=int(metadata.get("turn", 0)),
|
| 182 |
+
kind=str(metadata["kind"]),
|
| 183 |
+
actor=str(metadata.get("actor", "")),
|
| 184 |
+
payload=dict(metadata.get("payload") or {}),
|
| 185 |
+
schema_version=int(metadata.get("schema_version", 1)),
|
| 186 |
+
)
|
| 187 |
+
except (KeyError, ValueError, TypeError): # pragma: no cover - defensive
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _result_items(hits: object) -> list[dict]:
|
| 192 |
+
"""Normalise ``mem0.search`` output to a list of hit dicts.
|
| 193 |
+
|
| 194 |
+
``mem0`` returns either ``{"results": [...]}`` (v1.1+) or a bare list,
|
| 195 |
+
depending on version/config; accept both so the backend is version-tolerant.
|
| 196 |
+
"""
|
| 197 |
+
if isinstance(hits, dict):
|
| 198 |
+
results = hits.get("results", [])
|
| 199 |
+
else:
|
| 200 |
+
results = hits
|
| 201 |
+
return [h for h in results if isinstance(h, dict)] if isinstance(results, list) else []
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ββ env gate βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 205 |
+
|
| 206 |
+
def _is_truthy(value: str | None) -> bool:
|
| 207 |
+
return (value or "").strip().lower() in _TRUTHY
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def memory_index_from_env(env: dict[str, str] | None = None) -> MemoryIndex | None:
|
| 211 |
+
"""Build a :class:`Mem0MemoryIndex` from the env gate, or ``None`` if unset.
|
| 212 |
+
|
| 213 |
+
Returns ``None`` (the offline default the suite exercises) unless
|
| 214 |
+
``MEMORY_INDEX`` is truthy. ``mem0`` is only imported later, on first use, so
|
| 215 |
+
a truthy gate without the package installed still imports cleanly and fails
|
| 216 |
+
loudly only when the index is actually exercised.
|
| 217 |
+
"""
|
| 218 |
+
source = os.environ if env is None else env
|
| 219 |
+
if not _is_truthy(source.get(INDEX_ENV)):
|
| 220 |
+
return None
|
| 221 |
+
raw_config = (source.get("MEMORY_INDEX_CONFIG") or "").strip()
|
| 222 |
+
config: dict | None = None
|
| 223 |
+
if raw_config:
|
| 224 |
+
import json
|
| 225 |
+
|
| 226 |
+
config = json.loads(raw_config)
|
| 227 |
+
return Mem0MemoryIndex(config=config)
|
src/core/registry.py
CHANGED
|
@@ -132,12 +132,12 @@ class Registry:
|
|
| 132 |
router.specs = specs
|
| 133 |
return router
|
| 134 |
|
| 135 |
-
def build_agent(self, name: str, router: ModelRouter, tools=None) -> Agent:
|
| 136 |
if name not in self.agents:
|
| 137 |
raise KeyError(f"unknown agent {name!r} (have: {sorted(self.agents)})")
|
| 138 |
manifest = self.agents[name]
|
| 139 |
cls = HANDLERS.get(manifest.handler, ManifestAgent) if manifest.handler else ManifestAgent
|
| 140 |
-
agent = cls(router, tools)
|
| 141 |
agent.manifest = manifest # YAML is the source of truth for declarative fields
|
| 142 |
return agent
|
| 143 |
|
|
@@ -146,7 +146,15 @@ class Registry:
|
|
| 146 |
raise KeyError(f"unknown scenario {name!r} (have: {sorted(self.scenarios)})")
|
| 147 |
cfg = self.scenarios[name]
|
| 148 |
router = router or self.build_router()
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return Scenario(
|
| 151 |
name=cfg.name,
|
| 152 |
default_seed=cfg.default_seed,
|
|
|
|
| 132 |
router.specs = specs
|
| 133 |
return router
|
| 134 |
|
| 135 |
+
def build_agent(self, name: str, router: ModelRouter, tools=None, memory_index=None) -> Agent:
|
| 136 |
if name not in self.agents:
|
| 137 |
raise KeyError(f"unknown agent {name!r} (have: {sorted(self.agents)})")
|
| 138 |
manifest = self.agents[name]
|
| 139 |
cls = HANDLERS.get(manifest.handler, ManifestAgent) if manifest.handler else ManifestAgent
|
| 140 |
+
agent = cls(router, tools, memory_index)
|
| 141 |
agent.manifest = manifest # YAML is the source of truth for declarative fields
|
| 142 |
return agent
|
| 143 |
|
|
|
|
| 146 |
raise KeyError(f"unknown scenario {name!r} (have: {sorted(self.scenarios)})")
|
| 147 |
cfg = self.scenarios[name]
|
| 148 |
router = router or self.build_router()
|
| 149 |
+
# Optional semantic relevance index β env-gated (MEMORY_INDEX), a derived
|
| 150 |
+
# lens over the ledger (ADR-0018). None offline; one engine-wide index is
|
| 151 |
+
# shared across the cast.
|
| 152 |
+
from src.core.memory_index import memory_index_from_env
|
| 153 |
+
|
| 154 |
+
memory_index = memory_index_from_env()
|
| 155 |
+
agents = tuple(
|
| 156 |
+
self.build_agent(agent_name, router, tools, memory_index) for agent_name in cfg.cast
|
| 157 |
+
)
|
| 158 |
return Scenario(
|
| 159 |
name=cfg.name,
|
| 160 |
default_seed=cfg.default_seed,
|
tests/test_memory_index.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic memory index tests (ADR-0018).
|
| 2 |
+
|
| 3 |
+
Three tiers, mirroring the optional-dependency tests elsewhere:
|
| 4 |
+
|
| 5 |
+
* A FAKE in-memory ``MemoryIndex`` (no ``mem0`` required) proves the layering:
|
| 6 |
+
when an index is attached, ``SalienceMemory`` retrieves by semantic rank;
|
| 7 |
+
with none it falls back to keyword Jaccard. Indexing is idempotent.
|
| 8 |
+
* The env gate returns ``None`` when unset and a backend when set β provable
|
| 9 |
+
with no ``mem0`` installed (construction is lazy).
|
| 10 |
+
* A guarded real-``mem0`` round-trip (skipped without the package or an
|
| 11 |
+
embedder configured) asserts an event survives index β search.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
import pytest
|
| 18 |
+
|
| 19 |
+
from src.agents.base import ManifestAgent
|
| 20 |
+
from src.core.events import Event
|
| 21 |
+
from src.core.manifest import AgentManifest, MemoryConfig
|
| 22 |
+
from src.core.memory import SalienceMemory
|
| 23 |
+
from src.core.memory_index import (
|
| 24 |
+
Mem0MemoryIndex,
|
| 25 |
+
MemoryIndex,
|
| 26 |
+
memory_index_from_env,
|
| 27 |
+
)
|
| 28 |
+
from src.models.router import ModelRouter
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _event(kind: str, actor: str = "x", turn: int = 1, text: str = "hello", eid: str | None = None) -> Event:
|
| 32 |
+
kwargs = {"run_id": "r", "turn": turn, "kind": kind, "actor": actor, "payload": {"text": text}}
|
| 33 |
+
if eid is not None:
|
| 34 |
+
kwargs["id"] = eid
|
| 35 |
+
return Event(**kwargs) # type: ignore[arg-type]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class _FakeIndex:
|
| 39 |
+
"""A deterministic in-memory ``MemoryIndex`` β no ``mem0``, no embeddings.
|
| 40 |
+
|
| 41 |
+
``search`` ranks indexed events by substring/word overlap so a test can steer
|
| 42 |
+
*which* event the salience layer treats as most relevant, independently of
|
| 43 |
+
the keyword-Jaccard the offline path would compute. Records calls so a test
|
| 44 |
+
can assert idempotent indexing.
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
def __init__(self) -> None:
|
| 48 |
+
self.store: dict[str, Event] = {}
|
| 49 |
+
self.add_calls: list[str] = []
|
| 50 |
+
|
| 51 |
+
def index(self, events: tuple[Event, ...]) -> None:
|
| 52 |
+
for e in events:
|
| 53 |
+
self.add_calls.append(e.id)
|
| 54 |
+
self.store[e.id] = e # upsert by id β idempotent
|
| 55 |
+
|
| 56 |
+
def search(self, query: str, k: int) -> list[Event]:
|
| 57 |
+
q = set(query.lower().split())
|
| 58 |
+
scored = [
|
| 59 |
+
(len(q & set(str(e.payload.get("text", "")).lower().split())), e)
|
| 60 |
+
for e in self.store.values()
|
| 61 |
+
]
|
| 62 |
+
scored.sort(key=lambda t: t[0], reverse=True)
|
| 63 |
+
return [e for _, e in scored[:k]]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ββ the fake satisfies the protocol (structural typing) βββββββββββββββββββββββββ
|
| 67 |
+
|
| 68 |
+
class TestProtocol:
|
| 69 |
+
def test_fake_is_memory_index(self):
|
| 70 |
+
assert isinstance(_FakeIndex(), MemoryIndex)
|
| 71 |
+
|
| 72 |
+
def test_mem0_backend_is_memory_index(self):
|
| 73 |
+
# No mem0 import needed: the backend is constructed lazily.
|
| 74 |
+
assert isinstance(Mem0MemoryIndex(), MemoryIndex)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ββ layering: index drives the relevance term, recency/importance intact ββββββββ
|
| 78 |
+
|
| 79 |
+
class TestSalienceUsesIndex:
|
| 80 |
+
def test_semantic_hit_outranks_keyword_irrelevant(self):
|
| 81 |
+
"""An event the index ranks top wins even with no keyword overlap to the
|
| 82 |
+
query β proving the relevance term came from the index, not Jaccard."""
|
| 83 |
+
idx = _FakeIndex()
|
| 84 |
+
# The query shares NO words with either event text; the fake index is
|
| 85 |
+
# seeded to rank the 'beacon' event first via its own signal.
|
| 86 |
+
target = _event("world.observed", turn=2, text="beacon glow signal", eid="hit")
|
| 87 |
+
other = _event("world.observed", turn=2, text="quiet empty room", eid="miss")
|
| 88 |
+
mem = SalienceMemory("x", top_k=1, index=idx)
|
| 89 |
+
|
| 90 |
+
# Steer the fake: query overlaps only the target's words.
|
| 91 |
+
recalled = mem.visible((other, target), current_turn=3, query="beacon glow")
|
| 92 |
+
assert [e.id for e in recalled] == ["hit"]
|
| 93 |
+
|
| 94 |
+
def test_falls_back_to_keyword_without_index(self):
|
| 95 |
+
match = _event("world.observed", turn=5, text="golden spores drift upward")
|
| 96 |
+
miss = _event("world.observed", turn=5, text="completely unrelated content")
|
| 97 |
+
mem = SalienceMemory("a") # no index β keyword path
|
| 98 |
+
s_match = mem.score(match, current_turn=6, query="golden spores")
|
| 99 |
+
s_miss = mem.score(miss, current_turn=6, query="golden spores")
|
| 100 |
+
assert s_match > s_miss
|
| 101 |
+
|
| 102 |
+
def test_index_is_populated_from_visible_events_only(self):
|
| 103 |
+
"""The index is DERIVED from the ledger: only events that pass the
|
| 104 |
+
visibility filter are indexed, never another agent's private thoughts."""
|
| 105 |
+
idx = _FakeIndex()
|
| 106 |
+
mine = _event("agent.thought", actor="a", turn=1, text="my secret", eid="mine")
|
| 107 |
+
theirs = _event("agent.thought", actor="b", turn=1, text="their secret", eid="theirs")
|
| 108 |
+
glob = _event("world.observed", actor="narrator", turn=1, text="the stage", eid="glob")
|
| 109 |
+
mem = SalienceMemory("a", index=idx)
|
| 110 |
+
mem.visible((mine, theirs, glob), current_turn=2, query="stage")
|
| 111 |
+
assert set(idx.store) == {"mine", "glob"} # 'theirs' never indexed
|
| 112 |
+
|
| 113 |
+
def test_recency_still_applies_with_index(self):
|
| 114 |
+
"""Relevance is one term; recency must still separate equally-relevant
|
| 115 |
+
events so the index does not flatten the composite score."""
|
| 116 |
+
idx = _FakeIndex()
|
| 117 |
+
old = _event("world.observed", turn=1, text="same words here", eid="old")
|
| 118 |
+
new = _event("world.observed", turn=10, text="same words here", eid="new")
|
| 119 |
+
mem = SalienceMemory("x", top_k=2, index=idx)
|
| 120 |
+
recalled = mem.visible((old, new), current_turn=12, query="same words here")
|
| 121 |
+
# both relevant + chronological order, but recency makes 'new' score higher
|
| 122 |
+
s_old = mem.score(old, current_turn=12, query="x", relevance=1.0)
|
| 123 |
+
s_new = mem.score(new, current_turn=12, query="x", relevance=1.0)
|
| 124 |
+
assert s_new > s_old
|
| 125 |
+
assert {e.id for e in recalled} == {"old", "new"}
|
| 126 |
+
|
| 127 |
+
def test_format_for_prompt_shape_with_index(self):
|
| 128 |
+
idx = _FakeIndex()
|
| 129 |
+
e = _event("world.observed", turn=1, text="something", eid="e1")
|
| 130 |
+
out = SalienceMemory("x", index=idx).format_for_prompt((e,), current_turn=2, query="something")
|
| 131 |
+
assert isinstance(out, str)
|
| 132 |
+
assert "something" in out and "sal=" in out # output shape unchanged
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ββ idempotent indexing (derived, rebuildable) ββββββββββββββββββββββββββββββββββ
|
| 136 |
+
|
| 137 |
+
class TestIdempotentIndexing:
|
| 138 |
+
def test_reindex_does_not_duplicate(self):
|
| 139 |
+
idx = _FakeIndex()
|
| 140 |
+
events = (_event("world.observed", turn=1, text="a", eid="e1"),
|
| 141 |
+
_event("world.observed", turn=2, text="b", eid="e2"))
|
| 142 |
+
idx.index(events)
|
| 143 |
+
idx.index(events) # re-index same slice
|
| 144 |
+
assert len(idx.store) == 2 # keyed by id β no duplicates
|
| 145 |
+
|
| 146 |
+
def test_mem0_backend_skips_already_indexed_ids(self):
|
| 147 |
+
"""The real backend dedupes by id before touching mem0, so a process that
|
| 148 |
+
re-indexes the same ledger slice each turn does not re-embed it."""
|
| 149 |
+
backend = Mem0MemoryIndex()
|
| 150 |
+
backend._indexed.add("e1") # pretend already indexed this process
|
| 151 |
+
# No mem0 call should happen for an already-indexed id; _memory() would
|
| 152 |
+
# raise (mem0 may be absent), so reaching it on a dup would surface here.
|
| 153 |
+
backend.index((_event("world.observed", eid="e1"),)) # no-op, no import
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ββ env gate (no mem0 required) ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 157 |
+
|
| 158 |
+
class TestEnvGate:
|
| 159 |
+
def test_none_when_unset(self):
|
| 160 |
+
assert memory_index_from_env({}) is None
|
| 161 |
+
|
| 162 |
+
def test_none_when_falsey(self):
|
| 163 |
+
assert memory_index_from_env({"MEMORY_INDEX": "0"}) is None
|
| 164 |
+
|
| 165 |
+
def test_backend_when_truthy(self):
|
| 166 |
+
idx = memory_index_from_env({"MEMORY_INDEX": "1"})
|
| 167 |
+
assert isinstance(idx, Mem0MemoryIndex)
|
| 168 |
+
|
| 169 |
+
def test_config_blob_is_parsed(self):
|
| 170 |
+
idx = memory_index_from_env(
|
| 171 |
+
{"MEMORY_INDEX": "true", "MEMORY_INDEX_CONFIG": '{"version": "v1.1"}'}
|
| 172 |
+
)
|
| 173 |
+
assert isinstance(idx, Mem0MemoryIndex)
|
| 174 |
+
assert idx._config == {"version": "v1.1"}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ββ agent wiring: _recall threads the index into salience ββββββββββββββββββββββββ
|
| 178 |
+
|
| 179 |
+
class _SalienceAgent(ManifestAgent):
|
| 180 |
+
manifest = AgentManifest(
|
| 181 |
+
name="recaller",
|
| 182 |
+
persona="p",
|
| 183 |
+
may_emit=["agent.spoke"],
|
| 184 |
+
memory=MemoryConfig(use_salience=True, salience_top_k=1),
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
class TestRecallWiring:
|
| 189 |
+
def test_recall_uses_attached_index(self):
|
| 190 |
+
idx = _FakeIndex()
|
| 191 |
+
agent = _SalienceAgent(ModelRouter(offline=True), memory_index=idx)
|
| 192 |
+
from src.core.projections import StageProjection
|
| 193 |
+
|
| 194 |
+
events = (
|
| 195 |
+
_event("world.observed", actor="n", turn=1, text="beacon glow signal", eid="hit"),
|
| 196 |
+
_event("world.observed", actor="n", turn=1, text="quiet empty room", eid="miss"),
|
| 197 |
+
)
|
| 198 |
+
proj = StageProjection(current_scene="beacon glow")
|
| 199 |
+
out = agent._recall(turn=2, projection=proj, recent_events=events)
|
| 200 |
+
assert "beacon" in out # the index-ranked event made it into the prompt
|
| 201 |
+
assert idx.store # the index was derived from the ledger events
|
| 202 |
+
|
| 203 |
+
def test_recall_without_index_is_keyword_path(self):
|
| 204 |
+
agent = _SalienceAgent(ModelRouter(offline=True)) # no index attached
|
| 205 |
+
from src.core.projections import StageProjection
|
| 206 |
+
|
| 207 |
+
e = _event("world.observed", actor="n", turn=1, text="golden spores")
|
| 208 |
+
out = agent._recall(turn=2, projection=StageProjection(current_scene="golden spores"), recent_events=(e,))
|
| 209 |
+
assert isinstance(out, str) and "golden" in out
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ββ guarded real-mem0 round-trip (requires mem0 + an embedder) βββββββββββββββββββ
|
| 213 |
+
|
| 214 |
+
class TestMem0RoundTrip:
|
| 215 |
+
def test_index_then_search_recovers_event(self):
|
| 216 |
+
pytest.importorskip("mem0")
|
| 217 |
+
if not os.getenv("OPENAI_API_KEY") and not os.getenv("MEMORY_INDEX_CONFIG"):
|
| 218 |
+
pytest.skip("mem0 needs an embedder (OPENAI_API_KEY or MEMORY_INDEX_CONFIG)")
|
| 219 |
+
|
| 220 |
+
backend = Mem0MemoryIndex()
|
| 221 |
+
ev = _event("world.observed", turn=1, text="golden spores drift over the glass forest", eid="rt1")
|
| 222 |
+
try:
|
| 223 |
+
backend.index((ev,))
|
| 224 |
+
hits = backend.search("golden spores", k=5)
|
| 225 |
+
except Exception as exc: # pragma: no cover - environment dependent
|
| 226 |
+
pytest.skip(f"mem0 backend unavailable: {exc}")
|
| 227 |
+
|
| 228 |
+
assert any(h.id == "rt1" for h in hits)
|
| 229 |
+
# Mapped back to a real Event with payload intact (derived from metadata).
|
| 230 |
+
hit = next(h for h in hits if h.id == "rt1")
|
| 231 |
+
assert hit.kind == "world.observed"
|
| 232 |
+
assert hit.payload.get("text", "").startswith("golden spores")
|