LearnNewsExamplesServices
Frontmatter
id10557
titleMemory Core: guard StorageRouter against non-iterable strategicNeighbors
stateClosed
labels
bugai
assigneesneo-opus-4-7
createdAtMay 1, 2026, 10:20 AM
updatedAtMay 1, 2026, 11:08 AM
githubUrlhttps://github.com/neomjs/neo/issues/10557
authorneo-opus-4-7
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 1, 2026, 11:08 AM

Memory Core: guard StorageRouter against non-iterable strategicNeighbors

Closedbugai
neo-opus-4-7
neo-opus-4-7 commented on May 1, 2026, 10:20 AM

Context

Empirical observation in session 1f30c9d8-4a36-4be0-98a5-bd5b89289227 (2026-05-01): get_context_frontier MCP tool errors with "Failed to retrieve context frontier. Message: strategicNeighbors is not iterable". Sibling bug surfaced alongside the chromadb tenant migration gap (#10556) but with an independent root cause; filing separately per AGENTS.md Gate 1 scoping discipline.

The Problem

ai/mcp/server/memory-core/managers/StorageRouter.mjs:94 has an optional-chain guard that's insufficient:

const topology = GraphService.getContextFrontier();
const graphWeights = new Map();

if (topology?.strategicNeighbors) {
    topology.strategicNeighbors.forEach(n => {
        graphWeights.set(n.id, n.weight);
        if (n.semanticVectorId) graphWeights.set(n.semanticVectorId, n.weight);
    });
}

The topology?.strategicNeighbors check passes when strategicNeighbors exists but is non-iterable (object, plain value, or unexpected shape). .forEach then throws.

The get_context_frontier MCP tool catches and surfaces this as the user-facing error. The two-pass query re-ranker in StorageRouter degrades silently to topologyMultiplier: 1.0 when this branch is skipped, but the error path turns it into a hard failure for direct get_context_frontier callers.

The Architectural Reality

Two callsites for GraphService.getContextFrontier():

  1. StorageRouter.injectQueryReRanker — uses topology to apply Pass-2 weighting on query results
  2. get_context_frontier MCP tool — surfaces the topology directly to agents (used at boot per AGENTS_STARTUP.md Step 6)

The shape contract for strategicNeighbors is implicit. Either:

  • GraphService.getContextFrontier() is supposed to return an array but returns wrong shape under some condition
  • Or the consumer's expectation (always-iterable) is wrong and the producer is allowed to return a non-array

Files in scope:

  • ai/mcp/server/memory-core/managers/StorageRouter.mjs:94 — defensive guard
  • ai/mcp/server/memory-core/services/GraphService.mjs — producer contract verification
  • Test file (canonical path): test/playwright/unit/ai/mcp/server/memory-core/services/GraphService.ContextFrontier.spec.mjs (extend or new)

The Fix

Two-line guard at the consumer site:

const neighbors = topology?.strategicNeighbors;
if (Array.isArray(neighbors)) {
    neighbors.forEach(n => {
        graphWeights.set(n.id, n.weight);
        if (n.semanticVectorId) graphWeights.set(n.semanticVectorId, n.weight);
    });
}

Plus a defensive contract assertion in GraphService.getContextFrontier() if the producer is the source of the bad shape — investigation during implementation will determine which side fixes it (or both).

Acceptance Criteria

  • StorageRouter.injectQueryReRanker uses Array.isArray() check instead of bare optional-chain truthy guard
  • GraphService.getContextFrontier() documented contract: strategicNeighbors is always an array (possibly empty), never undefined or non-iterable; if a regression in the producer surfaces this, fix at the producer site
  • get_context_frontier MCP tool returns successfully on this session's local data (currently errors)
  • Permanent Playwright unit test asserts: (a) re-ranker passes with strategicNeighbors: [], (b) re-ranker passes with non-array shape (regression coverage), (c) get_context_frontier end-to-end returns valid shape

Out of Scope

  • The actual semantic correctness of the strategic-neighbors weighting algorithm (Pass-2 re-ranking) — this ticket only fixes the iteration crash
  • Why getContextFrontier() returns wrong shape under some condition — investigation belongs in implementation, but the fix is the guard regardless

Avoided Traps

  • Add try/catch around the forEach — would silently degrade re-ranking; harder to diagnose. Type guard surfaces wrong shape as observable code path.
  • Throw on non-array shape at the consumer — propagates the same crash up; goal is graceful degradation.

Related

  • Surfaced alongside: #10556 (Memory Core ChromaDB legacy userId backfill)
  • Adjacent observability: healthcheck migration block (HealthService.mjs:290+) — separate concern

Origin Session ID: 1f30c9d8-4a36-4be0-98a5-bd5b89289227 Retrieval Hint: "StorageRouter strategicNeighbors not iterable get_context_frontier"

tobiu referenced in commit 1a0e08a - "feat(ai): unify TextEmbeddingService and guard StorageRouter (#10003, #10557) (#10558) on May 1, 2026, 11:08 AM
tobiu closed this issue on May 1, 2026, 11:08 AM