mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
feat(context): Retrieval Reflex — teach the agent when/what to retrieve (#1981)
Deterministic per-turn pointer layer in the context engine: a zero-LLM, precision-biased scan resolves salient entities (names, @handles) to existing brain pages and injects compact pointers (name → slug → safe synopsis). Detect + point, never auto-dump. Fail-open, capped, suppression on prior context only. Engine-aware resolver ladder (no second DB connection): host ctx.brainQuery → PGLite serve resolve IPC (unix socket) → Postgres cached direct → disabled. Synopsis runs through get_page's privacy strip. Plus the retrieval-reflex recipe + policy skill, the retrieval_reflex_health doctor check, config gate, and the init next-step hint.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Unit tests for the Retrieval Reflex pure extractor (#1981, T1).
|
||||
* No DB, no SDK — just the deterministic candidate extraction + precision filters.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { extractCandidates, MAX_CANDIDATES } from '../../src/core/context/entity-salience.ts';
|
||||
|
||||
function queries(text: string): string[] {
|
||||
return extractCandidates(text).map((c) => c.query);
|
||||
}
|
||||
|
||||
describe('extractCandidates', () => {
|
||||
test('multi-word capitalized run', () => {
|
||||
expect(queries('what do you think about Garry Tan?')).toContain('Garry Tan');
|
||||
});
|
||||
|
||||
test('@handles are captured without the @ in the query, with @ in display', () => {
|
||||
const c = extractCandidates('ping @garry about it');
|
||||
const handle = c.find((x) => x.display === '@garry');
|
||||
expect(handle).toBeDefined();
|
||||
expect(handle!.query).toBe('garry');
|
||||
});
|
||||
|
||||
test('drops hard stopwords even capitalized', () => {
|
||||
const q = queries('What should We do? The plan is set.');
|
||||
expect(q).not.toContain('What');
|
||||
expect(q).not.toContain('We');
|
||||
expect(q).not.toContain('The');
|
||||
});
|
||||
|
||||
test('drops weekday/common words seen only at sentence start', () => {
|
||||
expect(queries('Monday we ship. Today is busy.')).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps a real name even at sentence start', () => {
|
||||
expect(queries('Sarah went home early.')).toContain('Sarah');
|
||||
});
|
||||
|
||||
test('keeps a common-looking word if also seen capitalized mid-sentence', () => {
|
||||
// "Apple" appears mid-sentence → strong entity signal, kept despite being common-ish.
|
||||
expect(queries('I love Apple. Apple makes phones.')).toContain('Apple');
|
||||
});
|
||||
|
||||
test('rejects single chars and pure numbers', () => {
|
||||
const q = queries('A 2026 plan');
|
||||
expect(q).not.toContain('A');
|
||||
expect(q).not.toContain('2026');
|
||||
});
|
||||
|
||||
test('strips possessive', () => {
|
||||
expect(queries("Garry's idea")).toContain('Garry');
|
||||
});
|
||||
|
||||
test('dedups on normalized form', () => {
|
||||
const q = queries('Garry and Garry again');
|
||||
expect(q.filter((x) => x.toLowerCase() === 'garry')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('caps at MAX_CANDIDATES', () => {
|
||||
const many = Array.from({ length: 30 }, (_, i) => `Person${String.fromCharCode(65 + (i % 26))}x${i}`).join(' ');
|
||||
expect(extractCandidates(many).length).toBeLessThanOrEqual(MAX_CANDIDATES);
|
||||
});
|
||||
|
||||
test('empty / non-string input → []', () => {
|
||||
expect(extractCandidates('')).toEqual([]);
|
||||
// @ts-expect-error intentional bad input
|
||||
expect(extractCandidates(null)).toEqual([]);
|
||||
});
|
||||
|
||||
test('documented v1 limit: lowercase names are NOT detected', () => {
|
||||
expect(queries('what about garry tan')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Retrieval Reflex resolve IPC round-trip tests (#1981, T3/T5).
|
||||
*/
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
resolveSocketPath,
|
||||
startResolveIpcServer,
|
||||
resolveViaIpc,
|
||||
IPC_UNAVAILABLE,
|
||||
} from '../../src/core/context/resolve-ipc.ts';
|
||||
import type { PointerBlock } from '../../src/core/context/retrieval-reflex.ts';
|
||||
|
||||
const servers: Array<{ close: () => void }> = [];
|
||||
afterEach(() => {
|
||||
for (const s of servers.splice(0)) { try { s.close(); } catch { /* noop */ } }
|
||||
});
|
||||
|
||||
function tmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), 'rr-ipc-'));
|
||||
}
|
||||
|
||||
describe('resolve IPC', () => {
|
||||
test('round-trip: client gets the pointer block the server returns', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const block: PointerBlock = { pointers: [{ display: 'Alice', slug: 'people/alice', synopsis: 'x' }], text: 'BLOCK' };
|
||||
const server = await startResolveIpcServer(sock, async (req) => {
|
||||
expect(req.candidates[0].query).toBe('Alice');
|
||||
return block;
|
||||
});
|
||||
expect(server).not.toBeNull();
|
||||
servers.push(server!);
|
||||
|
||||
const got = await resolveViaIpc(sock, { candidates: [{ display: 'Alice', query: 'Alice' }] });
|
||||
expect(got).not.toBe(IPC_UNAVAILABLE);
|
||||
expect((got as PointerBlock).text).toBe('BLOCK');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('absent socket → IPC_UNAVAILABLE (caller falls through ladder)', async () => {
|
||||
const dir = tmpDir();
|
||||
const got = await resolveViaIpc(resolveSocketPath(dir), { candidates: [{ display: 'A', query: 'A' }] });
|
||||
expect(got).toBe(IPC_UNAVAILABLE);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('server returning null relays as null (resolved, nothing found)', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const server = await startResolveIpcServer(sock, async () => null);
|
||||
servers.push(server!);
|
||||
const got = await resolveViaIpc(sock, { candidates: [{ display: 'A', query: 'A' }] });
|
||||
expect(got).toBeNull();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('stale socket file is cleaned up so a fresh server can bind', async () => {
|
||||
const dir = tmpDir();
|
||||
const sock = resolveSocketPath(dir);
|
||||
const s1 = await startResolveIpcServer(sock, async () => null);
|
||||
servers.push(s1!);
|
||||
s1!.close();
|
||||
// bind again at the same path — startResolveIpcServer must unlink the stale file
|
||||
const s2 = await startResolveIpcServer(sock, async () => null);
|
||||
expect(s2).not.toBeNull();
|
||||
servers.push(s2!);
|
||||
expect(existsSync(sock)).toBe(true);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user