diff --git a/.trajectories/completed/2026-04/traj_8senjkr25v60.json b/.trajectories/completed/2026-04/traj_8senjkr25v60.json new file mode 100644 index 0000000..cb6c350 --- /dev/null +++ b/.trajectories/completed/2026-04/traj_8senjkr25v60.json @@ -0,0 +1,65 @@ +{ + "id": "traj_8senjkr25v60", + "version": 1, + "task": { + "title": "Add metadata-bearing librarian VFS enumeration" + }, + "status": "completed", + "startedAt": "2026-04-25T22:31:06.254Z", + "completedAt": "2026-04-25T22:35:56.838Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-04-25T22:31:49.123Z" + } + ], + "chapters": [ + { + "id": "chap_e1bno33ile41", + "title": "Work", + "agentName": "default", + "startedAt": "2026-04-25T22:31:49.123Z", + "endedAt": "2026-04-25T22:35:56.838Z", + "events": [ + { + "ts": 1777156309124, + "type": "decision", + "content": "Use apiFallback source for post-filter-empty retry without VFS errors: Use apiFallback source for post-filter-empty retry without VFS errors", + "raw": { + "question": "Use apiFallback source for post-filter-empty retry without VFS errors", + "chosen": "Use apiFallback source for post-filter-empty retry without VFS errors", + "alternatives": [], + "reasoning": "Required tests specify apiFallback when fallback replaces non-matching VFS entries; mixed is reserved for fallback after captured VFS errors." + }, + "significance": "high" + }, + { + "ts": 1777156556756, + "type": "reflection", + "content": "Engine implementation and validation are complete; tests and TypeScript pass, remaining work is commit and PR.", + "raw": { + "confidence": 0.9 + }, + "significance": "high", + "tags": [ + "confidence:0.9" + ] + } + ] + } + ], + "retrospective": { + "summary": "Added optional metadata-bearing librarian VFS enumeration, source diagnostics, and post-filter fallback retry with shared engine coverage.", + "approach": "Standard approach", + "confidence": 0.9 + }, + "commits": [], + "filesChanged": [], + "projectId": "/Users/khaliqgant/Projects/AgentWorkforce/agent-assistant-vfs-query", + "tags": [], + "_trace": { + "startRef": "abf8d621217c4db16972cddf54a1b7a959a43fe8", + "endRef": "abf8d621217c4db16972cddf54a1b7a959a43fe8" + } +} \ No newline at end of file diff --git a/.trajectories/completed/2026-04/traj_8senjkr25v60.md b/.trajectories/completed/2026-04/traj_8senjkr25v60.md new file mode 100644 index 0000000..9caf0bc --- /dev/null +++ b/.trajectories/completed/2026-04/traj_8senjkr25v60.md @@ -0,0 +1,32 @@ +# Trajectory: Add metadata-bearing librarian VFS enumeration + +> **Status:** ✅ Completed +> **Confidence:** 90% +> **Started:** April 26, 2026 at 12:31 AM +> **Completed:** April 26, 2026 at 12:35 AM + +--- + +## Summary + +Added optional metadata-bearing librarian VFS enumeration, source diagnostics, and post-filter fallback retry with shared engine coverage. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Use apiFallback source for post-filter-empty retry without VFS errors +- **Chose:** Use apiFallback source for post-filter-empty retry without VFS errors +- **Reasoning:** Required tests specify apiFallback when fallback replaces non-matching VFS entries; mixed is reserved for fallback after captured VFS errors. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Use apiFallback source for post-filter-empty retry without VFS errors: Use apiFallback source for post-filter-empty retry without VFS errors +- Engine implementation and validation are complete; tests and TypeScript pass, remaining work is commit and PR. diff --git a/.trajectories/index.json b/.trajectories/index.json index c3ef19b..d8d44b3 100644 --- a/.trajectories/index.json +++ b/.trajectories/index.json @@ -1,6 +1,6 @@ { "version": 1, - "lastUpdated": "2026-04-23T12:31:15.527Z", + "lastUpdated": "2026-04-25T22:35:56.918Z", "trajectories": { "traj_34202idd4b5w": { "title": "aa-proactive-signals-03-slack-presence-workflow", @@ -309,6 +309,13 @@ "startedAt": "2026-04-12T21:05:08.379Z", "completedAt": "2026-04-12T21:13:42.155Z", "path": "/Users/khaliqgant/Projects/AgentWorkforce/agent-assistant-proactive-signals/.trajectories/completed/traj_1776027908379_f3c289da.json" + }, + "traj_8senjkr25v60": { + "title": "Add metadata-bearing librarian VFS enumeration", + "status": "completed", + "startedAt": "2026-04-25T22:31:06.254Z", + "completedAt": "2026-04-25T22:35:56.838Z", + "path": "/Users/khaliqgant/Projects/AgentWorkforce/agent-assistant-vfs-query/.trajectories/completed/2026-04/traj_8senjkr25v60.json" } } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8695fa1..43ca559 100644 --- a/package-lock.json +++ b/package-lock.json @@ -251,7 +251,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -3476,7 +3475,7 @@ }, "packages/cloudflare-runtime": { "name": "@agent-assistant/cloudflare-runtime", - "version": "0.1.2", + "version": "0.1.4", "dependencies": { "@agent-assistant/continuation": "^0.3.4", "@agent-assistant/surfaces": "^0.3.0", @@ -3491,7 +3490,7 @@ }, "packages/connectivity": { "name": "@agent-assistant/connectivity", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "nanoid": "^5.1.6" @@ -3503,7 +3502,7 @@ }, "packages/continuation": { "name": "@agent-assistant/continuation", - "version": "0.3.4", + "version": "0.3.6", "dependencies": { "@agent-assistant/harness": "^0.4.0 || ^0.6.0" }, @@ -3514,7 +3513,7 @@ }, "packages/coordination": { "name": "@agent-assistant/coordination", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "@agent-assistant/connectivity": "^0.2.6", @@ -3537,7 +3536,7 @@ }, "packages/core": { "name": "@agent-assistant/core", - "version": "0.3.2", + "version": "0.3.5", "devDependencies": { "@agent-assistant/traits": "file:../traits", "typescript": "^5.9.3", @@ -3570,7 +3569,7 @@ }, "packages/harness": { "name": "@agent-assistant/harness", - "version": "0.6.4", + "version": "0.6.6", "dependencies": { "@agent-assistant/connectivity": "^0.2.6", "@agent-assistant/coordination": "^0.2.6", @@ -3627,7 +3626,7 @@ }, "packages/inbox": { "name": "@agent-assistant/inbox", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "@agent-assistant/turn-context": ">=0.1.0" @@ -4415,7 +4414,7 @@ }, "packages/memory": { "name": "@agent-assistant/memory", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "@agent-relay/memory": "^4.0.23" @@ -4427,7 +4426,7 @@ }, "packages/policy": { "name": "@agent-assistant/policy", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "nanoid": "^5.0.0" @@ -4439,7 +4438,7 @@ }, "packages/proactive": { "name": "@agent-assistant/proactive", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "@agent-assistant/surfaces": ">=0.2.19", @@ -4461,7 +4460,7 @@ }, "packages/sdk": { "name": "@agent-assistant/sdk", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "dependencies": { "@agent-assistant/core": ">=0.1.0", @@ -4478,7 +4477,7 @@ }, "packages/sessions": { "name": "@agent-assistant/sessions", - "version": "0.3.2", + "version": "0.3.5", "devDependencies": { "typescript": "^5.9.3", "vitest": "^3.2.4" @@ -4486,7 +4485,7 @@ }, "packages/specialists": { "name": "@agent-assistant/specialists", - "version": "0.4.2", + "version": "0.4.5", "dependencies": { "@agent-assistant/coordination": "^0.2.6", "@agent-assistant/vfs": "^0.2.6", @@ -4525,7 +4524,7 @@ }, "packages/surfaces": { "name": "@agent-assistant/surfaces", - "version": "0.3.2", + "version": "0.3.5", "devDependencies": { "typescript": "^5.9.3", "vitest": "^3.2.4" @@ -4533,7 +4532,7 @@ }, "packages/telemetry": { "name": "@agent-assistant/telemetry", - "version": "0.2.4", + "version": "0.2.6", "dependencies": { "@agent-assistant/harness": "^0.4.0 || ^0.6.0" }, @@ -4545,7 +4544,7 @@ }, "packages/traits": { "name": "@agent-assistant/traits", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "devDependencies": { "typescript": "^5.9.3", @@ -4554,7 +4553,7 @@ }, "packages/turn-context": { "name": "@agent-assistant/turn-context", - "version": "0.3.4", + "version": "0.3.6", "license": "MIT", "dependencies": { "@agent-assistant/harness": "^0.4.0 || ^0.6.0", @@ -4583,7 +4582,7 @@ }, "packages/vfs": { "name": "@agent-assistant/vfs", - "version": "0.3.2", + "version": "0.3.5", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", @@ -4610,7 +4609,7 @@ }, "packages/webhook-runtime": { "name": "@agent-assistant/webhook-runtime", - "version": "0.2.5", + "version": "0.2.7", "dependencies": { "@agent-assistant/specialists": "^0.3.5", "@agent-assistant/surfaces": "^0.3.0", diff --git a/packages/specialists/src/shared/librarian-engine.test.ts b/packages/specialists/src/shared/librarian-engine.test.ts new file mode 100644 index 0000000..34840b0 --- /dev/null +++ b/packages/specialists/src/shared/librarian-engine.test.ts @@ -0,0 +1,256 @@ +import type { VfsEntry } from '@agent-assistant/vfs'; +import { describe, expect, it, vi } from 'vitest'; + +import { + createLibrarian, + type LibrarianAdapter, + type LibrarianVfs, +} from './librarian-engine.js'; + +type TestEntityType = 'pr' | 'issue'; + +const testAdapter: LibrarianAdapter = { + capability: 'test.enumerate', + entityTypes: ['pr', 'issue'], + filterKeys: ['state', 'type'], + listRoots(types) { + return types.length > 0 ? types.map((type) => `/root/${type}`) : ['/root']; + }, + inferFilters(_text, filters) { + return filters; + }, + valuesForFilter(entry, key) { + const properties = entry.properties ?? {}; + if (key === 'type') { + const type = inferEntityType(entry); + return [properties.type, type === 'unknown' ? undefined : type].filter(isString); + } + + return [properties.state].filter(isString); + }, + inferEntityType, + toEvidence(entry, type) { + const properties = entry.properties ?? {}; + return { + id: properties.id ?? entry.path, + kind: 'test_hit', + content: { + path: entry.path, + state: properties.state, + type, + }, + }; + }, + searchProvider: 'test', + searchTerm(type) { + return type; + }, +}; + +function inferEntityType(entry: VfsEntry): TestEntityType | 'unknown' { + const propertyType = entry.properties?.type; + if (propertyType === 'pr' || propertyType === 'issue') return propertyType; + if (entry.path.includes('/pr/')) return 'pr'; + if (entry.path.includes('/issue/')) return 'issue'; + return 'unknown'; +} + +function entry(id: string, properties: Record = {}): VfsEntry { + const type = properties.type ?? 'pr'; + return { + path: `/root/${type}/${id}`, + type: 'file', + provider: 'test', + updatedAt: '2026-04-17T12:00:00.000Z', + properties: { + id, + ...properties, + }, + }; +} + +function evidenceIds(result: Awaited['handler']['execute']>>) { + return result.evidence.map((item) => item.id); +} + +function createTestLibrarian(vfs: LibrarianVfs, apiFallback?: Parameters>[1]['apiFallback']) { + return createLibrarian(testAdapter, { + vfs, + apiFallback, + }); +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +describe('createLibrarian VFS enumeration flow', () => { + it('uses metadata-bearing enumerate for filtered requests when available', async () => { + const list = vi.fn(async () => [entry('list-hit', { state: 'open', type: 'pr' })]); + const enumerate = vi.fn(async () => [entry('enumerate-hit', { state: 'open', type: 'pr' })]); + const librarian = createTestLibrarian({ list, enumerate }); + + const result = await librarian.handler.execute('type:pr state:open'); + + expect(enumerate).toHaveBeenCalledWith({ + roots: ['/root/pr'], + filters: { type: ['pr'], state: ['open'] }, + limit: 1_000, + }); + expect(list).not.toHaveBeenCalled(); + expect(result.metadata.source).toBe('vfs-enumerate'); + expect(evidenceIds(result)).toEqual(['enumerate-hit']); + }); + + it('does not use enumerate for unfiltered requests', async () => { + const enumerate = vi.fn(async () => [entry('enumerate-hit')]); + const search = vi.fn(async () => [entry('search-hit')]); + const librarian = createTestLibrarian({ enumerate, search }); + + const result = await librarian.handler.execute('plain text'); + + expect(enumerate).not.toHaveBeenCalled(); + expect(search).toHaveBeenCalledWith('plain text', { provider: 'test', limit: 1_000 }); + expect(result.metadata.source).toBe('vfs-search'); + expect(evidenceIds(result)).toEqual(['search-hit']); + }); + + it('falls back to list when enumerate is unavailable', async () => { + const list = vi.fn(async () => [entry('list-hit', { state: 'open', type: 'pr' })]); + const librarian = createTestLibrarian({ list }); + + const result = await librarian.handler.execute('type:pr state:open'); + + expect(list).toHaveBeenCalledWith('/root/pr', { depth: 5, limit: 1_000 }); + expect(result.metadata.source).toBe('vfs-list'); + expect(evidenceIds(result)).toEqual(['list-hit']); + }); + + it('retries apiFallback when filtered VFS entries are all removed by post-filtering', async () => { + const closedEntries = Array.from({ length: 100 }, (_value, index) => + entry(String(index), { state: 'closed', type: 'pr' }), + ); + const list = vi.fn(async () => closedEntries); + const apiFallback = vi.fn(async () => + Array.from({ length: 5 }, (_value, index) => + entry(`fallback-${index}`, { state: 'open', type: 'pr' }), + ), + ); + const librarian = createTestLibrarian({ list }, apiFallback); + + const result = await librarian.handler.execute('type:pr state:open'); + + expect(apiFallback).toHaveBeenCalledOnce(); + expect(result.metadata.source).toBe('apiFallback'); + expect(evidenceIds(result)).toEqual([ + 'fallback-0', + 'fallback-1', + 'fallback-2', + 'fallback-3', + 'fallback-4', + ]); + }); + + it('does not retry apiFallback for unfiltered VFS results', async () => { + const search = vi.fn(async () => [entry('unknown-hit', {})]); + const apiFallback = vi.fn(async () => [entry('fallback-hit')]); + const librarian = createTestLibrarian({ search }, apiFallback); + + const result = await librarian.handler.execute('plain text'); + + expect(apiFallback).not.toHaveBeenCalled(); + expect(result.metadata.source).toBe('vfs-search'); + expect(evidenceIds(result)).toEqual(['unknown-hit']); + }); + + it('still uses apiFallback when the VFS path returns no entries', async () => { + const list = vi.fn(async () => []); + const apiFallback = vi.fn(async () => [entry('fallback-hit', { state: 'open', type: 'pr' })]); + const librarian = createTestLibrarian({ list }, apiFallback); + + const result = await librarian.handler.execute('type:pr state:open'); + + expect(apiFallback).toHaveBeenCalledOnce(); + expect(result.metadata.source).toBe('apiFallback'); + expect(evidenceIds(result)).toEqual(['fallback-hit']); + }); + + it('captures enumerate errors and continues to apiFallback', async () => { + const enumerate = vi.fn(async () => { + throw new Error('indexed enumeration unavailable'); + }); + const apiFallback = vi.fn(async () => [entry('fallback-hit', { state: 'open', type: 'pr' })]); + const librarian = createTestLibrarian({ enumerate }, apiFallback); + + const result = await librarian.handler.execute('type:pr state:open'); + + expect(apiFallback).toHaveBeenCalledOnce(); + expect(result.status).toBe('partial'); + expect(result.metadata.source).toBe('mixed'); + expect(result.metadata.errors).toEqual(['indexed enumeration unavailable']); + expect(evidenceIds(result)).toEqual(['fallback-hit']); + }); + + // Regression for codex P1 review on PR #61 — when the query has no + // explicit/inferred `type` filter, `requestedTypes()` returns []. The + // previous code passed that empty array straight into + // `adapter.listRoots(types, filters)`. For adapters whose listRoots is + // `types.map(...)` (every current adapter), the result was `roots: []`, + // and the property-bearing backend was queried with no roots → zero results. + it('expands empty types to adapter.entityTypes when computing enumerate roots', async () => { + const enumerate = vi.fn(async () => [entry('pr-hit', { state: 'open', type: 'pr' })]); + const librarian = createTestLibrarian({ enumerate }); + + // `state:open` is a non-type filter, so `hasFilters` is true but + // `types` is []. Without the `effectiveTypes = adapter.entityTypes` + // expansion, `roots` would be [] and enumerate would never see the data. + await librarian.handler.execute('state:open'); + + expect(enumerate).toHaveBeenCalledOnce(); + const callArg = enumerate.mock.calls[0]?.[0] as { roots: string[] }; + expect(callArg.roots).toEqual(['/root/pr', '/root/issue']); + }); + + // Regression for codex P2 review on PR #61 — enumerated entries used to + // be accepted directly via `entries.map(entry => ({entry, enumerationType: inferEntityType(entry)}))`, + // bypassing `toEnumerationEntry`'s type-constraint enforcement. For + // adapters where `type` is not in `filterKeys`, `matchesRequestedFilters` + // would not catch wrong-type entries, so an enumerate backend returning + // mixed kinds could leak the wrong type past a `type:`-scoped query. + it('rejects enumerate results whose inferred type is not in the requested types', async () => { + const enumerate = vi.fn(async () => [ + entry('pr-hit', { state: 'open', type: 'pr' }), + entry('issue-hit', { state: 'open', type: 'issue' }), + ]); + const librarian = createTestLibrarian({ enumerate }); + + const result = await librarian.handler.execute('type:pr state:open'); + + expect(enumerate).toHaveBeenCalledOnce(); + // Only the 'pr' entry survives — the 'issue' entry is dropped by + // toEnumerationEntry because it's not in the requested types. + expect(evidenceIds(result)).toEqual(['pr-hit']); + }); + + // Regression for devin P1 review on PR #61 — when the zero-entry gate + // already invoked apiFallback and got back entries that all failed the + // post-filter, the post-filter-empty safety net would call apiFallback + // AGAIN with identical params. Track `apiFallbackAttempted` and skip the + // second call. + it('does not call apiFallback twice when the zero-entry gate already tried it', async () => { + // VFS returns nothing → first fallback fires. + const enumerate = vi.fn(async () => []); + // apiFallback returns an entry that does NOT match the filter (state=closed + // when query asks for state=open), so post-filter empties the result. + const apiFallback = vi.fn(async () => [entry('mismatch', { state: 'closed', type: 'pr' })]); + const librarian = createTestLibrarian({ enumerate }, apiFallback); + + const result = await librarian.handler.execute('type:pr state:open'); + + // Critical: exactly ONE call. The post-filter-empty safety net must + // not re-invoke apiFallback with identical parameters. + expect(apiFallback).toHaveBeenCalledOnce(); + expect(evidenceIds(result)).toEqual([]); + expect(result.status).toBe('failed'); + }); +}); diff --git a/packages/specialists/src/shared/librarian-engine.ts b/packages/specialists/src/shared/librarian-engine.ts index 53df614..56da0f7 100644 --- a/packages/specialists/src/shared/librarian-engine.ts +++ b/packages/specialists/src/shared/librarian-engine.ts @@ -3,7 +3,7 @@ import type { VfsEntry } from '@agent-assistant/vfs'; import { parseQuery } from './query-syntax.js'; export type LibrarianStatus = 'complete' | 'partial' | 'failed'; -export type LibrarianSource = 'vfs' | 'apiFallback' | 'mixed'; +export type LibrarianSource = 'vfs-list' | 'vfs-enumerate' | 'vfs-search' | 'apiFallback' | 'mixed'; export interface LibrarianEvidence { id: string; @@ -33,6 +33,25 @@ export interface LibrarianAdapter { export interface LibrarianVfs { list?(path: string, options?: { depth?: number; limit?: number }): Promise; search?(query: string, options?: { provider?: string; limit?: number }): Promise; + /** + * Optional metadata-bearing enumeration. Returns entries with `properties` + * populated so the engine can filter by structured fields (state, label, type). + * Providers without an indexed/property-aware backend leave this undefined. + * + * Filter semantics: OR within a key (any value matches), AND across keys + * (all keys must match). Implementations MUST NOT silently drop unsupported + * filter keys - return entries the engine can re-filter defensively, and + * the engine WILL post-filter for correctness. + * + * Roots are normalized path prefixes (no globs in v1). Multiple roots are + * legitimate for cross-repo / cross-collection enumeration; the provider + * may batch internally. + */ + enumerate?(input: { + roots: string[]; + filters: Record; + limit: number; + }): Promise; } export interface LibrarianFallbackRequest { @@ -111,30 +130,66 @@ export function createLibrarian( const parsed = parseQuery(instruction); const filters = cloneFilters(adapter.inferFilters(parsed.text, cloneFilters(parsed.filters))); const types = requestedTypes(adapter, filters); + // When the query supplies no `type:` filter, fall back to every entity + // type the adapter advertises. This mirrors the existing list-path + // behavior at the `else { listEnumerationEntries(..., adapter.entityTypes, ...) }` + // branch and is required for adapters whose `listRoots` is `types.map(...)` — + // passing `[]` would collapse the search space to `roots: []`. + const effectiveTypes = types.length > 0 ? types : adapter.entityTypes; + const hasFilters = Object.values(filters).some((values) => values.length > 0); const errors: string[] = []; - let source: LibrarianSource = 'vfs'; + let source: LibrarianSource = 'vfs-list'; let entries: EnumerationEntry[] = []; + // Track whether apiFallback has already been invoked with the current + // request shape so the post-filter-empty safety net at the bottom does + // not double-call it with identical params after the zero-entry gate + // already tried. + let apiFallbackAttempted = false; - try { - if (types.length > 0) { - entries = await listEnumerationEntries(adapter, options.vfs, types, filters, errors, { - limit, - listDepth, - }); - } else if (hasNoFilters(filters)) { - entries = await searchEntries(adapter, options.vfs, parsed.text, errors, limit); - } else { - entries = await listEnumerationEntries(adapter, options.vfs, adapter.entityTypes, filters, errors, { + if (hasFilters && options.vfs.enumerate) { + try { + const enumerated = await options.vfs.enumerate({ + roots: adapter.listRoots(effectiveTypes, filters), + filters, limit, - listDepth, }); + // Route enumerated entries through `toEnumerationEntry` so type + // constraints are enforced on adapters where `type` is not in + // `filterKeys` (e.g., Linear). A property-bearing backend that + // returns mixed entity kinds must not leak the wrong type past + // a `type:`-scoped query just because `matchesRequestedFilters` + // doesn't see `type` as a filter key on that adapter. + entries = enumerated.flatMap((entry) => + toEnumerationEntry(adapter, entry, effectiveTypes), + ); + source = 'vfs-enumerate'; + } catch (error) { + errors.push(errorMessage(error)); + } + } else if (options.vfs.list || options.vfs.search) { + try { + if (types.length > 0) { + entries = await listEnumerationEntries(adapter, options.vfs, types, filters, errors, { + limit, + listDepth, + }); + } else if (hasNoFilters(filters)) { + entries = await searchEntries(adapter, options.vfs, parsed.text, errors, limit); + source = 'vfs-search'; + } else { + entries = await listEnumerationEntries(adapter, options.vfs, adapter.entityTypes, filters, errors, { + limit, + listDepth, + }); + } + } catch (error) { + errors.push(errorMessage(error)); } - } catch (error) { - errors.push(errorMessage(error)); } if (entries.length === 0 && options.apiFallback) { try { + apiFallbackAttempted = true; const fallbackEntries = await loadFallbackEntries(options.apiFallback, { instruction, text: parsed.text, @@ -143,19 +198,57 @@ export function createLibrarian( }); if (fallbackEntries.length > 0) { source = errors.length > 0 ? 'mixed' : 'apiFallback'; - entries = fallbackEntries.map((entry) => ({ - entry, - enumerationType: adapter.inferEntityType(entry), - })); + entries = fallbackEntries.flatMap((entry) => + toEnumerationEntry(adapter, entry, effectiveTypes), + ); } } catch (error) { errors.push(errorMessage(error)); } } - const matchedEntries = dedupeEntries(entries) + let matchedEntries = dedupeEntries(entries) .filter(({ entry }) => matchesRequestedFilters(adapter, entry, filters)) .sort(compareEntries); + + // Post-filter-empty safety net: when filters reduce VFS results to + // zero AND apiFallback exists AND it hasn't already been tried for + // this turn, retry via fallback then re-filter. Skipping when + // `apiFallbackAttempted` is true avoids duplicate identical-param + // round-trips when the zero-entry gate above already ran. + if ( + hasFilters && + matchedEntries.length === 0 && + entries.length > 0 && + options.apiFallback && + !apiFallbackAttempted + ) { + try { + apiFallbackAttempted = true; + const fallbackEntries = await loadFallbackEntries(options.apiFallback, { + instruction, + text: parsed.text, + filters, + types, + }); + if (fallbackEntries.length > 0) { + const fallbackMatched = dedupeEntries( + fallbackEntries.flatMap((entry) => + toEnumerationEntry(adapter, entry, effectiveTypes), + ), + ) + .filter(({ entry }) => matchesRequestedFilters(adapter, entry, filters)) + .sort(compareEntries); + if (fallbackMatched.length > 0) { + matchedEntries = fallbackMatched; + source = errors.length > 0 ? 'mixed' : 'apiFallback'; + } + } + } catch (error) { + errors.push(errorMessage(error)); + } + } + const evidence = matchedEntries.map(({ entry, enumerationType }) => adapter.toEvidence(entry, enumerationType), );