Files
gbrain/test/retrieval-reflex.test.ts
T
8f45624e55 v0.42.39.0 feat(context): Retrieval Reflex — teach the agent when/what to retrieve (#1981) (#2019)
* fix(integrations): parameterize resolver-row fence by recipe id

The install fence was hardcoded gbrain:agent-voice:resolver-rows, so any
second copy-into-host-repo recipe wrote a mislabeled block (and refresh/
uninstall keyed on recipe id would miss it). Derive it from manifest.recipe.

* 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.

* chore: bump version and changelog (v0.42.38.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): KEY_FILES entries for Retrieval Reflex surface (#1981)

Document the new src/core/context/ modules, the context-engine resolver
ladder, the serve resolve IPC, the retrieval_reflex_health doctor check,
and the recipe-id-keyed install fence. Current-state only.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:12:33 -07:00

176 lines
7.5 KiB
TypeScript

/**
* 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');
});
});