feat(memory): generative reflection — synthesize insight nodes from clusters (Generative-Agents)#80
Conversation
…lusters Point 1 of the remaining memory-brain roadmap. Every prior mechanic (Hebbian, decay, spreading activation, communities, importance) only connects/ranks/decays memories that already exist. Reflection is the one step that CREATES knowledge: it clusters related memories, asks the engine to synthesize a higher-level insight per cluster, and stores each as a NEW `semantic` node linked back to the observations it came from (`derived_from` edges) — the Generative-Agents reflection tree, and the heart of "memory that works like a brain". - engine.reflect(owner_id, llm, min_cluster, max_reflections): model-agnostic — `llm` is an injected `(prompt) -> text`. Only reflects over raw observations (skips prior insights, so passes don't runaway), caps output, and degrades to "no insight" on empty LLM output. - sndr/memory/llm.py `make_openai_llm`: a dependency-free (urllib) OpenAI-compat `/v1/chat/completions` caller that binds reflect() to the running vLLM engine (thinking disabled; non-2xx degrades to ""). - Reachable end-to-end: POST /api/v1/memory/reflect (503 until SNDR_OPENAI_BASE_URL is set) + MemoryHTTPClient.reflect + `sndr mem.reflect` (CLI_REFERENCE §7). TDD: test_memory_reflection.py (8) — creates a derived node, marks it semantic+derived+sources, links to sources, skips small clusters, empty-output creates nothing, caps at max_reflections, never reflects on derived nodes, and the prompt carries the cluster contents. test_memory_llm.py (3) — request construction, /v1 idempotence, error→empty. Plus the reflect client+CLI verb. Engine stays model-agnostic; regression clean (66 focused memory tests green).
There was a problem hiding this comment.
Code Review
This pull request introduces a generative reflection feature (mem.reflect) to the memory engine, allowing the system to cluster related memories, synthesize higher-level insights using an LLM, and store them as new semantic nodes linked to their sources. The changes span documentation, CLI commands, client APIs, core engine logic, a lightweight OpenAI-compatible LLM helper, and corresponding product API endpoints and tests. The review feedback highlights a potential N+1 query performance issue when retrieving nodes in a loop, as well as a potential AttributeError if a node's properties are None.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| communities = self.detect_communities(owner_id=owner_id) | ||
| by_comm: dict[int, list[int]] = {} | ||
| for nid, comm in communities.items(): | ||
| by_comm.setdefault(comm, []).append(nid) | ||
|
|
||
| created = 0 | ||
| for _comm, nids in sorted(by_comm.items()): | ||
| if created >= max_reflections: | ||
| break | ||
| nodes = [self.store.get_node(nid) for nid in nids] |
There was a problem hiding this comment.
Performance: Avoid N+1 Queries
Currently, self.store.get_node(nid) is called in a loop for every node in every community. If the underlying store is database-backed (e.g., Postgres), this results in an N+1 query pattern, causing significant latency.
Since we can fetch all nodes for the owner in a single pass using self.store.iter_nodes(owner_id), we should pre-populate a map of nodes and look them up in memory.
communities = self.detect_communities(owner_id=owner_id)
nodes_map = {n.id: n for n in self.store.iter_nodes(owner_id) if n}
by_comm: dict[int, list[int]] = {}
for nid, comm in communities.items():
by_comm.setdefault(comm, []).append(nid)
created = 0
for _comm, nids in sorted(by_comm.items()):
if created >= max_reflections:
break
nodes = [nodes_map.get(nid) for nid in nids]| observations = [ | ||
| n for n in nodes if n and not n.properties.get("derived") | ||
| ] |
There was a problem hiding this comment.
Defensive Programming: Handle Nullable properties
The properties attribute of a node can be None (as seen in MemoryEngine.remember where properties: dict[str, Any] | None = None). If a node has properties set to None in the store, calling n.properties.get("derived") will raise an AttributeError: 'NoneType' object has no attribute 'get'.
We should safely guard against None using (n.properties or {}).get("derived").
| observations = [ | |
| n for n in nodes if n and not n.properties.get("derived") | |
| ] | |
| observations = [ | |
| n for n in nodes if n and not (n.properties or {}).get("derived") | |
| ] |
Why
Point 1 of the remaining memory-brain roadmap, and the heart of "memory that works like a brain". Every other mechanic only connects/ranks/decays existing memories. Reflection is the one step that CREATES knowledge: cluster related memories → LLM synthesizes a higher-level insight → store it as a new
semanticnode linked back to its source observations (derived_fromedges) — the Generative-Agents reflection tree.What
engine.reflect(owner_id, llm, …)— model-agnostic (llmis an injected(prompt)->text). Reflects only over raw observations (skips prior insights → no runaway), caps output, degrades to "no insight" on empty output.sndr/memory/llm.pymake_openai_llm— dependency-free (urllib) OpenAI-compat/v1/chat/completionscaller binding reflect() to the running vLLM engine (thinking off; non-2xx → "").POST /api/v1/memory/reflect(503 untilSNDR_OPENAI_BASE_URLset) +MemoryHTTPClient.reflect+sndr mem.reflect(CLI_REFERENCE §7).TDD
test_memory_reflection.py(8): creates a derived node · marks it semantic+derived+sources · links to sources · skips small clusters · empty-output creates nothing · caps at max_reflections · never reflects on derived nodes · prompt carries the cluster contents.test_memory_llm.py(3): request construction,/v1idempotence, error→empty. Plus the reflect client+CLI verb tests.Verification
Remaining roadmap
working-memory tier · Obsidian path-keyed dedup · typed entity/relation KG + LLM fact-extraction · GUI/TUI coherence.