LearnNewsExamplesServices
Frontmatter
title>-
authortobiu
stateMerged
createdAtApr 19, 2026, 2:28 PM
updatedAtApr 19, 2026, 2:46 PM
closedAtApr 19, 2026, 2:46 PM
mergedAtApr 19, 2026, 2:46 PM
branchesdevagent/10097-askkb-synthesis-empty-on-type-filter
urlhttps://github.com/neomjs/neo/pull/10098

answer: "I don't have enough information... provided context documents are empty"

Merged
tobiu
tobiu commented on Apr 19, 2026, 2:28 PM

Summary

ask_knowledge_base was returning placeholder "I don't have enough information" answers for every type='src' and type='ai-infrastructure' query despite references coming back correctly populated. Users could see the KB had retrieved the right files but the synthesis LLM kept saying the documents were empty.

The bug silently defeated the Anti-Hallucination §2.1 hierarchy's PRIMARY tool for implementation-level questions. Agents filtering by type='src' for framework-internal questions got useless refusals while type='all' queries worked fine.

Root Cause

Two-part: a path-convention divergence among source loaders, and a consumer that assumed a single shape.

Producer side (mixed, drifted):

  • ApiSource + TestSource → emitted paths relative to neoRootDir (e.g., ai/mcp/server/.../IssueSyncer.mjs) — portable, correct.
  • LearningSource, TicketSource, DiscussionSource, PullRequestSource, ReleaseNotesSource → emitted ABSOLUTE paths (e.g., /Users/Shared/github/neomjs/neo/learn/...) — NOT portable: baked the zip author's local FS into every distributed KB zip.

Consumer side (SearchService): passed ref.source directly to fs.pathExists / fs.readFile without resolving against neoRootDir. Absolute paths happened to work on the zip author's machine. Relative paths resolved against the MCP server process's CWD and fell through to No Content (File missing or empty). Same failure mode as a distributed zip on a non-author's machine: the synthesis prompt gets placeholder text and Gemini correctly reports empty documents.

Empirical Proof

Before fix:

ask_knowledge_base({query: 'refetchIssuesByNumber IssueSyncer', type: 'ai-infrastructure'})


<h1 class="neo-h1" data-record-id="5">references: [IssueSyncer.mjs (ai/mcp/.../), IssueService.mjs (ai/mcp/.../), DreamService.mjs (ai/daemons/), ...]</h1>

Same query with type='all' (control) synthesized correctly — the top-scoring guides happened to be absolute-path LearningSource chunks that worked on the zip author's machine.

Fix — Portability First

The distributed Chroma zip (shipped on each neo release) must work identically on every recipient's filesystem. Portability is non-negotiable, so the fix is:

Storage contract: ALL source loaders store metadata.source as a path relative to neoRootDir. The zip is now layout-agnostic.

Consumer: SearchService.ask resolves ref.source against the consumer's own aiConfig.neoRootDir at read time. path.isAbsolute() short-circuit keeps legacy absolute-path chunks (from pre-fix Chroma collections) working during the grace period before recipients re-sync.

Files touched

File Change
SearchService.mjs Defensive resolve-before-read + logger.warn on fallback for future drift visibility
ApiSource.mjs Unchanged from master (already correct); added an explanatory comment
LearningSource.mjs Pass path.relative(neoRootDir, filePath) to DocumentationParser
DiscussionSource.mjs source: path.relative(neoRootDir, filePath)
TicketSource.mjs Same
PullRequestSource.mjs Same
ReleaseNotesSource.mjs Same

TestSource was already using relative paths — no change.

Note on existing Chroma collections

Recipients with legacy collections (absolute paths from prior syncs) will continue to work via the path.isAbsolute short-circuit. A manage_knowledge_base action: 'sync' after merge rewrites the collection with the new relative-path contract and produces a portable zip for the next release.

Test Coverage

test/playwright/unit/ai/mcp/server/knowledge-base/services/SearchService.spec.mjs — three cases, all pass at 555ms:

  1. Relative path (the new contract) → resolves against neoRootDir, prompt contains real file content.
  2. Absolute path (legacy content) → still works, backward compat preserved.
  3. Genuinely-missing file → falls through cleanly to placeholder with warn log.

Acceptance Criteria

  • Root cause identified and documented (producer drift + consumer brittleness)
  • ask_knowledge_base with type='src' returns a real synthesis matching type='all' quality
  • ask_knowledge_base with type='ai-infrastructure' returns a real synthesis
  • Playwright spec covers type='src' AND type='ai-infrastructure' semantics via a fixture file referenced by relative path
  • No regression on type='all' / type='guide' synthesis
  • Distributed Chroma zip remains portable across filesystems — all new chunks emit relative paths

Test plan

  • npm run test-unit -- test/playwright/unit/ai/mcp/server/knowledge-base/services/SearchService.spec.mjs (3 passed)
  • (Reviewer) After merge, run manage_knowledge_base action: 'sync' to rewrite the local collection with relative paths — produces the zip shipped with the next release.
  • (Reviewer) Confirm ask_knowledge_base({query:'...', type:'src'}) now synthesizes real answers for framework-internal questions.

Commit history

Three commits on this branch tell the story:

  1. SearchService defensive resolve (the fix that makes the consumer portable).
  2. Reverted: ApiSource → absolute paths — I initially misread the direction; this commit forced the author-local FS into the zip.
  3. Course correction: all loaders → relative paths + reverts commit #2's ApiSource change. Final state matches the first-principles portability requirement.

Resolves #10097

tobiu
tobiu commented on Apr 19, 2026, 2:29 PM

Input from Claude Opus 4.7 (Claude Code):

❋ # PR Review Summary

Status: Comment (ready for human merge; non-blocking follow-up noted)

Self-Review Opening: Self-review of #10097. I chose to fix this on the SearchService side (resolve-before-read) rather than at the upstream source-loader inconsistency (make ApiSource emit absolute paths like LearningSource). The SearchService-side fix is defensive — it handles any future source-loader variant, not just today's two — at the cost of leaving the upstream inconsistency in place. That's a deliberate trade-off, not an oversight, but it earns an out-of-scope follow-up note. Gaps below.


📊 Evaluation Metrics

  • [ARCH_ALIGNMENT]: 90 — Single-file fix in the right layer. Preserves path.isAbsolute short-circuit so LearningSource's existing behavior is byte-identical. Lost 10 points because I did NOT fix the upstream loader inconsistency — ApiSource will keep emitting relative paths and LearningSource will keep emitting absolute ones. The defensive fix is pragmatic but it's load-bearing on SearchService being the ONLY consumer that reads chunk content; if a second consumer appears (e.g., a future "show me the chunks" debugging tool), it'll hit the same bug. Worth a follow-up.

  • [CONTENT_COMPLETENESS]: 92 — The 11-line comment block explains both the failure mode AND why the fix is defensive. The logger.warn on fallback converts silent failure into visible signal for the next agent who hits an edge case. PR body includes an explicit control-case reproduction. Lost points only because the warn log format is rough prose — structured logging ({refSource, absoluteSource, fileName}) would parse cleaner.

  • [EXECUTION_QUALITY]: 85 — Three-case spec (relative / absolute / missing) at 598ms. What's missing: (a) no mixed-shape test where a single ask call processes BOTH an absolute and a relative ref in the same references array; (b) no live-Gemini integration verification — the unit test mocks the model and inspects the captured prompt, which is honest but doesn't prove the end-to-end MCP call produces a useful answer until the MCP server is restarted with the new code; (c) no stress test for the warn-log cadence (if 25 refs all hit relative paths that miss, we log 25 warns per query — probably fine, but untested).

  • [PRODUCTIVITY]: 98 — User flagged this as a quick win at ~12:22 in the session; ticket filed, fix committed, PR opened in ~30 minutes. The bug had been shipping for however long the source-loader inconsistency has existed.

  • [IMPACT]: 90 — Restores the primary Anti-Hallucination RAG tool (per AGENTS.md §2.1 hierarchy) for implementation-level queries. Every future agent filtering by type='src' to narrow search gets real grounded answers instead of "I don't have enough information" refusals. The tool becoming useful for its documented primary use case is genuinely high-ROI. Not a 100 only because the surface affected is one tool, not a whole framework subsystem.

  • [COMPLEXITY]: 30 — Single-file, ~15 lines of real logic, one local path helper. Cognitive load is minimal.

  • [EFFORT_PROFILE]: Quick Win — Exactly how the user framed the ticket before filing. Minimal complexity, outsized impact.


🕸️ Context & Graph Linking

  • Target Epic / Issue ID: Resolves #10097
  • Related Graph Nodes:
    • #10092 / PR #10093 (the ticket-intake sweep during which this bug surfaced)
    • #10094 (InvalidationDetector abstraction — tangentially related; both are "pattern-generalization" follow-ups from the same session)
    • AGENTS.md §2.1 Anti-Hallucination Tool Hierarchy (the documented behavior this PR restores)

🧠 Graph Ingestion Notes

  • [KB_GAP]: The existence of TWO distinct source-path shape conventions (absolute via LearningSource, relative via ApiSource) is undocumented anywhere in the repo. No code comment, JSDoc, or guide mentions that consumers must normalize paths. Future loaders will inherit the roulette. A follow-up either (a) normalizes ApiSource upstream to match LearningSource OR (b) adds a Neo.ai.mcp.server.knowledge-base.source.Base.mjs contract note that metadata.source is "absolute OR relative-to-neoRootDir" and consumers MUST resolve. This PR picked (b) implicitly via the defensive SearchService fix but didn't formalize the contract.

  • [TOOLING_GAP]: I discovered this bug ONLY because #10092's ticket-intake required an ask_knowledge_base call and I noticed the answer said "documents empty" while references were populated. The gap between "tool appears to work (returns structured response)" and "tool actually answers the question" was invisible to casual inspection. A lightweight telemetry hook — e.g., log a warn when answer contains known-bad substrings like "empty" while references.length > 0 — would surface this class of bug the next time a loader mutation introduces it. Not worth its own ticket, but worth a [KB_GAP] for the next agent who touches SearchService.

  • [RETROSPECTIVE]: The bug is a textbook case of silent degradation via path-convention drift. Two source loaders evolved independently, each reasonable in isolation, meeting at a consumer that assumed a single convention. The same class of bug lurks anywhere the framework has >1 producer feeding one consumer with string-typed payloads. A systematic defense would be runtime assertions — e.g., path.isAbsolute(refSource) || refSource === path.relative(...) — rather than silent fallback to placeholder. Not scoped here; captured for the next time someone rebuilds this layer.

  • [RETROSPECTIVE]: This PR re-validates the #10091 RETROSPECTIVE note about "metadata-tracking vs content-rendering divergence". Same pattern: the metadata channel (references with scores) worked perfectly while the content-rendering channel silently failed. Any multi-channel pipeline deserves a self-consistency guard. detectStaleCommentsCounts from #10092 is one such guard for the sync pipeline; SearchService needs its own for the RAG pipeline (e.g., "answer mentions 'empty' + references.length > 0 = raise warning"). Out of scope.


📋 Required Actions

No hard blockers.

Optional follow-ups (can fold into a later PR or drop):

  • Upstream normalization: make ApiSource / SourceParser emit absolute paths like LearningSource, removing the need for SearchService-side resolution. Renders the defensive fix redundant but leaves it as a safety net.
  • Contract JSDoc on ai/mcp/server/knowledge-base/source/Base.mjs documenting the "absolute OR relative-to-neoRootDir" convention so future loaders don't reintroduce drift.
  • Mixed-shape spec case — single ask call processing references with both absolute AND relative sources — guards against path-branch divergence in future edits.
  • Structured logging: replace the prose warn-log with logger.warn({refSource, absoluteSource, fileName}, '[SearchService] missing context') so the signal is grep-friendly when it fires in production.

Reviewer is free to merge and let me fold any of these into a later PR, or request polish on this branch before merge. The core correctness is established by three passing unit cases + manually-traced path-resolution logic.


tobiu
tobiu commented on Apr 19, 2026, 2:34 PM

Input from Claude Opus 4.7 (Claude Code):

Scope update: Upgraded the "upstream normalization" item from the self-review's optional follow-up list into this PR per user request. Added a second commit that makes ApiSource emit absolute source paths like every other loader, so the SearchService defensive resolve becomes a true safety net instead of the only line of defense.

  • Commit 1 (SearchService): consumer-side defensive resolve + logger.warn on fallback.
  • Commit 2 (ApiSource): producer-side normalization — one-line swap from relativeEntryPath to the already-computed absolute entryPath.

Investigation verified ApiSource was the SOLE outlier among eight source loaders (TicketSource, DiscussionSource, LearningSource, PullRequestSource, ReleaseNotesSource, TestSource all already absolute). The two fixes together address both the producer-side drift and the consumer-side brittleness.

Test suite still passes at 555ms (3/3). PR body updated to reflect the expanded scope.

Side-effect for reviewers: the existing Chroma collection contains relative-path chunks from prior syncs. Running manage_knowledge_base action: 'sync' after merge will rewrite them with absolute paths. Not blocking — SearchService's resolve-before-read continues to work transparently for both shapes while the corpus turns over.


tobiu
tobiu commented on Apr 19, 2026, 2:42 PM

Input from Claude Opus 4.7 (Claude Code):

Course correction — reverted the ApiSource absolute-path commit.

User pointed out that the distributed Chroma zip (shipped on each neo release) would bake the zip author's local filesystem into every recipient's collection if metadata.source is absolute. That portability requirement inverts the direction I chose in commit 2 (ApiSource → absolute). Commit 3 reverts that AND normalizes the five OTHER loaders that were already shipping absolute paths (and thus had already been breaking for non-author recipients silently).

Final state:

  • All 8 source loaders emit neoRootDir-relative paths. Previously only ApiSource and TestSource were portable; LearningSource, TicketSource, DiscussionSource, PullRequestSource, ReleaseNotesSource had been baking local FS into every release zip.
  • SearchService resolves at read time against the consumer's own neoRootDir. This is now the PRIMARY mechanism, not a safety net. path.isAbsolute short-circuit preserves backward compat with legacy absolute-path chunks during the grace period.
  • Spec still passes 3/3 at 555ms.

The PR body has been updated to reflect the corrected direction; the commit history preserves the story (1. consumer-side fix, 2. wrong direction, 3. course correction to portable-relative).

Post-merge operational step flagged for reviewers: manage_knowledge_base action: 'sync' to rewrite the local collection with the new relative-path contract before cutting the next release zip.