mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
Three infrastructure modules for BrainBench v1 Complete Cats 5/8/9/11.
**eval/runner/loaders/pdf.ts** — Thin pdf-parse wrapper. Lazy import keeps
pdf-parse out of the module-load path (avoids library debug-mode side
effects). Size cap (50MB default), encryption detection, structured error
classes (PdfEncryptedError, PdfTooLargeError, PdfParseError). Only Cat 11
multimodal will import this; production bundle never sees pdf-parse.
**eval/runner/tool-bridge.ts** — Maps 12 read-only operations from
src/core/operations.ts to Anthropic tool definitions + adds 3 dry_run write
tools. Three structural invariants enforced:
1. No hidden LLM calls. `operations.query` defaults expand=true which
routes through expansion.ts → Haiku. Bridge strips `expand` from the
query tool's input schema AND executor hard-sets expand:false. Zero
nested Haiku calls in any agent trace.
2. Mutating ops throw ForbiddenOpError. put_page, add_link, delete_page,
etc. are rejected by name. Agents record intent via dry_run_put_page /
dry_run_add_link / dry_run_add_timeline_entry which persist to the
flight-recorder without mutating the engine. This is how Cat 8's
back_link_compliance + citation_format metrics measure anything with
a read-only tool surface.
3. Poison tagged by the bridge, not the judge. Every tool result is
scanned for slugs matching gold/poison.json fixtures. Matched
fixture_ids flow into tool_call_summary.saw_poison_items for the
structured-evidence judge contract. Judge never reads raw tool
output — Section-3 defense against paraphrased prompt injections
(poison payloads never reach the judge model at all).
32K-token cap (~128K chars) with "…[truncated]" suffix.
**eval/runner/recorder.ts** — Per-run flight-recorder bundle emitter. Full
6-artifact bundle (transcript.md, brain-export.json, entity-graph.json,
citations.json, scorecard.json, judge-notes.md) when the adapter provides
an AdapterExport; 3-artifact fallback (transcript + scorecard +
judge-notes) otherwise. Atomic writes via tmp+rename. Collision-safe:
duplicate directory names get incremental -2, -3 suffix. `safeStringify`
handles circular references without throwing and JSON-serializes
Float32Array embeddings.
**package.json:** adds pdf-parse@2.4.5 as a devDependency. Scoped to eval/
use only; production gbrain binary unaffected.
**Tests:** 63 new — 30 tool-bridge, 21 recorder, 12 pdf-loader. All pass.
Fake engine uses a Proxy with `__default__` fallback so poison-matching
tests don't have to mock the exact engine method name that each operation
calls (some route via searchKeyword, others via getPage — proxy handles
both uniformly).
Total eval suite now: 132 pass, 0 fail, 923 expect() calls.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
489 lines
18 KiB
TypeScript
489 lines
18 KiB
TypeScript
/**
|
|
* Tool bridge — gbrain operations → Anthropic tool definitions + executor.
|
|
*
|
|
* Wraps the 12 read-only operations from `src/core/operations.ts` as Anthropic
|
|
* `tool_use` definitions so the agent adapter (Cat 8/Cat 9) can call them.
|
|
* Adds 3 `dry_run_*` write tools that record INTENDED writes to the
|
|
* flight-recorder without mutating engine state — that's how Cat 8's
|
|
* back_link_compliance and citation_format metrics measure anything.
|
|
*
|
|
* Three structural invariants enforced here:
|
|
*
|
|
* 1. **No hidden LLM calls.** `operations.query` defaults `expand: true`
|
|
* which routes through `src/core/search/expansion.ts` → Anthropic Haiku.
|
|
* The `query` tool strips `expand` from its input schema AND the
|
|
* executor hard-sets `expand: false`. Zero nested Haiku calls in any
|
|
* agent-loop trace.
|
|
*
|
|
* 2. **Mutating ops throw.** Any attempt to call `put_page`, `add_link`,
|
|
* `delete_page`, etc. by name raises `ForbiddenOpError`. Agents must
|
|
* use `dry_run_*` to record intent.
|
|
*
|
|
* 3. **Poison is tagged by the bridge, not the judge.** Every tool result
|
|
* is scanned for slugs that match `gold/poison.json` fixtures. Matched
|
|
* fixture_ids flow into `tool_call_summary.saw_poison_items`. The judge
|
|
* receives that structured summary — never the raw tool output. This
|
|
* is the Section-3 defense against paraphrased prompt injections.
|
|
*
|
|
* Output capping: every `tool_result` content string is capped at
|
|
* `DEFAULT_MAX_CHARS` characters (~32K tokens at ~4 chars/token). Truncated
|
|
* results get a literal `…[truncated]` suffix.
|
|
*/
|
|
|
|
import { operations as OPERATIONS } from '../../src/core/operations.ts';
|
|
import type { Operation, OperationContext, ParamDef } from '../../src/core/operations.ts';
|
|
import type { BrainEngine } from '../../src/core/engine.ts';
|
|
import type { GBrainConfig } from '../../src/core/config.ts';
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────
|
|
|
|
/** 12 read-only operations exposed to the agent. Pinned in eval/schemas/tool-schema.schema.json. */
|
|
export const EXPOSED_READ_TOOLS = [
|
|
'search',
|
|
'query',
|
|
'get_page',
|
|
'list_pages',
|
|
'get_backlinks',
|
|
'get_links',
|
|
'get_timeline',
|
|
'get_tags',
|
|
'traverse_graph',
|
|
'resolve_slugs',
|
|
'get_chunks',
|
|
'get_stats',
|
|
] as const satisfies readonly string[];
|
|
|
|
export type ReadToolName = typeof EXPOSED_READ_TOOLS[number];
|
|
|
|
/** 3 dry-run write tools. Record intent to the flight-recorder; never mutate. */
|
|
export const DRY_RUN_TOOLS = [
|
|
'dry_run_put_page',
|
|
'dry_run_add_link',
|
|
'dry_run_add_timeline_entry',
|
|
] as const satisfies readonly string[];
|
|
|
|
export type DryRunToolName = typeof DRY_RUN_TOOLS[number];
|
|
|
|
/** ~32K tokens at ~4 chars/token. Matches eval/schemas/tool-schema.schema.json const. */
|
|
const DEFAULT_MAX_CHARS = 32_768 * 4;
|
|
const TRUNCATION_SUFFIX = '…[truncated]';
|
|
|
|
// ─── Errors ───────────────────────────────────────────────────────────
|
|
|
|
export class ForbiddenOpError extends Error {
|
|
readonly kind = 'forbidden_op' as const;
|
|
constructor(toolName: string, reason: string) {
|
|
super(`Forbidden tool call: ${toolName} — ${reason}`);
|
|
this.name = 'ForbiddenOpError';
|
|
}
|
|
}
|
|
|
|
export class UnknownToolError extends Error {
|
|
readonly kind = 'unknown_tool' as const;
|
|
constructor(toolName: string) {
|
|
super(`Unknown tool: ${toolName}`);
|
|
this.name = 'UnknownToolError';
|
|
}
|
|
}
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────
|
|
|
|
export interface PoisonFixture {
|
|
fixture_id: string;
|
|
slug: string;
|
|
kind?: string;
|
|
}
|
|
|
|
export interface DryRunWrite {
|
|
tool_name: DryRunToolName;
|
|
input: Record<string, unknown>;
|
|
ts: string;
|
|
/** Cat 8 back_link_compliance metric input. */
|
|
has_back_links?: boolean;
|
|
/** Cat 8 citation_format metric input. */
|
|
citation_format_ok?: boolean;
|
|
/** Page slug being written to (for structured evidence contract). */
|
|
slug?: string;
|
|
}
|
|
|
|
export interface ToolBridgeState {
|
|
/** Poison fixture_ids whose slug showed up in any tool result. */
|
|
saw_poison_items: string[];
|
|
/** Intended writes captured by dry_run_* tools. */
|
|
made_dry_run_writes: DryRunWrite[];
|
|
/** Per-tool call counts. Cat 8 brain_first_compliance uses this. */
|
|
count_by_tool: Record<string, number>;
|
|
/** Sequential list of tool calls in trace order (used by brain_first_ordering). */
|
|
call_order: string[];
|
|
}
|
|
|
|
export interface ToolBridgeConfig {
|
|
engine: BrainEngine;
|
|
/** Gold poison fixtures loaded from eval/data/gold/poison.json. */
|
|
poisonFixtures: PoisonFixture[];
|
|
/** Character cap per tool_result. Default DEFAULT_MAX_CHARS (~32K tokens). */
|
|
maxCharsPerResult?: number;
|
|
/** Config forwarded to OperationContext. Defaults to minimal eval-safe config. */
|
|
gbrainConfig?: GBrainConfig;
|
|
}
|
|
|
|
/** Anthropic tool-use compatible definition. */
|
|
export interface AnthropicToolDef {
|
|
name: string;
|
|
description: string;
|
|
input_schema: {
|
|
type: 'object';
|
|
properties: Record<string, AnthropicInputProp>;
|
|
required?: string[];
|
|
};
|
|
}
|
|
|
|
interface AnthropicInputProp {
|
|
type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array';
|
|
description?: string;
|
|
enum?: string[];
|
|
items?: AnthropicInputProp;
|
|
}
|
|
|
|
export interface ToolResult {
|
|
/** Serialized content for tool_result block. String or JSON-stringified object. */
|
|
content: string;
|
|
/** True if content was truncated at the char cap. */
|
|
truncated: boolean;
|
|
/** Poison fixture_ids that matched slugs in this result. */
|
|
matched_poison_fixture_ids: string[];
|
|
}
|
|
|
|
// ─── Tool-def builders ────────────────────────────────────────────────
|
|
|
|
function paramDefToProp(def: ParamDef): AnthropicInputProp {
|
|
const prop: AnthropicInputProp = {
|
|
type: def.type === 'object' ? 'object' : def.type === 'array' ? 'array' : def.type,
|
|
};
|
|
if (def.description) prop.description = def.description;
|
|
if (def.enum) prop.enum = def.enum;
|
|
if (def.items) prop.items = paramDefToProp(def.items);
|
|
return prop;
|
|
}
|
|
|
|
function opToToolDef(op: Operation): AnthropicToolDef {
|
|
const properties: Record<string, AnthropicInputProp> = {};
|
|
const required: string[] = [];
|
|
|
|
for (const [paramName, def] of Object.entries(op.params)) {
|
|
// CRITICAL: strip `expand` from the `query` tool. This param, when true,
|
|
// routes the handler through expansion.ts → Anthropic Haiku, which is a
|
|
// hidden nested LLM call the agent must never be able to trigger.
|
|
if (op.name === 'query' && paramName === 'expand') continue;
|
|
|
|
properties[paramName] = paramDefToProp(def);
|
|
if (def.required) required.push(paramName);
|
|
}
|
|
|
|
return {
|
|
name: op.name,
|
|
description: op.description,
|
|
input_schema: {
|
|
type: 'object',
|
|
properties,
|
|
...(required.length > 0 ? { required } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build tool definitions for the 12 read ops + 3 dry_run tools.
|
|
* The order matches EXPOSED_READ_TOOLS followed by DRY_RUN_TOOLS.
|
|
*/
|
|
export function buildToolDefs(): AnthropicToolDef[] {
|
|
const byName = new Map<string, Operation>();
|
|
for (const op of OPERATIONS) byName.set(op.name, op);
|
|
|
|
const defs: AnthropicToolDef[] = [];
|
|
|
|
for (const name of EXPOSED_READ_TOOLS) {
|
|
const op = byName.get(name);
|
|
if (!op) {
|
|
throw new Error(
|
|
`tool-bridge: expected operation "${name}" not found in OPERATIONS registry. ` +
|
|
`This is a contract break — update EXPOSED_READ_TOOLS or src/core/operations.ts.`,
|
|
);
|
|
}
|
|
if (op.mutating) {
|
|
throw new Error(
|
|
`tool-bridge: operation "${name}" is marked mutating but appears in EXPOSED_READ_TOOLS. ` +
|
|
`Remove it from the list or fix the op's mutating flag.`,
|
|
);
|
|
}
|
|
defs.push(opToToolDef(op));
|
|
}
|
|
|
|
// Dry-run write tools. These are local to the bridge — not backed by any
|
|
// real operation. They record intent to the flight-recorder without touching
|
|
// the engine.
|
|
defs.push({
|
|
name: 'dry_run_put_page',
|
|
description:
|
|
'Record an intended brain-page write to the flight-recorder. Does NOT mutate the engine. ' +
|
|
'Use when the task asks the agent to update or create a page — the scorer measures intent, not side effects.',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
slug: { type: 'string', description: 'Page slug (e.g. "people/jane-chen").' },
|
|
title: { type: 'string' },
|
|
compiled_truth: { type: 'string', description: 'The main prose body.' },
|
|
timeline: { type: 'string', description: 'Timeline section text. Empty string if none.' },
|
|
frontmatter: { type: 'object', description: 'Optional frontmatter metadata.' },
|
|
},
|
|
required: ['slug', 'title', 'compiled_truth'],
|
|
},
|
|
});
|
|
|
|
defs.push({
|
|
name: 'dry_run_add_link',
|
|
description:
|
|
'Record an intended typed link between two pages. Does NOT mutate the engine. ' +
|
|
'Recorded to the flight-recorder for Cat 8 back_link_compliance scoring.',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
from: { type: 'string', description: 'Source page slug.' },
|
|
to: { type: 'string', description: 'Target page slug.' },
|
|
type: {
|
|
type: 'string',
|
|
description: 'Link type (e.g. works_at, founded, invested_in, attended, mentions).',
|
|
},
|
|
evidence: {
|
|
type: 'array',
|
|
description: 'Optional list of slugs or source refs supporting this link.',
|
|
items: { type: 'string' },
|
|
},
|
|
},
|
|
required: ['from', 'to', 'type'],
|
|
},
|
|
});
|
|
|
|
defs.push({
|
|
name: 'dry_run_add_timeline_entry',
|
|
description:
|
|
'Record an intended timeline entry on a page. Does NOT mutate the engine. ' +
|
|
'Format: "YYYY-MM-DD | Source — Summary".',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
slug: { type: 'string' },
|
|
date: { type: 'string', description: 'YYYY-MM-DD.' },
|
|
summary: { type: 'string' },
|
|
source: { type: 'string', description: 'Citation slug or URL.' },
|
|
},
|
|
required: ['slug', 'date', 'summary'],
|
|
},
|
|
});
|
|
|
|
return defs;
|
|
}
|
|
|
|
// ─── Poison tagging ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan a tool result for slugs matching poison fixtures. Returns the list
|
|
* of fixture_ids whose slug appears anywhere in the serialized content.
|
|
*
|
|
* Matching is conservative: we look for the literal slug string, bounded
|
|
* by non-identifier characters (start-of-string, whitespace, quote, slash
|
|
* segment boundary). This keeps us from flagging `people/jane-chen` as a
|
|
* match for `people/jane` if both exist.
|
|
*/
|
|
function matchPoison(serializedContent: string, poisonFixtures: PoisonFixture[]): string[] {
|
|
const matched: string[] = [];
|
|
for (const fixture of poisonFixtures) {
|
|
// Slug shape: `dir/name`. Bound left + right with lookbehind/lookahead
|
|
// on non-alphanumeric chars so `people/jane` doesn't match `people/jane-chen`.
|
|
const escaped = fixture.slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`(^|[^a-z0-9-])${escaped}($|[^a-z0-9-])`, 'i');
|
|
if (re.test(serializedContent)) matched.push(fixture.fixture_id);
|
|
}
|
|
return matched;
|
|
}
|
|
|
|
// ─── Truncation ────────────────────────────────────────────────────────
|
|
|
|
function truncateContent(raw: string, maxChars: number): { content: string; truncated: boolean } {
|
|
if (raw.length <= maxChars) return { content: raw, truncated: false };
|
|
const truncatedLen = maxChars - TRUNCATION_SUFFIX.length;
|
|
return {
|
|
content: raw.slice(0, Math.max(0, truncatedLen)) + TRUNCATION_SUFFIX,
|
|
truncated: true,
|
|
};
|
|
}
|
|
|
|
// ─── Dry-run helpers ──────────────────────────────────────────────────
|
|
|
|
function extractBackLinks(compiledTruth: string): string[] {
|
|
// Markdown links: [text](slug) where slug has exactly one slash.
|
|
const re = /\[[^\]]+\]\(([a-z][a-z0-9-]*\/[a-z0-9][a-z0-9-]*)\)/gi;
|
|
const hits: string[] = [];
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(compiledTruth)) !== null) hits.push(m[1]);
|
|
return hits;
|
|
}
|
|
|
|
function checkCitationFormat(timeline: string): boolean {
|
|
if (!timeline || !timeline.trim()) return true; // no timeline = nothing to validate
|
|
// Required format per skills/_output-rules.md:
|
|
// - **YYYY-MM-DD** | Source — Summary
|
|
const lines = timeline.split('\n').filter(l => l.trim().startsWith('- '));
|
|
if (lines.length === 0) return true;
|
|
const re = /^- \*\*\d{4}-\d{2}-\d{2}\*\*\s*\|\s*.+?\s*[—-]\s*.+/;
|
|
return lines.every(l => re.test(l));
|
|
}
|
|
|
|
// ─── Tool bridge factory ──────────────────────────────────────────────
|
|
|
|
export interface ToolBridge {
|
|
/** Tool definitions to pass to Anthropic Messages API. */
|
|
toolDefs: AnthropicToolDef[];
|
|
/** Execute a tool call. Updates `state` as a side effect. */
|
|
executeTool(name: string, input: Record<string, unknown>): Promise<ToolResult>;
|
|
/** Mutating state for the structured-evidence judge contract. */
|
|
state: ToolBridgeState;
|
|
}
|
|
|
|
export function createToolBridge(config: ToolBridgeConfig): ToolBridge {
|
|
const maxChars = config.maxCharsPerResult ?? DEFAULT_MAX_CHARS;
|
|
const opsByName = new Map<string, Operation>();
|
|
for (const op of OPERATIONS) opsByName.set(op.name, op);
|
|
|
|
const state: ToolBridgeState = {
|
|
saw_poison_items: [],
|
|
made_dry_run_writes: [],
|
|
count_by_tool: {},
|
|
call_order: [],
|
|
};
|
|
|
|
const ctx: OperationContext = {
|
|
engine: config.engine,
|
|
config: config.gbrainConfig ?? evalSafeConfig(),
|
|
logger: silentLogger(),
|
|
dryRun: false,
|
|
remote: true, // agent loop is untrusted caller — matches MCP posture
|
|
};
|
|
|
|
const readNames = new Set<string>(EXPOSED_READ_TOOLS);
|
|
const dryRunNames = new Set<string>(DRY_RUN_TOOLS);
|
|
|
|
async function executeTool(
|
|
name: string,
|
|
input: Record<string, unknown>,
|
|
): Promise<ToolResult> {
|
|
state.call_order.push(name);
|
|
state.count_by_tool[name] = (state.count_by_tool[name] ?? 0) + 1;
|
|
|
|
// ── Dry-run tools (no engine call) ──
|
|
if (dryRunNames.has(name)) {
|
|
return executeDryRun(name as DryRunToolName, input, state, maxChars);
|
|
}
|
|
|
|
// ── Read-only tools (dispatch to OPERATIONS) ──
|
|
if (!readNames.has(name)) {
|
|
// Unknown or forbidden. Mutating ops surface as forbidden rather than unknown
|
|
// so an agent trying to cheat gets a clear signal (and the test asserts on it).
|
|
const op = opsByName.get(name);
|
|
if (op && op.mutating) {
|
|
throw new ForbiddenOpError(
|
|
name,
|
|
'mutating operations are not exposed to the agent loop; use dry_run_* tools to record intent',
|
|
);
|
|
}
|
|
throw new UnknownToolError(name);
|
|
}
|
|
|
|
const op = opsByName.get(name);
|
|
if (!op) {
|
|
throw new Error(
|
|
`tool-bridge internal: EXPOSED_READ_TOOLS lists "${name}" but OPERATIONS does not. ` +
|
|
`Regenerate tool schemas.`,
|
|
);
|
|
}
|
|
|
|
// CRITICAL: force expand=false on query. Belt-and-suspenders with the
|
|
// schema strip — even if the model somehow passes an `expand` field, we
|
|
// overwrite it.
|
|
const safeInput = { ...input };
|
|
if (name === 'query') safeInput.expand = false;
|
|
|
|
const raw = await op.handler(ctx, safeInput);
|
|
const serialized = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
const { content, truncated } = truncateContent(serialized, maxChars);
|
|
const matched = matchPoison(content, config.poisonFixtures);
|
|
for (const id of matched) {
|
|
if (!state.saw_poison_items.includes(id)) state.saw_poison_items.push(id);
|
|
}
|
|
|
|
return { content, truncated, matched_poison_fixture_ids: matched };
|
|
}
|
|
|
|
return { toolDefs: buildToolDefs(), executeTool, state };
|
|
}
|
|
|
|
function executeDryRun(
|
|
name: DryRunToolName,
|
|
input: Record<string, unknown>,
|
|
state: ToolBridgeState,
|
|
maxChars: number,
|
|
): ToolResult {
|
|
const ts = new Date().toISOString();
|
|
const write: DryRunWrite = { tool_name: name, input: { ...input }, ts };
|
|
|
|
if (name === 'dry_run_put_page') {
|
|
const slug = typeof input.slug === 'string' ? input.slug : undefined;
|
|
const compiled = typeof input.compiled_truth === 'string' ? input.compiled_truth : '';
|
|
const timeline = typeof input.timeline === 'string' ? input.timeline : '';
|
|
write.slug = slug;
|
|
write.has_back_links = extractBackLinks(compiled).length > 0;
|
|
write.citation_format_ok = checkCitationFormat(timeline);
|
|
} else if (name === 'dry_run_add_link') {
|
|
write.slug = typeof input.from === 'string' ? input.from : undefined;
|
|
write.has_back_links = true; // by definition
|
|
write.citation_format_ok = true;
|
|
} else if (name === 'dry_run_add_timeline_entry') {
|
|
const slug = typeof input.slug === 'string' ? input.slug : undefined;
|
|
const date = typeof input.date === 'string' ? input.date : '';
|
|
const source = typeof input.source === 'string' ? input.source : '';
|
|
write.slug = slug;
|
|
write.has_back_links = false;
|
|
write.citation_format_ok = /^\d{4}-\d{2}-\d{2}$/.test(date) && source.length > 0;
|
|
}
|
|
|
|
state.made_dry_run_writes.push(write);
|
|
|
|
const ack = JSON.stringify({
|
|
recorded: true,
|
|
tool: name,
|
|
slug: write.slug,
|
|
has_back_links: write.has_back_links,
|
|
citation_format_ok: write.citation_format_ok,
|
|
note: 'Intent recorded to flight-recorder. Engine state unchanged.',
|
|
});
|
|
const { content, truncated } = truncateContent(ack, maxChars);
|
|
return { content, truncated, matched_poison_fixture_ids: [] };
|
|
}
|
|
|
|
// ─── Minimal OperationContext fill ────────────────────────────────────
|
|
|
|
function evalSafeConfig(): GBrainConfig {
|
|
// OperationContext.config is typed as GBrainConfig but the read ops we expose
|
|
// don't actually use it. Return a minimal object; operations that need
|
|
// specific config fields already guard with defaults.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return {} as any;
|
|
}
|
|
|
|
function silentLogger() {
|
|
return {
|
|
info: () => {},
|
|
warn: () => {},
|
|
error: () => {},
|
|
};
|
|
}
|