Skip to content

feat(memory): generative reflection — synthesize insight nodes from clusters (Generative-Agents)#80

Merged
Sandermage merged 1 commit into
mainfrom
memory-reflection-2026-07-08
Jul 8, 2026
Merged

feat(memory): generative reflection — synthesize insight nodes from clusters (Generative-Agents)#80
Sandermage merged 1 commit into
mainfrom
memory-reflection-2026-07-08

Conversation

@Sandermage

Copy link
Copy Markdown
Owner

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 semantic node linked back to its source observations (derived_from edges) — the Generative-Agents reflection tree.

What

  • engine.reflect(owner_id, llm, …) — model-agnostic (llm is 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.py make_openai_llm — dependency-free (urllib) OpenAI-compat /v1/chat/completions caller binding reflect() to the running vLLM engine (thinking off; non-2xx → "").
  • Reachable end-to-end: POST /api/v1/memory/reflect (503 until SNDR_OPENAI_BASE_URL 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 · prompt carries the cluster contents. test_memory_llm.py (3): request construction, /v1 idempotence, error→empty. Plus the reflect client+CLI verb tests.

Verification

  • 66 focused memory tests green; ruff-clean; audit_public_docs ✓ · security_scan ✓
  • engine stays model-agnostic (the LLM is injected)

Remaining roadmap

working-memory tier · Obsidian path-keyed dedup · typed entity/relation KG + LLM fact-extraction · GUI/TUI coherence.

…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).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread sndr/memory/engine.py
Comment on lines +238 to +247
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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

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]

Comment thread sndr/memory/engine.py
Comment on lines +249 to +251
observations = [
n for n in nodes if n and not n.properties.get("derived")
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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").

Suggested change
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")
]

@Sandermage Sandermage merged commit 869ed19 into main Jul 8, 2026
7 checks passed
@Sandermage Sandermage deleted the memory-reflection-2026-07-08 branch July 8, 2026 07:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant