Skip to content

fix(memory): decouple MemoryMiddleware from knowledge_store#18

Merged
mabry1985 merged 1 commit intodevfrom
fix/memory-wire-decouple
Apr 20, 2026
Merged

fix(memory): decouple MemoryMiddleware from knowledge_store#18
mabry1985 merged 1 commit intodevfrom
fix/memory-wire-decouple

Conversation

@mabry1985
Copy link
Copy Markdown
Contributor

@mabry1985 mabry1985 commented Apr 20, 2026

Fixes bug #3 from v0.2.0 smoke testing: MemoryMiddleware silently skipped when knowledge: false (the default config).

Root cause

graph/agent.py:32 had an and knowledge_store conjunction on the MemoryMiddleware activation guard. Default config has knowledge: false so knowledge_store=None, silently disabling memory despite memory: true.

Changes

  • graph/agent.py: drop and knowledge_store from the guard
  • graph/middleware/memory.py: knowledge_store is now optional (default None). Guard knowledge-extraction block when store is None. Add standalone <prior_sessions> injection via before_model — only fires when self._store is None, so no double-injection with KnowledgeMiddleware.

Behavior by config

memory knowledge <prior_sessions> source Persistence
true false MemoryMiddleware yes
true true KnowledgeMiddleware yes
false true KnowledgeMiddleware no
false false no

Authored by Ava after the original agent completed the plan correctly but hit PR-create rate limits 3x and the retry budget exhausted.

Summary by CodeRabbit

  • Improvements
    • Memory and conversation history functionality is now more flexible and independent from the knowledge store. The system can reference prior sessions and maintain conversation context without requiring a connected knowledge store, enabling more flexible deployment scenarios while preserving conversation continuity across sessions.

Addresses bug #3 from v0.2.0 smoke test: MemoryMiddleware was silently
skipped when knowledge: false (the default config), so session memory
never worked out of the box.

- graph/agent.py: drop `and knowledge_store` from activation guard — memory
  middleware now activates whenever memory: true, regardless of knowledge store
- graph/middleware/memory.py: knowledge_store is now optional (default None);
  guard knowledge-extraction block when store is None; add standalone
  prior_sessions injection via before_model when running without
  KnowledgeMiddleware (no double-injection: only fires when self._store is None)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

Walkthrough

The changes introduce a standalone operation mode for MemoryMiddleware where it can function without a persistent knowledge store. When no knowledge store is provided, the middleware loads prior session summaries from disk, injects them into conversations via a pre-model hook, and skips knowledge extraction in the post-agent hook. The agent's middleware composition is updated to permit this optional configuration.

Changes

Cohort / File(s) Summary
Memory Middleware Standalone Mode
graph/middleware/memory.py
Made knowledge_store parameter optional. Added before_model/abefore_model hooks that conditionally inject cached prior-session XML summaries from MEMORY_PATH when no knowledge store exists. Introduced _load_prior_sessions() helper to scan, parse, and format up to 10 session files with ~2k character truncation. Modified after_agent to skip knowledge extraction when in standalone mode.
Middleware Composition
graph/agent.py
Updated _build_middleware to allow MemoryMiddleware instantiation with optional (potentially None) knowledge_store parameter when memory middleware is enabled.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description covers the root cause, changes made, and behavior by config, but is missing the Closes section from the template and lacks a formal Test plan section. Add 'Closes #3' to link the bug fix and include a Test plan section with verification steps for the different config combinations.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: decoupling MemoryMiddleware from knowledge_store, which is the core fix addressing the activation guard issue.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/memory-wire-decouple

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@graph/agent.py`:
- Around line 32-33: When constructing MemoryMiddleware, ensure it receives None
for the store when knowledge middleware is disabled: change the logic around
config.memory_middleware/config.knowledge_middleware so that MemoryMiddleware is
instantiated with knowledge_store only if config.knowledge_middleware is true,
otherwise pass None (e.g., MemoryMiddleware(None)). Update the middleware
creation site that currently calls MemoryMiddleware(knowledge_store) so it
conditionally passes None when config.knowledge_middleware is false, preserving
the intended memory=true, knowledge=false behavior and preventing store-backed
paths from being taken.

In `@graph/middleware/memory.py`:
- Around line 176-240: The duplicate JSON-scan→XML-format→token-budget logic in
MemoryMiddleware._load_prior_sessions and KnowledgeMiddleware.load_memory should
be extracted into a single helper function (e.g., build_prior_sessions_xml)
placed in a shared module (for example graph.middleware.shared_memory or
graph.middleware.utils). Implement the helper to accept the directory path
(MEMORY_PATH), max sessions/count limits, and token budget parameters and return
the XML string exactly matching the current outputs ("<prior_sessions/>" or
"<prior_sessions>...</prior_sessions>"). Replace
MemoryMiddleware._load_prior_sessions and KnowledgeMiddleware.load_memory to
call this helper, preserving existing behavior (file filtering, JSON decoding
fallback, message/content truncation lengths, session ordering and dropping
oldest entries to meet the token budget). Update imports and remove duplicated
code blocks from both files.
- Around line 216-229: The code builds XML fragments unsafely (ts, sid, role,
content, final) which can break the XML or alter structure; fix by XML-escaping
all inserted values (timestamp, session_id, message content, final_output) using
a standard escaper (e.g., xml.sax.saxutils.escape or html.escape) and stop using
the raw role as an element name—emit a safe tag such as <message
role="...">...</message> (escape role when used as an attribute) instead of f"  
<{role}>...</{role}>", and update the code paths that create lines (the
variables ts, sid, msgs loop, role, content, final) to apply escaping
consistently.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fdf17714-1588-4bfc-966b-753a870696bf

📥 Commits

Reviewing files that changed from the base of the PR and between 118c9b2 and 8a4fcee.

📒 Files selected for processing (2)
  • graph/agent.py
  • graph/middleware/memory.py

Comment thread graph/agent.py
Comment on lines +32 to 33
if config.memory_middleware:
middleware.append(MemoryMiddleware(knowledge_store))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Pass None to MemoryMiddleware when knowledge middleware is off.

Line 33 still forwards a non-null knowledge_store even when config.knowledge_middleware is false. In that configuration, MemoryMiddleware takes its store-backed path, so standalone <prior_sessions> injection is skipped and post-agent extraction is turned back on. That breaks the PR’s stated memory=true, knowledge=false behavior whenever a store object is present.

Proposed fix
-    if config.memory_middleware:
-        middleware.append(MemoryMiddleware(knowledge_store))
+    if config.memory_middleware:
+        memory_store = knowledge_store if config.knowledge_middleware else None
+        middleware.append(MemoryMiddleware(memory_store))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@graph/agent.py` around lines 32 - 33, When constructing MemoryMiddleware,
ensure it receives None for the store when knowledge middleware is disabled:
change the logic around config.memory_middleware/config.knowledge_middleware so
that MemoryMiddleware is instantiated with knowledge_store only if
config.knowledge_middleware is true, otherwise pass None (e.g.,
MemoryMiddleware(None)). Update the middleware creation site that currently
calls MemoryMiddleware(knowledge_store) so it conditionally passes None when
config.knowledge_middleware is false, preserving the intended memory=true,
knowledge=false behavior and preventing store-backed paths from being taken.

Comment on lines +176 to +240
def _load_prior_sessions(self) -> str:
"""Lazy-load prior session summaries when standalone (no KnowledgeMiddleware).

When KnowledgeMiddleware is also in the chain it owns `<prior_sessions>`
injection. This method runs only when `self._store is None`, so there is
no double-injection risk.

Reads from MEMORY_PATH, returns an XML block or empty string on first
run. Mirrors KnowledgeMiddleware.load_memory() but without the store
dependency — single source of truth would be cleaner but would couple
the two files.
"""
if not os.path.isdir(MEMORY_PATH):
return ""
try:
entries = []
for fname in os.listdir(MEMORY_PATH):
if not fname.endswith(".json"):
continue
fpath = os.path.join(MEMORY_PATH, fname)
try:
entries.append((os.path.getmtime(fpath), fpath))
except OSError:
continue
entries.sort(reverse=True)
except OSError:
return ""
if not entries:
return "<prior_sessions/>"
summaries = []
for _, fpath in entries[:10]:
try:
with open(fpath, encoding="utf-8") as fh:
summaries.append(json.load(fh))
except (OSError, json.JSONDecodeError, ValueError):
continue
if not summaries:
return "<prior_sessions/>"
lines_out = []
for s in summaries:
ts = s.get("timestamp", "unknown")
sid = s.get("session_id", "unknown")
lines = [f'<session id="{sid}" timestamp="{ts}">']
msgs = s.get("messages", []) or []
if msgs:
lines.append(" <messages>")
for m in msgs:
role = m.get("role", "unknown")
content = (m.get("content", "") or "")[:500]
lines.append(f" <{role}>{content}</{role}>")
lines.append(" </messages>")
final = (s.get("final_output") or "")[:300]
if final:
lines.append(f" <final_output>{final}</final_output>")
lines.append("</session>")
lines_out.append("\n".join(lines))
# 2K token budget — chars // 4 approx, drop oldest first
while lines_out:
joined = "\n".join(lines_out)
if max(1, len(joined) // 4) <= 2000:
break
lines_out.pop()
if not lines_out:
return "<prior_sessions/>"
return "<prior_sessions>\n" + "\n".join(lines_out) + "\n</prior_sessions>"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Extract prior-session loading into a shared helper.

This is now a second implementation of the same JSON-scan → XML-format → token-budget logic that already exists in graph/middleware/knowledge.py:61-165. Keeping both copies aligned will be error-prone, and any future change to truncation or formatting can silently make standalone memory behave differently from knowledge-backed memory.

🧰 Tools
🪛 Ruff (0.15.10)

[warning] 176-176: Too many branches (16 > 12)

(PLR0912)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@graph/middleware/memory.py` around lines 176 - 240, The duplicate
JSON-scan→XML-format→token-budget logic in MemoryMiddleware._load_prior_sessions
and KnowledgeMiddleware.load_memory should be extracted into a single helper
function (e.g., build_prior_sessions_xml) placed in a shared module (for example
graph.middleware.shared_memory or graph.middleware.utils). Implement the helper
to accept the directory path (MEMORY_PATH), max sessions/count limits, and token
budget parameters and return the XML string exactly matching the current outputs
("<prior_sessions/>" or "<prior_sessions>...</prior_sessions>"). Replace
MemoryMiddleware._load_prior_sessions and KnowledgeMiddleware.load_memory to
call this helper, preserving existing behavior (file filtering, JSON decoding
fallback, message/content truncation lengths, session ordering and dropping
oldest entries to meet the token budget). Update imports and remove duplicated
code blocks from both files.

Comment on lines +216 to +229
ts = s.get("timestamp", "unknown")
sid = s.get("session_id", "unknown")
lines = [f'<session id="{sid}" timestamp="{ts}">']
msgs = s.get("messages", []) or []
if msgs:
lines.append(" <messages>")
for m in msgs:
role = m.get("role", "unknown")
content = (m.get("content", "") or "")[:500]
lines.append(f" <{role}>{content}</{role}>")
lines.append(" </messages>")
final = (s.get("final_output") or "")[:300]
if final:
lines.append(f" <final_output>{final}</final_output>")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Escape persisted values before embedding them in <prior_sessions>.

These fields are inserted raw into XML. A saved message containing <, &, or tag-like text will produce malformed prompt context on the next run, and using role as an element name also lets malformed session files change the structure of the injected block.

Proposed fix
+        from xml.sax.saxutils import escape
         lines_out = []
         for s in summaries:
-            ts = s.get("timestamp", "unknown")
-            sid = s.get("session_id", "unknown")
+            ts = escape(str(s.get("timestamp", "unknown")))
+            sid = escape(str(s.get("session_id", "unknown")))
             lines = [f'<session id="{sid}" timestamp="{ts}">']
             msgs = s.get("messages", []) or []
             if msgs:
                 lines.append("  <messages>")
                 for m in msgs:
-                    role = m.get("role", "unknown")
-                    content = (m.get("content", "") or "")[:500]
-                    lines.append(f"    <{role}>{content}</{role}>")
+                    role = escape(str(m.get("role", "unknown")))
+                    content = escape(str((m.get("content", "") or "")[:500]))
+                    lines.append(f'    <message role="{role}">{content}</message>')
                 lines.append("  </messages>")
-            final = (s.get("final_output") or "")[:300]
+            final = escape(str((s.get("final_output") or "")[:300]))
             if final:
                 lines.append(f"  <final_output>{final}</final_output>")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@graph/middleware/memory.py` around lines 216 - 229, The code builds XML
fragments unsafely (ts, sid, role, content, final) which can break the XML or
alter structure; fix by XML-escaping all inserted values (timestamp, session_id,
message content, final_output) using a standard escaper (e.g.,
xml.sax.saxutils.escape or html.escape) and stop using the raw role as an
element name—emit a safe tag such as <message role="...">...</message> (escape
role when used as an attribute) instead of f"    <{role}>...</{role}>", and
update the code paths that create lines (the variables ts, sid, msgs loop, role,
content, final) to apply escaping consistently.

@mabry1985 mabry1985 merged commit 665fc46 into dev Apr 20, 2026
1 check passed
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