mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:34:35 +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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Doctor retrieval_reflex_health check (#1981, T8).
|
||||
*/
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildRetrievalReflexCheck } from '../src/commands/doctor.ts';
|
||||
|
||||
const origEnv = process.env.GBRAIN_RETRIEVAL_REFLEX;
|
||||
afterEach(() => {
|
||||
if (origEnv === undefined) delete process.env.GBRAIN_RETRIEVAL_REFLEX;
|
||||
else process.env.GBRAIN_RETRIEVAL_REFLEX = origEnv;
|
||||
});
|
||||
|
||||
describe('buildRetrievalReflexCheck', () => {
|
||||
test('disabled via env → warn, names the right check', () => {
|
||||
process.env.GBRAIN_RETRIEVAL_REFLEX = 'false';
|
||||
const c = buildRetrievalReflexCheck(null);
|
||||
expect(c.name).toBe('retrieval_reflex_health');
|
||||
expect(c.status).toBe('warn');
|
||||
expect(c.message).toContain('disabled');
|
||||
expect((c.details as any)?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('enabled → reports policy-skill install state in details', () => {
|
||||
process.env.GBRAIN_RETRIEVAL_REFLEX = 'true';
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rr-doctor-'));
|
||||
mkdirSync(join(dir, 'retrieval-reflex'), { recursive: true });
|
||||
writeFileSync(join(dir, 'retrieval-reflex', 'SKILL.md'), '# stub\n');
|
||||
const c = buildRetrievalReflexCheck(dir);
|
||||
expect(c.name).toBe('retrieval_reflex_health');
|
||||
expect((c.details as any)?.enabled).toBe(true);
|
||||
expect((c.details as any)?.policy_skill_installed).toBe(true);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('enabled, policy skill absent → message includes the install hint', () => {
|
||||
process.env.GBRAIN_RETRIEVAL_REFLEX = 'true';
|
||||
const dir = mkdtempSync(join(tmpdir(), 'rr-doctor-2-'));
|
||||
const c = buildRetrievalReflexCheck(dir);
|
||||
expect((c.details as any)?.policy_skill_installed).toBe(false);
|
||||
expect(c.message).toContain('gbrain integrations install retrieval-reflex');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,19 @@ describe('installRecipeIntoHostRepo — happy path', () => {
|
||||
expect(agentsMd).toContain('voice-post-call');
|
||||
});
|
||||
|
||||
// #1981 fence-parameterization regression: a SECOND copy-into-host-repo recipe
|
||||
// must fence its rows by ITS OWN recipe id, not the hardcoded agent-voice id.
|
||||
it('fences resolver rows by the recipe id (retrieval-reflex, not agent-voice)', async () => {
|
||||
await installRecipeIntoHostRepo('retrieval-reflex', { target: scratch });
|
||||
const agentsMd = readFileSync(join(scratch, 'AGENTS.md'), 'utf8');
|
||||
expect(agentsMd).toContain('gbrain:retrieval-reflex:resolver-rows');
|
||||
expect(agentsMd).toContain('/gbrain:retrieval-reflex:resolver-rows');
|
||||
expect(agentsMd).not.toContain('gbrain:agent-voice:resolver-rows');
|
||||
expect(agentsMd).toContain('retrieval-reflex |');
|
||||
// the policy skill landed
|
||||
expect(existsSync(join(scratch, 'skills/retrieval-reflex/SKILL.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('respects file modes from the manifest', async () => {
|
||||
await installRecipeIntoHostRepo('agent-voice', { target: scratch });
|
||||
const serverPath = join(scratch, 'services/voice-agent/code/server.mjs');
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Retrieval Reflex — resolver + assemble() regression tests (#1981, T5).
|
||||
*
|
||||
* Encodes the motivating failure: a turn naming an entity with an existing brain
|
||||
* page must surface a pointer BEFORE the agent answers. Runs against a hermetic
|
||||
* in-memory PGLite engine (no file lock). The PGLite-in-production path is
|
||||
* covered by exercising the resolver through an injected resolver (the same
|
||||
* shape the serve IPC / host ctx.brainQuery supply).
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { normalizeAlias } from '../src/core/search/alias-normalize.ts';
|
||||
import { resolveEntitiesToPointers } from '../src/core/context/retrieval-reflex.ts';
|
||||
import { extractCandidates } from '../src/core/context/entity-salience.ts';
|
||||
import { createGBrainContextEngine } from '../src/core/context-engine.ts';
|
||||
import { disposeReflex } from '../src/core/context/reflex.ts';
|
||||
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
async function seed(slug: string, title: string, body: string, source = 'default') {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
|
||||
VALUES ($1, $2, 'person', $3, $4, '')`,
|
||||
[slug, source, title, body],
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
await disposeReflex();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await engine.executeRaw('DELETE FROM page_aliases').catch(() => {});
|
||||
await engine.executeRaw('DELETE FROM pages');
|
||||
});
|
||||
|
||||
describe('resolveEntitiesToPointers', () => {
|
||||
test('namespaced slug resolves from a bare title (the recall fix, D6)', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Alice is an early founder.');
|
||||
const candidates = extractCandidates('what do you think about Alice Example?');
|
||||
const block = await resolveEntitiesToPointers(engine, 'default', candidates, {});
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.pointers[0].slug).toBe('people/alice-example');
|
||||
expect(block!.text).toContain('people/alice-example');
|
||||
expect(block!.text).toContain('use get_page');
|
||||
});
|
||||
|
||||
test('alias arm resolves an unambiguous single-slug hit', async () => {
|
||||
await seed('people/swami-x', 'Swami X', 'A close friend.');
|
||||
await engine.setPageAliases('people/swami-x', 'default', [normalizeAlias('Swami')]);
|
||||
const block = await resolveEntitiesToPointers(engine, 'default', extractCandidates('Spoke with Swami today'), {});
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.pointers.some((p) => p.slug === 'people/swami-x')).toBe(true);
|
||||
});
|
||||
|
||||
test('privacy (D5): takes-fence content never leaks into the synopsis', async () => {
|
||||
const body = `${TAKES_FENCE_BEGIN}\nSECRET_HUNCH_DO_NOT_LEAK\n${TAKES_FENCE_END}\nAlice is a founder.`;
|
||||
await seed('people/alice-example', 'Alice Example', body);
|
||||
const block = await resolveEntitiesToPointers(engine, 'default', extractCandidates('about Alice Example'), {});
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.text).not.toContain('SECRET_HUNCH_DO_NOT_LEAK');
|
||||
});
|
||||
|
||||
test('suppression: a slug already in PRIOR context is dropped', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'A founder.');
|
||||
const candidates = extractCandidates('tell me about Alice Example');
|
||||
const block = await resolveEntitiesToPointers(engine, 'default', candidates, {
|
||||
priorContextText: 'earlier we already opened people/alice-example and read it',
|
||||
});
|
||||
expect(block).toBeNull();
|
||||
});
|
||||
|
||||
test('empty candidates → null', async () => {
|
||||
expect(await resolveEntitiesToPointers(engine, 'default', [], {})).toBeNull();
|
||||
});
|
||||
|
||||
test('cap to maxPointers', async () => {
|
||||
await seed('people/aa', 'Aa Bb', 'x');
|
||||
await seed('people/cc', 'Cc Dd', 'y');
|
||||
await seed('people/ee', 'Ee Ff', 'z');
|
||||
const block = await resolveEntitiesToPointers(
|
||||
engine,
|
||||
'default',
|
||||
extractCandidates('met Aa Bb, Cc Dd, and Ee Ff'),
|
||||
{ maxPointers: 2 },
|
||||
);
|
||||
expect(block!.pointers.length).toBe(2);
|
||||
});
|
||||
|
||||
test('pre-v110 brains: alias-table absence does not break the slug arm', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'A founder.');
|
||||
// Simulate no page_aliases table.
|
||||
await engine.executeRaw('DROP TABLE IF EXISTS page_aliases');
|
||||
const block = await resolveEntitiesToPointers(engine, 'default', extractCandidates('about Alice Example'), {});
|
||||
expect(block).not.toBeNull();
|
||||
expect(block!.pointers[0].slug).toBe('people/alice-example');
|
||||
// restore for other tests
|
||||
await engine.initSchema();
|
||||
});
|
||||
});
|
||||
|
||||
describe('context-engine assemble() — Retrieval Reflex integration', () => {
|
||||
beforeEach(() => {
|
||||
process.env.GBRAIN_RETRIEVAL_REFLEX = 'true';
|
||||
});
|
||||
|
||||
test('regression: a named entity with a page surfaces a pointer (host resolver path)', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'Alice is a founder.');
|
||||
// Inject a resolver the way the OpenClaw host (ctx.brainQuery) or serve IPC would.
|
||||
const ce = createGBrainContextEngine({
|
||||
workspaceDir: '/tmp/rr-test-ws',
|
||||
resolveEntities: (candidates, opts) =>
|
||||
resolveEntitiesToPointers(engine, 'default', candidates, opts),
|
||||
});
|
||||
const res = await ce.assemble({
|
||||
sessionId: 's1',
|
||||
messages: [{ role: 'user', content: 'what do you think about Alice Example?' }],
|
||||
});
|
||||
expect(res.systemPromptAddition).toContain('Brain pages mentioned this turn');
|
||||
expect(res.systemPromptAddition).toContain('people/alice-example');
|
||||
expect(res.systemPromptAddition).toContain('use get_page');
|
||||
});
|
||||
|
||||
test('no resolver available (PGLite, no serve/host) → no throw, live context still present', async () => {
|
||||
const ce = createGBrainContextEngine({ workspaceDir: '/tmp/rr-test-ws-2' });
|
||||
const res = await ce.assemble({
|
||||
sessionId: 's2',
|
||||
messages: [{ role: 'user', content: 'what about Alice Example?' }],
|
||||
});
|
||||
// Live Context block always ships; no pointer block (nothing resolved).
|
||||
expect(res.systemPromptAddition).toContain('Live Context');
|
||||
expect(res.systemPromptAddition).not.toContain('Brain pages mentioned this turn');
|
||||
});
|
||||
|
||||
test('zero salient candidates → no brain touch, no pointer block', async () => {
|
||||
let called = false;
|
||||
const ce = createGBrainContextEngine({
|
||||
workspaceDir: '/tmp/rr-test-ws-3',
|
||||
resolveEntities: async () => { called = true; return null; },
|
||||
});
|
||||
const res = await ce.assemble({
|
||||
sessionId: 's3',
|
||||
messages: [{ role: 'user', content: 'can you help me with this?' }],
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(res.systemPromptAddition).not.toContain('Brain pages mentioned this turn');
|
||||
});
|
||||
|
||||
test('suppression uses PRIOR turns only, not the current message', async () => {
|
||||
await seed('people/alice-example', 'Alice Example', 'A founder.');
|
||||
const ce = createGBrainContextEngine({
|
||||
workspaceDir: '/tmp/rr-test-ws-4',
|
||||
resolveEntities: (candidates, opts) =>
|
||||
resolveEntitiesToPointers(engine, 'default', candidates, opts),
|
||||
});
|
||||
// The current message names Alice Example; prior context does NOT. Must fire.
|
||||
const res = await ce.assemble({
|
||||
sessionId: 's4',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'assistant', content: 'hi there' },
|
||||
{ role: 'user', content: 'what do you think about Alice Example?' },
|
||||
],
|
||||
});
|
||||
expect(res.systemPromptAddition).toContain('people/alice-example');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user