mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
Five verified-open fixes to the dream/synthesize output family: - #1586: thread the cycle's resolved sourceId (cycleSourceId) through runPhaseSynthesize -> SubagentHandlerData.source_id -> subagent tool OperationContext, and stamp collected refs + summary page with the same source, so synthesized pages stop landing in 'default'. - #2415: new config knob dream.synthesize.output_root (default 'wiki', zero behavior change unless set) drives the synthesize prompt slug templates, the patterns reflection lookup + prompt, and remaps the filing-rule allow-list globs. Registered in KNOWN_CONFIG_KEYS. - #2163: synthesize_concepts writes concept pages through importFromContent (put_page's parse->chunk->embed pipeline) instead of bare engine.putPage, so concepts/ pages are chunked + embedded and reachable by retrieval. - #2569: stampDreamProvenance persists dream_generated + dream_cycle_date into pages.frontmatter (JSONB merge via executeRawJsonb) at write time, so generated pages are DB-queryable and put_page write-through can't erase the marker. - #2606: chronicle judge detects stopReason 'length' truncation and no-JSON-array parse failures as distinct skipped reasons (judge_truncated / judge_parse_failed) instead of a false terminal no_events; output cap raised to 4000 and configurable via chronicle.judge_max_tokens. Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Garry Tan <garrytan@gmail.com>
227 lines
9.5 KiB
TypeScript
227 lines
9.5 KiB
TypeScript
/**
|
|
* Subagent brain-tool registry tests. Covers:
|
|
* - every allow-list name exists in OPERATIONS (catches renames upstream)
|
|
* - Anthropic tool-name constraint enforced
|
|
* - put_page schema is namespace-wrapped per subagent
|
|
* - execute() invokes the op handler with viaSubagent=true + subagentId
|
|
* - filterAllowedTools narrows registry + rejects unknown names
|
|
* - denied ops (file_upload etc.) do NOT appear in the registry
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { operations, OperationError } from '../src/core/operations.ts';
|
|
import {
|
|
BRAIN_TOOL_ALLOWLIST,
|
|
buildBrainTools,
|
|
filterAllowedTools,
|
|
__testing,
|
|
} from '../src/core/minions/tools/brain-allowlist.ts';
|
|
import type { GBrainConfig } from '../src/core/config.ts';
|
|
import type { ToolCtx } from '../src/core/minions/types.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
const config: GBrainConfig = { engine: 'pglite' } as GBrainConfig;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ database_url: '' });
|
|
await engine.initSchema();
|
|
}, 60_000); // OAuth v25 + full migration chain needs breathing room
|
|
|
|
afterAll(async () => {
|
|
if (engine) await engine.disconnect();
|
|
}, 60_000);
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM pages');
|
|
});
|
|
|
|
describe('BRAIN_TOOL_ALLOWLIST', () => {
|
|
test('every name exists in src/core/operations.ts OPERATIONS', () => {
|
|
const opNames = new Set(operations.map(o => o.name));
|
|
const missing = [...BRAIN_TOOL_ALLOWLIST].filter(n => !opNames.has(n));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
test('contains the v0.15 read-only 10 + put_page + v0.29 salience pair + v114 list_link_sources', () => {
|
|
// v0.29 added get_recent_salience + find_anomalies (read-only).
|
|
// get_recent_transcripts is deliberately excluded — subagent calls always
|
|
// have ctx.remote=true, and the v0.29 trust gate rejects remote callers.
|
|
// v114 (#1941) added list_link_sources (read-only provenance discovery);
|
|
// the edge-WRITE ops add_link/remove_link stay out (separate trust call).
|
|
// #2778 added add_timeline_entry (write, fenced like put_page via
|
|
// operations.ts:enforceSubagentSlugFence).
|
|
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(15);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('add_timeline_entry')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('query')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('search')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('get_page')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('list_pages')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('put_page')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_salience')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('find_anomalies')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('list_link_sources')).toBe(true);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('add_link')).toBe(false);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('remove_link')).toBe(false);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('get_recent_transcripts')).toBe(false);
|
|
});
|
|
|
|
test('does NOT contain destructive ops', () => {
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('file_upload')).toBe(false);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('delete_page')).toBe(false);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('delete_file')).toBe(false);
|
|
expect(BRAIN_TOOL_ALLOWLIST.has('sync')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('buildBrainTools', () => {
|
|
test('produces one ToolDef per allow-listed op that exists in operations.ts', () => {
|
|
const tools = buildBrainTools({ subagentId: 42, engine, config });
|
|
const opNames = new Set(operations.map(o => o.name));
|
|
const expected = [...BRAIN_TOOL_ALLOWLIST].filter(n => opNames.has(n)).length;
|
|
expect(tools.length).toBe(expected);
|
|
});
|
|
|
|
test('tool names are brain_<op> and match Anthropic constraint', () => {
|
|
const tools = buildBrainTools({ subagentId: 7, engine, config });
|
|
for (const t of tools) {
|
|
expect(t.name).toMatch(__testing.ANTHROPIC_NAME_RE);
|
|
expect(t.name.startsWith('brain_')).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('tools are flagged idempotent in v0.15', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
expect(tools.every(t => t.idempotent === true)).toBe(true);
|
|
});
|
|
|
|
test('tools carry the op description verbatim', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
const getPage = tools.find(t => t.name === 'brain_get_page');
|
|
const op = operations.find(o => o.name === 'get_page');
|
|
expect(getPage?.description).toBe(op!.description);
|
|
});
|
|
|
|
test('put_page schema is namespace-wrapped per subagent', () => {
|
|
const tools42 = buildBrainTools({ subagentId: 42, engine, config });
|
|
const putPage42 = tools42.find(t => t.name === 'brain_put_page');
|
|
const slug42 = ((putPage42!.input_schema as any).properties as any).slug;
|
|
expect(slug42.pattern).toBe('^wiki/agents/42/.+');
|
|
expect(slug42.description).toContain('wiki/agents/42/');
|
|
|
|
const tools7 = buildBrainTools({ subagentId: 7, engine, config });
|
|
const putPage7 = tools7.find(t => t.name === 'brain_put_page');
|
|
const slug7 = ((putPage7!.input_schema as any).properties as any).slug;
|
|
expect(slug7.pattern).toBe('^wiki/agents/7/.+');
|
|
});
|
|
|
|
test('non-put_page tools do NOT get a pattern on slug', () => {
|
|
const tools = buildBrainTools({ subagentId: 42, engine, config });
|
|
const getPage = tools.find(t => t.name === 'brain_get_page');
|
|
const slug = ((getPage!.input_schema as any).properties as any).slug;
|
|
expect(slug).toBeDefined();
|
|
expect(slug.pattern).toBeUndefined();
|
|
});
|
|
|
|
test('execute() on put_page with valid namespace slug succeeds', async () => {
|
|
const tools = buildBrainTools({ subagentId: 42, engine, config });
|
|
const putPage = tools.find(t => t.name === 'brain_put_page');
|
|
const ctx: ToolCtx = { engine, jobId: 1, remote: true };
|
|
const res = await putPage!.execute(
|
|
{ slug: 'wiki/agents/42/notes', content: '---\ntitle: Notes\n---\nbody' },
|
|
ctx,
|
|
);
|
|
expect(res).toBeTruthy();
|
|
});
|
|
|
|
test('execute() on put_page with out-of-namespace slug throws permission_denied', async () => {
|
|
const tools = buildBrainTools({ subagentId: 42, engine, config });
|
|
const putPage = tools.find(t => t.name === 'brain_put_page');
|
|
const ctx: ToolCtx = { engine, jobId: 1, remote: true };
|
|
await expect(
|
|
putPage!.execute(
|
|
{ slug: 'wiki/analysis/stomp', content: '---\ntitle: x\n---\nb' },
|
|
ctx,
|
|
),
|
|
).rejects.toBeInstanceOf(OperationError);
|
|
});
|
|
|
|
// #1586: sourceId threads through buildBrainTools → buildOpContext →
|
|
// put_page → importFromContent, so subagent writes land in the cycle's
|
|
// resolved source instead of the hardcoded 'default'.
|
|
test('execute() on put_page writes to the configured sourceId (#1586)', async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
|
VALUES ('mybrain', 'My Brain', '/tmp/mybrain', '{}'::jsonb, false, now())
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
);
|
|
const tools = buildBrainTools({
|
|
subagentId: 42,
|
|
engine,
|
|
config,
|
|
allowedSlugPrefixes: ['wiki/personal/reflections/*'],
|
|
sourceId: 'mybrain',
|
|
});
|
|
const putPage = tools.find(t => t.name === 'brain_put_page');
|
|
const ctx: ToolCtx = { engine, jobId: 1, remote: true };
|
|
await putPage!.execute(
|
|
{ slug: 'wiki/personal/reflections/2026-07-17-scoped', content: '---\ntitle: Scoped\n---\nbody' },
|
|
ctx,
|
|
);
|
|
const rows = await engine.executeRaw<{ source_id: string }>(
|
|
`SELECT source_id FROM pages WHERE slug = 'wiki/personal/reflections/2026-07-17-scoped'`,
|
|
);
|
|
expect(rows.length).toBe(1);
|
|
expect(rows[0].source_id).toBe('mybrain');
|
|
});
|
|
|
|
test('buildBrainTools rejects a malformed sourceId at build time (#1586)', () => {
|
|
expect(() =>
|
|
buildBrainTools({ subagentId: 1, engine, config, sourceId: '../evil' }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('filterAllowedTools', () => {
|
|
test('passes prefixed names through', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
const filtered = filterAllowedTools(tools, ['brain_get_page', 'brain_search']);
|
|
expect(filtered.map(t => t.name)).toEqual(['brain_get_page', 'brain_search']);
|
|
});
|
|
|
|
test('accepts un-prefixed names as a convenience', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
const filtered = filterAllowedTools(tools, ['get_page', 'search']);
|
|
expect(filtered.map(t => t.name)).toEqual(['brain_get_page', 'brain_search']);
|
|
});
|
|
|
|
test('rejects unknown tool names (no silent ignore)', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
expect(() => filterAllowedTools(tools, ['brain_typo_nope'])).toThrow(/unknown tool/);
|
|
});
|
|
|
|
test('deduplicates when both prefixed + unprefixed given', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
const filtered = filterAllowedTools(tools, ['brain_get_page', 'get_page']);
|
|
expect(filtered.length).toBe(1);
|
|
});
|
|
|
|
test('empty array yields empty registry', () => {
|
|
const tools = buildBrainTools({ subagentId: 1, engine, config });
|
|
expect(filterAllowedTools(tools, [])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('sanitizeToolName', () => {
|
|
test('returns within 64 chars', () => {
|
|
// Synthetic: simulate an op name long enough to need slicing.
|
|
const long = 'a'.repeat(100);
|
|
expect(__testing.sanitizeToolName(long).length).toBeLessThanOrEqual(64);
|
|
});
|
|
|
|
test('replaces non-conforming chars with _', () => {
|
|
expect(__testing.sanitizeToolName('foo.bar')).toBe('brain_foo_bar');
|
|
});
|
|
});
|