Skip to content

CLA-87: Fix Neo4j info field deserialization (JSON string → dict) + gateway round-trip test - #2

Open
0xjackyang wants to merge 7 commits into
mainfrom
cla-87-memos-recall-fix
Open

CLA-87: Fix Neo4j info field deserialization (JSON string → dict) + gateway round-trip test#2
0xjackyang wants to merge 7 commits into
mainfrom
cla-87-memos-recall-fix

Conversation

@0xjackyang

Copy link
Copy Markdown
Owner

Problem

CLA-81's _sanitize_neo4j_metadata() JSON-stringifies the info dict before Neo4j write. On read, Pydantic rejected strings where it expected a dict, causing 290 silent ValidationErrors and dropping every memory with a non-null info field. 1,629 memories written, zero recallable.

Fix

Added _parse_info field_validator on TextualMemoryMetadata.info (mode='before') that auto-parses JSON strings back to dicts on the read path.

Key Test: Gateway Plugin Round-Trip

Per Jack's constraint — test with the exact gateway plugin payload (including info field), not a clean curl. A clean curl without info passes even if the fix is broken.

test_gateway_plugin_payload_roundtrip:

  1. POST /product/add with exact plugin format: info={sessionKey: ..., agentId: main}
  2. Search for the written memory
  3. Assert info comes back as a dict with agentId='main'not a string

Acceptance Criteria

  • AC1: field_validator added, 8 unit tests covering all cases
  • AC2: TextualMemoryItem round-trips cleanly with stringified info
  • AC3: Live recall — MANGO-TANGO, PAPAYA-SUNSET, CRIMSON-FALCON all return results
  • AC4: Gateway plugin recall returning non-empty results post-deploy
  • AC5: 0 validation error for TextualMemoryItem post-restart (was 290)
  • AC-gateway: Full round-trip with plugin payload — info survives as dict

Test Results

14 passed in 74.14s
  - 10 unit tests (info parsing: 8 cases + round-trip: 2)
  - 3 live recall tests (MANGO-TANGO, PAPAYA-SUNSET, CRIMSON-FALCON) ✅
  - 1 gateway payload round-trip test ✅ (info.agentId='main' as dict)

Claw added 7 commits February 26, 2026 21:18
Bug fixes in neo4j_community.py:
1. _parse_node: sources[idx][0] == "}" → sources[idx][-1] == "}"
   (was always False, preventing json.loads deserialization of sources)
2. _parse_nodes: same fix in batch retrieval path
3. add_node: removed double-serialization of sources
   (_prepare_node_metadata already serializes sources; doing it twice
   produces nested JSON strings that can never be decoded correctly)

Also adds .env config (not committed — gitignored):
- ENABLE_CHAT_API=true (fixes 503 on /product/chat/* endpoints)
- CHAT_MODEL_LIST: OpenRouter claude-sonnet-4-6 wired as chat LLM

Integration test (tests/test_cla81_integration.py):
- TestCrossSessionRecall: write in session_a → recall in session_b (7 tests)
- TestChatHandler: chat/complete not 503, responds
- TestNeo4jSourcesDeserialization: sources round-trip as list not string
- TestServiceHealth: search + scheduler reachable

7/7 tests passing
AC1: Chat handler 503 — already resolved; ENABLE_CHAT_API=true and
     CHAT_MODEL_LIST properly configured in .env. Verified: chat/complete
     returns memories without explicit model param.

AC2: Fix Neo4j CypherTypeError: Map{} (two root causes)

Bug 1 — neo4j.py _prepare_node_metadata:
  'metadata["sources"]' -> KeyError when sources=None (excluded by
  model_dump exclude_none=True), silently dropping ALL nodes.
  Fix: use metadata.get('sources') and guard isinstance(item, dict)
  to avoid double-serializing already-converted strings.

Bug 2 — manager.py _add_memories_batch:
  model_dump() produces nested dicts for fields like 'history'
  (list[ArchivedTextualMemory]) which Neo4j cannot store as node
  properties (Map{} error).
  Fix: new _sanitize_neo4j_metadata() helper — JSON-stringifies any
  dict value or list-of-dicts value. Also ensures 'sources' key exists
  so downstream _prepare_node_metadata never KeyErrors.
  Applied to both working_metadata and metadata_dict paths.

Verified:
  - /product/add -> memory_id 9a3f4a24 stored in both Qdrant AND Neo4j
  - /product/search returns MANGO-TANGO as top result
  - /product/chat/complete recalls passphrase correctly
launchd PATH does not include /usr/sbin, so 'lsof' silently fails
and the pre-start port kill never runs. Result: [Errno 48] address
already in use on every LaunchAgent restart loop.

Fix: use full path /usr/sbin/lsof in all lsof calls.
CLA-81 added _sanitize_neo4j_metadata() which JSON-stringifies dicts
(including info) before Neo4j write. On the read path, Pydantic expects
metadata.info as a dict but receives a str → validation fails →
memory silently dropped from search results.

Fix: add field_validator('info', mode='before') to TextualMemoryMetadata
that parses JSON strings back to dicts. Handles both dict (cloud/new data)
and str (Neo4j-sanitized existing data) transparently.

Verified:
- AC1: _parse_info validator in TextualMemoryMetadata
- AC2: existing memories with stringified info recalled (info type: dict)
- AC3: MANGO-TANGO recalled at 0.771 relevance (10 results)
- AC4: zero memos-cloud errors in gateway.err.log
- AC5: zero TextualMemoryItem validation errors post-restart
Add _parse_info field_validator on TextualMemoryMetadata.info to
auto-parse JSON-stringified dicts back to dicts on the read path.

CLA-81's _sanitize_neo4j_metadata() JSON-stringifies the info dict
before writing to Neo4j. On read, Pydantic rejected strings where
it expected a dict, causing 290 silent ValidationErrors and dropping
every memory with a non-null info field.

Fixes:
- TextualMemoryMetadata._parse_info: JSON str → dict (mode='before')
- Handles: empty {}, invalid JSON, non-dict JSON (returns {})
- Inherited by TreeNodeTextualMemoryMetadata automatically

Verified:
- 10/10 unit tests + 3/3 live recall tests (MANGO-TANGO, PAPAYA-SUNSET,
  CRIMSON-FALCON) all passing
- 0 post-restart validation errors in memos-server.log (was 290)
- PAPAYA-SUNSET memory (info={'agentId':'main'}) now surfaces correctly
Per Jack's constraint: test must use the exact gateway plugin payload
including info={sessionKey, agentId} — not a clean curl without info.

Changes:
- Add _flatten_memories() helper (refactor from inline)
- Add test_gateway_plugin_payload_roundtrip:
  * Posts /product/add with exact plugin format (info as dict, async_mode=sync)
  * Searches after add completes (sync = no poll loop needed)
  * Asserts info comes back as a dict with agentId='main'
  * Proves field_validator works on the FULL Neo4j write→read round-trip

Why this matters: a clean curl without info passes even if the fix is
broken — info=null nodes never hit the validation code path.
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @jacksclaw, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical bug in the MemOS system where the info field, when serialized as a JSON string for Neo4j storage, was not being properly deserialized upon retrieval. This resulted in data loss and validation errors. The fix involves adding a field_validator to ensure proper deserialization. Additionally, the PR includes new health monitoring scripts to improve the reliability and observability of the MemOS server.

Highlights

  • Neo4j Info Field Deserialization: Fixes an issue where the info field in Neo4j was being stored as a JSON string but not deserialized back into a dictionary upon retrieval, leading to validation errors and data loss.
  • Gateway Plugin Round-Trip Test: Adds a test to ensure that data written via the gateway plugin, including the info field, is correctly stored and retrieved as a dictionary.
  • Field Validator Implementation: Implements a field_validator on TextualMemoryMetadata.info to automatically parse JSON strings back into dictionaries on the read path.
  • Health Monitoring Scripts: Introduces new scripts for health probing and monitoring of the MemOS server, including checks for HTTP liveness, write/recall functionality, and log scanning for errors.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • scripts/health-probe.sh
    • Added a new script for basic MemOS server health checking via HTTP.
  • scripts/memos-healthcheck.sh
    • Added a comprehensive health monitoring script for MemOS, including checks for HTTP liveness, write/recall functionality, and log scanning.
  • src/memos/graph_dbs/neo4j.py
    • Modified to use .get() to safely access metadata and prevent KeyError exceptions.
    • Added a check to avoid double-serializing strings in the sources field.
  • src/memos/graph_dbs/neo4j_community.py
    • Removed redundant serialization of the sources field, as it is already handled by _prepare_node_metadata.
    • Added deserialization logic to restore JSON-serialized source dictionaries back to objects.
  • src/memos/memories/textual/item.py
    • Implemented a field_validator called _parse_info to automatically parse JSON strings back into dictionaries for the info field.
  • src/memos/memories/textual/tree_text_memory/organize/manager.py
    • Added _sanitize_neo4j_metadata function to JSON-stringify dictionary values in metadata before writing to Neo4j.
    • Ensured the sources key always exists to prevent KeyError exceptions.
  • start-memos.sh
    • Added a script to start the MemOS server with a specific Python 3.13 virtual environment.
    • Includes logic to kill existing server processes before starting a new instance.
  • start_memos.sh
    • Added a script to start the MemOS server with pre-start port cleanup to prevent address already in use errors.
  • tests/test_cla81_integration.py
    • Added a new integration test suite to verify end-to-end functionality, including cross-session recall and chat endpoint availability.
  • tests/test_cla87_recall_fix.py
    • Added a new test suite specifically for verifying the Neo4j info field deserialization fix.
Activity
  • The pull request includes a fix for a bug that caused data loss due to incorrect deserialization of the info field in Neo4j.
  • A new field_validator was added to TextualMemoryMetadata.info to automatically parse JSON strings back into dictionaries.
  • Comprehensive tests were added to verify the fix, including unit tests, live recall tests, and a gateway payload round-trip test.
  • New health monitoring scripts were introduced to improve the reliability and observability of the MemOS server.
  • The changes address several acceptance criteria, including ensuring clean round-trips with stringified info and resolving validation errors.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@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 addresses a critical deserialization issue with the info field in Neo4j, preventing data loss on memory recall. The fix, which involves adding a Pydantic field_validator to handle JSON string parsing, is well-implemented. The accompanying unit and integration tests, especially the gateway round-trip test, are thorough and effectively validate the fix. My review focuses on some improvements for the newly added shell scripts to enhance their security and portability.

Comment thread start_memos.sh
set -euo pipefail

PORT=8001
VENV_UVICORN="/Users/jack_family_office/.openclaw/workspace/projects/MemOS/.venv/bin/uvicorn"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

This line contains a hardcoded absolute path, which makes the script non-portable and will cause it to fail on any other machine or for any other user. The path to the virtual environment should be determined dynamically relative to the script's location.

Suggested change
VENV_UVICORN="/Users/jack_family_office/.openclaw/workspace/projects/MemOS/.venv/bin/uvicorn"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VENV_UVICORN="$SCRIPT_DIR/.venv/bin/uvicorn"

Comment thread scripts/health-probe.sh
Comment on lines +6 to +22
QUIET="${1:-}"
ENDPOINT="http://localhost:8001/product/search"
PAYLOAD='{"query":"healthcheck","limit":1,"user_id":"openclaw-user"}'
TIMEOUT=5

response=$(curl -s -m "$TIMEOUT" -o /tmp/memos-health.json -w "%{http_code}" \
-X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" 2>/dev/null)

http_code="$response"

if [ "$http_code" = "200" ]; then
code=$(python3 -c "import json,sys; d=json.load(open('/tmp/memos-health.json')); print(d.get('code',''))" 2>/dev/null)
if [ "$code" = "200" ]; then
[ -z "$QUIET" ] && echo "✅ MemOS healthy (HTTP 200, code=200, port 8001)"
exit 0

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

Using a hardcoded file path in /tmp is insecure and can lead to race conditions. It's better to use mktemp to create a secure, unique temporary file. Additionally, a trap should be used to ensure the temporary file is cleaned up on script exit, even in case of errors.

Suggested change
QUIET="${1:-}"
ENDPOINT="http://localhost:8001/product/search"
PAYLOAD='{"query":"healthcheck","limit":1,"user_id":"openclaw-user"}'
TIMEOUT=5
response=$(curl -s -m "$TIMEOUT" -o /tmp/memos-health.json -w "%{http_code}" \
-X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" 2>/dev/null)
http_code="$response"
if [ "$http_code" = "200" ]; then
code=$(python3 -c "import json,sys; d=json.load(open('/tmp/memos-health.json')); print(d.get('code',''))" 2>/dev/null)
if [ "$code" = "200" ]; then
[ -z "$QUIET" ] && echo "✅ MemOS healthy (HTTP 200, code=200, port 8001)"
exit 0
QUIET="${1:-}"
ENDPOINT="http://localhost:8001/product/search"
PAYLOAD='{"query":"healthcheck","limit":1,"user_id":"openclaw-user"}'
TIMEOUT=5
TMP_FILE=$(mktemp)
trap 'rm -f "$TMP_FILE"' EXIT
response=$(curl -s -m "$TIMEOUT" -o "$TMP_FILE" -w "%{http_code}" \
-X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" 2>/dev/null)
http_code="$response"
if [ "$http_code" = "200" ]; then
code=$(python3 -c "import json,sys; d=json.load(open('$TMP_FILE')); print(d.get('code',''))" 2>/dev/null)
if [ "$code" = "200" ]; then
[ -z "$QUIET" ] && echo "✅ MemOS healthy (HTTP 200, code=200, port 8001)"
exit 0

Comment on lines +25 to +27
TMP_SEARCH="/tmp/memos-hc-search.json"
TMP_WRITE="/tmp/memos-hc-write.json"
TMP_RECALL="/tmp/memos-hc-recall.json"

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

Using hardcoded file paths in /tmp is insecure and can lead to race conditions. It's better to use mktemp to create secure, unique temporary files. Please also add a trap command to ensure these temporary files are cleaned up when the script exits, even on error.

Suggested change
TMP_SEARCH="/tmp/memos-hc-search.json"
TMP_WRITE="/tmp/memos-hc-write.json"
TMP_RECALL="/tmp/memos-hc-recall.json"
TMP_SEARCH=$(mktemp)
TMP_WRITE=$(mktemp)
TMP_RECALL=$(mktemp)
trap 'rm -f "$TMP_SEARCH" "$TMP_WRITE" "$TMP_RECALL"' EXIT

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