Frontmatter
| title | >- |
| author | tobiu |
| state | Merged |
| createdAt | Apr 19, 2026, 2:28 PM |
| updatedAt | Apr 19, 2026, 2:46 PM |
| closedAt | Apr 19, 2026, 2:46 PM |
| mergedAt | Apr 19, 2026, 2:46 PM |
| branches | dev ← agent/10097-askkb-synthesis-empty-on-type-filter |
| url | https://github.com/neomjs/neo/pull/10098 |
answer: "I don't have enough information... provided context documents are empty"

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. Preservespath.isAbsoluteshort-circuit so LearningSource's existing behavior is byte-identical. Lost 10 points because I did NOT fix the upstream loader inconsistency —ApiSourcewill keep emitting relative paths andLearningSourcewill 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. Thelogger.warnon 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 singleaskcall 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 bytype='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 aNeo.ai.mcp.server.knowledge-base.source.Base.mjscontract note thatmetadata.sourceis "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 anask_knowledge_basecall 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 whenanswercontains known-bad substrings like"empty"whilereferences.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.detectStaleCommentsCountsfrom #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/SourceParseremit absolute paths likeLearningSource, 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.mjsdocumenting the "absolute OR relative-to-neoRootDir" convention so future loaders don't reintroduce drift.- Mixed-shape spec case — single
askcall 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.

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
ApiSourceemit 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
relativeEntryPathto the already-computed absoluteentryPath.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.

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.isAbsoluteshort-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.
Summary
ask_knowledge_basewas returning placeholder"I don't have enough information"answers for everytype='src'andtype='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 whiletype='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 toneoRootDir(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.sourcedirectly tofs.pathExists/fs.readFilewithout resolving againstneoRootDir. Absolute paths happened to work on the zip author's machine. Relative paths resolved against the MCP server process's CWD and fell through toNo 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.sourceas a path relative toneoRootDir. The zip is now layout-agnostic.Consumer:
SearchService.askresolvesref.sourceagainst the consumer's ownaiConfig.neoRootDirat 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
SearchService.mjslogger.warnon fallback for future drift visibilityApiSource.mjsLearningSource.mjspath.relative(neoRootDir, filePath)toDocumentationParserDiscussionSource.mjssource: path.relative(neoRootDir, filePath)TicketSource.mjsPullRequestSource.mjsReleaseNotesSource.mjsTestSourcewas 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.isAbsoluteshort-circuit. Amanage_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:neoRootDir, prompt contains real file content.Acceptance Criteria
ask_knowledge_basewithtype='src'returns a real synthesis matchingtype='all'qualityask_knowledge_basewithtype='ai-infrastructure'returns a real synthesistype='src'ANDtype='ai-infrastructure'semantics via a fixture file referenced by relative pathtype='all'/type='guide'synthesisTest plan
npm run test-unit -- test/playwright/unit/ai/mcp/server/knowledge-base/services/SearchService.spec.mjs(3 passed)manage_knowledge_base action: 'sync'to rewrite the local collection with relative paths — produces the zip shipped with the next release.ask_knowledge_base({query:'...', type:'src'})now synthesizes real answers for framework-internal questions.Commit history
Three commits on this branch tell the story:
Resolves #10097