mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
32
Commits
master
...
Jul13-sina
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b839dc996 | ||
|
+3 |
1c9acde3c2 | ||
|
|
cd33efdd68 | ||
|
|
062915fe7d | ||
|
|
62592c939e | ||
|
|
7c04f46f12 | ||
|
|
cde2f0ec79 | ||
|
|
92b6f00833 | ||
|
|
0d76183528 | ||
|
|
f4593769aa | ||
|
|
9c04c42f89 | ||
|
|
819e807ed3 | ||
|
|
cd4e3d2c47 | ||
|
|
9695c7383a | ||
|
|
f29f24dfb1 | ||
|
|
f2e7a87910 | ||
|
|
7639493e8c | ||
|
|
22a9c985d2 | ||
|
|
e6d392ad08 | ||
|
|
bce5f20c25 | ||
|
|
3265dc2c93 | ||
|
|
f589739469 | ||
|
|
cc90471a96 | ||
|
|
41d72585d0 | ||
|
|
a21abd9d49 | ||
|
|
9102ce75e1 | ||
|
|
89af5e3c09 | ||
|
|
63f6665b73 | ||
|
|
78f3ebea33 | ||
|
|
62bee97d20 | ||
|
|
08bdae892e | ||
|
|
0621687276 |
@@ -133,14 +133,15 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
|
||||
* Returns the resolved status for a migration based on its entries.
|
||||
*
|
||||
* Semantics (Bug 3 — keep "complete wins" safety):
|
||||
* - If any entry is `complete`, the version is complete. Terminal state.
|
||||
* - Otherwise, if the latest entry is `retry`, the version is pending
|
||||
* (user requested a fresh attempt).
|
||||
* - If the latest entry is `retry`, the version is pending. This is the
|
||||
* explicit escape hatch written by `--force-retry`, and it overrides an
|
||||
* earlier `complete` entry without hand-editing the ledger.
|
||||
* - Otherwise, if any entry is `complete`, the version is complete.
|
||||
* - Otherwise, if any entry is `partial`, the version is partial.
|
||||
* - Otherwise, pending.
|
||||
*
|
||||
* `complete` never regresses. A later accidental `partial` append cannot
|
||||
* undo a completed migration.
|
||||
* `complete` never regresses accidentally. A later `partial` append cannot
|
||||
* undo a completed migration; only a trailing, explicit `retry` marker can.
|
||||
*/
|
||||
function statusForVersion(
|
||||
version: string,
|
||||
@@ -148,9 +149,9 @@ function statusForVersion(
|
||||
): 'complete' | 'partial' | 'pending' | 'wedged' {
|
||||
const entries = idx.byVersion.get(version) ?? [];
|
||||
if (entries.length === 0) return 'pending';
|
||||
if (entries.some(e => e.status === 'complete')) return 'complete';
|
||||
const latest = entries[entries.length - 1];
|
||||
if (latest.status === 'retry') return 'pending';
|
||||
if (entries.some(e => e.status === 'complete')) return 'complete';
|
||||
// Bug 3 attempt cap — count consecutive partials from the end (stopping
|
||||
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
|
||||
// the migration is wedged and needs explicit --force-retry to try again.
|
||||
|
||||
@@ -476,6 +476,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
||||
|
||||
let consecutiveErrors = 0;
|
||||
// Parser-probe fixture warning is once-per-process, not once-per-cycle
|
||||
// (compiled-binary installs have no source tree; don't spam the log).
|
||||
let parserProbeFixtureWarned = false;
|
||||
// v0.37.7.0 #1162 — counter for consecutive reconnect failures.
|
||||
// Reset on every successful health probe or reconnect. Threshold
|
||||
// controlled by GBRAIN_AUTOPILOT_MAX_RECONNECT_FAILS env (default 30).
|
||||
@@ -1022,17 +1025,36 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
// loop. Probe runs even when cycleOk=false (probe may surface signal
|
||||
// explaining why the cycle is failing).
|
||||
try {
|
||||
const probeEnabled = cfg?.autopilot?.nightly_quality_probe?.enabled === true;
|
||||
const { resolveProbeEnabled, resolveProbeMaxUsd, runNightlyQualityProbe } = await import('../core/cycle/nightly-quality-probe.ts');
|
||||
// Dual-plane read: `gbrain config set` (what the doctor enable hint
|
||||
// prints) writes the DB plane; ~/.gbrain/config.json is the fallback.
|
||||
let dbEnabled: string | null = null;
|
||||
let dbMaxUsd: string | null = null;
|
||||
try {
|
||||
dbEnabled = await engine.getConfig('autopilot.nightly_quality_probe.enabled');
|
||||
dbMaxUsd = await engine.getConfig('autopilot.nightly_quality_probe.max_usd');
|
||||
} catch { /* DB unavailable → file plane only */ }
|
||||
const probeEnabled = resolveProbeEnabled(dbEnabled, cfg?.autopilot?.nightly_quality_probe?.enabled);
|
||||
if (probeEnabled) {
|
||||
const { runNightlyQualityProbe } = await import('../core/cycle/nightly-quality-probe.ts');
|
||||
const { runLongMemEvalForProbe, runCrossModalBatchForProbe } = await import('../core/cycle/nightly-probe-adapters.ts');
|
||||
const { isAvailable } = await import('../core/ai/gateway.ts');
|
||||
const maxUsd = Number(cfg?.autopilot?.nightly_quality_probe?.max_usd ?? 5);
|
||||
const { existsSync } = await import('node:fs');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const { join } = await import('node:path');
|
||||
const maxUsd = resolveProbeMaxUsd(dbMaxUsd, cfg?.autopilot?.nightly_quality_probe?.max_usd);
|
||||
// The committed fixture (test/fixtures/longmemeval-nightly.jsonl)
|
||||
// lives in the gbrain PACKAGE, not the brain repo — repoPath is
|
||||
// sync.repo_path (the user's brain), where the fixture never
|
||||
// exists, so the probe error'd on every real install. Resolve the
|
||||
// package root from the module location; keep repoPath as the
|
||||
// fallback for setups that vendor the fixture into the brain repo.
|
||||
const pkgRoot = fileURLToPath(new URL('../..', import.meta.url));
|
||||
const fixtureAtPkgRoot = existsSync(join(pkgRoot, 'test', 'fixtures', 'longmemeval-nightly.jsonl'));
|
||||
await runNightlyQualityProbe({
|
||||
isEnabled: () => true, // already gated above; phase re-checks for defense-in-depth
|
||||
hasEmbeddingProvider: () => isAvailable('embedding'),
|
||||
resolveMaxUsd: () => maxUsd,
|
||||
resolveRepoRoot: () => repoPath ?? gbrainHomePath('.'),
|
||||
resolveRepoRoot: () => (fixtureAtPkgRoot ? pkgRoot : repoPath ?? gbrainHomePath('.')),
|
||||
runLongMemEval: runLongMemEvalForProbe,
|
||||
runCrossModalBatch: runCrossModalBatchForProbe,
|
||||
now: () => new Date(),
|
||||
@@ -1044,6 +1066,62 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
// informational; autopilot loop continues.
|
||||
}
|
||||
|
||||
// 4.6 — Nightly conversation-parser probe (v0.41.16.0 phase module;
|
||||
// the scheduler wire-up was deferred at ship and is added here). Same
|
||||
// posture as 4.5: the phase owns its gates (enabled/mode-gate, LLM
|
||||
// key), the wiring owns invocation + the audit row, and a probe
|
||||
// failure NEVER crashes the autopilot loop. Per D10 the probe is
|
||||
// default-ON for search.mode=tokenmax, opt-in otherwise.
|
||||
try {
|
||||
const { runConversationParserNightlyProbe } = await import('../core/conversation-parser/nightly-probe.ts');
|
||||
const { logParserProbeEvent, parserProbeRanWithin } = await import('../core/audit-parser-probe.ts');
|
||||
const { isAvailable } = await import('../core/ai/gateway.ts');
|
||||
const { existsSync } = await import('node:fs');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const { join } = await import('node:path');
|
||||
// Flag reads dual-plane: the DB row (`gbrain config set …`) wins,
|
||||
// ~/.gbrain/config.json is the fallback. search.mode lives on the
|
||||
// DB plane only (mode.ts owns it).
|
||||
let parserDbEnabled: string | null = null;
|
||||
let dbSearchMode: string | null = null;
|
||||
try {
|
||||
parserDbEnabled = await engine.getConfig('autopilot.conversation_parser_probe.enabled');
|
||||
dbSearchMode = await engine.getConfig('search.mode');
|
||||
} catch { /* DB unavailable → file plane only */ }
|
||||
const parserEnabled = parserDbEnabled != null
|
||||
? parserDbEnabled === 'true'
|
||||
: cfg?.autopilot?.conversation_parser_probe?.enabled === true;
|
||||
const searchMode = dbSearchMode ?? '';
|
||||
// Fixtures are committed in the gbrain package (test/fixtures/…),
|
||||
// NOT the brain repo — resolve from the module location. Compiled
|
||||
// binaries carry no source tree: skip quietly instead of writing
|
||||
// failure rows that would flip doctor to WARN on every binary install.
|
||||
const pkgRoot = fileURLToPath(new URL('../..', import.meta.url));
|
||||
const fixturePath = join(pkgRoot, 'test', 'fixtures', 'conversation-formats', 'all.jsonl');
|
||||
const adversarialPath = join(pkgRoot, 'test', 'fixtures', 'conversation-formats', 'adversarial.jsonl');
|
||||
const shouldInvoke = parserEnabled || searchMode === 'tokenmax';
|
||||
if (shouldInvoke && existsSync(fixturePath) && existsSync(adversarialPath)) {
|
||||
const result = await runConversationParserNightlyProbe({
|
||||
isEnabled: () => parserEnabled,
|
||||
searchMode: () => searchMode,
|
||||
hasLlmKey: () => isAvailable('chat'),
|
||||
resolveFixturePath: () => fixturePath,
|
||||
resolveAdversarialPath: () => adversarialPath,
|
||||
now: () => new Date(),
|
||||
shouldSkipForRateLimit: () => parserProbeRanWithin(24 * 60 * 60 * 1000),
|
||||
});
|
||||
// rate_limited is a non-run: the loop ticks every few minutes, so
|
||||
// logging every skip would flood the audit file with no-signal rows.
|
||||
if (result.outcome !== 'rate_limited') logParserProbeEvent(result);
|
||||
} else if (shouldInvoke && !parserProbeFixtureWarned) {
|
||||
parserProbeFixtureWarned = true;
|
||||
console.error(`[parser-probe] fixtures not found under ${pkgRoot}; skipping (probe needs a source-checkout install)`);
|
||||
}
|
||||
} catch (e) {
|
||||
logError('autopilot.parser_probe', e);
|
||||
// Informational, like 4.5: do NOT bump consecutiveErrors.
|
||||
}
|
||||
|
||||
// Wait for next cycle
|
||||
await new Promise(r => setTimeout(r, interval * 1000));
|
||||
}
|
||||
|
||||
+79
-14
@@ -2896,6 +2896,54 @@ function _resolveSyncFreshnessHours(varName: string, fallback: number): number {
|
||||
* branch (disabled / enabled-no-events / enabled-all-pass / enabled-with-failures)
|
||||
* without spinning up the audit JSONL or a real config file.
|
||||
*/
|
||||
/**
|
||||
* Pure function form of the conversation_parser_probe_health check.
|
||||
* Mirrors computeNightlyQualityProbeHealthCheck: skip-with-hint when the
|
||||
* probe is off and silent, surface the last 7 days of audit events when
|
||||
* it has run, WARN on any non-pass outcome.
|
||||
*
|
||||
* `effectiveEnabled` folds the D10 mode-gate in: explicitly enabled OR
|
||||
* search.mode=tokenmax (where the probe is default-on).
|
||||
*/
|
||||
export function computeConversationParserProbeHealthCheck(
|
||||
effectiveEnabled: boolean,
|
||||
events: ReadonlyArray<{ outcome: string; ts: string; reason?: string }>,
|
||||
): Check {
|
||||
const name = 'conversation_parser_probe_health';
|
||||
if (!effectiveEnabled && events.length === 0) {
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message:
|
||||
'disabled (opt-in; default-on only for search.mode=tokenmax). Enable with: ' +
|
||||
'`gbrain config set autopilot.conversation_parser_probe.enabled true`',
|
||||
};
|
||||
}
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: 'enabled but no probe events in the last 7 days (next run by autopilot; fixtures require a source-checkout install).',
|
||||
};
|
||||
}
|
||||
const bad = events.filter(e => e.outcome !== 'pass');
|
||||
const latest = events[events.length - 1]!;
|
||||
if (bad.length > 0) {
|
||||
return {
|
||||
name,
|
||||
status: 'warn',
|
||||
message:
|
||||
`${bad.length}/${events.length} probe run(s) in the last 7 days did not pass; ` +
|
||||
`latest: ${latest.outcome}${latest.reason ? ` (${latest.reason})` : ''}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
status: 'ok',
|
||||
message: `${events.length} probe run(s) in the last 7 days, all pass (latest ${latest.ts}).`,
|
||||
};
|
||||
}
|
||||
|
||||
export function computeNightlyQualityProbeHealthCheck(
|
||||
probeEnabled: boolean,
|
||||
events: ReadonlyArray<{ outcome: string; ts: string; detail?: string }>,
|
||||
@@ -4779,10 +4827,17 @@ export async function buildChecks(
|
||||
try {
|
||||
const { readRecentQualityProbeEvents } = await import('../core/audit-quality-probe.ts');
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const { resolveProbeEnabled } = await import('../core/cycle/nightly-quality-probe.ts');
|
||||
let probeEnabled = false;
|
||||
try {
|
||||
// Dual-plane read, matching the autopilot gate: the DB row (what the
|
||||
// enable hint's `gbrain config set` writes) wins; file plane fallback.
|
||||
let dbVal: string | null = null;
|
||||
try {
|
||||
dbVal = engine ? await engine.getConfig('autopilot.nightly_quality_probe.enabled') : null;
|
||||
} catch { /* DB unavailable → file plane only */ }
|
||||
const cfg = loadConfig();
|
||||
probeEnabled = Boolean((cfg as any)?.autopilot?.nightly_quality_probe?.enabled);
|
||||
probeEnabled = resolveProbeEnabled(dbVal, (cfg as any)?.autopilot?.nightly_quality_probe?.enabled);
|
||||
} catch { /* config unavailable → treat as disabled */ }
|
||||
const events = readRecentQualityProbeEvents(7);
|
||||
const check = computeNightlyQualityProbeHealthCheck(probeEnabled, events);
|
||||
@@ -4965,19 +5020,29 @@ export async function buildChecks(
|
||||
|
||||
// 3d.5 v0.41.13.0 — conversation_parser_probe_health. Mode-gated
|
||||
// per D10: ON when search.mode=tokenmax, opt-in for other modes.
|
||||
// Surface the last 7 days of nightly-probe events; warn on FAIL /
|
||||
// BUDGET_EXCEEDED / adversarial_false_positive.
|
||||
//
|
||||
// v0.41.13.0 ships the probe as opt-in (autopilot wiring deferred
|
||||
// to T7 in the cathedral plan); this check skips with an enable
|
||||
// hint until the probe has at least one audit event written.
|
||||
checks.push({
|
||||
name: 'conversation_parser_probe_health',
|
||||
status: 'ok',
|
||||
message:
|
||||
'Skipped (nightly probe is opt-in; enable with ' +
|
||||
'`gbrain config set autopilot.conversation_parser_probe.enabled true`)',
|
||||
});
|
||||
// Surfaces the last 7 days of nightly-probe audit events; warn on any
|
||||
// non-pass outcome (fail / budget_exceeded / adversarial_false_positive).
|
||||
// (Until the autopilot wire-up this was a hardcoded "Skipped" stub.)
|
||||
try {
|
||||
const { readRecentParserProbeEvents } = await import('../core/audit-parser-probe.ts');
|
||||
let parserProbeEnabled = false;
|
||||
try {
|
||||
let dbVal: string | null = null;
|
||||
let dbMode: string | null = null;
|
||||
try {
|
||||
dbVal = engine ? await engine.getConfig('autopilot.conversation_parser_probe.enabled') : null;
|
||||
dbMode = engine ? await engine.getConfig('search.mode') : null;
|
||||
} catch { /* DB unavailable → file plane only */ }
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const fileVal = (loadConfig() as any)?.autopilot?.conversation_parser_probe?.enabled;
|
||||
const flagOn = dbVal != null ? dbVal === 'true' : fileVal === true;
|
||||
parserProbeEnabled = flagOn || dbMode === 'tokenmax';
|
||||
} catch { /* config unavailable → treat as disabled */ }
|
||||
const parserEvents = readRecentParserProbeEvents(7);
|
||||
checks.push(computeConversationParserProbeHealthCheck(parserProbeEnabled, parserEvents));
|
||||
} catch {
|
||||
// Best-effort; audit-log read failure shouldn't stop doctor.
|
||||
}
|
||||
|
||||
// 3e. home_dir_in_worktree (v0.35.8.0). Walks up from `gbrainPath()`
|
||||
// looking for a `.git` directory OR file. If found, warns: `~/.gbrain/`
|
||||
|
||||
@@ -466,6 +466,14 @@ interface BatchRow {
|
||||
question_id: string;
|
||||
question: string;
|
||||
hypothesis: string;
|
||||
/**
|
||||
* Gold answer from the benchmark dataset, when the upstream eval emits
|
||||
* it (eval-longmemeval does). Folded into the judge task so CORRECTNESS
|
||||
* is verifiable — without it a judge panel that sees only
|
||||
* {question, hypothesis} cannot validate a terse factual answer against
|
||||
* a haystack it never saw.
|
||||
*/
|
||||
answer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,6 +587,7 @@ function readBatchRows(path: string): BatchReadResult {
|
||||
question_id: typeof obj.question_id === 'string' ? obj.question_id : `line-${lineNo}`,
|
||||
question: obj.question,
|
||||
hypothesis: obj.hypothesis,
|
||||
...(typeof obj.answer === 'string' && obj.answer.length > 0 ? { answer: obj.answer } : {}),
|
||||
});
|
||||
}
|
||||
if (summarySkipped > 0) {
|
||||
@@ -695,7 +704,11 @@ async function runBatchMode(parsed: ParsedArgs, opts: RunCrossModalOpts): Promis
|
||||
fn: async (row, idx) => {
|
||||
process.stderr.write(`[eval cross-modal batch] ${idx + 1}/${rows.length} ${row.question_id} starting...\n`);
|
||||
return await runEvalFn({
|
||||
task: row.question,
|
||||
// With a gold answer the judges can actually verify correctness;
|
||||
// without one they see only {question, hypothesis} and cannot.
|
||||
task: row.answer
|
||||
? `${row.question}\n\nExpected answer (gold label from the benchmark dataset): ${row.answer}`
|
||||
: row.question,
|
||||
output: row.hypothesis,
|
||||
slug: row.question_id,
|
||||
dimensions,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type AliasMap,
|
||||
} from '../eval/longmemeval/extract.ts';
|
||||
import { extractCandidateEntities } from '../core/think/entity-extract.ts';
|
||||
import { splitProviderModelId } from '../core/model-id.ts';
|
||||
import { resolveEntitySlugWithSource, type ResolutionSource } from '../core/entities/resolve.ts';
|
||||
import { formatTrajectoryBlock } from '../core/trajectory-format.ts';
|
||||
|
||||
@@ -469,14 +470,22 @@ export async function runEvalLongMemEval(args: string[], runOpts: RunOpts = {}):
|
||||
});
|
||||
|
||||
// Wrap Anthropic SDK so its `.messages.create` shape matches ThinkLLMClient.
|
||||
// Same pattern as src/core/think/index.ts:247-249.
|
||||
// Same pattern as src/core/think/index.ts:247-249 — EXCEPT think's default
|
||||
// client routes through the gateway, which parses `provider:model` recipe
|
||||
// ids. This eval's client is a raw SDK by design (hermetic, no gateway
|
||||
// dependency), and resolveModel returns RECIPE ids (`anthropic:claude-…`);
|
||||
// passing one through unstripped 404s every answer/extractor call, which
|
||||
// surfaces downstream as all-upstream_error batches in the nightly probe.
|
||||
const toSdkModel = (m: string): string => splitProviderModelId(m).model || m;
|
||||
const realClient = new Anthropic();
|
||||
const client: ThinkLLMClient = runOpts.client ?? {
|
||||
create: (params, callOpts) => realClient.messages.create(params, callOpts),
|
||||
create: (params, callOpts) =>
|
||||
realClient.messages.create({ ...params, model: toSdkModel(params.model) }, callOpts),
|
||||
};
|
||||
// v0.40.2.0 — separate extractor client (defaults to same SDK).
|
||||
const extractorClient: ThinkLLMClient = runOpts.extractorClient ?? {
|
||||
create: (params, callOpts) => realClient.messages.create(params, callOpts),
|
||||
create: (params, callOpts) =>
|
||||
realClient.messages.create({ ...params, model: toSdkModel(params.model) }, callOpts),
|
||||
};
|
||||
const trajectoryEnabled = !opts.noTrajectory;
|
||||
const extractorModel = trajectoryEnabled
|
||||
@@ -751,6 +760,11 @@ async function runOneQuestion(
|
||||
// v0.40.1.0 (Track D / T2) — copy question_type into the row so the
|
||||
// by_type_summary can be rebuilt from the file on resume runs.
|
||||
question_type: q.question_type,
|
||||
// Gold answer for downstream consumers that verify correctness (the
|
||||
// cross-modal --batch judge folds it into the task; evaluate_qa.py
|
||||
// ignores unknown fields). Without it a judge can't validate a terse
|
||||
// factual hypothesis against a haystack it never saw.
|
||||
...(q.answer !== undefined ? { answer: q.answer } : {}),
|
||||
hypothesis,
|
||||
retrieved_session_ids: retrievedSessionIds,
|
||||
...(recallHit !== undefined ? { recall_hit: recallHit } : {}),
|
||||
|
||||
@@ -90,6 +90,26 @@ export function resolveBootstrapToken(
|
||||
return { kind: 'ok', token: trimmed, fromEnv: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2624: decide whether the generated admin bootstrap token is hidden from
|
||||
* the startup banner. Fail-safe default: a generated token is NOT printed
|
||||
* unless stderr is an interactive TTY, so containerized (non-TTY) deploys
|
||||
* never ship the secret to centralized log storage. Env-sourced tokens are
|
||||
* always hidden (operator already holds them). Explicit --suppress hides
|
||||
* everything; --print-admin-token forces the raw value even on a non-TTY.
|
||||
*/
|
||||
export function shouldSuppressBootstrapPrint(opts: {
|
||||
suppress: boolean;
|
||||
fromEnv: boolean;
|
||||
forcePrint: boolean;
|
||||
isTty: boolean;
|
||||
}): boolean {
|
||||
if (opts.suppress) return true;
|
||||
if (opts.fromEnv) return true;
|
||||
if (opts.forcePrint) return false;
|
||||
return !opts.isTty;
|
||||
}
|
||||
|
||||
export type ProbeHealthResult =
|
||||
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
|
||||
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
|
||||
@@ -304,6 +324,14 @@ interface ServeHttpOptions {
|
||||
* tracking the regenerated value through other means.
|
||||
*/
|
||||
suppressBootstrapToken?: boolean;
|
||||
/**
|
||||
* #2624: force-print the generated admin bootstrap token even on a
|
||||
* non-TTY (containerized) start. By default the raw token is only printed
|
||||
* when stderr is an interactive TTY, so it never lands in centralized log
|
||||
* storage for headless deploys. Set this when you genuinely need the value
|
||||
* captured to a non-interactive log and accept the leak.
|
||||
*/
|
||||
printAdminToken?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -530,7 +558,12 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
let bootstrapToken: string = resolved.token;
|
||||
let bootstrapFromEnv: boolean = resolved.fromEnv;
|
||||
const bootstrapHash = createHash('sha256').update(bootstrapToken).digest('hex');
|
||||
const suppressBootstrapPrint = options.suppressBootstrapToken === true;
|
||||
const suppressBootstrapPrint = shouldSuppressBootstrapPrint({
|
||||
suppress: options.suppressBootstrapToken === true,
|
||||
fromEnv: bootstrapFromEnv,
|
||||
forcePrint: options.printAdminToken === true,
|
||||
isTty: process.stderr.isTTY === true,
|
||||
});
|
||||
const adminSessions = new Map<string, number>(); // sessionId → expiresAt
|
||||
|
||||
// SSE clients for live activity feed
|
||||
@@ -2166,10 +2199,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
║ MCP: http://localhost:${port}/mcp${' '.repeat(Math.max(0, 21 - String(port).length))}║
|
||||
║ Health: http://localhost:${port}/health${' '.repeat(Math.max(0, 18 - String(port).length))}║
|
||||
╠══════════════════════════════════════════════════════╣
|
||||
${suppressBootstrapPrint
|
||||
? '║ Admin Token: suppressed (--suppress-bootstrap-token) ║\n╚══════════════════════════════════════════════════════╝'
|
||||
: bootstrapFromEnv
|
||||
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
|
||||
${bootstrapFromEnv
|
||||
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
|
||||
: suppressBootstrapPrint
|
||||
? '║ Admin Token: hidden (non-TTY log-leak guard) ║\n║ set $GBRAIN_ADMIN_BOOTSTRAP_TOKEN, or pass ║\n║ --print-admin-token on a trusted terminal. ║\n╚══════════════════════════════════════════════════════╝'
|
||||
: `║ Admin Token (paste into /admin login): ║\n║ ${bootstrapToken.substring(0, 50)} ║\n║ ${bootstrapToken.substring(50).padEnd(50)} ║\n╚══════════════════════════════════════════════════════╝`}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -118,8 +118,13 @@ export async function runServe(
|
||||
// restart.
|
||||
const suppressBootstrapToken = args.includes('--suppress-bootstrap-token');
|
||||
|
||||
// #2624: by default the generated token only prints on an interactive
|
||||
// TTY (never into container log storage). --print-admin-token forces the
|
||||
// raw value even on a non-TTY start.
|
||||
const printAdminToken = args.includes('--print-admin-token');
|
||||
|
||||
const { runServeHttp } = await import('./serve-http.ts');
|
||||
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken });
|
||||
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken, printAdminToken });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
|
||||
// plane field now exists (GBrainConfig type) and gets mapped here, so
|
||||
// setting it via `~/.gbrain/config.json` propagates into the gateway.
|
||||
if (c.zeroentropy_api_key) envFromConfig.ZEROENTROPY_API_KEY = c.zeroentropy_api_key;
|
||||
// Same seam for OpenRouter: `gbrain config set openrouter_api_key X` (or
|
||||
// config.json) must reach the openrouter recipe's OPENROUTER_API_KEY.
|
||||
// process.env still wins via the later spread.
|
||||
if (c.openrouter_api_key) envFromConfig.OPENROUTER_API_KEY = c.openrouter_api_key;
|
||||
|
||||
// v0.32 codex finding #4+#5 fix: thread local-server _BASE_URL env vars
|
||||
// into base_urls so the gateway hits the user's configured port. Without
|
||||
|
||||
+140
-15
@@ -377,7 +377,8 @@ export function applyOpenAICompatConfig(
|
||||
cfg: AIGatewayConfig,
|
||||
): { baseURL: string; fetch?: typeof fetch } {
|
||||
if (recipe.resolveOpenAICompatConfig) {
|
||||
return recipe.resolveOpenAICompatConfig(cfg.env);
|
||||
const resolved = recipe.resolveOpenAICompatConfig(cfg.env);
|
||||
return { ...resolved, fetch: resolved.fetch ?? recipe.compat?.fetch };
|
||||
}
|
||||
const baseURL = cfg.base_urls?.[recipe.id] ?? recipe.base_url_default;
|
||||
if (!baseURL) {
|
||||
@@ -386,7 +387,7 @@ export function applyOpenAICompatConfig(
|
||||
recipe.setup_hint,
|
||||
);
|
||||
}
|
||||
return { baseURL };
|
||||
return { baseURL, fetch: recipe.compat?.fetch };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2383,6 +2384,58 @@ export interface ChatToolDef {
|
||||
* production subagent jobs) throws "messages do not match the ModelMessage[]
|
||||
* schema" the moment the model calls a tool. Surfaced by the SkillOpt eval.
|
||||
*/
|
||||
/**
|
||||
* Default per-call max output tokens. Thinking-by-default Claude 5 models
|
||||
* (`anthropic:claude-*-5`) burn a large chunk of the budget on internal
|
||||
* reasoning before emitting any text, so a 4096 default leaves them with empty
|
||||
* final text on the subagent tool loop. Give those models headroom; providers
|
||||
* bill actual tokens, not the cap, so it is free for the models that don't use
|
||||
* it. Everything else keeps 4096 on purpose: raising the default blanket-wide
|
||||
* would exceed some openai-compat providers' hard max-output caps (DeepSeek
|
||||
* 8192, gpt-4o 16384) and 400 on them — a regression for exactly the
|
||||
* non-Anthropic subagent users the gateway loop exists to serve.
|
||||
*/
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
||||
const THINKING_MODEL_MAX_OUTPUT_TOKENS = 32000;
|
||||
const THINKING_BY_DEFAULT_MODEL_RE = /^anthropic[:/]claude-[a-z0-9]+-5(?:[.-]|$)/i;
|
||||
function defaultMaxOutputTokens(modelStr: string | undefined): number {
|
||||
return modelStr && THINKING_BY_DEFAULT_MODEL_RE.test(modelStr)
|
||||
? THINKING_MODEL_MAX_OUTPUT_TOKENS
|
||||
: DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-serialize a tool output into a plain JSON value for the AI SDK v6
|
||||
* ModelMessage schema. node-postgres returns `timestamptz` columns as JS
|
||||
* `Date` instances, and AI SDK v6's `JSONValue` schema rejects a raw Date,
|
||||
* throwing "Invalid prompt ... ModelMessage[] schema" the moment a
|
||||
* timestamp-bearing tool result (e.g. `brain_get_page`, `brain_list_pages`)
|
||||
* is fed back — dead-lettering the whole multi-tool loop. The JSON round-trip
|
||||
* runs `Date.prototype.toJSON` (ISO string) recursively and drops `undefined`.
|
||||
* This is a serialization fix at the SDK boundary, NOT a `::jsonb` DB cast —
|
||||
* it never touches Postgres. (BigInt / circular outputs still throw in
|
||||
* JSON.stringify; those aren't LLM-serializable and are out of scope.)
|
||||
*/
|
||||
function toJsonSafe(value: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? null));
|
||||
} catch {
|
||||
// BigInt / circular output isn't LLM-serializable; degrade to a string
|
||||
// rather than throwing and dead-lettering the whole tool loop.
|
||||
return safeStringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stringify that never throws (bigint/circular fall back to String()). */
|
||||
function safeStringify(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value ?? null);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function toModelMessages(messages: ChatMessage[]): unknown[] {
|
||||
return messages.map((m) => {
|
||||
if (typeof m.content === 'string') return { role: m.role, content: m.content };
|
||||
@@ -2398,24 +2451,86 @@ export function toModelMessages(messages: ChatMessage[]): unknown[] {
|
||||
toolCallId: b.toolCallId,
|
||||
toolName: b.toolName,
|
||||
output: b.isError
|
||||
? { type: 'error-text' as const, value: typeof b.output === 'string' ? b.output : JSON.stringify(b.output) }
|
||||
? { type: 'error-text' as const, value: safeStringify(b.output) }
|
||||
: (typeof b.output === 'string'
|
||||
? { type: 'text' as const, value: b.output }
|
||||
: { type: 'json' as const, value: (b.output ?? null) as never }),
|
||||
: { type: 'json' as const, value: toJsonSafe(b.output) as never }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: m.role,
|
||||
content: blocks.map((b) => {
|
||||
if (b.type === 'text') return { type: 'text' as const, text: b.text };
|
||||
if (b.type === 'tool-call') return { type: 'tool-call' as const, toolCallId: b.toolCallId, toolName: b.toolName, input: b.input };
|
||||
return b;
|
||||
}),
|
||||
// Drop text blocks whose `text` isn't a string: reasoning models
|
||||
// (DeepSeek v4, etc.) surface `text: null/undefined` thinking parts that
|
||||
// AI SDK v6's Zod schema rejects, poisoning the whole call. `''` is valid
|
||||
// and kept.
|
||||
content: blocks
|
||||
.filter((b) => b.type !== 'text' || typeof b.text === 'string')
|
||||
.map((b) => {
|
||||
if (b.type === 'text') return { type: 'text' as const, text: b.text };
|
||||
if (b.type === 'tool-call') return { type: 'tool-call' as const, toolCallId: b.toolCallId, toolName: b.toolName, input: b.input };
|
||||
return b;
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort normalization at the `chat()` boundary: back-fill error stubs for
|
||||
* any assistant tool-call that isn't answered by the immediately-following
|
||||
* tool-result turn. The subagent handler already balances its own transcript
|
||||
* (see reconcileGatewayReplay), so this is a no-op there — it exists for the
|
||||
* paths reconcile can't reach: a partially-answered turn, a provider that
|
||||
* duplicates or drops tool-call IDs (local vLLM), or a `finishReason:'length'`
|
||||
* truncation mid-batch. Without it those histories throw
|
||||
* AI_MissingToolResultsError inside `generateText`. No-op on balanced input.
|
||||
*
|
||||
* @internal exported for tests.
|
||||
*/
|
||||
export function repairToolPairing(messages: ChatMessage[]): ChatMessage[] {
|
||||
const out: ChatMessage[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
out.push(m);
|
||||
if (typeof m.content === 'string' || m.role !== 'assistant') continue;
|
||||
|
||||
const calls = m.content.filter(
|
||||
(b): b is Extract<ChatBlock, { type: 'tool-call' }> => b.type === 'tool-call',
|
||||
);
|
||||
if (calls.length === 0) continue;
|
||||
|
||||
// v6 only accepts results in the immediately-following message.
|
||||
const next = messages[i + 1];
|
||||
const nextBlocks = next && typeof next.content !== 'string' ? next.content : [];
|
||||
const resolved = new Set(
|
||||
nextBlocks
|
||||
.filter((b): b is Extract<ChatBlock, { type: 'tool-result' }> => b.type === 'tool-result')
|
||||
.map((b) => b.toolCallId),
|
||||
);
|
||||
|
||||
const missing = calls.filter((c) => !resolved.has(c.toolCallId));
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
const stubs: ChatBlock[] = missing.map((c) => ({
|
||||
type: 'tool-result',
|
||||
toolCallId: c.toolCallId,
|
||||
toolName: c.toolName,
|
||||
output: 'tool result unavailable (recovered after interrupted run)',
|
||||
isError: true,
|
||||
}));
|
||||
|
||||
if (resolved.size > 0) {
|
||||
// A tool-result message follows but is incomplete — merge the stubs in.
|
||||
out.push({ role: next!.role, content: [...(nextBlocks as ChatBlock[]), ...stubs] });
|
||||
i++; // the merged message replaces the original; don't emit it twice.
|
||||
} else {
|
||||
// No following tool-result message at all — synthesize one.
|
||||
out.push({ role: 'user', content: stubs });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
/** Final text content concatenated from text blocks. */
|
||||
text: string;
|
||||
@@ -2697,7 +2812,7 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
|
||||
}
|
||||
}
|
||||
const estimatedInputTokens = estimateChatInputTokens(opts);
|
||||
const maxOutputTokens = opts.maxTokens ?? 4096;
|
||||
const maxOutputTokens = opts.maxTokens ?? defaultMaxOutputTokens(modelStrEarly);
|
||||
|
||||
// TX5: reserve BEFORE the provider call. Throws BudgetExhausted on cost,
|
||||
// runtime, or no_pricing (when cap is set). Pre-resolution model id is
|
||||
@@ -2804,9 +2919,9 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
|
||||
const result = await generateText({
|
||||
model,
|
||||
system: opts.system,
|
||||
messages: toModelMessages(opts.messages) as any,
|
||||
messages: toModelMessages(repairToolPairing(opts.messages)) as any,
|
||||
tools: opts.tools && opts.tools.length > 0 ? tools : undefined,
|
||||
maxOutputTokens: opts.maxTokens ?? 4096,
|
||||
maxOutputTokens: opts.maxTokens ?? defaultMaxOutputTokens(modelStr),
|
||||
// v0.42.20.0 — default a chat timeout (composes with the caller's signal,
|
||||
// shorter wins). Covers native-anthropic (the default provider + facts Haiku).
|
||||
abortSignal: withDefaultTimeout(opts.abortSignal, AI_CHAT_TIMEOUT_MS),
|
||||
@@ -2957,6 +3072,14 @@ export interface ToolLoopOpts {
|
||||
) => Promise<{ gbrainToolUseId: string }>;
|
||||
onToolCallComplete?: (gbrainToolUseId: string, output: unknown) => Promise<void>;
|
||||
onToolCallFailed?: (gbrainToolUseId: string, error: string) => Promise<void>;
|
||||
/**
|
||||
* Persist the tool-result user turn that closes each tool round, BEFORE it is
|
||||
* appended to the in-memory history. Without this the loop only kept the
|
||||
* tool-result turn in memory, so a resumed job reloaded assistant tool-calls
|
||||
* with no matching results and non-Anthropic providers rejected the
|
||||
* unbalanced history (AI_MissingToolResultsError). Fires per completed round.
|
||||
*/
|
||||
onToolResultTurn?: (turnIdx: number, messageIdx: number, blocks: ChatBlock[]) => Promise<void>;
|
||||
|
||||
/** Optional per-call heartbeat for observability. */
|
||||
onHeartbeat?: (event: string, data: Record<string, unknown>) => void;
|
||||
@@ -2991,7 +3114,7 @@ export interface ToolLoopResult {
|
||||
*/
|
||||
export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
|
||||
const maxTurns = opts.maxTurns ?? 20;
|
||||
const maxTokens = opts.maxTokens ?? 4096;
|
||||
const maxTokens = opts.maxTokens ?? defaultMaxOutputTokens(opts.model ?? getChatModel());
|
||||
const handlers = opts.toolHandlers;
|
||||
const totalUsage: ChatResult['usage'] = {
|
||||
input_tokens: 0,
|
||||
@@ -3180,9 +3303,11 @@ export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
|
||||
|
||||
if (stopReason === 'aborted') break;
|
||||
|
||||
// Feed all tool results back as a single user message.
|
||||
// Persist + feed all tool results back as a single user message. The
|
||||
// persist-before-push mirrors onAssistantTurn's write-ordering: a crash
|
||||
// after this leaves a balanced transcript for the next resume.
|
||||
const userMessageIdx = messageIdx++;
|
||||
void userMessageIdx;
|
||||
await opts.onToolResultTurn?.(turnIdx, userMessageIdx, toolResultBlocks);
|
||||
messages.push({ role: 'user', content: toolResultBlocks });
|
||||
|
||||
turnIdx++;
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
import type { Recipe } from '../types.ts';
|
||||
|
||||
/**
|
||||
* `deepseek-reasoner` returns its answer in a separate `reasoning_content`
|
||||
* field and leaves `content` empty/whitespace when the whole response was
|
||||
* reasoning. The AI SDK's openai-compatible adapter reads only `content`, so
|
||||
* the model appears to answer with nothing. This transport shim promotes
|
||||
* `reasoning_content` into `content` when `content` is empty, before the
|
||||
* adapter parses the body. Fail-open: any error returns the original response.
|
||||
* Non-streaming JSON chat completions only.
|
||||
*
|
||||
* @internal exported for tests.
|
||||
*/
|
||||
// Cast through `unknown` because TS's `typeof fetch` includes a `preconnect`
|
||||
// member the arrow function does not implement (matches azure-openai.ts).
|
||||
export const deepseekReasoningContentCompatFetch = (async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const res = await fetch(input as any, init as any);
|
||||
try {
|
||||
if (!res.ok) return res;
|
||||
const ctype = res.headers.get('content-type') ?? '';
|
||||
if (!ctype.includes('application/json')) return res;
|
||||
const json = await res.clone().json();
|
||||
const choices = Array.isArray(json?.choices) ? json.choices : [];
|
||||
let modified = false;
|
||||
for (const choice of choices) {
|
||||
const msg = choice?.message;
|
||||
if (!msg) continue;
|
||||
// A tool-call turn legitimately carries content:null — the answer is the
|
||||
// tool call, not text. NEVER promote reasoning_content there: DeepSeek's
|
||||
// chain-of-thought must not be fed back to the model (it would be
|
||||
// persisted as assistant text and replayed every subsequent turn,
|
||||
// contaminating context and inflating tokens). Only promote on a terminal
|
||||
// text turn whose content is empty.
|
||||
const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
|
||||
const content = msg.content;
|
||||
const reasoning = msg.reasoning_content;
|
||||
const contentEmpty = content == null || (typeof content === 'string' && content.trim() === '');
|
||||
if (!hasToolCalls && contentEmpty && typeof reasoning === 'string' && reasoning.trim() !== '') {
|
||||
msg.content = reasoning;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (!modified) return res;
|
||||
// Rebuild with a fresh header set: the body length changed, so the
|
||||
// upstream content-length / content-encoding would now be wrong.
|
||||
const headers = new Headers(res.headers);
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
return new Response(JSON.stringify(json), {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
} catch {
|
||||
return res;
|
||||
}
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
/**
|
||||
* DeepSeek exposes an OpenAI-compatible /v1/chat/completions endpoint.
|
||||
* Useful as the second hop in a refusal-fallback chain and for cheap-
|
||||
@@ -16,6 +75,9 @@ export const deepseek: Recipe = {
|
||||
required: ['DEEPSEEK_API_KEY'],
|
||||
setup_url: 'https://platform.deepseek.com/api_keys',
|
||||
},
|
||||
compat: {
|
||||
fetch: deepseekReasoningContentCompatFetch,
|
||||
},
|
||||
touchpoints: {
|
||||
chat: {
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
|
||||
@@ -20,6 +20,15 @@ export const litellmProxy: Recipe = {
|
||||
setup_url: 'https://docs.litellm.ai/docs/proxy/quick_start',
|
||||
},
|
||||
touchpoints: {
|
||||
chat: {
|
||||
// Models depend on the proxy's config; the openai-compat tier accepts
|
||||
// user-provided IDs and lets the proxied backend validate them.
|
||||
models: [],
|
||||
supports_tools: true,
|
||||
supports_subagent_loop: false,
|
||||
supports_prompt_cache: false,
|
||||
price_last_verified: '2026-04-20',
|
||||
},
|
||||
embedding: {
|
||||
// Models depend on the proxy's config; declare empties so wizard prompts user.
|
||||
models: [],
|
||||
@@ -42,5 +51,5 @@ export const litellmProxy: Recipe = {
|
||||
supports_multimodal: true,
|
||||
},
|
||||
},
|
||||
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
|
||||
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1), optionally set LITELLM_API_KEY, and use litellm:<model> for chat. For embeddings, also pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
|
||||
};
|
||||
|
||||
@@ -326,6 +326,18 @@ export interface Recipe {
|
||||
baseURL: string;
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
/**
|
||||
* Optional inbound-response rewriter for openai-compatible recipes whose wire
|
||||
* shape needs normalizing before the AI SDK adapter parses it. `fetch` wraps
|
||||
* the transport and MUST be fail-open (return the original response on any
|
||||
* error). Used by DeepSeek to promote `reasoning_content` into `content` when
|
||||
* the reasoner returns an empty `content` (the adapter reads only `content`).
|
||||
* Applied by `applyOpenAICompatConfig`; a `resolveOpenAICompatConfig`-provided
|
||||
* fetch takes precedence when both are present.
|
||||
*/
|
||||
compat?: {
|
||||
fetch?: typeof fetch;
|
||||
};
|
||||
/**
|
||||
* v0.32 (D13=A): optional runtime readiness check for local-server
|
||||
* recipes (ollama, llama-server, future lmstudio-recipe). Returns
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Nightly conversation-parser probe audit trail.
|
||||
*
|
||||
* One event per REAL probe run lands in
|
||||
* `~/.gbrain/audit/parser-probe-YYYY-Www.jsonl` (ISO-week rotation via the
|
||||
* shared audit-writer primitive; honors `GBRAIN_AUDIT_DIR`).
|
||||
* Scheduler-cadence skips (`rate_limited`) are NOT logged — the autopilot
|
||||
* loop ticks every few minutes, so logging every skip would flood the
|
||||
* audit file with rows that carry no signal.
|
||||
*
|
||||
* Read by `gbrain doctor`'s `conversation_parser_probe_health` check and
|
||||
* by the autopilot wiring's 24h rate-limit gate (`parserProbeRanWithin`).
|
||||
*/
|
||||
|
||||
import { createAuditWriter } from './audit/audit-writer.ts';
|
||||
import type { NightlyProbeResult } from './conversation-parser/nightly-probe.ts';
|
||||
|
||||
export type ParserProbeAuditEvent = NightlyProbeResult;
|
||||
|
||||
const writer = createAuditWriter<ParserProbeAuditEvent>({
|
||||
featureName: 'parser-probe',
|
||||
errorLabel: 'gbrain',
|
||||
errorMessagePrefix: 'parser-probe audit ',
|
||||
errorTrailer: '; probe continues',
|
||||
});
|
||||
|
||||
/** Append one parser-probe event. Best-effort; never throws. */
|
||||
export function logParserProbeEvent(event: ParserProbeAuditEvent): void {
|
||||
writer.log(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read recent parser-probe events (current + previous ISO week, filtered
|
||||
* to the window). Missing files and corrupt rows are skipped silently.
|
||||
*/
|
||||
export function readRecentParserProbeEvents(
|
||||
days = 7,
|
||||
now: Date = new Date(),
|
||||
): ParserProbeAuditEvent[] {
|
||||
return writer.readRecent(days, now);
|
||||
}
|
||||
|
||||
/** Exposed for tests pinning the rotation edge cases. */
|
||||
export function computeParserProbeAuditFilename(now: Date = new Date()): string {
|
||||
return writer.computeFilename(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* 24h rate-limit gate for the autopilot wiring: true when any audited run
|
||||
* happened within `windowMs` of `now`. Only REAL outcomes are audited (see
|
||||
* module header), so a pass/fail today blocks re-runs until tomorrow while
|
||||
* scheduler-cadence skips never extend the window.
|
||||
*/
|
||||
export function parserProbeRanWithin(
|
||||
windowMs: number,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
const cutoff = now.getTime() - windowMs;
|
||||
return readRecentParserProbeEvents(2, now).some((ev) => {
|
||||
const ts = Date.parse(ev.ts);
|
||||
return Number.isFinite(ts) && ts >= cutoff;
|
||||
});
|
||||
}
|
||||
@@ -41,6 +41,13 @@ export interface GBrainConfig {
|
||||
* merge → buildGatewayConfig env dict → recipe reads ZEROENTROPY_API_KEY.
|
||||
*/
|
||||
zeroentropy_api_key?: string;
|
||||
/**
|
||||
* OpenRouter API key. File-plane slot so `gbrain config set
|
||||
* openrouter_api_key X` (or config.json) reaches the openrouter recipe:
|
||||
* file plane → loadConfig env merge → buildGatewayConfig env dict → recipe
|
||||
* reads OPENROUTER_API_KEY.
|
||||
*/
|
||||
openrouter_api_key?: string;
|
||||
/** AI gateway config (v0.14+). v0.36+ default: "zeroentropyai:zembed-1" / 1280 / "anthropic:claude-haiku-4-5-20251001". */
|
||||
embedding_model?: string;
|
||||
embedding_dimensions?: number;
|
||||
@@ -96,6 +103,16 @@ export interface GBrainConfig {
|
||||
*/
|
||||
max_usd?: number;
|
||||
};
|
||||
/**
|
||||
* v0.41.16.0 — nightly conversation-parser probe. Per D10: default ON
|
||||
* for `search.mode=tokenmax` brains, opt-in for conservative/balanced.
|
||||
* ~$0.05/night with the committed fixtures × Haiku polish. Gated
|
||||
* INSIDE the autopilot tick body, like nightly_quality_probe.
|
||||
*/
|
||||
conversation_parser_probe?: {
|
||||
/** Enable for non-tokenmax modes. Defaults to false. */
|
||||
enabled?: boolean;
|
||||
};
|
||||
/**
|
||||
* v0.42.x (#1685 GAP D) — extract_atoms backlog auto-drain. Default ON so a
|
||||
* pack-gated silent backlog never piles up unseen; daily-spend-capped so the
|
||||
@@ -526,6 +543,7 @@ export function loadConfig(): GBrainConfig | null {
|
||||
...(process.env.OPENAI_API_KEY ? { openai_api_key: process.env.OPENAI_API_KEY } : {}),
|
||||
...(process.env.ANTHROPIC_API_KEY ? { anthropic_api_key: process.env.ANTHROPIC_API_KEY } : {}),
|
||||
...(process.env.ZEROENTROPY_API_KEY ? { zeroentropy_api_key: process.env.ZEROENTROPY_API_KEY } : {}),
|
||||
...(process.env.OPENROUTER_API_KEY ? { openrouter_api_key: process.env.OPENROUTER_API_KEY } : {}),
|
||||
...(process.env.GBRAIN_EMBEDDING_MODEL ? { embedding_model: process.env.GBRAIN_EMBEDDING_MODEL } : {}),
|
||||
...(process.env.GBRAIN_EMBEDDING_DIMENSIONS ? { embedding_dimensions: parseInt(process.env.GBRAIN_EMBEDDING_DIMENSIONS, 10) } : {}),
|
||||
...(process.env.GBRAIN_EXPANSION_MODEL ? { expansion_model: process.env.GBRAIN_EXPANSION_MODEL } : {}),
|
||||
@@ -815,6 +833,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'database_path',
|
||||
'openai_api_key',
|
||||
'anthropic_api_key',
|
||||
'zeroentropy_api_key',
|
||||
'openrouter_api_key',
|
||||
'embedding_model',
|
||||
'embedding_dimensions',
|
||||
'embedding_disabled',
|
||||
@@ -836,6 +856,11 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
'sync',
|
||||
'sync.repo_path',
|
||||
'sync.last_commit',
|
||||
// Gateway-native subagent loop toggle (routes subagent jobs through the
|
||||
// provider-agnostic gateway.toolLoop for non-Anthropic providers). The
|
||||
// subagent handler's error message tells users to `config set` this, so it
|
||||
// must be a known key or `config set` rejects it without --force.
|
||||
'agent.use_gateway_loop',
|
||||
// DB-plane (v0.32.3 search modes + related)
|
||||
'search.mode',
|
||||
'search.cache.enabled',
|
||||
@@ -921,6 +946,16 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
// operator had to discover these by reading source. Registered so `config
|
||||
// set` accepts them directly. See docs/operations/spend-controls.md.
|
||||
'spend.posture',
|
||||
// Life Chronicle (v0.42.56.0, #2390). The release notes' enable command is
|
||||
// `gbrain config set auto_chronicle true`, but the key was never registered
|
||||
// — so the documented command failed with "Unknown config key" and the
|
||||
// operator had to discover --force by reading source. Same class as the
|
||||
// spend-controls registration above.
|
||||
'auto_chronicle',
|
||||
// Takes bootstrap (v0.41.18.0, A12). The onboard remediation's two-gate
|
||||
// consent reads this key, and enabling it is the documented path to
|
||||
// `gbrain takes extract --from-pages` — same unregistered-key class.
|
||||
'takes.bootstrap_enabled',
|
||||
'sync.cost_gate_min_usd',
|
||||
'sync.federated_v2',
|
||||
'embed.backfill_cooldown_min',
|
||||
@@ -943,6 +978,7 @@ export const KNOWN_CONFIG_KEY_PREFIXES: readonly string[] = [
|
||||
'content_sanity.', // v0.41 content-sanity tunables
|
||||
'mcp.', // mcp.publish_skills, mcp.skills_dir (PR1 skill catalog)
|
||||
'autopilot.', // autopilot.nightly_quality_probe.*, autopilot.auto_drain.* (#1685)
|
||||
'chronicle.', // chronicle.tz + future Life Chronicle knobs (#2390)
|
||||
'self_upgrade.', // v0.42 self-upgrade (mode, quiet_hours, state)
|
||||
];
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import { embedBatch } from './embedding.ts';
|
||||
import { resolveContextualRetrievalMode } from './contextual-retrieval-resolver.ts';
|
||||
import {
|
||||
buildContextualPrefix,
|
||||
extractFirstTwoSentences,
|
||||
modeRequiresHaiku,
|
||||
modeRequiresWrapper,
|
||||
sanitizeTitle,
|
||||
@@ -56,10 +55,8 @@ import {
|
||||
SYNOPSIS_PROMPT_VERSION,
|
||||
type GeneratePerChunkSynopsisResult,
|
||||
} from './page-summary.ts';
|
||||
import {
|
||||
logSynopsisFailure,
|
||||
type SynopsisFailureKind,
|
||||
} from './audit-synopsis.ts';
|
||||
import type { SynopsisFailureKind } from './audit-synopsis.ts';
|
||||
import { runSlidingPool } from './worker-pool.ts';
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { ChunkInput, CRMode, Page } from './types.ts';
|
||||
import type { SourceRow } from './sources-ops.ts';
|
||||
@@ -72,6 +69,24 @@ import type { SourceRow } from './sources-ops.ts';
|
||||
* corpus_generation hash.
|
||||
*/
|
||||
export const TITLE_WRAPPER_VERSION = 1;
|
||||
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
|
||||
export const DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY = 4;
|
||||
export const MAX_CONTEXTUAL_CHUNK_CONCURRENCY = 16;
|
||||
|
||||
export function resolveContextualChunkConcurrency(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): number {
|
||||
const raw = env.GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
if (raw === undefined || raw.trim() === '') return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
return clampContextualChunkConcurrency(n);
|
||||
}
|
||||
|
||||
function clampContextualChunkConcurrency(n: number): number {
|
||||
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
|
||||
return Math.max(1, Math.min(MAX_CONTEXTUAL_CHUNK_CONCURRENCY, Math.trunc(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding model placeholder. The actual model name lands here from
|
||||
@@ -196,12 +211,16 @@ export interface ReembedPageArgs {
|
||||
* src/core/minions/rate-leases.ts here; inline callers (import-file,
|
||||
* reindex command) pass undefined and rely on gateway-level retry.
|
||||
*/
|
||||
acquireSynopsisLease?: () => Promise<void>;
|
||||
releaseSynopsisLease?: () => Promise<void>;
|
||||
acquireSynopsisLease?: () => Promise<unknown>;
|
||||
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
|
||||
/**
|
||||
* Intra-page per-chunk synopsis concurrency. 1 preserves the legacy
|
||||
* sequential loop exactly; higher values only parallelize Haiku synopsis
|
||||
* calls. Embedding remains one batch after all synopses succeed.
|
||||
*/
|
||||
chunkConcurrency?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
|
||||
|
||||
/**
|
||||
* Re-embed one page through the active CR mode. Implements the D26 P0-2
|
||||
* two-phase build pattern.
|
||||
@@ -415,82 +434,41 @@ async function tryBuildPhase1(opts: {
|
||||
}
|
||||
|
||||
// per_chunk_synopsis path. Read source text via fallback chain,
|
||||
// generate synopsis per chunk sequentially within this page (D10),
|
||||
// generate synopsis per chunk through a bounded sliding pool, then
|
||||
// batch embed at the end (D27 P2-2).
|
||||
const sourceText = readSourceTextWithFallback(page, chunks);
|
||||
const wrappedTexts: string[] = [];
|
||||
const wrappedTexts: string[] = new Array(chunks.length);
|
||||
const chunkConcurrency = clampContextualChunkConcurrency(
|
||||
args.chunkConcurrency ?? resolveContextualChunkConcurrency(),
|
||||
);
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const c = chunks[i];
|
||||
|
||||
// Code chunks always bypass the wrapper (D20-T4) — pass through.
|
||||
if (c.chunk_source === 'fenced_code') {
|
||||
wrappedTexts.push(c.chunk_text);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
|
||||
// hooks; only the Minion handler wires through rate-leases.ts.
|
||||
if (args.acquireSynopsisLease) {
|
||||
await args.acquireSynopsisLease();
|
||||
}
|
||||
|
||||
let synopsisResult: GeneratePerChunkSynopsisResult;
|
||||
try {
|
||||
synopsisResult = await generatePerChunkSynopsis({
|
||||
documentText: sourceText,
|
||||
chunkText: c.chunk_text,
|
||||
pageTitle: page.title,
|
||||
pageSlug: args.pageSlug,
|
||||
sourceId: args.sourceId,
|
||||
chunkIndex: c.chunk_index,
|
||||
model: haikuModel,
|
||||
abortSignal: args.abortSignal,
|
||||
const poolResult = await runSlidingPool({
|
||||
items: chunks,
|
||||
workers: chunkConcurrency,
|
||||
signal: args.abortSignal,
|
||||
onError: 'abort',
|
||||
failureLabel: (c) => String(c.chunk_index),
|
||||
onItem: async (c, i) => {
|
||||
wrappedTexts[i] = await buildWrappedChunkText({
|
||||
chunk: c,
|
||||
sourceText,
|
||||
safeTitle,
|
||||
page,
|
||||
args,
|
||||
haikuModel,
|
||||
});
|
||||
} finally {
|
||||
if (args.releaseSynopsisLease) {
|
||||
try {
|
||||
await args.releaseSynopsisLease();
|
||||
} catch {
|
||||
// Lease release failure shouldn't abort the page; surfacing it
|
||||
// would race with the synopsis result. Audit-only.
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (synopsisResult.kind === 'success') {
|
||||
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
|
||||
wrappedTexts.push(
|
||||
wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source),
|
||||
);
|
||||
continue;
|
||||
if (poolResult.failures.length > 0) {
|
||||
const failure = [...poolResult.failures].sort((a, b) => a.idx - b.idx)[0].error;
|
||||
if (failure instanceof ChunkSynopsisPhase1Error) {
|
||||
return failure.result;
|
||||
}
|
||||
|
||||
// Failure classification per D27 P1-2:
|
||||
// refusal | empty | malformed → page-level fall-back to title-only
|
||||
// auth_failure → permanent (won't fix with retry)
|
||||
// rate_limit | timeout | network | provider_5xx → transient
|
||||
// source_missing → walked into fallback already; would be 'malformed'
|
||||
// from generatePerChunkSynopsis if we ever propagated it here
|
||||
if (
|
||||
synopsisResult.kind === 'refusal' ||
|
||||
synopsisResult.kind === 'empty' ||
|
||||
synopsisResult.kind === 'malformed'
|
||||
) {
|
||||
return { kind: 'page_level_fallback_requested', cause: synopsisResult.kind };
|
||||
}
|
||||
if (synopsisResult.kind === 'auth_failure') {
|
||||
return {
|
||||
kind: 'permanent',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'auth failure',
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'transient',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'transient',
|
||||
};
|
||||
throw failure;
|
||||
}
|
||||
if (poolResult.aborted || args.abortSignal?.aborted) {
|
||||
return { kind: 'transient', cause: 'timeout', detail: 'aborted' };
|
||||
}
|
||||
|
||||
// All chunks synthesized successfully. Single batch embed (D27 P2-2).
|
||||
@@ -511,6 +489,113 @@ async function tryBuildPhase1(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
class ChunkSynopsisPhase1Error extends Error {
|
||||
constructor(readonly result: Exclude<Phase1Result, Phase1Success>) {
|
||||
super(`chunk synopsis failed: ${result.kind}`);
|
||||
this.name = 'ChunkSynopsisPhase1Error';
|
||||
}
|
||||
}
|
||||
|
||||
async function buildWrappedChunkText(opts: {
|
||||
chunk: ChunkInput;
|
||||
sourceText: string;
|
||||
safeTitle: string;
|
||||
page: Page;
|
||||
args: ReembedPageArgs;
|
||||
haikuModel: string;
|
||||
}): Promise<string> {
|
||||
const { chunk: c, sourceText, safeTitle, page, args, haikuModel } = opts;
|
||||
|
||||
// Code chunks always bypass the wrapper (D20-T4) — pass through.
|
||||
if (c.chunk_source === 'fenced_code') {
|
||||
return c.chunk_text;
|
||||
}
|
||||
|
||||
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
|
||||
// hooks; only the Minion handler wires through rate-leases.ts.
|
||||
let lease: unknown;
|
||||
let leaseAcquired = false;
|
||||
let synopsisResult: GeneratePerChunkSynopsisResult;
|
||||
try {
|
||||
if (args.acquireSynopsisLease) {
|
||||
try {
|
||||
lease = await args.acquireSynopsisLease();
|
||||
} catch (err) {
|
||||
if (args.abortSignal?.aborted || isAbortError(err)) {
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'transient',
|
||||
cause: 'timeout',
|
||||
detail: 'aborted',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
leaseAcquired = true;
|
||||
}
|
||||
synopsisResult = await generatePerChunkSynopsis({
|
||||
documentText: sourceText,
|
||||
chunkText: c.chunk_text,
|
||||
pageTitle: page.title,
|
||||
pageSlug: args.pageSlug,
|
||||
sourceId: args.sourceId,
|
||||
chunkIndex: c.chunk_index,
|
||||
model: haikuModel,
|
||||
abortSignal: args.abortSignal,
|
||||
});
|
||||
} finally {
|
||||
if (leaseAcquired && args.releaseSynopsisLease) {
|
||||
try {
|
||||
await args.releaseSynopsisLease(lease);
|
||||
} catch {
|
||||
// Lease release failure shouldn't abort the page; surfacing it
|
||||
// would race with the synopsis result. Audit-only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (synopsisResult.kind === 'success') {
|
||||
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
|
||||
return wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source);
|
||||
}
|
||||
|
||||
// Failure classification per D27 P1-2:
|
||||
// refusal | empty | malformed → page-level fall-back to title-only
|
||||
// auth_failure → permanent (won't fix with retry)
|
||||
// rate_limit | timeout | network | provider_5xx → transient
|
||||
// source_missing → walked into fallback already; would be 'malformed'
|
||||
// from generatePerChunkSynopsis if we ever propagated it here
|
||||
if (
|
||||
synopsisResult.kind === 'refusal' ||
|
||||
synopsisResult.kind === 'empty' ||
|
||||
synopsisResult.kind === 'malformed'
|
||||
) {
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'page_level_fallback_requested',
|
||||
cause: synopsisResult.kind,
|
||||
});
|
||||
}
|
||||
if (synopsisResult.kind === 'auth_failure') {
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'permanent',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'auth failure',
|
||||
});
|
||||
}
|
||||
throw new ChunkSynopsisPhase1Error({
|
||||
kind: 'transient',
|
||||
cause: synopsisResult.kind,
|
||||
detail: synopsisResult.detail ?? 'transient',
|
||||
});
|
||||
}
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
(err as { name?: unknown }).name === 'AbortError'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-text fallback chain per D11:
|
||||
* 1. read page.source_path from disk (truest "document")
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* Cost: ~$0.05/night with default fixtures × Haiku polish. Bounded
|
||||
* by the active BudgetTracker the autopilot loop creates per-tick.
|
||||
*
|
||||
* **Wiring into the autopilot loop is deferred to a follow-up**
|
||||
* (filed in TODOS.md). v0.41.16.0 ships the phase as a callable
|
||||
* module so doctor + future cron drivers can invoke it; the
|
||||
* scheduler wire-up follows the same shape as
|
||||
* `src/core/cycle/nightly-quality-probe.ts` (v0.40.1.0 Track D / T6).
|
||||
* Wired into the autopilot loop (step 4.6 in autopilot.ts), following
|
||||
* the same shape as `src/core/cycle/nightly-quality-probe.ts`
|
||||
* (v0.40.1.0 Track D / T6): the wiring resolves fixtures from the
|
||||
* gbrain package root, writes real outcomes to the parser-probe audit
|
||||
* trail (`audit-parser-probe.ts`), and never crashes the loop.
|
||||
*
|
||||
* Test seam: all dependencies are injected via NightlyProbeDeps so
|
||||
* unit tests don't touch real LLMs or real fixtures.
|
||||
|
||||
@@ -44,7 +44,12 @@ export const DEFAULT_DIMENSIONS: string[] = [
|
||||
* `--slot-a-model`, `--slot-b-model`, `--slot-c-model` on the CLI.
|
||||
*/
|
||||
export const DEFAULT_SLOTS: SlotConfig[] = [
|
||||
{ id: 'A', model: 'openai:gpt-4o' },
|
||||
// Every default MUST be listed in its recipe's chat touchpoint (pinned by
|
||||
// test/cross-modal-default-slots.test.ts) — `openai:gpt-4o` sat here after
|
||||
// the OpenAI recipe dropped it, so slot A errored "not listed for OpenAI
|
||||
// chat" on every install and the 3-slot panel could never reach its
|
||||
// 2-model quorum without a Google key (verdict: permanently inconclusive).
|
||||
{ id: 'A', model: 'openai:gpt-5.2' },
|
||||
{ id: 'B', model: 'anthropic:claude-opus-4-7' },
|
||||
{ id: 'C', model: 'google:gemini-1.5-pro' },
|
||||
];
|
||||
|
||||
@@ -61,16 +61,56 @@ const ATOM_TYPES = [
|
||||
'critique', 'collection',
|
||||
] as const;
|
||||
|
||||
// v0.41.2.1 (D2): brain-page discovery constants. Hardcoded for now;
|
||||
// future pack-aware refactor is a one-line change to pull from the
|
||||
// active pack manifest (symmetric with the existing
|
||||
// src/core/facts/eligibility.ts:49 TODO).
|
||||
const EXTRACTABLE_PAGE_TYPES = [
|
||||
// v0.41.2.1 (D2): brain-page discovery constants.
|
||||
//
|
||||
// Legacy floor: the pre-pack hardcoded atom-extraction types. Retained as a
|
||||
// back-compat union member so a gbrain-base brain never loses an extraction
|
||||
// target when we begin honoring the pack manifest's `extractable` flags.
|
||||
const LEGACY_EXTRACTABLE_TYPES = [
|
||||
'meeting', 'source', 'article', 'video', 'book', 'original',
|
||||
] as const;
|
||||
|
||||
// Synthesis outputs are never extraction inputs: extracting atoms from atoms or
|
||||
// concepts would loop (concepts are synthesized FROM atoms). Mirrors
|
||||
// facts/eligibility.ts, which likewise excludes `concept` despite its
|
||||
// extractable:true flag being a documented forward-compat marker.
|
||||
const SYNTHESIS_OUTPUT_TYPES = new Set<string>(['atom', 'concept']);
|
||||
|
||||
const PAGE_DISCOVERY_BUDGET = 50;
|
||||
const MIN_PAGE_CHARS_FOR_EXTRACTION = 500;
|
||||
|
||||
/**
|
||||
* Pure allowlist policy: the legacy floor UNION the pack's `extractable: true`
|
||||
* types, MINUS synthesis outputs. Exported for unit tests; keep I/O-free.
|
||||
*/
|
||||
export function unionExtractableTypes(packExtractable: Iterable<string>): string[] {
|
||||
const types = new Set<string>(LEGACY_EXTRACTABLE_TYPES);
|
||||
for (const t of packExtractable) types.add(t);
|
||||
for (const t of SYNTHESIS_OUTPUT_TYPES) types.delete(t);
|
||||
return [...types];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the atom-extraction type allowlist from the active schema pack.
|
||||
* Closes the D2 TODO of honoring the pack manifest (so a type declared
|
||||
* extractable — e.g. `note` — actually extracts) while preserving behavior for
|
||||
* gbrain-base via the legacy-floor union. Fail-soft: any pack-load error falls
|
||||
* back to the legacy floor.
|
||||
*/
|
||||
async function resolveExtractableTypes(): Promise<string[]> {
|
||||
let packExtractable: Iterable<string> = [];
|
||||
try {
|
||||
const { loadConfig } = await import('../config.ts');
|
||||
const { loadActivePack } = await import('../schema-pack/load-active.ts');
|
||||
const { extractableTypesFromPack } = await import('../schema-pack/extractable.ts');
|
||||
const resolved = await loadActivePack({ cfg: loadConfig(), remote: false });
|
||||
packExtractable = extractableTypesFromPack(resolved.manifest);
|
||||
} catch {
|
||||
// Pack unavailable (test seams, bootstrap) — legacy floor only.
|
||||
}
|
||||
return unionExtractableTypes(packExtractable);
|
||||
}
|
||||
|
||||
export interface ExtractAtomsOpts {
|
||||
brainDir?: string;
|
||||
sourceId?: string;
|
||||
@@ -195,7 +235,7 @@ export async function discoverExtractablePages(
|
||||
`;
|
||||
const params: unknown[] = [
|
||||
sourceId,
|
||||
EXTRACTABLE_PAGE_TYPES as unknown as string[],
|
||||
await resolveExtractableTypes(),
|
||||
MIN_PAGE_CHARS_FOR_EXTRACTION,
|
||||
PAGE_DISCOVERY_BUDGET,
|
||||
];
|
||||
@@ -272,9 +312,10 @@ export async function countExtractAtomsBacklog(
|
||||
AND atom.frontmatter->>'source_hash' = substring(p.content_hash from 1 for 16)
|
||||
AND atom.deleted_at IS NULL
|
||||
)`;
|
||||
const extractableTypes = await resolveExtractableTypes();
|
||||
const params = scoped
|
||||
? [sourceId, EXTRACTABLE_PAGE_TYPES as unknown as string[], MIN_PAGE_CHARS_FOR_EXTRACTION]
|
||||
: [EXTRACTABLE_PAGE_TYPES as unknown as string[], MIN_PAGE_CHARS_FOR_EXTRACTION];
|
||||
? [sourceId, extractableTypes, MIN_PAGE_CHARS_FOR_EXTRACTION]
|
||||
: [extractableTypes, MIN_PAGE_CHARS_FOR_EXTRACTION];
|
||||
const rows = await engine.executeRaw<{ cnt: string | number }>(sql, params);
|
||||
return Number(rows[0]?.cnt ?? 0);
|
||||
} catch (err) {
|
||||
|
||||
@@ -70,6 +70,28 @@ export async function runLongMemEvalForProbe(args: LongMemEvalProbeArgs): Promis
|
||||
* the batch input) or unparseable (cross-modal wrote garbage). Both
|
||||
* cases are paste-ready in the error message.
|
||||
*/
|
||||
/**
|
||||
* QA-shaped judge dimensions for the nightly probe. The batch judge's
|
||||
* DEFAULT_DIMENSIONS rubric (DEPTH / SOURCING / SPECIFICITY / …) is built
|
||||
* for rich agent responses; LongMemEval hypotheses are deliberately terse
|
||||
* factual answers ("in widget-co") that can never score ≥7 on DEPTH or
|
||||
* SOURCING — so with the default rubric the probe FAILs every night even
|
||||
* when retrieval + answering are perfectly healthy. The probe owns its
|
||||
* invocation of the eval tool and passes dimensions matching the
|
||||
* fixture's QA shape instead.
|
||||
*
|
||||
* NOTE: the `--dimensions` CLI flag splits on commas, so these dimension
|
||||
* descriptions must stay comma-free.
|
||||
*/
|
||||
export const PROBE_QA_DIMENSIONS: string[] = [
|
||||
// No faithfulness/grounding dimension on purpose: the judge never sees
|
||||
// the haystack, so any accurate detail beyond the terse gold label reads
|
||||
// as "invented" and correct answers fail (verified empirically — a
|
||||
// correct "before + dates" answer scored 4/10 on such a dimension).
|
||||
'CORRECTNESS — Does the hypothesis state the same fact as the expected answer? A terse direct answer is ideal.',
|
||||
'DIRECTNESS — Does it answer THIS question without hedging or padding or answering something else?',
|
||||
];
|
||||
|
||||
export async function runCrossModalBatchForProbe(
|
||||
args: CrossModalProbeArgs,
|
||||
): Promise<{ exitCode: number; summary: CrossModalBatchSummary }> {
|
||||
@@ -81,6 +103,8 @@ export async function runCrossModalBatchForProbe(
|
||||
args.summaryPath,
|
||||
'--max-usd',
|
||||
String(args.maxUsd),
|
||||
'--dimensions',
|
||||
PROBE_QA_DIMENSIONS.join(','),
|
||||
'--yes',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
@@ -62,6 +62,42 @@ export interface NightlyProbeDeps {
|
||||
now: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-plane flag resolution (same precedent as `mcp.publish_skills` in
|
||||
* serve-http.ts): the DB config row — what `gbrain config set` writes —
|
||||
* wins when present; the file plane (~/.gbrain/config.json) is the
|
||||
* fallback. Doctor's paste-ready enable hint says `gbrain config set
|
||||
* autopilot.nightly_quality_probe.enabled true`, so the gate MUST read
|
||||
* the DB plane — a file-only read turns that hint into a silent no-op.
|
||||
*/
|
||||
export function resolveProbeEnabled(
|
||||
dbVal: string | null | undefined,
|
||||
fileVal: unknown,
|
||||
): boolean {
|
||||
if (dbVal != null) return dbVal === 'true';
|
||||
return fileVal === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same dual-plane rule for the per-run cost cap. Malformed or negative
|
||||
* values on either plane fall through to the next plane / the default.
|
||||
*/
|
||||
export function resolveProbeMaxUsd(
|
||||
dbVal: string | null | undefined,
|
||||
fileVal: unknown,
|
||||
fallback: number = DEFAULT_MAX_USD,
|
||||
): number {
|
||||
if (dbVal != null) {
|
||||
const n = Number(dbVal);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
if (fileVal != null) {
|
||||
const n = Number(fileVal);
|
||||
if (Number.isFinite(n) && n >= 0) return n;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function: decide whether the probe should run given the audit
|
||||
* history. Returns reason when skipping.
|
||||
@@ -101,21 +137,17 @@ export async function runNightlyQualityProbe(deps: NightlyProbeDeps): Promise<Ni
|
||||
return { outcome: 'disabled', exit_code: 0, detail: 'feature flag off' };
|
||||
}
|
||||
|
||||
// 24h rate limit — skip + audit "rate_limited".
|
||||
// 24h rate limit — skip WITHOUT an audit row. The autopilot loop invokes
|
||||
// the probe every cycle (~5-10 min), so all but one invocation per day
|
||||
// lands here; logging each skip floods the audit file (~hundreds of
|
||||
// rows/day) and — because doctor treats any non-pass outcome as bad
|
||||
// signal — flips nightly_quality_probe_health to a permanent WARN the
|
||||
// moment the probe is enabled. A skip is a non-event: the real runs are
|
||||
// the signal, and their rows are what gates the next 24h window.
|
||||
const now = deps.now();
|
||||
const recent = readRecentQualityProbeEvents(2, now); // 2-day window is enough for 24h check
|
||||
const decision = shouldRunNightly(now, recent);
|
||||
if (!decision.run) {
|
||||
logQualityProbeEvent({
|
||||
outcome: 'rate_limited',
|
||||
exit_code: 0,
|
||||
pass_count: 0,
|
||||
fail_count: 0,
|
||||
inconclusive_count: 0,
|
||||
error_count: 0,
|
||||
est_cost_usd: 0,
|
||||
detail: 'already ran within 24h window',
|
||||
});
|
||||
return { outcome: 'rate_limited', exit_code: 0, detail: 'already ran within 24h' };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
*/
|
||||
|
||||
const THIRTY_MIN_MS = 30 * 60 * 1000;
|
||||
const SIXTY_MIN_MS = 60 * 60 * 1000;
|
||||
const TEN_MIN_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,10 @@ export const HANDLER_DEFAULT_TIMEOUT_MS: Readonly<Record<string, number>> = {
|
||||
// few writes. Generous 10-min budget (vs the tight null-default) covers a
|
||||
// slow gateway without the 30-min loop budget.
|
||||
chronicle_extract: TEN_MIN_MS,
|
||||
// Per-page contextual reindex jobs process chunks sequentially with one
|
||||
// rate-leased LLM synopsis call per chunk; large transcript pages need more
|
||||
// than the standard 30-min long-job budget.
|
||||
contextual_reindex_per_chunk: SIXTY_MIN_MS,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,7 @@ import { UnrecoverableError } from '../types.ts';
|
||||
import type { BrainEngine } from '../../engine.ts';
|
||||
import {
|
||||
reembedPageWithContextualRetrieval,
|
||||
resolveContextualChunkConcurrency,
|
||||
type ReembedPageResult,
|
||||
} from '../../contextual-retrieval-service.ts';
|
||||
import {
|
||||
@@ -132,7 +133,7 @@ export function makeContextualReindexHandler(opts: MakeContextualReindexHandlerO
|
||||
// call inside the service acquires/releases a lease against the
|
||||
// shared key across all worker processes.
|
||||
const maxConcurrent = resolveMaxConcurrent();
|
||||
let currentLeaseId: number | null = null;
|
||||
const chunkConcurrency = resolveContextualChunkConcurrency();
|
||||
|
||||
const result: ReembedPageResult = await reembedPageWithContextualRetrieval({
|
||||
engine,
|
||||
@@ -141,32 +142,32 @@ export function makeContextualReindexHandler(opts: MakeContextualReindexHandlerO
|
||||
globalMode,
|
||||
killSwitchDisabled,
|
||||
abortSignal: ctx.signal,
|
||||
chunkConcurrency,
|
||||
acquireSynopsisLease: async () => {
|
||||
// Poll-acquire with brief backoff. The service's per-chunk loop
|
||||
// is sequential within a page; this guards against the cross-
|
||||
// worker pile-up.
|
||||
// is bounded within a page; this guards against the cross-worker
|
||||
// pile-up and remains the global rate governor.
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60; // ~1 min max wait per chunk before giving up
|
||||
while (attempts < maxAttempts) {
|
||||
if (ctx.signal.aborted) throw abortError();
|
||||
const res = await acquireLease(engine, RATE_LEASE_KEY, ctx.id, maxConcurrent, {
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
if (res.acquired && res.leaseId != null) {
|
||||
currentLeaseId = res.leaseId;
|
||||
return;
|
||||
return res.leaseId;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
await sleepWithAbort(1000, ctx.signal);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to acquire ${RATE_LEASE_KEY} lease after ${maxAttempts} attempts; ` +
|
||||
`Haiku rate limit pile-up too deep.`,
|
||||
);
|
||||
},
|
||||
releaseSynopsisLease: async () => {
|
||||
if (currentLeaseId != null) {
|
||||
await releaseLease(engine, currentLeaseId);
|
||||
currentLeaseId = null;
|
||||
releaseSynopsisLease: async (lease) => {
|
||||
if (typeof lease === 'number') {
|
||||
await releaseLease(engine, lease);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -218,6 +219,26 @@ async function tryLoadPageAcrossSources(
|
||||
return null;
|
||||
}
|
||||
|
||||
function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(abortError());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
function classifyResult(
|
||||
pageSlug: string,
|
||||
result: ReembedPageResult,
|
||||
|
||||
@@ -777,22 +777,57 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
|
||||
});
|
||||
}
|
||||
|
||||
// Convert prior Anthropic-shape messages → ChatMessage with ChatBlock content.
|
||||
// v1 rows store Anthropic content blocks ({type:'tool_use'|'tool_result'|...});
|
||||
// we adapt them to ChatBlock shape (type: 'tool-call' | 'tool-result' | 'text').
|
||||
const priorChatMessages: ChatMessage[] = priorMessages.map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: adaptContentBlocksToChatBlocks(m.content_blocks),
|
||||
}));
|
||||
// Token rollup across the prior transcript (returned as-is on the terminal
|
||||
// early-return path; the loop adds only NEW-turn usage otherwise).
|
||||
const priorTokens = { in: 0, out: 0, cache_read: 0, cache_create: 0 };
|
||||
for (const m of priorMessages) {
|
||||
if (m.tokens_in) priorTokens.in += m.tokens_in;
|
||||
if (m.tokens_out) priorTokens.out += m.tokens_out;
|
||||
if (m.tokens_cache_read) priorTokens.cache_read += m.tokens_cache_read;
|
||||
if (m.tokens_cache_create) priorTokens.cache_create += m.tokens_cache_create;
|
||||
}
|
||||
|
||||
// Reconcile an unbalanced transcript from a prior crashed/resumed run. The
|
||||
// gateway loop persists each assistant turn but (pre-fix) never persisted the
|
||||
// following tool-result user turn, so a resumed job reloads assistant
|
||||
// tool-calls with no matching results — which non-Anthropic (openai-compat)
|
||||
// providers reject with AI_MissingToolResultsError, dead-lettering the job.
|
||||
// reconcileGatewayReplay heals every such dangling turn from settled tool
|
||||
// executions (mirroring the legacy Anthropic path) and reports the terminal
|
||||
// case where the prior run already reached end_turn.
|
||||
const priorToolsV1 = await loadPriorTools(engine, ctx.id);
|
||||
const { chatMessages: priorChatMessages, nextMessageIdx: reconciledNextIdx, terminalText } =
|
||||
await reconcileGatewayReplay({
|
||||
engine,
|
||||
jobId: ctx.id,
|
||||
priorMessages,
|
||||
priorTools: priorToolsV1,
|
||||
toolDefs,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
|
||||
// Terminal early-return (#1151 parity): the prior run already reached
|
||||
// end_turn. Non-Anthropic providers reject a trailing assistant "prefill",
|
||||
// so surface the persisted text and skip the loop entirely.
|
||||
if (terminalText !== null) {
|
||||
return {
|
||||
result: terminalText,
|
||||
turns_count: priorChatMessages.filter(m => m.role === 'assistant').length,
|
||||
stop_reason: 'end_turn',
|
||||
tokens: priorTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// Initial seed message if no prior state.
|
||||
const initialMessages: ChatMessage[] = priorChatMessages.length === 0
|
||||
? [{ role: 'user', content: data.prompt }]
|
||||
: [];
|
||||
|
||||
// Persist seed user message at idx 0 if fresh start.
|
||||
let nextMessageIdx = priorChatMessages.length;
|
||||
if (nextMessageIdx === 0) {
|
||||
// Persist seed user message at idx 0 if fresh start. reconciledNextIdx is
|
||||
// max(known message_idx) + 1 (0 when no prior rows), which keeps the loop's
|
||||
// subsequent writes clear of any healed tool-result turn we just inserted.
|
||||
let nextMessageIdx = reconciledNextIdx;
|
||||
if (priorChatMessages.length === 0) {
|
||||
await persistMessage(engine, ctx.id, {
|
||||
message_idx: 0,
|
||||
role: 'user',
|
||||
@@ -904,6 +939,22 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
|
||||
[errorMsg, gbrainToolUseId],
|
||||
);
|
||||
},
|
||||
// Persist the tool-result user turn so a resume reloads a balanced
|
||||
// transcript. JSON.stringify inside persistMessage ISO-izes any Date in
|
||||
// tool output, and the value binds through $N::text::jsonb (never
|
||||
// JSON.stringify into a bare ::jsonb).
|
||||
onToolResultTurn: async (_turnIdx, messageIdx, blocks) => {
|
||||
await persistMessage(engine, ctx.id, {
|
||||
message_idx: messageIdx,
|
||||
role: 'user',
|
||||
content_blocks: blocks as unknown as ContentBlock[],
|
||||
tokens_in: null,
|
||||
tokens_out: null,
|
||||
tokens_cache_read: null,
|
||||
tokens_cache_create: null,
|
||||
model: null,
|
||||
});
|
||||
},
|
||||
onHeartbeat: heartbeat,
|
||||
});
|
||||
|
||||
@@ -934,6 +985,158 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
|
||||
};
|
||||
}
|
||||
|
||||
interface ReconcileArgs {
|
||||
engine: BrainEngine;
|
||||
jobId: number;
|
||||
priorMessages: PersistedMessage[];
|
||||
priorTools: PersistedToolExec[];
|
||||
toolDefs: ToolDef[];
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
interface ReconcileResult {
|
||||
chatMessages: ChatMessage[];
|
||||
/** max(known/healed message_idx) + 1 — 0 when there is no prior transcript. */
|
||||
nextMessageIdx: number;
|
||||
/** Non-null when the transcript already ended on an assistant text turn. */
|
||||
terminalText: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal an unbalanced gateway replay transcript before it reaches the provider.
|
||||
*
|
||||
* The gateway loop persists each assistant turn but historically never
|
||||
* persisted the following tool-result user turn, so a resumed job reloaded
|
||||
* assistant tool-calls with no matching results. Non-Anthropic (openai-compat)
|
||||
* providers reject that with AI_MissingToolResultsError and the job
|
||||
* dead-letters. This walks EVERY prior assistant turn that carries tool-call
|
||||
* blocks (not just the tail — a pre-fix multi-turn job persisted several
|
||||
* consecutive dangling assistant turns) and, if the turn isn't already answered
|
||||
* by the following tool-result user turn, synthesizes that turn from the
|
||||
* settled `subagent_tool_executions` rows with the same semantics as the legacy
|
||||
* Anthropic replay path:
|
||||
* - complete → stored output
|
||||
* - failed → stored error (isError)
|
||||
* - idempotent-pending / missing row → re-dispatch, persist, use output
|
||||
* - non-idempotent-pending → throw (cannot safely re-run)
|
||||
* - tool no longer registered → persist failed + error stub
|
||||
* Each synthesized turn is persisted at `assistant.message_idx + 1` (the free
|
||||
* slot the pre-fix loop skipped) via `ON CONFLICT DO NOTHING`, so the next
|
||||
* resume stays balanced.
|
||||
*/
|
||||
async function reconcileGatewayReplay(args: ReconcileArgs): Promise<ReconcileResult> {
|
||||
const { engine, jobId, priorMessages, priorTools, toolDefs, signal } = args;
|
||||
|
||||
const work = priorMessages.map(m => {
|
||||
const adapted = adaptContentBlocksToChatBlocks(m.content_blocks);
|
||||
return {
|
||||
message_idx: m.message_idx,
|
||||
role: m.role,
|
||||
blocks: typeof adapted === 'string' ? [{ type: 'text', text: adapted } as ChatBlock] : adapted,
|
||||
};
|
||||
});
|
||||
|
||||
// Settled executions, looked up by (assistant message_idx, provider
|
||||
// tool_use_id) with an ordinal-position fallback for legacy rows.
|
||||
const execByKey = new Map<string, PersistedToolExec>();
|
||||
const execByMsg = new Map<number, PersistedToolExec[]>();
|
||||
for (const t of priorTools) {
|
||||
execByKey.set(`${t.message_idx}:${t.tool_use_id}`, t);
|
||||
const arr = execByMsg.get(t.message_idx) ?? [];
|
||||
arr.push(t);
|
||||
execByMsg.set(t.message_idx, arr);
|
||||
}
|
||||
|
||||
let maxIdx = work.reduce((mx, w) => Math.max(mx, w.message_idx), -1);
|
||||
|
||||
for (let i = 0; i < work.length; i++) {
|
||||
const msg = work[i];
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const toolCalls = msg.blocks.filter(
|
||||
(b): b is Extract<ChatBlock, { type: 'tool-call' }> => b.type === 'tool-call',
|
||||
);
|
||||
if (toolCalls.length === 0) continue;
|
||||
|
||||
// Skip if a following tool-result user turn exists AT ALL. A fully-answered
|
||||
// turn is already balanced; a PARTIALLY-answered turn (only reachable from
|
||||
// externally-corrupted data — gbrain persists all of a turn's results in one
|
||||
// message) is left for repairToolPairing() at the chat() boundary, which
|
||||
// back-fills only the missing ids. Synthesizing a full turn here would
|
||||
// duplicate the answered results and collide with the persisted row.
|
||||
const next = work[i + 1];
|
||||
if (next && next.role === 'user' && next.blocks.some(b => b.type === 'tool-result')) continue;
|
||||
|
||||
const results: ChatBlock[] = [];
|
||||
for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) {
|
||||
const call = toolCalls[callIdx];
|
||||
// Prefer an exact (message_idx, provider tool_use_id) match. The
|
||||
// positional fallback is used only when the row at that ordinal is for
|
||||
// the SAME tool, so a missing row can't mis-attribute a sibling's output.
|
||||
const fallback = execByMsg.get(msg.message_idx)?.[callIdx];
|
||||
const exec = execByKey.get(`${msg.message_idx}:${call.toolCallId}`)
|
||||
?? (fallback && fallback.tool_name === call.toolName ? fallback : undefined);
|
||||
if (exec?.status === 'complete') {
|
||||
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: exec.output ?? null });
|
||||
continue;
|
||||
}
|
||||
if (exec?.status === 'failed') {
|
||||
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: exec.error ?? 'tool failed', isError: true });
|
||||
continue;
|
||||
}
|
||||
const toolDef = toolDefs.find(t => t.name === call.toolName);
|
||||
if (!toolDef) {
|
||||
await persistToolExecFailed(engine, jobId, msg.message_idx, call.toolCallId, call.toolName, call.input, `tool "${call.toolName}" is not in the registry for this subagent`);
|
||||
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: `tool "${call.toolName}" is not available`, isError: true });
|
||||
continue;
|
||||
}
|
||||
if (exec?.status === 'pending' && !toolDef.idempotent) {
|
||||
throw new Error(`non-idempotent tool "${call.toolName}" pending on resume; cannot safely re-run`);
|
||||
}
|
||||
await persistToolExecPending(engine, jobId, msg.message_idx, call.toolCallId, call.toolName, call.input);
|
||||
try {
|
||||
const output = await toolDef.execute(call.input, { engine, jobId, remote: true, signal });
|
||||
await persistToolExecComplete(engine, jobId, call.toolCallId, output);
|
||||
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output });
|
||||
} catch (e) {
|
||||
const errText = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
||||
await persistToolExecFailed(engine, jobId, msg.message_idx, call.toolCallId, call.toolName, call.input, errText);
|
||||
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: errText, isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
const resultIdx = msg.message_idx + 1;
|
||||
await persistMessage(engine, jobId, {
|
||||
message_idx: resultIdx,
|
||||
role: 'user',
|
||||
content_blocks: results as unknown as ContentBlock[],
|
||||
tokens_in: null, tokens_out: null, tokens_cache_read: null, tokens_cache_create: null, model: null,
|
||||
});
|
||||
maxIdx = Math.max(maxIdx, resultIdx);
|
||||
work.splice(i + 1, 0, { message_idx: resultIdx, role: 'user' as const, blocks: results });
|
||||
i++; // skip the turn we just inserted
|
||||
}
|
||||
|
||||
const chatMessages: ChatMessage[] = work.map(w => ({ role: w.role, content: w.blocks }));
|
||||
|
||||
// Terminal case: the transcript already ends on an assistant turn that
|
||||
// carried real text and made no tool calls (prior run reached end_turn).
|
||||
// Surface its text; skip the loop. An assistant turn whose blocks are all
|
||||
// empty (e.g. a reasoning-only/null-text turn that adaptation dropped) is NOT
|
||||
// terminal — falling through lets the loop re-issue the call rather than
|
||||
// returning an empty result.
|
||||
const lastMsg = work[work.length - 1];
|
||||
let terminalText: string | null = null;
|
||||
if (lastMsg && lastMsg.role === 'assistant' && !lastMsg.blocks.some(b => b.type === 'tool-call')) {
|
||||
const text = lastMsg.blocks
|
||||
.filter((b): b is Extract<ChatBlock, { type: 'text' }> => b.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join('\n');
|
||||
if (text.trim() !== '') terminalText = text;
|
||||
}
|
||||
|
||||
return { chatMessages, nextMessageIdx: maxIdx + 1, terminalText };
|
||||
}
|
||||
|
||||
function recipeIdFromModel(modelString: string): string {
|
||||
const idx = modelString.indexOf(':');
|
||||
return idx > 0 ? modelString.slice(0, idx) : 'anthropic';
|
||||
@@ -1087,7 +1290,8 @@ async function loadPriorTools(engine: BrainEngine, jobId: number): Promise<Persi
|
||||
const rows = await engine.executeRaw<Record<string, unknown>>(
|
||||
`SELECT message_idx, tool_use_id, tool_name, input, status, output, error
|
||||
FROM subagent_tool_executions
|
||||
WHERE job_id = $1`,
|
||||
WHERE job_id = $1
|
||||
ORDER BY message_idx, COALESCE(ordinal, 0), id`,
|
||||
[jobId],
|
||||
);
|
||||
return rows.map(r => ({
|
||||
|
||||
@@ -1656,8 +1656,8 @@ export class PGLiteEngine implements BrainEngine {
|
||||
* chunk_text are compared as-stored).
|
||||
* - Empty-query guard returns no results without binding SQL.
|
||||
*
|
||||
* Postgres engine is intentionally untouched (multi-tenant deployments
|
||||
* can install pgroonga / zhparser when needed; out of scope here).
|
||||
* Ported to postgres-engine.ts `_searchKeywordCJK` (Postgres parity) so
|
||||
* both engines share the same CJK fallback semantics.
|
||||
*/
|
||||
private async _searchKeywordCJK(
|
||||
query: string,
|
||||
|
||||
+247
-7
@@ -62,6 +62,7 @@ import { ConnectionManager } from './connection-manager.ts';
|
||||
import { logConnectionEvent } from './connection-audit.ts';
|
||||
import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake, isUndefinedTableError, warnOncePerProcess } from './utils.ts';
|
||||
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
|
||||
import { hasCJK, escapeLikePattern } from './cjk.ts';
|
||||
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte } from './search/sql-ranking.ts';
|
||||
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
|
||||
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
|
||||
@@ -1586,6 +1587,30 @@ export class PostgresEngine implements BrainEngine {
|
||||
const hardExcludePrefixes = resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes);
|
||||
const hardExcludeClause = buildHardExcludeClause('p.slug', hardExcludePrefixes);
|
||||
|
||||
// v0.26.5: visibility filter hides soft-deleted pages and pages from
|
||||
// archived sources. Joined `sources s` lets the predicate compile to a
|
||||
// column lookup. NOT bypassed by detail=high — soft-delete is a contract,
|
||||
// not a temporal preference. (Hoisted above the CJK branch so the
|
||||
// fallback can reuse it, mirroring the PGLite hoist.)
|
||||
const visibilityClause = buildVisibilityClause('p', 's');
|
||||
|
||||
// v0.32.7 CJK branch (Postgres parity): `websearch_to_tsquery('english')`
|
||||
// can't tokenize CJK — a Chinese/Japanese/Korean query compiles to an
|
||||
// empty tsquery and the FTS path silently returns nothing. Switch to
|
||||
// ILIKE on chunk_text with occurrence-count ranking as the ts_rank
|
||||
// substitute when the query contains CJK characters, mirroring the
|
||||
// PGLite engine's `_searchKeywordCJK`. ASCII path stays exactly the
|
||||
// same below.
|
||||
if (hasCJK(query)) {
|
||||
return this._searchKeywordCJK(query, {
|
||||
limit, offset, innerLimit, sourceFactorCase,
|
||||
hardExcludeClause, visibilityClause,
|
||||
detailFilter: detailLow ? `AND cc.chunk_source = 'compiled_truth'` : '',
|
||||
opts,
|
||||
dedup: true,
|
||||
});
|
||||
}
|
||||
|
||||
const params: unknown[] = [query];
|
||||
let typeClause = '';
|
||||
if (type) {
|
||||
@@ -1648,11 +1673,8 @@ export class PostgresEngine implements BrainEngine {
|
||||
params.push(offset);
|
||||
const offsetParam = `$${params.length}`;
|
||||
|
||||
// v0.26.5: visibility filter hides soft-deleted pages and pages from
|
||||
// archived sources. Joined `sources s` lets the predicate compile to a
|
||||
// column lookup. NOT bypassed by detail=high — soft-delete is a contract,
|
||||
// not a temporal preference.
|
||||
const visibilityClause = buildVisibilityClause('p', 's');
|
||||
// visibilityClause already declared above (v0.32.7 parity: hoisted so
|
||||
// the CJK branch can reuse it).
|
||||
|
||||
const rawQuery = `
|
||||
WITH ranked_chunks AS (
|
||||
@@ -1703,6 +1725,204 @@ export class PostgresEngine implements BrainEngine {
|
||||
return rows.map(rowToSearchResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.32.7 CJK keyword fallback (Postgres parity port of the PGLite
|
||||
* engine's `_searchKeywordCJK`). `websearch_to_tsquery('english')` can't
|
||||
* tokenize CJK so the FTS path returns empty for Chinese / Japanese /
|
||||
* Korean queries. This routes to an ILIKE substring scan with
|
||||
* occurrence-count ranking as a ts_rank substitute.
|
||||
*
|
||||
* Same discipline as the PGLite implementation (codex outside-voice C8):
|
||||
* - Two distinct parameter bindings: $1 qLike (LIKE-escaped, for ILIKE)
|
||||
* and $2 qRaw (un-escaped, for ranking arithmetic via
|
||||
* position/replace). Escaped chars cannot be reused as ranking
|
||||
* substrings.
|
||||
* - Explicit `ESCAPE '\'` on the ILIKE clause.
|
||||
* - Symmetric: no asymmetric whitespace strip (caller's query and
|
||||
* chunk_text are compared as-stored).
|
||||
* - Empty-query guard returns no results without binding SQL.
|
||||
*
|
||||
* Adapted to this engine's conventions rather than copied verbatim:
|
||||
* postgres.js `sql.begin()` + `SET LOCAL statement_timeout` (pool-safe,
|
||||
* R6-F006), filter params first with limit/offset appended last,
|
||||
* `false AS stale`, and this engine's full filter set (type / types /
|
||||
* exclude_slugs / language / symbolKind / afterDate / beforeDate /
|
||||
* source scope — the superset the ASCII FTS paths support here).
|
||||
*/
|
||||
private async _searchKeywordCJK(
|
||||
query: string,
|
||||
ctx: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
innerLimit: number;
|
||||
sourceFactorCase: string;
|
||||
hardExcludeClause: string;
|
||||
visibilityClause: string;
|
||||
detailFilter: string;
|
||||
opts: SearchOpts | undefined;
|
||||
dedup: boolean;
|
||||
},
|
||||
): Promise<SearchResult[]> {
|
||||
const sql = this.sql;
|
||||
const { limit, offset, innerLimit, sourceFactorCase, hardExcludeClause, visibilityClause, detailFilter, opts, dedup } = ctx;
|
||||
const qRaw = query;
|
||||
if (qRaw.length === 0) return [];
|
||||
const qLike = escapeLikePattern(qRaw);
|
||||
|
||||
// $1 = qLike (escaped for ILIKE)
|
||||
// $2 = qRaw (raw for position()/replace() ranking arithmetic)
|
||||
// Filter params next; inner-limit/limit/offset appended last (this
|
||||
// engine's named-param convention).
|
||||
const params: unknown[] = [qLike, qRaw];
|
||||
let typeClause = '';
|
||||
if (opts?.type) {
|
||||
params.push(opts.type);
|
||||
typeClause = `AND p.type = $${params.length}`;
|
||||
}
|
||||
// v0.33: multi-type filter for whoknows. AND-applied alongside the
|
||||
// single-value `type` filter (callers can use either or both).
|
||||
let typesClause = '';
|
||||
if (opts?.types && opts.types.length > 0) {
|
||||
params.push(opts.types);
|
||||
typesClause = `AND p.type = ANY($${params.length}::text[])`;
|
||||
}
|
||||
let excludeSlugsClause = '';
|
||||
if (opts?.exclude_slugs?.length) {
|
||||
params.push(opts.exclude_slugs);
|
||||
excludeSlugsClause = `AND p.slug != ALL($${params.length}::text[])`;
|
||||
}
|
||||
let languageClause = '';
|
||||
if (opts?.language) {
|
||||
params.push(opts.language);
|
||||
languageClause = `AND cc.language = $${params.length}`;
|
||||
}
|
||||
let symbolKindClause = '';
|
||||
if (opts?.symbolKind) {
|
||||
params.push(opts.symbolKind);
|
||||
symbolKindClause = `AND cc.symbol_type = $${params.length}`;
|
||||
}
|
||||
// v0.27.0: date filtering support
|
||||
let afterDateClause = '';
|
||||
if (opts?.afterDate) {
|
||||
params.push(opts.afterDate);
|
||||
afterDateClause = `AND COALESCE(p.updated_at, p.created_at) > $${params.length}::timestamptz`;
|
||||
}
|
||||
let beforeDateClause = '';
|
||||
if (opts?.beforeDate) {
|
||||
params.push(opts.beforeDate);
|
||||
beforeDateClause = `AND COALESCE(p.updated_at, p.created_at) < $${params.length}::timestamptz`;
|
||||
}
|
||||
// v0.34.1 (#861 — P0 leak seal): source-isolation on the CJK fallback
|
||||
// path too. Array form wins over scalar.
|
||||
let sourceClause = '';
|
||||
if (opts?.sourceIds && opts.sourceIds.length > 0) {
|
||||
params.push(opts.sourceIds);
|
||||
sourceClause = `AND p.source_id = ANY($${params.length}::text[])`;
|
||||
} else if (opts?.sourceId) {
|
||||
params.push(opts.sourceId);
|
||||
sourceClause = `AND p.source_id = $${params.length}`;
|
||||
}
|
||||
|
||||
// Occurrence-count ranking: count occurrences of $qRaw in chunk_text via
|
||||
// (length(chunk) - length(replace(chunk, q, ''))) / length(q). Acts as
|
||||
// a ts_rank substitute. position()-tiebreaker so earlier-in-chunk hits
|
||||
// outrank later ones at the same occurrence count. Same expression as
|
||||
// the PGLite implementation.
|
||||
const scoreExpr = `
|
||||
((LENGTH(cc.chunk_text) - LENGTH(REPLACE(cc.chunk_text, $2, ''))) / NULLIF(LENGTH($2), 0)::real
|
||||
+ 1.0 / NULLIF(POSITION($2 IN cc.chunk_text), 0)::real)
|
||||
* ${sourceFactorCase}
|
||||
`;
|
||||
|
||||
let rawQuery: string;
|
||||
if (dedup) {
|
||||
params.push(innerLimit);
|
||||
const innerLimitParam = `$${params.length}`;
|
||||
params.push(limit);
|
||||
const limitParam = `$${params.length}`;
|
||||
params.push(offset);
|
||||
const offsetParam = `$${params.length}`;
|
||||
rawQuery = `
|
||||
WITH ranked_chunks AS (
|
||||
SELECT
|
||||
p.slug, p.id as page_id, p.title, p.type, p.source_id,
|
||||
p.effective_date, p.effective_date_source,
|
||||
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
|
||||
${scoreExpr} AS score
|
||||
FROM content_chunks cc
|
||||
JOIN pages p ON p.id = cc.page_id
|
||||
JOIN sources s ON s.id = p.source_id
|
||||
WHERE cc.chunk_text ILIKE '%' || $1 || '%' ESCAPE '\\'
|
||||
${typeClause}
|
||||
${typesClause}
|
||||
${excludeSlugsClause}
|
||||
${detailFilter}
|
||||
${languageClause}
|
||||
${symbolKindClause}
|
||||
${afterDateClause}
|
||||
${beforeDateClause}
|
||||
${sourceClause}
|
||||
${hardExcludeClause}
|
||||
${visibilityClause}
|
||||
-- v0.27.1: hide image rows from text-keyword search so OCR text
|
||||
-- doesn't drown text-page hits (same as the FTS path).
|
||||
AND cc.modality = 'text'
|
||||
ORDER BY score DESC
|
||||
LIMIT ${innerLimitParam}
|
||||
),
|
||||
${buildBestPerPagePoolCte('ranked_chunks')}
|
||||
SELECT slug, page_id, title, type, source_id,
|
||||
effective_date, effective_date_source,
|
||||
chunk_id, chunk_index, chunk_text, chunk_source, score,
|
||||
false AS stale
|
||||
FROM best_per_page
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limitParam}
|
||||
OFFSET ${offsetParam}
|
||||
`;
|
||||
} else {
|
||||
params.push(limit);
|
||||
const limitParam = `$${params.length}`;
|
||||
params.push(offset);
|
||||
const offsetParam = `$${params.length}`;
|
||||
rawQuery = `
|
||||
SELECT
|
||||
p.slug, p.id as page_id, p.title, p.type, p.source_id,
|
||||
p.effective_date, p.effective_date_source,
|
||||
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
|
||||
${scoreExpr} AS score,
|
||||
false AS stale
|
||||
FROM content_chunks cc
|
||||
JOIN pages p ON p.id = cc.page_id
|
||||
JOIN sources s ON s.id = p.source_id
|
||||
WHERE cc.chunk_text ILIKE '%' || $1 || '%' ESCAPE '\\'
|
||||
${typeClause}
|
||||
${typesClause}
|
||||
${excludeSlugsClause}
|
||||
${detailFilter}
|
||||
${languageClause}
|
||||
${symbolKindClause}
|
||||
${afterDateClause}
|
||||
${beforeDateClause}
|
||||
${sourceClause}
|
||||
${hardExcludeClause}
|
||||
${visibilityClause}
|
||||
ORDER BY score DESC
|
||||
LIMIT ${limitParam}
|
||||
OFFSET ${offsetParam}
|
||||
`;
|
||||
}
|
||||
|
||||
// Search-only timeout, same pool-safety contract as the FTS paths:
|
||||
// SET LOCAL inside sql.begin() scopes the GUC to the transaction so
|
||||
// it can never leak onto a pooled connection.
|
||||
const rows = await sql.begin(async sql => {
|
||||
await sql`SET LOCAL statement_timeout = '8s'`;
|
||||
return await sql.unsafe(rawQuery, params as Parameters<typeof sql.unsafe>[1]);
|
||||
});
|
||||
return rows.map(rowToSearchResult);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.20.0 Cathedral II Layer 3 (1b) chunk-grain keyword search.
|
||||
* Ranks chunks via content_chunks.search_vector WITHOUT the
|
||||
@@ -1735,6 +1955,26 @@ export class PostgresEngine implements BrainEngine {
|
||||
const hardExcludePrefixes = resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes);
|
||||
const hardExcludeClause = buildHardExcludeClause('p.slug', hardExcludePrefixes);
|
||||
|
||||
// v0.26.5: visibility filter for searchKeywordChunks (anchor primitive).
|
||||
// (Hoisted above the CJK branch so the fallback can reuse it.)
|
||||
const visibilityClause = buildVisibilityClause('p', 's');
|
||||
|
||||
// v0.32.7 CJK branch (Postgres parity): same as searchKeyword but
|
||||
// without page-dedup — english tsquery can't tokenize CJK, so route to
|
||||
// the ILIKE + occurrence-count fallback (mirrors the PGLite engine's
|
||||
// chunk-grain CJK call site).
|
||||
if (hasCJK(query)) {
|
||||
return this._searchKeywordCJK(query, {
|
||||
limit, offset,
|
||||
innerLimit: 0, // unused on chunk-grain (no inner CTE)
|
||||
sourceFactorCase,
|
||||
hardExcludeClause, visibilityClause,
|
||||
detailFilter: detailLow ? `AND cc.chunk_source = 'compiled_truth'` : '',
|
||||
opts,
|
||||
dedup: false,
|
||||
});
|
||||
}
|
||||
|
||||
const params: unknown[] = [query];
|
||||
let typeClause = '';
|
||||
if (type) {
|
||||
@@ -1790,8 +2030,8 @@ export class PostgresEngine implements BrainEngine {
|
||||
params.push(offset);
|
||||
const offsetParam = `$${params.length}`;
|
||||
|
||||
// v0.26.5: visibility filter for searchKeywordChunks (anchor primitive).
|
||||
const visibilityClause = buildVisibilityClause('p', 's');
|
||||
// visibilityClause already declared above (v0.32.7 parity: hoisted so
|
||||
// the CJK branch can reuse it).
|
||||
|
||||
const rawQuery = `
|
||||
SELECT
|
||||
|
||||
+14
-1
@@ -152,6 +152,19 @@ export interface ThinkResult {
|
||||
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = 4000;
|
||||
|
||||
// Thinking-by-default Claude 5 models (`anthropic:claude-*-5`) spend a large
|
||||
// share of the output budget on internal reasoning before emitting any answer,
|
||||
// so the 4000 default leaves `think` with empty or truncated text. Give those
|
||||
// models headroom; providers bill actual tokens, not the cap. Everything else
|
||||
// keeps 4000.
|
||||
const THINKING_DEFAULT_MAX_OUTPUT_TOKENS = 16000;
|
||||
const THINKING_BY_DEFAULT_MODEL_RE = /^anthropic[:/]claude-[a-z0-9]+-5(?:[.-]|$)/i;
|
||||
export function maxOutputTokensFor(modelStr: string): number {
|
||||
return THINKING_BY_DEFAULT_MODEL_RE.test(modelStr)
|
||||
? THINKING_DEFAULT_MAX_OUTPUT_TOKENS
|
||||
: DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
}
|
||||
|
||||
function inferIntent(question: string, anchor?: string): string {
|
||||
if (anchor) return 'entity';
|
||||
const q = question.toLowerCase();
|
||||
@@ -465,7 +478,7 @@ export async function runThink(
|
||||
}
|
||||
const result = await client.create({
|
||||
model: modelUsed,
|
||||
max_tokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
max_tokens: maxOutputTokensFor(normalizeModelId(modelUsed)),
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface TrajectoryRegression {
|
||||
from_date: string; // YYYY-MM-DD
|
||||
to_value: number;
|
||||
to_date: string;
|
||||
delta_pct: number; // negative for a drop; range typically [-1, 0)
|
||||
delta_pct: number; // negative for a numeric drop; may be < -1 across zero
|
||||
}
|
||||
|
||||
export interface TrajectoryStats {
|
||||
@@ -82,8 +82,10 @@ function cosineSim(a: Float32Array, b: Float32Array): number {
|
||||
*
|
||||
* Iterates per-metric (so trajectories that interleave mrr + arr + team_size
|
||||
* don't trip false regressions across metric boundaries). Within each metric,
|
||||
* walks consecutive value pairs; a pair fires when
|
||||
* `(newer - older) / older <= -threshold`.
|
||||
* walks consecutive value pairs; a pair fires when the newer value is lower
|
||||
* than the older value by at least the threshold. The relative delta uses
|
||||
* `abs(older)` as the denominator so negative-valued metrics (net income,
|
||||
* cash flow, etc.) do not invert improvement and regression.
|
||||
*
|
||||
* Pre-condition: caller passed points sorted by (valid_from ASC, fact_id ASC).
|
||||
* The engine's `findTrajectory` enforces this. No re-sort here.
|
||||
@@ -111,7 +113,7 @@ export function detectRegressions(
|
||||
// Guard against division-by-zero: a metric starting at exactly 0
|
||||
// can't compute a relative delta. Skip.
|
||||
if (oldVal === 0) continue;
|
||||
const delta = (newVal - oldVal) / oldVal;
|
||||
const delta = (newVal - oldVal) / Math.abs(oldVal);
|
||||
if (delta <= -threshold) {
|
||||
out.push({
|
||||
metric,
|
||||
|
||||
@@ -86,6 +86,26 @@ describe('buildGatewayConfig env-baseURL passthrough', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGatewayConfig config-plane API-key folding', () => {
|
||||
test('openrouter_api_key folds into gateway env as OPENROUTER_API_KEY', async () => {
|
||||
await withEnv({ OPENROUTER_API_KEY: undefined }, async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
openrouter_api_key: 'sk-or-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.OPENROUTER_API_KEY).toBe('sk-or-config-plane');
|
||||
});
|
||||
});
|
||||
|
||||
test('a real OPENROUTER_API_KEY process.env value wins over the config-plane fallback', async () => {
|
||||
await withEnv({ OPENROUTER_API_KEY: 'sk-or-env-plane' }, async () => {
|
||||
const cfg = buildGatewayConfig({
|
||||
openrouter_api_key: 'sk-or-config-plane',
|
||||
} as unknown as GBrainConfig);
|
||||
expect(cfg.env.OPENROUTER_API_KEY).toBe('sk-or-env-plane');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGatewayConfig env empty-string clobber guard (#1249)', () => {
|
||||
test('an empty-string process.env value does NOT clobber a valid config-plane key', async () => {
|
||||
// Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Pins the DeepSeek reasoning_content transport shim. `deepseek-reasoner`
|
||||
* returns its answer in a separate `reasoning_content` field and leaves
|
||||
* `content` empty when the whole turn was reasoning; the AI SDK's
|
||||
* openai-compatible adapter reads only `content`, so the model appears to
|
||||
* answer with nothing. The shim promotes `reasoning_content` into `content`
|
||||
* when `content` is empty, fail-open on anything unexpected.
|
||||
*/
|
||||
import { describe, test, expect, afterEach } from 'bun:test';
|
||||
import { deepseekReasoningContentCompatFetch, deepseek } from '../../src/core/ai/recipes/deepseek.ts';
|
||||
import { applyOpenAICompatConfig } from '../../src/core/ai/gateway.ts';
|
||||
import type { Recipe, AIGatewayConfig } from '../../src/core/ai/types.ts';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => { globalThis.fetch = realFetch; });
|
||||
|
||||
function stubFetch(body: unknown, init?: { status?: number; contentType?: string }) {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: init?.status ?? 200,
|
||||
headers: { 'content-type': init?.contentType ?? 'application/json' },
|
||||
})) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe('deepseekReasoningContentCompatFetch', () => {
|
||||
test('promotes reasoning_content when content is empty', async () => {
|
||||
stubFetch({ choices: [{ message: { role: 'assistant', content: '', reasoning_content: 'the answer' } }] });
|
||||
const res = await deepseekReasoningContentCompatFetch('https://api.deepseek.com/v1/chat/completions');
|
||||
const json = await res.json();
|
||||
expect(json.choices[0].message.content).toBe('the answer');
|
||||
});
|
||||
|
||||
test('promotes when content is null or whitespace-only', async () => {
|
||||
stubFetch({ choices: [{ message: { content: null, reasoning_content: 'from null' } }, { message: { content: ' ', reasoning_content: 'from ws' } }] });
|
||||
const res = await deepseekReasoningContentCompatFetch('u');
|
||||
const json = await res.json();
|
||||
expect(json.choices[0].message.content).toBe('from null');
|
||||
expect(json.choices[1].message.content).toBe('from ws');
|
||||
});
|
||||
|
||||
test('leaves non-empty content untouched (no duplication)', async () => {
|
||||
stubFetch({ choices: [{ message: { content: 'real content', reasoning_content: 'ignored' } }] });
|
||||
const res = await deepseekReasoningContentCompatFetch('u');
|
||||
const json = await res.json();
|
||||
expect(json.choices[0].message.content).toBe('real content');
|
||||
});
|
||||
|
||||
test('both empty: stays empty, no crash', async () => {
|
||||
stubFetch({ choices: [{ message: { content: '', reasoning_content: '' } }] });
|
||||
const res = await deepseekReasoningContentCompatFetch('u');
|
||||
const json = await res.json();
|
||||
expect(json.choices[0].message.content).toBe('');
|
||||
});
|
||||
|
||||
test('tool-call turn (content:null + tool_calls) is NOT promoted — never feed CoT back', async () => {
|
||||
// content:null is the standard OpenAI shape on a tool-call turn. Promoting
|
||||
// reasoning_content here would inject the whole chain-of-thought as assistant
|
||||
// text, which the loop persists + replays every turn. Must be left alone.
|
||||
stubFetch({ choices: [{ finish_reason: 'tool_calls', message: {
|
||||
content: null,
|
||||
reasoning_content: 'INTERNAL CHAIN OF THOUGHT — must not leak',
|
||||
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'brain_search', arguments: '{}' } }],
|
||||
} }] });
|
||||
const res = await deepseekReasoningContentCompatFetch('u');
|
||||
const json = await res.json();
|
||||
expect(json.choices[0].message.content).toBeNull();
|
||||
expect(json.choices[0].message.tool_calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('rebuilt response drops stale content-length header', async () => {
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: '', reasoning_content: 'x' } }] }), {
|
||||
status: 200, headers: { 'content-type': 'application/json', 'content-length': '999999' },
|
||||
})) as unknown as typeof fetch;
|
||||
const res = await deepseekReasoningContentCompatFetch('u');
|
||||
expect(res.headers.get('content-length')).toBeNull();
|
||||
expect((await res.json()).choices[0].message.content).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyOpenAICompatConfig — compat.fetch wiring (gateway seam)', () => {
|
||||
const cfg = { env: {}, base_urls: {} } as unknown as AIGatewayConfig;
|
||||
|
||||
test('threads recipe.compat.fetch onto the resolved config (deepseek)', () => {
|
||||
// Guards the src/core/ai/gateway.ts wiring: without `?? recipe.compat?.fetch`
|
||||
// the DeepSeek shim would never install in production.
|
||||
const resolved = applyOpenAICompatConfig(deepseek, cfg);
|
||||
expect(resolved.fetch).toBe(deepseekReasoningContentCompatFetch);
|
||||
});
|
||||
|
||||
test('a resolveOpenAICompatConfig-provided fetch takes precedence over compat.fetch', () => {
|
||||
const ownFetch = (async () => new Response('{}')) as unknown as typeof fetch;
|
||||
const recipe = {
|
||||
id: 'x', name: 'X', tier: 'openai-compat', implementation: 'openai-compatible',
|
||||
touchpoints: {},
|
||||
compat: { fetch: deepseekReasoningContentCompatFetch },
|
||||
resolveOpenAICompatConfig: () => ({ baseURL: 'http://x', fetch: ownFetch }),
|
||||
} as unknown as Recipe;
|
||||
expect(applyOpenAICompatConfig(recipe, cfg).fetch).toBe(ownFetch);
|
||||
});
|
||||
|
||||
test('falls back to compat.fetch when resolveOpenAICompatConfig omits a fetch', () => {
|
||||
const recipe = {
|
||||
id: 'y', name: 'Y', tier: 'openai-compat', implementation: 'openai-compatible',
|
||||
touchpoints: {},
|
||||
compat: { fetch: deepseekReasoningContentCompatFetch },
|
||||
resolveOpenAICompatConfig: () => ({ baseURL: 'http://y' }),
|
||||
} as unknown as Recipe;
|
||||
expect(applyOpenAICompatConfig(recipe, cfg).fetch).toBe(deepseekReasoningContentCompatFetch);
|
||||
});
|
||||
|
||||
test('fail-open on non-ok / non-json responses', async () => {
|
||||
stubFetch({ error: 'nope' }, { status: 500 });
|
||||
const res = await deepseekReasoningContentCompatFetch('u');
|
||||
expect(res.status).toBe(500);
|
||||
globalThis.fetch = (async () =>
|
||||
new Response('plain text', { status: 200, headers: { 'content-type': 'text/plain' } })) as unknown as typeof fetch;
|
||||
const res2 = await deepseekReasoningContentCompatFetch('u');
|
||||
expect(await res2.text()).toBe('plain text');
|
||||
});
|
||||
|
||||
test('recipe wires the shim via compat.fetch', () => {
|
||||
expect(deepseek.compat?.fetch).toBe(deepseekReasoningContentCompatFetch);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,47 @@ describe('gateway.toolLoop (v0.38 D11 — provider-agnostic loop control)', () =
|
||||
expect(events[4]).toBe('onAssistantTurn(1)'); // final assistant turn
|
||||
});
|
||||
|
||||
it('persists the tool-result user turn via onToolResultTurn before the next chat', async () => {
|
||||
let turn = 0;
|
||||
__setChatTransportForTests(async () => {
|
||||
turn++;
|
||||
if (turn === 1) {
|
||||
return {
|
||||
text: '',
|
||||
blocks: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'x' } }] as ChatBlock[],
|
||||
stopReason: 'tool_calls',
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: 'anthropic:claude-sonnet-4-6',
|
||||
providerId: 'anthropic',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: 'done',
|
||||
blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
|
||||
stopReason: 'end',
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: 'anthropic:claude-sonnet-4-6',
|
||||
providerId: 'anthropic',
|
||||
};
|
||||
});
|
||||
|
||||
const resultTurns: Array<{ turnIdx: number; messageIdx: number; blocks: ChatBlock[] }> = [];
|
||||
await toolLoop({
|
||||
initialMessages: [{ role: 'user', content: 'go' }],
|
||||
tools: [{ name: 'search', description: 's', inputSchema: { type: 'object' } }],
|
||||
toolHandlers: new Map([['search', { idempotent: true, async execute() { return { hits: 1 }; } }]]),
|
||||
onToolResultTurn: async (turnIdx, messageIdx, blocks) => {
|
||||
resultTurns.push({ turnIdx, messageIdx, blocks });
|
||||
},
|
||||
});
|
||||
|
||||
// Fired exactly once, for the single tool round, carrying the tool-result.
|
||||
expect(resultTurns).toHaveLength(1);
|
||||
expect(resultTurns[0].turnIdx).toBe(0);
|
||||
expect(resultTurns[0].blocks[0].type).toBe('tool-result');
|
||||
expect((resultTurns[0].blocks[0] as Extract<ChatBlock, { type: 'tool-result' }>).toolCallId).toBe('tc1');
|
||||
});
|
||||
|
||||
it('replay short-circuits a complete prior tool execution', async () => {
|
||||
let chatCalls = 0;
|
||||
__setChatTransportForTests(async () => {
|
||||
@@ -230,6 +271,30 @@ describe('gateway.toolLoop (v0.38 D11 — provider-agnostic loop control)', () =
|
||||
).rejects.toThrow(/non-idempotent.*pending/i);
|
||||
});
|
||||
|
||||
it('defaults max output tokens per model: 4096 for non-thinking, 32000 for Claude 5', async () => {
|
||||
const seen: Array<number | undefined> = [];
|
||||
__setChatTransportForTests(async (opts) => {
|
||||
seen.push(opts.maxTokens);
|
||||
return {
|
||||
text: 'ok',
|
||||
blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[],
|
||||
stopReason: 'end',
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: opts.model ?? 'anthropic:claude-sonnet-4-6',
|
||||
providerId: 'anthropic',
|
||||
};
|
||||
});
|
||||
|
||||
await toolLoop({ model: 'openai:gpt-4o', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
||||
await toolLoop({ model: 'anthropic:claude-sonnet-4-6', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
||||
await toolLoop({ model: 'anthropic:claude-sonnet-5', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
||||
await toolLoop({ model: 'anthropic:claude-fable-5', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
||||
|
||||
// Non-thinking / non-Claude-5 stay 4096 (safe under openai-compat caps);
|
||||
// thinking-by-default Claude 5 models get 32000 headroom.
|
||||
expect(seen).toEqual([4096, 4096, 32000, 32000]);
|
||||
});
|
||||
|
||||
it('hits max_turns when the model keeps calling tools', async () => {
|
||||
__setChatTransportForTests(async () => ({
|
||||
text: '',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Pins `repairToolPairing` (the chat()-boundary safety net) and proves, against
|
||||
* the REAL AI SDK v6 `generateText`, that the two failure modes this wave fixes
|
||||
* are gone:
|
||||
* - an unbalanced tool history (assistant tool-call with no tool-result) is
|
||||
* back-filled so v6 no longer throws AI_MissingToolResultsError, and
|
||||
* - a Date-bearing tool-result (Postgres timestamptz) passes v6's ModelMessage
|
||||
* JSONValue schema after `toModelMessages` ISO-izes it, whereas a raw Date
|
||||
* is still rejected (control).
|
||||
*
|
||||
* MockLanguageModelV3 = no network / no keys.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateText } from 'ai';
|
||||
import { MockLanguageModelV3 } from 'ai/test';
|
||||
import { repairToolPairing, toModelMessages, type ChatMessage } from '../../src/core/ai/gateway.ts';
|
||||
|
||||
function mockModel(): MockLanguageModelV3 {
|
||||
return new MockLanguageModelV3({
|
||||
doGenerate: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
finishReason: 'stop',
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
warnings: [],
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
describe('repairToolPairing', () => {
|
||||
it('is a no-op on a balanced history', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: 'hi' },
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} }] },
|
||||
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { ok: 1 } }] },
|
||||
];
|
||||
expect(repairToolPairing(msgs)).toEqual(msgs);
|
||||
});
|
||||
|
||||
it('synthesizes a tool-result turn when the assistant tool-call is fully unanswered', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} }] },
|
||||
];
|
||||
const out = repairToolPairing(msgs);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[1].role).toBe('user');
|
||||
const block = (out[1].content as any[])[0];
|
||||
expect(block).toMatchObject({ type: 'tool-result', toolCallId: 'c1', isError: true });
|
||||
});
|
||||
|
||||
it('merges stubs into a PARTIALLY-answered turn without duplicating the answered id', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'assistant', content: [
|
||||
{ type: 'tool-call', toolCallId: 'a', toolName: 'search', input: {} },
|
||||
{ type: 'tool-call', toolCallId: 'b', toolName: 'search', input: {} },
|
||||
] },
|
||||
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'a', toolName: 'search', output: { ok: 1 } }] },
|
||||
];
|
||||
const out = repairToolPairing(msgs);
|
||||
expect(out).toHaveLength(2); // merged in place, not appended
|
||||
const ids = (out[1].content as any[]).map((x) => x.toolCallId);
|
||||
expect(ids).toEqual(['a', 'b']); // 'a' kept once, 'b' back-filled
|
||||
expect((out[1].content as any[]).filter((x) => x.toolCallId === 'a')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real AI SDK v6 validation', () => {
|
||||
it('an unbalanced history passes generateText after repairToolPairing', async () => {
|
||||
const model = mockModel();
|
||||
const unbalanced: ChatMessage[] = [
|
||||
{ role: 'user', content: 'go' },
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} }] },
|
||||
// no tool-result turn
|
||||
];
|
||||
const result = await generateText({
|
||||
model: model as any,
|
||||
messages: toModelMessages(repairToolPairing(unbalanced)) as any,
|
||||
});
|
||||
expect(result.text).toBe('ok');
|
||||
const prompt = model.doGenerateCalls[0]!.prompt as any[];
|
||||
expect(prompt.some((m) => m.role === 'tool')).toBe(true); // stub promoted to tool role
|
||||
});
|
||||
|
||||
it('a Date-bearing tool-result passes after toModelMessages ISO-izes it; a raw Date is rejected', async () => {
|
||||
const withDate: ChatMessage[] = [
|
||||
{ role: 'user', content: 'go' },
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'brain_get_page', input: {} }] },
|
||||
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'brain_get_page', output: { updated_at: new Date('2026-06-26T06:56:59.000Z') } }] },
|
||||
];
|
||||
// Fixed path: converted history validates.
|
||||
await expect(generateText({ model: mockModel() as any, messages: toModelMessages(withDate) as any })).resolves.toBeDefined();
|
||||
|
||||
// Control: a raw Date placed straight into a v6 ModelMessage json value is rejected.
|
||||
const rawDateMessages = [
|
||||
{ role: 'user', content: 'go' },
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'brain_get_page', input: {} }] },
|
||||
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'brain_get_page', output: { type: 'json', value: { updated_at: new Date('2026-06-26T06:56:59.000Z') } } }] },
|
||||
];
|
||||
await expect(generateText({ model: mockModel() as any, messages: rawDateMessages as any })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { defaultResolveAuth } from '../../src/core/ai/gateway.ts';
|
||||
import { assertTouchpoint } from '../../src/core/ai/model-resolver.ts';
|
||||
import { getRecipe } from '../../src/core/ai/recipes/index.ts';
|
||||
|
||||
describe('recipe: litellm proxy', () => {
|
||||
test('registered with expected openai-compatible shape', () => {
|
||||
const r = getRecipe('litellm');
|
||||
expect(r).toBeDefined();
|
||||
expect(r!.id).toBe('litellm');
|
||||
expect(r!.tier).toBe('openai-compat');
|
||||
expect(r!.implementation).toBe('openai-compatible');
|
||||
expect(r!.base_url_default).toBe('http://localhost:4000');
|
||||
expect(r!.auth_env?.required ?? []).toEqual([]);
|
||||
expect(r!.auth_env?.optional ?? []).toContain('LITELLM_BASE_URL');
|
||||
expect(r!.auth_env?.optional ?? []).toContain('LITELLM_API_KEY');
|
||||
});
|
||||
|
||||
test('chat touchpoint accepts arbitrary proxied model IDs', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
expect(r.touchpoints.chat).toBeDefined();
|
||||
expect(r.touchpoints.chat!.models).toEqual([]);
|
||||
expect(r.touchpoints.chat!.supports_tools).toBe(true);
|
||||
expect(r.touchpoints.chat!.supports_subagent_loop).toBe(false);
|
||||
expect(() => assertTouchpoint(r, 'chat', 'gpt-4o')).not.toThrow();
|
||||
expect(() => assertTouchpoint(r, 'chat', 'deepseek-v4-pro')).not.toThrow();
|
||||
});
|
||||
|
||||
test('embedding touchpoint still uses user-provided models and dimensions', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
expect(r.touchpoints.embedding).toBeDefined();
|
||||
expect(r.touchpoints.embedding!.models).toEqual([]);
|
||||
expect(r.touchpoints.embedding!.user_provided_models).toBe(true);
|
||||
expect(r.touchpoints.embedding!.default_dims).toBe(0);
|
||||
expect(r.touchpoints.embedding!.no_batch_cap).toBe(true);
|
||||
});
|
||||
|
||||
test('default auth honors LITELLM_API_KEY and ignores URL-only config', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
|
||||
const noAuth = defaultResolveAuth(r, {}, 'chat');
|
||||
expect(noAuth.headerName).toBe('Authorization');
|
||||
expect(noAuth.token).toBe('Bearer unauthenticated');
|
||||
|
||||
const urlOnly = defaultResolveAuth(r, { LITELLM_BASE_URL: 'http://proxy.example' }, 'chat');
|
||||
expect(urlOnly.token).toBe('Bearer unauthenticated');
|
||||
|
||||
const withKey = defaultResolveAuth(r, {
|
||||
LITELLM_BASE_URL: 'http://proxy.example',
|
||||
LITELLM_API_KEY: 'sk-litellm-fake',
|
||||
}, 'chat');
|
||||
expect(withKey.token).toBe('Bearer sk-litellm-fake');
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,41 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('force-retry escape hatch', () => {
|
||||
test("complete then retry-latest → pending and buildPlan lists the version as pending", () => {
|
||||
const idx = indexCompleted([
|
||||
{ version: '0.11.0', status: 'complete' },
|
||||
{ version: '0.11.0', status: 'retry' },
|
||||
]);
|
||||
|
||||
expect(statusForVersion('0.11.0', idx)).toBe('pending');
|
||||
const plan = buildPlan(idx, '0.11.1', '0.11.0');
|
||||
expect(plan.pending.map(m => m.version)).toEqual(['0.11.0']);
|
||||
expect(plan.applied).toEqual([]);
|
||||
expect(plan.partial).toEqual([]);
|
||||
expect(plan.wedged).toEqual([]);
|
||||
});
|
||||
|
||||
test('complete then stray partial without retry → still complete', () => {
|
||||
const idx = indexCompleted([
|
||||
{ version: '0.11.0', status: 'complete' },
|
||||
{ version: '0.11.0', status: 'partial' },
|
||||
]);
|
||||
|
||||
expect(statusForVersion('0.11.0', idx)).toBe('complete');
|
||||
});
|
||||
|
||||
test('retry followed by a newer complete → complete', () => {
|
||||
const idx = indexCompleted([
|
||||
{ version: '0.11.0', status: 'complete' },
|
||||
{ version: '0.11.0', status: 'retry' },
|
||||
{ version: '0.11.0', status: 'complete' },
|
||||
]);
|
||||
|
||||
expect(statusForVersion('0.11.0', idx)).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
// v0.36.1.x (cherry-pick #1062): list, dry-run, and "all migrations up to
|
||||
// date" paths must exit 0 so shell scripts gating on the exit code work.
|
||||
// Pre-fix, these `return` statements left the CLI dispatcher's implicit
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Tests for the parser-probe audit trail + the 24h rate-limit gate.
|
||||
*
|
||||
* Uses GBRAIN_AUDIT_DIR override pointed at a tmpdir for hermeticity
|
||||
* (same pattern as audit-slug-fallback.serial.test.ts). Serial because
|
||||
* the env override is process-global.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
computeParserProbeAuditFilename,
|
||||
logParserProbeEvent,
|
||||
parserProbeRanWithin,
|
||||
readRecentParserProbeEvents,
|
||||
type ParserProbeAuditEvent,
|
||||
} from '../src/core/audit-parser-probe.ts';
|
||||
|
||||
let auditDir: string;
|
||||
let savedEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
auditDir = mkdtempSync(join(tmpdir(), 'parser-probe-audit-'));
|
||||
savedEnv = process.env.GBRAIN_AUDIT_DIR;
|
||||
process.env.GBRAIN_AUDIT_DIR = auditDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) delete process.env.GBRAIN_AUDIT_DIR;
|
||||
else process.env.GBRAIN_AUDIT_DIR = savedEnv;
|
||||
rmSync(auditDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeEvent(overrides: Partial<ParserProbeAuditEvent> = {}): ParserProbeAuditEvent {
|
||||
return {
|
||||
schema_version: 1,
|
||||
ts: new Date().toISOString(),
|
||||
outcome: 'pass',
|
||||
fixtures_total: 12,
|
||||
fixtures_passed: 12,
|
||||
recall_mean: 0.98,
|
||||
participants_recall_mean: 0.97,
|
||||
adversarial_false_positives: 0,
|
||||
failed_fixture_ids: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parser-probe audit trail', () => {
|
||||
test('log + readRecent round-trip', () => {
|
||||
logParserProbeEvent(makeEvent({ outcome: 'fail', reason: '2 fixture(s) failed' }));
|
||||
const events = readRecentParserProbeEvents(7);
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]!.outcome).toBe('fail');
|
||||
expect(events[0]!.reason).toBe('2 fixture(s) failed');
|
||||
const files = readdirSync(auditDir);
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).toMatch(/^parser-probe-\d{4}-W\d{2}\.jsonl$/);
|
||||
});
|
||||
|
||||
test('filename uses ISO-week rotation with the parser-probe prefix', () => {
|
||||
// Year-boundary edge pinned by the shared writer's own tests; here we
|
||||
// pin the prefix wiring.
|
||||
expect(computeParserProbeAuditFilename(new Date('2026-07-06T12:00:00Z'))).toBe(
|
||||
'parser-probe-2026-W28.jsonl',
|
||||
);
|
||||
});
|
||||
|
||||
test('readRecent filters by window', () => {
|
||||
const old = new Date(Date.now() - 10 * 86400000).toISOString();
|
||||
logParserProbeEvent(makeEvent({ ts: old }));
|
||||
expect(readRecentParserProbeEvents(7).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parserProbeRanWithin — 24h rate-limit gate', () => {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
test('false when no runs are audited', () => {
|
||||
expect(parserProbeRanWithin(DAY_MS)).toBe(false);
|
||||
});
|
||||
|
||||
test('true when a run landed within the window', () => {
|
||||
logParserProbeEvent(makeEvent({ ts: new Date(Date.now() - 60_000).toISOString() }));
|
||||
expect(parserProbeRanWithin(DAY_MS)).toBe(true);
|
||||
});
|
||||
|
||||
test('false when the last run is older than the window', () => {
|
||||
logParserProbeEvent(makeEvent({ ts: new Date(Date.now() - 25 * 3600_000).toISOString() }));
|
||||
expect(parserProbeRanWithin(DAY_MS)).toBe(false);
|
||||
});
|
||||
|
||||
test('non-pass outcomes also hold the window (mirrors quality-probe semantics)', () => {
|
||||
logParserProbeEvent(makeEvent({
|
||||
outcome: 'no_embedding_key',
|
||||
ts: new Date(Date.now() - 3600_000).toISOString(),
|
||||
}));
|
||||
expect(parserProbeRanWithin(DAY_MS)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -31,10 +31,15 @@ describe('autopilot wiring: nightly quality probe', () => {
|
||||
expect(SOURCE).toContain(`runCrossModalBatchForProbe`);
|
||||
});
|
||||
|
||||
test('feature flag gate present: cfg.autopilot.nightly_quality_probe.enabled', () => {
|
||||
test('feature flag gate present: dual-plane read (DB row wins, file plane fallback)', () => {
|
||||
// Per D10: the scheduler ONLY checks the feature flag. The 24h rate-limit
|
||||
// lives inside runNightlyQualityProbe itself (no scheduler-side precheck).
|
||||
expect(SOURCE).toContain(`nightly_quality_probe?.enabled === true`);
|
||||
// The flag resolves through resolveProbeEnabled so `gbrain config set
|
||||
// autopilot.nightly_quality_probe.enabled true` (the doctor hint, DB
|
||||
// plane) and ~/.gbrain/config.json (file plane) BOTH work — a file-only
|
||||
// read made the printed hint a silent no-op.
|
||||
expect(SOURCE).toContain(`getConfig('autopilot.nightly_quality_probe.enabled')`);
|
||||
expect(SOURCE).toMatch(/resolveProbeEnabled\(dbEnabled,\s*cfg\?\.autopilot\?\.nightly_quality_probe\?\.enabled\)/);
|
||||
});
|
||||
|
||||
test('NO scheduler-side rate-limit check (D10 simplification)', () => {
|
||||
@@ -64,12 +69,23 @@ describe('autopilot wiring: nightly quality probe', () => {
|
||||
expect(SOURCE).toContain(`now:`);
|
||||
});
|
||||
|
||||
test('resolveRepoRoot prefers the gbrain package root (committed fixture home), not the brain repoPath', () => {
|
||||
// The DI harness in nightly-quality-probe.test.ts passes process.cwd()
|
||||
// (= the gbrain repo in CI), which papered over the wiring passing
|
||||
// repoPath (= sync.repo_path, the user's BRAIN repo, where the fixture
|
||||
// never exists). Pin the package-root resolution + existence check.
|
||||
expect(SOURCE).toMatch(/fileURLToPath\(new URL\('\.\.\/\.\.', import\.meta\.url\)\)/);
|
||||
expect(SOURCE).toContain(`'longmemeval-nightly.jsonl'`);
|
||||
expect(SOURCE).toMatch(/fixtureAtPkgRoot \? pkgRoot : repoPath/);
|
||||
});
|
||||
|
||||
test('hasEmbeddingProvider reads from gateway.isAvailable("embedding") (codex round-2 #12 — in-process, not subprocess)', () => {
|
||||
expect(SOURCE).toContain(`isAvailable('embedding')`);
|
||||
expect(SOURCE).toContain(`gateway`);
|
||||
});
|
||||
|
||||
test('max_usd default = 5 when config unset (matches plan default per D10)', () => {
|
||||
expect(SOURCE).toMatch(/max_usd\s*\?\?\s*5/);
|
||||
test('max_usd resolves dual-plane (default = 5 pinned by resolveProbeMaxUsd unit tests)', () => {
|
||||
expect(SOURCE).toContain(`getConfig('autopilot.nightly_quality_probe.max_usd')`);
|
||||
expect(SOURCE).toMatch(/resolveProbeMaxUsd\(dbMaxUsd,\s*cfg\?\.autopilot\?\.nightly_quality_probe\?\.max_usd\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Source-shape regression tests for the autopilot wiring of
|
||||
* `runConversationParserNightlyProbe` (step 4.6).
|
||||
*
|
||||
* Same rationale as autopilot-nightly-probe-wiring.test.ts: the loop is
|
||||
* hard to drive end-to-end, so these pin the structural protections —
|
||||
* the dual-plane flag read, the D10 tokenmax mode-gate, the package-root
|
||||
* fixture resolution, the audit-flood guard, and the try/catch posture.
|
||||
*
|
||||
* The probe's own gate/scoring logic is pinned by the module's unit
|
||||
* tests; the audit trail by audit-parser-probe.serial.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const AUTOPILOT_SRC = resolve('src/commands/autopilot.ts');
|
||||
const SOURCE = readFileSync(AUTOPILOT_SRC, 'utf-8');
|
||||
|
||||
describe('autopilot wiring: conversation-parser probe', () => {
|
||||
test('invokes the phase module and the audit trail', () => {
|
||||
expect(SOURCE).toContain(`runConversationParserNightlyProbe`);
|
||||
expect(SOURCE).toContain(`conversation-parser/nightly-probe`);
|
||||
expect(SOURCE).toContain(`logParserProbeEvent`);
|
||||
expect(SOURCE).toContain(`audit-parser-probe`);
|
||||
});
|
||||
|
||||
test('flag reads dual-plane: DB row (gbrain config set) wins, file plane fallback', () => {
|
||||
expect(SOURCE).toContain(`getConfig('autopilot.conversation_parser_probe.enabled')`);
|
||||
expect(SOURCE).toContain(`cfg?.autopilot?.conversation_parser_probe?.enabled === true`);
|
||||
});
|
||||
|
||||
test('D10 mode-gate present: tokenmax brains run the probe by default', () => {
|
||||
expect(SOURCE).toMatch(/parserEnabled \|\| searchMode === 'tokenmax'/);
|
||||
});
|
||||
|
||||
test('fixtures resolve from the gbrain package root, NOT the brain repoPath', () => {
|
||||
// The committed fixtures live in the gbrain source tree; resolving
|
||||
// them against sync.repo_path would point into the user's brain repo.
|
||||
expect(SOURCE).toMatch(/fileURLToPath\(new URL\('\.\.\/\.\.', import\.meta\.url\)\)/);
|
||||
expect(SOURCE).toContain(`'conversation-formats', 'all.jsonl'`);
|
||||
expect(SOURCE).toContain(`'conversation-formats', 'adversarial.jsonl'`);
|
||||
});
|
||||
|
||||
test('missing fixtures skip quietly (no audit row, once-per-process stderr note)', () => {
|
||||
// Compiled-binary installs carry no source tree; writing failure rows
|
||||
// would flip doctor to WARN on every binary install.
|
||||
expect(SOURCE).toContain(`parserProbeFixtureWarned`);
|
||||
});
|
||||
|
||||
test('rate_limited outcomes are NOT audit-logged (flood guard)', () => {
|
||||
expect(SOURCE).toMatch(/outcome !== 'rate_limited'\) logParserProbeEvent\(result\)/);
|
||||
});
|
||||
|
||||
test('rate-limit gate delegates to the audit module, not inline event reads', () => {
|
||||
expect(SOURCE).toContain(`parserProbeRanWithin(24 * 60 * 60 * 1000)`);
|
||||
});
|
||||
|
||||
test('LLM-key gate reads gateway.isAvailable("chat") in-process', () => {
|
||||
expect(SOURCE).toContain(`isAvailable('chat')`);
|
||||
});
|
||||
|
||||
test('probe call wrapped in try/catch that does NOT bump consecutiveErrors', () => {
|
||||
expect(SOURCE).toMatch(/catch[\s\S]*?autopilot\.parser_probe[\s\S]*?do NOT bump consecutiveErrors/);
|
||||
});
|
||||
|
||||
test('DI shape: the exact 7 fields of the parser probe NightlyProbeDeps', () => {
|
||||
expect(SOURCE).toContain(`isEnabled:`);
|
||||
expect(SOURCE).toContain(`searchMode:`);
|
||||
expect(SOURCE).toContain(`hasLlmKey:`);
|
||||
expect(SOURCE).toContain(`resolveFixturePath:`);
|
||||
expect(SOURCE).toContain(`resolveAdversarialPath:`);
|
||||
expect(SOURCE).toContain(`shouldSkipForRateLimit:`);
|
||||
expect(SOURCE).toContain(`now:`);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,15 @@ describe('KNOWN_CONFIG_KEYS', () => {
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd');
|
||||
});
|
||||
|
||||
test('includes the gateway-loop toggle and provider API keys the wave wires', () => {
|
||||
// The subagent handler's error message tells users to run
|
||||
// `gbrain config set agent.use_gateway_loop true`; it must be a known key
|
||||
// or `config set` rejects the wave's own enable command without --force.
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('agent.use_gateway_loop');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('openrouter_api_key');
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('zeroentropy_api_key');
|
||||
});
|
||||
|
||||
test('no duplicate entries', () => {
|
||||
const set = new Set(KNOWN_CONFIG_KEYS);
|
||||
expect(set.size).toBe(KNOWN_CONFIG_KEYS.length);
|
||||
|
||||
@@ -253,3 +253,15 @@ describe('loadConfig — GBRAIN_MAX_MARKUP_RATIO env (v0.42 #1699)', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('KNOWN_CONFIG_KEYS — documented enable commands must be registered', () => {
|
||||
test('Life Chronicle keys are registered (v0.42.56.0 release notes say `config set auto_chronicle true`)', async () => {
|
||||
const { KNOWN_CONFIG_KEYS, KNOWN_CONFIG_KEY_PREFIXES } = await import('../src/core/config.ts');
|
||||
// The flag the chronicle backstop reads (isAutoChronicleEnabled).
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('auto_chronicle');
|
||||
// The takes bootstrap two-gate consent flag (v0.41.18.0 A12).
|
||||
expect(KNOWN_CONFIG_KEYS).toContain('takes.bootstrap_enabled');
|
||||
// chronicle.tz (chronicleTz) + future chronicle.* knobs.
|
||||
expect(KNOWN_CONFIG_KEY_PREFIXES.some(p => 'chronicle.tz'.startsWith(p))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
/**
|
||||
* Pure-function tests for src/core/contextual-retrieval-service.ts.
|
||||
*
|
||||
* The full service test (PHASE 1 + PHASE 2 happy path, refusal restart,
|
||||
* transient error propagation) needs a real PGLite + gateway stub seam.
|
||||
* That lands in test/e2e/contextual-retrieval.test.ts. This file pins
|
||||
* the service's pure helpers: corpus_generation hash composition + the
|
||||
* expectedMode helper used by the T9 reindex sweep predicate.
|
||||
* This file pins the service's pure helpers plus hermetic service behavior
|
||||
* driven through fake engine + gateway seams. Full PGLite coverage lives in
|
||||
* test/e2e/contextual-retrieval-pglite.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { afterEach, describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
computeCorpusGeneration,
|
||||
computeSourceTextHash,
|
||||
expectedModeForPageSourceOnly,
|
||||
reembedPageWithContextualRetrieval,
|
||||
resolveContextualChunkConcurrency,
|
||||
TITLE_WRAPPER_VERSION,
|
||||
} from '../src/core/contextual-retrieval-service.ts';
|
||||
import {
|
||||
__setChatTransportForTests,
|
||||
__setEmbedTransportForTests,
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
type ChatOpts,
|
||||
type ChatResult,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import type { ChunkInput } from '../src/core/types.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
const TEST_DIMS = 1536;
|
||||
|
||||
afterEach(() => {
|
||||
__setChatTransportForTests(null);
|
||||
__setEmbedTransportForTests(null);
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
describe('computeCorpusGeneration', () => {
|
||||
test('returns 16-char hex hash', () => {
|
||||
@@ -138,3 +156,317 @@ describe('expectedModeForPageSourceOnly (T9 reindex sweep helper)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveContextualChunkConcurrency', () => {
|
||||
test('defaults to 4 and reads the process env', async () => {
|
||||
await withEnv({ GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: undefined }, async () => {
|
||||
expect(resolveContextualChunkConcurrency()).toBe(4);
|
||||
});
|
||||
await withEnv({ GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '7' }, async () => {
|
||||
expect(resolveContextualChunkConcurrency()).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps to [1, 16] and ignores invalid values', () => {
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '0',
|
||||
})).toBe(1);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '-3',
|
||||
})).toBe(1);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '99',
|
||||
})).toBe(16);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '1.9',
|
||||
})).toBe(1);
|
||||
expect(resolveContextualChunkConcurrency({
|
||||
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: 'not-a-number',
|
||||
})).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-chunk synopsis concurrency', () => {
|
||||
test('concurrency > 1 preserves chunk-order embed input', async () => {
|
||||
const chunks = makeChunks(['alpha', 'beta', 'gamma', 'delta']);
|
||||
const delays: Record<string, number> = { alpha: 30, beta: 5, gamma: 20, delta: 1 };
|
||||
const sequential = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 1,
|
||||
delayForChunk: (chunk) => delays[chunk] ?? 1,
|
||||
});
|
||||
const parallel = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 4,
|
||||
delayForChunk: (chunk) => delays[chunk] ?? 1,
|
||||
});
|
||||
|
||||
expect(parallel.result.kind).toBe('success');
|
||||
expect(parallel.embedInputs).toEqual(sequential.embedInputs);
|
||||
expect(parallel.embeddedChunks.map((c) => c.chunk_text)).toEqual(
|
||||
chunks.map((c) => c.chunk_text),
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrency is bounded', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let leaseActive = 0;
|
||||
let maxLeaseActive = 0;
|
||||
let acquired = 0;
|
||||
let released = 0;
|
||||
const chunks = makeChunks(Array.from({ length: 8 }, (_, i) => `chunk-${i}`));
|
||||
const out = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 3,
|
||||
acquireSynopsisLease: async () => {
|
||||
acquired++;
|
||||
leaseActive++;
|
||||
maxLeaseActive = Math.max(maxLeaseActive, leaseActive);
|
||||
return acquired;
|
||||
},
|
||||
releaseSynopsisLease: async () => {
|
||||
released++;
|
||||
leaseActive--;
|
||||
},
|
||||
chat: async (opts) => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
try {
|
||||
await delay(20, opts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
expect(out.result.kind).toBe('success');
|
||||
expect(maxActive).toBeGreaterThan(1);
|
||||
expect(maxActive).toBeLessThanOrEqual(3);
|
||||
expect(maxLeaseActive).toBeLessThanOrEqual(3);
|
||||
expect(acquired).toBe(8);
|
||||
expect(released).toBe(8);
|
||||
expect(leaseActive).toBe(0);
|
||||
});
|
||||
|
||||
test('one chunk failure aborts queued work and falls back at page level', async () => {
|
||||
let started = 0;
|
||||
const chunks = makeChunks(Array.from({ length: 9 }, (_, i) => `chunk-${i}`));
|
||||
const out = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 3,
|
||||
chat: async (opts) => {
|
||||
started++;
|
||||
const chunk = extractChunk(opts);
|
||||
if (chunk === 'chunk-0') return chatSuccess('');
|
||||
await delay(30, opts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${chunk}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(out.result.kind).toBe('page_fallback');
|
||||
expect(started).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('fenced code chunks bypass synopsis calls and leases', async () => {
|
||||
let chatCalls = 0;
|
||||
let leaseCalls = 0;
|
||||
const chunks: ChunkInput[] = [
|
||||
{ chunk_index: 0, chunk_text: 'intro', chunk_source: 'compiled_truth' },
|
||||
{ chunk_index: 1, chunk_text: 'const x = 1;', chunk_source: 'fenced_code' },
|
||||
{ chunk_index: 2, chunk_text: 'outro', chunk_source: 'compiled_truth' },
|
||||
];
|
||||
|
||||
const out = await runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 3,
|
||||
acquireSynopsisLease: async () => {
|
||||
leaseCalls++;
|
||||
},
|
||||
releaseSynopsisLease: async () => {},
|
||||
chat: async (opts) => {
|
||||
chatCalls++;
|
||||
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
|
||||
},
|
||||
});
|
||||
|
||||
expect(out.result.kind).toBe('success');
|
||||
expect(chatCalls).toBe(2);
|
||||
expect(leaseCalls).toBe(2);
|
||||
expect(out.embedInputs[1]).toBe('const x = 1;');
|
||||
});
|
||||
|
||||
test('abortSignal cancels in-flight and queued synopsis work promptly', async () => {
|
||||
const controller = new AbortController();
|
||||
let started = 0;
|
||||
const chunks = makeChunks(Array.from({ length: 20 }, (_, i) => `chunk-${i}`));
|
||||
const startedAt = Date.now();
|
||||
const promise = runWithChatStub({
|
||||
chunks,
|
||||
concurrency: 4,
|
||||
abortSignal: controller.signal,
|
||||
chat: async (opts) => {
|
||||
started++;
|
||||
await delay(1000, opts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
|
||||
},
|
||||
});
|
||||
setTimeout(() => controller.abort(), 20);
|
||||
|
||||
const out = await promise;
|
||||
expect(out.result.kind).toBe('transient_error');
|
||||
if (out.result.kind === 'transient_error') {
|
||||
expect(out.result.cause).toBe('timeout');
|
||||
}
|
||||
expect(started).toBeLessThanOrEqual(4);
|
||||
expect(Date.now() - startedAt).toBeLessThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
function makeChunks(texts: string[]): ChunkInput[] {
|
||||
return texts.map((text, i) => ({
|
||||
chunk_index: i,
|
||||
chunk_text: text,
|
||||
chunk_source: 'compiled_truth',
|
||||
}));
|
||||
}
|
||||
|
||||
async function runWithChatStub(opts: {
|
||||
chunks: ChunkInput[];
|
||||
concurrency: number;
|
||||
abortSignal?: AbortSignal;
|
||||
delayForChunk?: (chunk: string) => number;
|
||||
chat?: (opts: ChatOpts) => Promise<ChatResult>;
|
||||
acquireSynopsisLease?: () => Promise<unknown>;
|
||||
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
|
||||
}) {
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: TEST_DIMS,
|
||||
env: { OPENAI_API_KEY: 'sk-test' },
|
||||
});
|
||||
|
||||
const embedInputs: string[][] = [];
|
||||
__setEmbedTransportForTests(async ({ values }: any) => {
|
||||
embedInputs.push([...values]);
|
||||
return {
|
||||
embeddings: values.map((_: string, i: number) =>
|
||||
Array.from({ length: TEST_DIMS }, () => 0.001 + i * 0.001),
|
||||
),
|
||||
usage: { tokens: 0 },
|
||||
} as any;
|
||||
});
|
||||
|
||||
__setChatTransportForTests(opts.chat ?? (async (chatOpts) => {
|
||||
const chunk = extractChunk(chatOpts);
|
||||
await delay(opts.delayForChunk?.(chunk) ?? 1, chatOpts.abortSignal);
|
||||
return chatSuccess(`Synopsis for ${chunk}`);
|
||||
}));
|
||||
|
||||
const engine = makeServiceEngine(opts.chunks);
|
||||
const result = await reembedPageWithContextualRetrieval({
|
||||
engine,
|
||||
pageSlug: 'wiki/concepts/concurrency-test',
|
||||
sourceId: 'default',
|
||||
globalMode: 'per_chunk_synopsis',
|
||||
chunkConcurrency: opts.concurrency,
|
||||
abortSignal: opts.abortSignal,
|
||||
...(opts.acquireSynopsisLease && { acquireSynopsisLease: opts.acquireSynopsisLease }),
|
||||
...(opts.releaseSynopsisLease && { releaseSynopsisLease: opts.releaseSynopsisLease }),
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
embedInputs: embedInputs.flat(),
|
||||
embeddedChunks: engine.embeddedChunks as ChunkInput[],
|
||||
};
|
||||
}
|
||||
|
||||
function makeServiceEngine(chunks: ChunkInput[]) {
|
||||
const engine: any = {
|
||||
embeddedChunks: [] as ChunkInput[],
|
||||
async getPage() {
|
||||
return {
|
||||
id: 1,
|
||||
slug: 'wiki/concepts/concurrency-test',
|
||||
source_id: 'default',
|
||||
type: 'concept',
|
||||
title: 'Concurrency Test',
|
||||
compiled_truth: chunks.map((c) => c.chunk_text).join('\n\n'),
|
||||
timeline: '',
|
||||
frontmatter: {},
|
||||
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||
updated_at: new Date('2026-01-01T00:00:00Z'),
|
||||
deleted_at: null,
|
||||
};
|
||||
},
|
||||
async executeRaw() {
|
||||
return [{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
local_path: null,
|
||||
last_commit: null,
|
||||
last_sync_at: null,
|
||||
config: {},
|
||||
created_at: new Date('2026-01-01T00:00:00Z'),
|
||||
contextual_retrieval_mode: null,
|
||||
trust_frontmatter_overrides: false,
|
||||
}];
|
||||
},
|
||||
async getChunks() {
|
||||
return chunks;
|
||||
},
|
||||
async transaction(fn: (tx: any) => Promise<void>) {
|
||||
await fn({
|
||||
upsertChunks: async (_slug: string, embedded: ChunkInput[]) => {
|
||||
engine.embeddedChunks = embedded;
|
||||
},
|
||||
updatePageContextualRetrievalState: async () => {},
|
||||
});
|
||||
},
|
||||
async updatePageContextualRetrievalState() {},
|
||||
};
|
||||
return engine;
|
||||
}
|
||||
|
||||
function extractChunk(opts: ChatOpts): string {
|
||||
const content = String(opts.messages[0]?.content ?? '');
|
||||
return content.match(/<chunk>\n([\s\S]*?)\n<\/chunk>/)?.[1] ?? '';
|
||||
}
|
||||
|
||||
function chatSuccess(text: string): ChatResult {
|
||||
return {
|
||||
text,
|
||||
blocks: [],
|
||||
stopReason: 'end',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_creation_tokens: 0,
|
||||
},
|
||||
model: 'stub:chat',
|
||||
providerId: 'stub',
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortError());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Consistency guard: every cross-modal DEFAULT_SLOTS model must be listed
|
||||
* in its recipe's chat touchpoint. `openai:gpt-4o` drifted out of the
|
||||
* OpenAI recipe while remaining the slot-A default — the gateway then
|
||||
* rejected slot A ("not listed for OpenAI chat") on every install, and the
|
||||
* 3-slot judge panel could never reach its 2-model quorum without a Google
|
||||
* key, pinning every batch verdict at inconclusive (which the nightly
|
||||
* quality probe surfaces as a doctor WARN).
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { DEFAULT_SLOTS } from '../src/core/cross-modal-eval/runner.ts';
|
||||
import { getRecipe } from '../src/core/ai/recipes/index.ts';
|
||||
import { splitProviderModelId } from '../src/core/model-id.ts';
|
||||
|
||||
describe('cross-modal DEFAULT_SLOTS ↔ recipe consistency', () => {
|
||||
test('every default slot model is listed in its recipe chat touchpoint', () => {
|
||||
for (const slot of DEFAULT_SLOTS) {
|
||||
const { provider, model } = splitProviderModelId(slot.model);
|
||||
expect(provider).not.toBeNull();
|
||||
const recipe = getRecipe(provider!);
|
||||
expect(recipe, `slot ${slot.id}: unknown recipe "${provider}"`).toBeDefined();
|
||||
const chatModels = recipe!.touchpoints.chat?.models ?? [];
|
||||
expect(
|
||||
chatModels,
|
||||
`slot ${slot.id}: "${model}" not listed for ${provider} chat — the judge slot can never run`,
|
||||
).toContain(model);
|
||||
}
|
||||
});
|
||||
|
||||
test('slots span three distinct providers (uncorrelated blind spots)', () => {
|
||||
const providers = new Set(DEFAULT_SLOTS.map(s => splitProviderModelId(s.model).provider));
|
||||
expect(providers.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
chat,
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function installDeepSeekResponse(message: Record<string, unknown>): void {
|
||||
globalThis.fetch = (async () => {
|
||||
const json = {
|
||||
id: 'fake-deepseek-chatcmpl',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model: 'deepseek-reasoner',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
...message,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 },
|
||||
};
|
||||
return new Response(JSON.stringify(json), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
async function runDeepSeekChat(): Promise<string> {
|
||||
configureGateway({
|
||||
chat_model: 'deepseek:deepseek-reasoner',
|
||||
env: { DEEPSEEK_API_KEY: 'sk-deepseek-fake' },
|
||||
});
|
||||
const result = await chat({
|
||||
model: 'deepseek:deepseek-reasoner',
|
||||
messages: [{ role: 'user', content: 'summarize this chunk' }],
|
||||
});
|
||||
return result.text;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
describe('DeepSeek reasoning_content compatibility fetch', () => {
|
||||
test('empty content + reasoning_content promotes reasoning text into chat output', async () => {
|
||||
installDeepSeekResponse({
|
||||
content: '',
|
||||
reasoning_content: 'The synopsis lives here.',
|
||||
});
|
||||
|
||||
await expect(runDeepSeekChat()).resolves.toBe('The synopsis lives here.');
|
||||
});
|
||||
|
||||
test('non-empty content + reasoning_content passes content through unchanged', async () => {
|
||||
installDeepSeekResponse({
|
||||
content: 'Use the final answer.',
|
||||
reasoning_content: 'Do not duplicate this reasoning.',
|
||||
});
|
||||
|
||||
await expect(runDeepSeekChat()).resolves.toBe('Use the final answer.');
|
||||
});
|
||||
|
||||
test('empty content + empty reasoning_content stays empty without crashing', async () => {
|
||||
installDeepSeekResponse({
|
||||
content: '',
|
||||
reasoning_content: '',
|
||||
});
|
||||
|
||||
await expect(runDeepSeekChat()).resolves.toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tests for computeConversationParserProbeHealthCheck — the pure function
|
||||
* behind doctor's conversation_parser_probe_health check, which replaced
|
||||
* the v0.41.13.0 hardcoded "Skipped" stub when the autopilot wiring
|
||||
* landed. Mirrors the branch coverage style of the quality-probe check.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { computeConversationParserProbeHealthCheck } from '../src/commands/doctor.ts';
|
||||
|
||||
const ev = (outcome: string, reason?: string, ts = new Date().toISOString()) => ({
|
||||
outcome,
|
||||
ts,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
});
|
||||
|
||||
describe('computeConversationParserProbeHealthCheck', () => {
|
||||
test('disabled + no events → ok with paste-ready enable hint', () => {
|
||||
const check = computeConversationParserProbeHealthCheck(false, []);
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('gbrain config set autopilot.conversation_parser_probe.enabled true');
|
||||
});
|
||||
|
||||
test('enabled + no events yet → ok, next run by autopilot', () => {
|
||||
const check = computeConversationParserProbeHealthCheck(true, []);
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('no probe events');
|
||||
});
|
||||
|
||||
test('disabled flag but events exist (tokenmax mode-gate ran it) → events win over the hint', () => {
|
||||
const check = computeConversationParserProbeHealthCheck(false, [ev('pass')]);
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('all pass');
|
||||
});
|
||||
|
||||
test('any non-pass outcome in the window → warn, latest surfaced with reason', () => {
|
||||
const check = computeConversationParserProbeHealthCheck(true, [
|
||||
ev('pass'),
|
||||
ev('adversarial_false_positive', '1 adversarial fixture(s) parsed to non-empty'),
|
||||
]);
|
||||
expect(check.status).toBe('warn');
|
||||
expect(check.message).toContain('adversarial_false_positive');
|
||||
expect(check.message).toContain('parsed to non-empty');
|
||||
});
|
||||
|
||||
test('all pass → ok with run count', () => {
|
||||
const check = computeConversationParserProbeHealthCheck(true, [ev('pass'), ev('pass')]);
|
||||
expect(check.status).toBe('ok');
|
||||
expect(check.message).toContain('2 probe run(s)');
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,23 @@ const SEED_PAGES: SeedPage[] = [
|
||||
body: 'example founder unrelated content for distraction',
|
||||
embeddingDim: 50,
|
||||
},
|
||||
// v0.32.7 CJK branch (Postgres parity) fixtures — synthetic Korean text
|
||||
// about a fictional "그리팅 서비스". Multi-hit page (3× 그리팅) must
|
||||
// outrank the one-hit page under occurrence-count ranking on BOTH engines.
|
||||
{
|
||||
slug: 'concepts/greeting-service',
|
||||
type: 'concept',
|
||||
title: '그리팅 서비스 개요',
|
||||
body: '그리팅 서비스는 가상의 인사 자동화 플랫폼입니다. 그리팅 봇이 아침마다 인사를 보냅니다. 오늘도 그리팅 팀은 새로운 인사말을 준비했습니다.',
|
||||
embeddingDim: 33,
|
||||
},
|
||||
{
|
||||
slug: 'notes/greeting-one-hit',
|
||||
type: 'note',
|
||||
title: '가상 메모',
|
||||
body: '어제 회의에서 그리팅 이야기가 잠깐 나왔다. 나머지는 다른 주제였다.',
|
||||
embeddingDim: 34,
|
||||
},
|
||||
];
|
||||
|
||||
async function seedEngine(eng: BrainEngine) {
|
||||
@@ -141,6 +158,48 @@ describeBoth('Engine parity — Postgres vs PGLite', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// v0.32.7 CJK branch (Postgres parity): websearch_to_tsquery('english')
|
||||
// can't tokenize CJK, so both engines route CJK queries to the ILIKE +
|
||||
// occurrence-count fallback. These pin the fallback's cross-engine
|
||||
// contract with purely synthetic Korean fixtures.
|
||||
test('CJK parity: searchKeyword("그리팅") finds the same pages, same top rank', async () => {
|
||||
const pgResults = await pgEngine.searchKeyword('그리팅', { limit: 5 });
|
||||
const pgliteResults = await pgliteEngine.searchKeyword('그리팅', { limit: 5 });
|
||||
|
||||
// Without the fallback the Postgres engine returned [] here (empty
|
||||
// english tsquery) while PGLite matched — the exact drift this pins.
|
||||
expect(pgResults.length).toBeGreaterThan(0);
|
||||
expect(pgliteResults.length).toBeGreaterThan(0);
|
||||
|
||||
// Occurrence-count ranking: 3-hit page outranks 1-hit page on BOTH.
|
||||
expect(pgResults[0].slug).toBe('concepts/greeting-service');
|
||||
expect(pgliteResults[0].slug).toBe('concepts/greeting-service');
|
||||
|
||||
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
|
||||
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
|
||||
expect(pgSlugs).toContain('notes/greeting-one-hit');
|
||||
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
|
||||
});
|
||||
|
||||
test('CJK parity: chunk-grain searchKeywordChunks matches across engines', async () => {
|
||||
const pgResults = await pgEngine.searchKeywordChunks('그리팅', { limit: 5 });
|
||||
const pgliteResults = await pgliteEngine.searchKeywordChunks('그리팅', { limit: 5 });
|
||||
|
||||
expect(pgResults.length).toBeGreaterThan(0);
|
||||
expect(pgliteResults.length).toBeGreaterThan(0);
|
||||
expect(pgResults[0].slug).toBe(pgliteResults[0].slug);
|
||||
});
|
||||
|
||||
test('CJK parity: LIKE-meta-char escape — literal % query does not wildcard-match', async () => {
|
||||
// After escapeLikePattern, ILIKE looks for a literal `%` character,
|
||||
// which no seeded fixture contains. Both engines must return empty
|
||||
// rather than treating % as a wildcard.
|
||||
const pgResults = await pgEngine.searchKeyword('100% 그리팅');
|
||||
const pgliteResults = await pgliteEngine.searchKeyword('100% 그리팅');
|
||||
expect(pgResults.length).toBe(0);
|
||||
expect(pgliteResults.length).toBe(0);
|
||||
});
|
||||
|
||||
test('searchVector: top result matches between engines', async () => {
|
||||
const queryVec = basisEmbedding(7); // article direction
|
||||
const pgResults = await pgEngine.searchVector(queryVec, { limit: 5 });
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* E2E: gateway-loop resume reconciliation (fix-wave A).
|
||||
*
|
||||
* The gateway-native subagent loop persists each assistant turn but historically
|
||||
* never persisted the following tool-result user turn. A resumed job therefore
|
||||
* reloaded assistant tool-calls with no matching tool-result, and non-Anthropic
|
||||
* (openai-compat) providers reject that unbalanced history with
|
||||
* AI_MissingToolResultsError — dead-lettering the job. This wave:
|
||||
* 1. forward-persists the tool-result user turn (onToolResultTurn), and
|
||||
* 2. reconciles an already-corrupted transcript on resume by rebuilding the
|
||||
* missing tool-result turns from settled subagent_tool_executions,
|
||||
* re-dispatching idempotent-pending tools and throwing on non-idempotent.
|
||||
*
|
||||
* Hermetic: PGLite in-memory engine, gateway transport stubbed. Seeds use the
|
||||
* sanctioned `$N::text::jsonb` positional bind (NEVER JSON.stringify into a
|
||||
* bare `::jsonb`) so the seed is Postgres-safe too.
|
||||
*
|
||||
* Supersedes the resume/replay work in #1934 #2062 #2065 #2112 #2274 #2487
|
||||
* #2802 #2336 #2257 #2499.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
||||
import { makeSubagentHandler } from '../../src/core/minions/handlers/subagent.ts';
|
||||
import type { MinionJobContext, ToolDef, ToolCtx } from '../../src/core/minions/types.ts';
|
||||
import {
|
||||
__setChatTransportForTests,
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
toModelMessages,
|
||||
type ChatBlock,
|
||||
type ChatMessage,
|
||||
type ChatResult,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
afterAll(async () => {
|
||||
__setChatTransportForTests(null);
|
||||
resetGateway();
|
||||
await engine.disconnect();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
await engine.setConfig('version', '85');
|
||||
await engine.setConfig('agent.use_gateway_loop', 'true');
|
||||
configureGateway({
|
||||
chat_model: 'anthropic:claude-sonnet-4-6',
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
expansion_model: 'anthropic:claude-haiku-4-5',
|
||||
env: { ANTHROPIC_API_KEY: 'stub', OPENAI_API_KEY: 'stub' },
|
||||
});
|
||||
});
|
||||
|
||||
async function makeJob(prompt: string, model: string): Promise<{ jobId: number; ctx: MinionJobContext }> {
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
|
||||
VALUES ('subagent', 'active', $1::text::jsonb, 'default', 0, now()) RETURNING id`,
|
||||
[JSON.stringify({ prompt, model })],
|
||||
);
|
||||
const jobId = rows[0].id;
|
||||
const ctx: MinionJobContext = {
|
||||
id: jobId, name: 'subagent', data: { prompt, model }, attempts_made: 1,
|
||||
signal: new AbortController().signal, shutdownSignal: new AbortController().signal,
|
||||
updateProgress: async () => {}, updateTokens: async () => {}, log: async () => {},
|
||||
isActive: async () => true, readInbox: async () => [],
|
||||
};
|
||||
return { jobId, ctx };
|
||||
}
|
||||
|
||||
function makeTools(executions: string[]): ToolDef[] {
|
||||
return [
|
||||
{ name: 'search', description: 's', input_schema: { type: 'object' }, idempotent: true,
|
||||
async execute(input: unknown, _c: ToolCtx) { executions.push('search'); return { results: ['fresh'] }; } },
|
||||
{ name: 'put_page', description: 'p', input_schema: { type: 'object' }, idempotent: false,
|
||||
async execute(_input: unknown, _c: ToolCtx) { executions.push('put_page'); return { saved: true }; } },
|
||||
];
|
||||
}
|
||||
|
||||
function buildHandler(toolRegistry: ToolDef[]) {
|
||||
return makeSubagentHandler({
|
||||
engine, config: {} as any, toolRegistry,
|
||||
makeAnthropic: () => ({ messages: { create: async () => { throw new Error('legacy path unused'); } } }) as any,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedMessage(jobId: number, idx: number, role: string, blocks: ChatBlock[]): Promise<void> {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, schema_version)
|
||||
VALUES ($1, $2, $3, $4::text::jsonb, 2)`,
|
||||
[jobId, idx, role, JSON.stringify(blocks)],
|
||||
);
|
||||
}
|
||||
|
||||
async function seedExec(jobId: number, msgIdx: number, toolUseId: string, name: string, status: string, output: unknown, ordinal: number, error?: string): Promise<void> {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO subagent_tool_executions
|
||||
(job_id, message_idx, tool_use_id, tool_name, input, status, output, error, schema_version, ordinal)
|
||||
VALUES ($1, $2, $3, $4, $5::text::jsonb, $6, $7::text::jsonb, $8, 2, $9)`,
|
||||
[jobId, msgIdx, toolUseId, name, JSON.stringify({}), status, output == null ? null : JSON.stringify(output), error ?? null, ordinal],
|
||||
);
|
||||
}
|
||||
|
||||
/** Assert every assistant tool-call turn is answered by a following tool-result. */
|
||||
function assertBalanced(messages: ChatMessage[]): void {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
if (m.role !== 'assistant' || typeof m.content === 'string') continue;
|
||||
const calls = m.content.filter((b): b is Extract<ChatBlock, { type: 'tool-call' }> => b.type === 'tool-call');
|
||||
if (calls.length === 0) continue;
|
||||
const next = messages[i + 1];
|
||||
expect(next, `assistant tool-call turn at ${i} must be followed by a tool-result turn`).toBeDefined();
|
||||
const answered = new Set(
|
||||
(typeof next!.content === 'string' ? [] : next!.content)
|
||||
.filter((b): b is Extract<ChatBlock, { type: 'tool-result' }> => b.type === 'tool-result')
|
||||
.map(b => b.toolCallId),
|
||||
);
|
||||
for (const c of calls) expect(answered.has(c.toolCallId), `tool-call ${c.toolCallId} unanswered`).toBe(true);
|
||||
}
|
||||
// The real provider path: this must not throw the ModelMessage schema error.
|
||||
expect(() => toModelMessages(messages)).not.toThrow();
|
||||
}
|
||||
|
||||
describe('gateway resume reconciliation', () => {
|
||||
it('forward-persists the tool-result user turn (idx 2) in a 2-turn flow', async () => {
|
||||
let turn = 0;
|
||||
__setChatTransportForTests(async () => {
|
||||
turn++;
|
||||
if (turn === 1) return {
|
||||
text: '', blocks: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'x' } }] as ChatBlock[],
|
||||
stopReason: 'tool_calls', usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: 'anthropic:claude-sonnet-4-6', providerId: 'anthropic',
|
||||
} satisfies ChatResult;
|
||||
return {
|
||||
text: 'done', blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
|
||||
stopReason: 'end', usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: 'anthropic:claude-sonnet-4-6', providerId: 'anthropic',
|
||||
} satisfies ChatResult;
|
||||
});
|
||||
const { jobId, ctx } = await makeJob('go', 'anthropic:claude-sonnet-4-6');
|
||||
await buildHandler(makeTools([]))(ctx);
|
||||
|
||||
const msgs = await engine.executeRaw<{ message_idx: number; role: string; content_blocks: unknown }>(
|
||||
`SELECT message_idx, role, content_blocks FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`, [jobId]);
|
||||
expect(msgs.map(m => [m.message_idx, m.role])).toEqual([[0, 'user'], [1, 'assistant'], [2, 'user'], [3, 'assistant']]);
|
||||
const toolResultTurn = typeof msgs[2].content_blocks === 'string' ? JSON.parse(msgs[2].content_blocks as string) : msgs[2].content_blocks;
|
||||
expect((toolResultTurn as any[])[0].type).toBe('tool-result');
|
||||
expect((toolResultTurn as any[])[0].toolCallId).toBe('tc1');
|
||||
});
|
||||
|
||||
it('self-heals a pre-fix corrupted job from the stored output (no re-execute), transcript balanced', async () => {
|
||||
const { jobId, ctx } = await makeJob('resume me', 'openai:gpt-4o'); // non-Anthropic: strict pairing
|
||||
// Corrupted pre-fix state: seed user + assistant(tool-call), a complete
|
||||
// exec row, but NO tool-result user turn at idx 2.
|
||||
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'resume me' }]);
|
||||
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'prov-tc-1', toolName: 'search', input: { q: 'x' } }]);
|
||||
await seedExec(jobId, 1, 'prov-tc-1', 'search', 'complete', { results: ['from-prior-run'] }, 0);
|
||||
|
||||
let captured: ChatMessage[] = [];
|
||||
__setChatTransportForTests(async (opts) => {
|
||||
captured = opts.messages;
|
||||
return {
|
||||
text: 'recovered and done', blocks: [{ type: 'text', text: 'recovered and done' }] as ChatBlock[],
|
||||
stopReason: 'end', usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: 'openai:gpt-4o', providerId: 'openai',
|
||||
} satisfies ChatResult;
|
||||
});
|
||||
const executions: string[] = [];
|
||||
const result = await buildHandler(makeTools(executions))(ctx);
|
||||
|
||||
expect(result.result).toBe('recovered and done');
|
||||
expect(executions.length).toBe(0); // stored output reused, tool NOT re-run
|
||||
assertBalanced(captured);
|
||||
// The healed tool-result carries the real stored output.
|
||||
const healed = (captured[2].content as ChatBlock[])[0] as Extract<ChatBlock, { type: 'tool-result' }>;
|
||||
expect(healed.output).toEqual({ results: ['from-prior-run'] });
|
||||
|
||||
// Durably persisted so the next resume stays balanced.
|
||||
const msgs = await engine.executeRaw<{ message_idx: number; role: string }>(
|
||||
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`, [jobId]);
|
||||
expect(msgs.map(m => [m.message_idx, m.role])).toEqual([[0, 'user'], [1, 'assistant'], [2, 'user'], [3, 'assistant']]);
|
||||
});
|
||||
|
||||
it('heals MULTIPLE consecutive dangling assistant turns (pre-fix multi-turn corruption)', async () => {
|
||||
const { jobId, ctx } = await makeJob('multi', 'openai:gpt-4o');
|
||||
// Pre-fix loop persisted assistants at 1 and 3 (gaps at 2 = skipped user idx).
|
||||
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'multi' }]);
|
||||
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-a', toolName: 'search', input: {} }]);
|
||||
await seedExec(jobId, 1, 'tc-a', 'search', 'complete', { results: ['a'] }, 0);
|
||||
await seedMessage(jobId, 3, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-b', toolName: 'search', input: {} }]);
|
||||
await seedExec(jobId, 3, 'tc-b', 'search', 'complete', { results: ['b'] }, 0);
|
||||
|
||||
let captured: ChatMessage[] = [];
|
||||
__setChatTransportForTests(async (opts) => {
|
||||
captured = opts.messages;
|
||||
return { text: 'ok', blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[], stopReason: 'end',
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult;
|
||||
});
|
||||
const executions: string[] = [];
|
||||
await buildHandler(makeTools(executions))(ctx);
|
||||
|
||||
expect(executions.length).toBe(0);
|
||||
assertBalanced(captured);
|
||||
// Both dangling turns healed and persisted (idx 2 and 4).
|
||||
const msgs = await engine.executeRaw<{ message_idx: number; role: string }>(
|
||||
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`, [jobId]);
|
||||
expect(msgs.map(m => m.message_idx)).toContain(2);
|
||||
expect(msgs.map(m => m.message_idx)).toContain(4);
|
||||
});
|
||||
|
||||
it('re-dispatches an idempotent tool that was still pending on resume', async () => {
|
||||
const { jobId, ctx } = await makeJob('redispatch', 'openai:gpt-4o');
|
||||
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'redispatch' }]);
|
||||
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-pending', toolName: 'search', input: {} }]);
|
||||
await seedExec(jobId, 1, 'tc-pending', 'search', 'pending', null, 0);
|
||||
|
||||
__setChatTransportForTests(async () => ({ text: 'ok', blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[], stopReason: 'end',
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult));
|
||||
const executions: string[] = [];
|
||||
await buildHandler(makeTools(executions))(ctx);
|
||||
expect(executions).toEqual(['search']); // idempotent-pending re-executed once
|
||||
});
|
||||
|
||||
it('throws on a non-idempotent tool still pending on resume', async () => {
|
||||
const { jobId, ctx } = await makeJob('unsafe', 'openai:gpt-4o');
|
||||
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'unsafe' }]);
|
||||
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-mut', toolName: 'put_page', input: {} }]);
|
||||
await seedExec(jobId, 1, 'tc-mut', 'put_page', 'pending', null, 0);
|
||||
|
||||
__setChatTransportForTests(async () => ({ text: '', blocks: [] as ChatBlock[], stopReason: 'end',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult));
|
||||
await expect(buildHandler(makeTools([]))(ctx)).rejects.toThrow(/non-idempotent tool "put_page" pending on resume/i);
|
||||
});
|
||||
|
||||
it('error-stubs a dangling tool-call whose tool is no longer registered', async () => {
|
||||
const { jobId, ctx } = await makeJob('gone tool', 'openai:gpt-4o');
|
||||
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'gone tool' }]);
|
||||
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-gone', toolName: 'removed_tool', input: {} }]);
|
||||
// No exec row and the tool isn't in the registry (only 'search'/'put_page').
|
||||
|
||||
let captured: ChatMessage[] = [];
|
||||
__setChatTransportForTests(async (opts) => {
|
||||
captured = opts.messages;
|
||||
return { text: 'handled', blocks: [{ type: 'text', text: 'handled' }] as ChatBlock[], stopReason: 'end',
|
||||
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult;
|
||||
});
|
||||
const result = await buildHandler(makeTools([]))(ctx);
|
||||
expect(result.result).toBe('handled');
|
||||
assertBalanced(captured);
|
||||
const stub = (captured[2].content as ChatBlock[])[0] as Extract<ChatBlock, { type: 'tool-result' }>;
|
||||
expect(stub.isError).toBe(true);
|
||||
expect(String(stub.output)).toContain('removed_tool');
|
||||
// Persisted as a failed exec so the next resume is stable.
|
||||
const rows = await engine.executeRaw<{ status: string }>(
|
||||
`SELECT status FROM subagent_tool_executions WHERE job_id = $1 AND tool_use_id = 'tc-gone'`, [jobId]);
|
||||
expect(rows[0].status).toBe('failed');
|
||||
});
|
||||
|
||||
it('terminal resume: a completed transcript returns its text without calling the model', async () => {
|
||||
const { jobId, ctx } = await makeJob('already done', 'openai:gpt-4o');
|
||||
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'already done' }]);
|
||||
await seedMessage(jobId, 1, 'assistant', [{ type: 'text', text: 'the final answer' }]);
|
||||
|
||||
let chatCalls = 0;
|
||||
__setChatTransportForTests(async () => { chatCalls++; return { text: 'SHOULD NOT RUN', blocks: [] as ChatBlock[], stopReason: 'end',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult; });
|
||||
const result = await buildHandler(makeTools([]))(ctx);
|
||||
expect(chatCalls).toBe(0);
|
||||
expect(result.result).toBe('the final answer');
|
||||
expect(result.stop_reason).toBe('end_turn');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { configureGateway } from '../src/core/ai/gateway.ts';
|
||||
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
import {
|
||||
detectRegressions,
|
||||
computeDriftScore,
|
||||
@@ -27,16 +27,19 @@ import type { TrajectoryPoint } from '../src/core/engine.ts';
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
// This file hardcodes 1536-d vectors (vecForMetric). initSchema sizes the
|
||||
// facts halfvec column from the AMBIENT gateway state, and a beforeAll runs
|
||||
// before the legacy-embedding-preload's per-test restore — so a preceding
|
||||
// file in the shard that left the gateway reset or non-1536 would seed a
|
||||
// 1280-d schema and every insert here would fail with a width mismatch.
|
||||
// Pin the schema shape explicitly (the pattern bunfig's preload documents).
|
||||
// Pin the embedding dim to 1536 BEFORE initSchema. This file hardcodes
|
||||
// Float32Array(1536) vectors, but initSchema sizes vector columns from
|
||||
// process-global gateway state (getEmbeddingDimensions(), default 1280 =
|
||||
// zeroentropyai). Whether this file passes therefore depended on which
|
||||
// test files happened to run before it in the shard: a predecessor that
|
||||
// leaves the gateway configured without dims (or a bare CI env) yields
|
||||
// vector(1280) and every insert here dies with "expected 1280 dimensions,
|
||||
// not 1536". Same fix + rationale as cosine-rescore-column.test.ts, which
|
||||
// documents this exact class.
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: 1536,
|
||||
env: { ...process.env },
|
||||
env: { OPENAI_API_KEY: 'sk-test-find-trajectory' },
|
||||
});
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
@@ -45,6 +48,7 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Pack-driven extractable-type allowlist (unionExtractableTypes): honors the
|
||||
* schema-pack manifest's `extractable: true` flags while preserving the legacy
|
||||
* hardcoded floor and excluding synthesis outputs. Closes the D2 TODO in
|
||||
* extract-atoms.ts (page discovery was ignoring the pack's extractable flag, so
|
||||
* a type declared extractable — e.g. `note` — never actually extracted).
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { unionExtractableTypes } from '../src/core/cycle/extract-atoms.ts';
|
||||
|
||||
const LEGACY = ['meeting', 'source', 'article', 'video', 'book', 'original'];
|
||||
|
||||
describe('unionExtractableTypes', () => {
|
||||
test('legacy floor is always present (back-compat)', () => {
|
||||
const r = unionExtractableTypes([]);
|
||||
for (const t of LEGACY) expect(r).toContain(t);
|
||||
});
|
||||
|
||||
test('pack-declared extractable types are added (e.g. note)', () => {
|
||||
const r = unionExtractableTypes(['note', 'writing']);
|
||||
expect(r).toContain('note');
|
||||
expect(r).toContain('writing');
|
||||
for (const t of LEGACY) expect(r).toContain(t);
|
||||
});
|
||||
|
||||
test('synthesis outputs are excluded even when the pack marks them extractable', () => {
|
||||
// gbrain-base declares `concept` extractable:true, but extracting atoms FROM
|
||||
// concepts would loop (concepts are synthesized from atoms).
|
||||
const r = unionExtractableTypes(['note', 'concept', 'atom']);
|
||||
expect(r).toContain('note');
|
||||
expect(r).not.toContain('concept');
|
||||
expect(r).not.toContain('atom');
|
||||
});
|
||||
|
||||
test('no duplicates when the pack repeats a legacy type', () => {
|
||||
const r = unionExtractableTypes(['meeting', 'source']);
|
||||
expect(r.filter((t) => t === 'meeting')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -108,12 +108,15 @@ async function seedPage(opts: {
|
||||
}
|
||||
|
||||
describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
|
||||
test('filters by all 6 extractable types', async () => {
|
||||
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original']) {
|
||||
test('discovers legacy + pack-extractable types, excludes synthesis outputs', async () => {
|
||||
// Legacy floor + `note` (declared extractable:true in gbrain-base, now
|
||||
// honored via the pack manifest — the D2 fix).
|
||||
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original', 'note']) {
|
||||
await seedPage({ slug: `${type}/x`, type });
|
||||
}
|
||||
// Add a non-extractable page that should NOT appear
|
||||
await seedPage({ slug: 'notes/skip-me', type: 'note' });
|
||||
// `concept` is also extractable:true in gbrain-base, but extracting atoms
|
||||
// FROM concepts would loop — synthesis outputs are always excluded.
|
||||
await seedPage({ slug: 'wiki/concepts/skip-me', type: 'concept' });
|
||||
|
||||
const discovered = await discoverExtractablePages(engine, 'default');
|
||||
const slugs = discovered.map((d) => d.slug).sort();
|
||||
@@ -121,6 +124,7 @@ describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
|
||||
'article/x',
|
||||
'book/x',
|
||||
'meeting/x',
|
||||
'note/x',
|
||||
'original/x',
|
||||
'source/x',
|
||||
'video/x',
|
||||
|
||||
@@ -107,6 +107,63 @@ describe('toModelMessages — v6 ModelMessage shape', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('Date in tool-result json output serializes to ISO string (Postgres timestamptz)', () => {
|
||||
// node-postgres returns timestamptz columns as JS Date; AI SDK v6's
|
||||
// JSONValue schema rejects a raw Date, dead-lettering the tool loop.
|
||||
const msgs: ChatMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'brain_get_page',
|
||||
output: { rows: [{ updated_at: new Date('2026-06-26T06:56:59.000Z'), nested: { created_at: new Date('2026-01-02T03:04:05.000Z') } }] },
|
||||
}],
|
||||
},
|
||||
];
|
||||
const out = toModelMessages(msgs) as any[];
|
||||
const value = out[0].content[0].output.value;
|
||||
expect(out[0].content[0].output.type).toBe('json');
|
||||
expect(value.rows[0].updated_at).toBe('2026-06-26T06:56:59.000Z');
|
||||
expect(value.rows[0].nested.created_at).toBe('2026-01-02T03:04:05.000Z');
|
||||
// No Date instance survives (would throw in AI SDK v6).
|
||||
expect(value.rows[0].updated_at instanceof Date).toBe(false);
|
||||
});
|
||||
|
||||
test('non-string text block is dropped (reasoning-model null-text guard)', () => {
|
||||
// DeepSeek v4 / reasoning models can emit text:null/undefined thinking
|
||||
// parts; AI SDK v6 rejects them. Dropped here; tool-call sibling kept.
|
||||
const msgs: ChatMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: null as unknown as string },
|
||||
{ type: 'text', text: undefined as unknown as string },
|
||||
{ type: 'text', text: 'kept' },
|
||||
{ type: 'text', text: '' }, // empty string is valid — kept
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
|
||||
],
|
||||
},
|
||||
];
|
||||
const out = toModelMessages(msgs) as any[];
|
||||
expect(out[0].content).toEqual([
|
||||
{ type: 'text', text: 'kept' },
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
|
||||
]);
|
||||
});
|
||||
|
||||
test('errored tool-result never throws on circular/bigint output (safeStringify)', () => {
|
||||
const circular: any = {};
|
||||
circular.self = circular;
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'x', output: circular, isError: true }] },
|
||||
];
|
||||
const out = toModelMessages(msgs) as any[];
|
||||
expect(out[0].content[0].output.type).toBe('error-text');
|
||||
expect(typeof out[0].content[0].output.value).toBe('string');
|
||||
});
|
||||
|
||||
test('full multi-turn conversation: user → assistant(tool-call) → tool(result)', () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: 'user', content: 'find widget' },
|
||||
|
||||
@@ -354,6 +354,13 @@ describe('MinionQueue: #1737 per-handler default timeout', () => {
|
||||
expect(sub.timeout_ms).toBe(30 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('contextual per-chunk reindex gets the 60-min default', async () => {
|
||||
const job = await queue.add('contextual_reindex_per_chunk', { page_slug: 'large-transcript' }, undefined, {
|
||||
allowProtectedSubmit: true,
|
||||
});
|
||||
expect(job.timeout_ms).toBe(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('explicit timeout_ms always wins over the default', async () => {
|
||||
const job = await queue.add('embed-backfill', { sourceId: 'x' }, { timeout_ms: 5000 });
|
||||
expect(job.timeout_ms).toBe(5000);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Regression test for the nightly-quality-probe config-plane split-brain.
|
||||
//
|
||||
// The doctor check prints a paste-ready enable hint — `gbrain config set
|
||||
// autopilot.nightly_quality_probe.enabled true` — which writes the DB config
|
||||
// plane. But both the autopilot gate and the doctor check used to read ONLY
|
||||
// the file plane (~/.gbrain/config.json via loadConfig), so following the
|
||||
// hint was a silent no-op: the probe never ran and doctor kept reporting
|
||||
// "disabled (opt-in)".
|
||||
//
|
||||
// resolveProbeEnabled / resolveProbeMaxUsd pin the dual-plane rule (same
|
||||
// precedent as `mcp.publish_skills` in serve-http.ts): DB row wins when
|
||||
// present, file plane is the fallback.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
resolveProbeEnabled,
|
||||
resolveProbeMaxUsd,
|
||||
} from '../src/core/cycle/nightly-quality-probe.ts';
|
||||
|
||||
describe('resolveProbeEnabled — dual-plane flag resolution', () => {
|
||||
test('DB plane "true" enables regardless of file plane (the doctor hint path)', () => {
|
||||
expect(resolveProbeEnabled('true', undefined)).toBe(true);
|
||||
expect(resolveProbeEnabled('true', false)).toBe(true);
|
||||
});
|
||||
|
||||
test('explicit DB "false" wins over file-plane true (config set off sticks)', () => {
|
||||
expect(resolveProbeEnabled('false', true)).toBe(false);
|
||||
});
|
||||
|
||||
test('file plane is the fallback when no DB row exists', () => {
|
||||
expect(resolveProbeEnabled(null, true)).toBe(true);
|
||||
expect(resolveProbeEnabled(undefined, true)).toBe(true);
|
||||
expect(resolveProbeEnabled(null, undefined)).toBe(false);
|
||||
expect(resolveProbeEnabled(null, false)).toBe(false);
|
||||
});
|
||||
|
||||
test('file plane stays strict boolean — string "true" in config.json does not enable', () => {
|
||||
// Matches the pre-fix autopilot gate (`=== true`); the doctor check used
|
||||
// Boolean(...) and could disagree with autopilot on a string value.
|
||||
// Both call sites now share this helper, so they can no longer diverge.
|
||||
expect(resolveProbeEnabled(null, 'true')).toBe(false);
|
||||
expect(resolveProbeEnabled(null, 1)).toBe(false);
|
||||
});
|
||||
|
||||
test('non-"true" DB strings are off (mcp.publish_skills semantics)', () => {
|
||||
expect(resolveProbeEnabled('1', true)).toBe(false);
|
||||
expect(resolveProbeEnabled('yes', true)).toBe(false);
|
||||
expect(resolveProbeEnabled('', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProbeMaxUsd — dual-plane cost cap resolution', () => {
|
||||
test('DB plane wins when parseable', () => {
|
||||
expect(resolveProbeMaxUsd('2.5', 10)).toBe(2.5);
|
||||
expect(resolveProbeMaxUsd('0', 10)).toBe(0);
|
||||
});
|
||||
|
||||
test('malformed or negative DB value falls through to file plane', () => {
|
||||
expect(resolveProbeMaxUsd('banana', 3)).toBe(3);
|
||||
expect(resolveProbeMaxUsd('-1', 3)).toBe(3);
|
||||
});
|
||||
|
||||
test('file plane used when no DB row; default when both absent/invalid', () => {
|
||||
expect(resolveProbeMaxUsd(null, 7)).toBe(7);
|
||||
expect(resolveProbeMaxUsd(null, '4')).toBe(4);
|
||||
expect(resolveProbeMaxUsd(null, undefined)).toBe(5);
|
||||
expect(resolveProbeMaxUsd(null, 'banana')).toBe(5);
|
||||
expect(resolveProbeMaxUsd(undefined, -2)).toBe(5);
|
||||
});
|
||||
|
||||
test('explicit fallback override is honored', () => {
|
||||
expect(resolveProbeMaxUsd(null, undefined, 12)).toBe(12);
|
||||
});
|
||||
});
|
||||
@@ -132,17 +132,20 @@ describe('runNightlyQualityProbe (DI stub harness)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('enabled + recent run within 24h → outcome: rate_limited', async () => {
|
||||
test('enabled + recent run within 24h → outcome: rate_limited, NO audit row', async () => {
|
||||
// Pre-seed a recent audit event by running the probe once first.
|
||||
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
|
||||
// First run succeeds.
|
||||
await runNightlyQualityProbe(makeDeps());
|
||||
// Second run, same hour → rate_limited.
|
||||
// Second run, same hour → rate_limited. A skip is a non-event: the
|
||||
// autopilot loop invokes the probe every cycle (~5-10 min), so
|
||||
// logging each skip would flood the audit file and flip doctor's
|
||||
// any-non-pass-is-bad filter to a permanent WARN.
|
||||
const r2 = await runNightlyQualityProbe(makeDeps());
|
||||
expect(r2.outcome).toBe('rate_limited');
|
||||
const events = await readEvents();
|
||||
expect(events.length).toBe(2);
|
||||
expect(events[1].outcome).toBe('rate_limited');
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].outcome).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -94,12 +94,75 @@ describe('postgres-engine / search path timeout isolation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v0.32.7 CJK branch (Postgres parity) guardrails. Live-DB coverage of the
|
||||
// fallback lives in test/e2e/engine-parity.test.ts (Korean parity case);
|
||||
// this block stays DB-free and locks in the source-level invariants that
|
||||
// make the port faithful to pglite-engine.ts `_searchKeywordCJK`.
|
||||
describe('postgres-engine / CJK keyword fallback (v0.32.7 parity)', () => {
|
||||
test('searchKeyword routes CJK queries to _searchKeywordCJK', () => {
|
||||
const fn = stripComments(extractMethod(SRC, 'searchKeyword'));
|
||||
expect(fn).toMatch(/if\s*\(\s*hasCJK\s*\(\s*query\s*\)\s*\)/);
|
||||
expect(fn).toMatch(/this\._searchKeywordCJK\s*\(/);
|
||||
});
|
||||
|
||||
test('searchKeywordChunks routes CJK queries to _searchKeywordCJK (chunk-grain, no dedup)', () => {
|
||||
const fn = stripComments(extractMethod(SRC, 'searchKeywordChunks'));
|
||||
expect(fn).toMatch(/if\s*\(\s*hasCJK\s*\(\s*query\s*\)\s*\)/);
|
||||
expect(fn).toMatch(/this\._searchKeywordCJK\s*\(/);
|
||||
expect(fn).toMatch(/dedup:\s*false/);
|
||||
});
|
||||
|
||||
test('_searchKeywordCJK matches via ILIKE with explicit ESCAPE, never tsquery', () => {
|
||||
const fn = extractCJKHelper(SRC);
|
||||
// ILIKE '%' || $1 || '%' ESCAPE '\' — the escaped-pattern binding.
|
||||
expect(fn).toMatch(/ILIKE\s+'%'\s*\|\|\s*\$1\s*\|\|\s*'%'\s+ESCAPE/);
|
||||
// The fallback must not route back through the english tokenizer it
|
||||
// exists to bypass.
|
||||
expect(stripComments(fn)).not.toMatch(/websearch_to_tsquery/);
|
||||
});
|
||||
|
||||
test('_searchKeywordCJK keeps the two-binding discipline (qLike escaped, qRaw for ranking)', () => {
|
||||
const fn = stripComments(extractCJKHelper(SRC));
|
||||
// $1 comes from escapeLikePattern; $2 is the raw string used by the
|
||||
// REPLACE/POSITION occurrence-count scoring. Escaped chars cannot be
|
||||
// reused as ranking substrings (codex outside-voice C8).
|
||||
expect(fn).toMatch(/escapeLikePattern\s*\(\s*qRaw\s*\)/);
|
||||
expect(fn).toMatch(/REPLACE\(cc\.chunk_text,\s*\$2/);
|
||||
expect(fn).toMatch(/POSITION\(\$2\s+IN\s+cc\.chunk_text\)/);
|
||||
});
|
||||
|
||||
test('_searchKeywordCJK honors the pool-safety contract (sql.begin + SET LOCAL)', () => {
|
||||
const fn = extractCJKHelper(SRC);
|
||||
expect(fn).toMatch(/sql\.begin\s*\(\s*async\s+sql\s*=>/);
|
||||
expect(fn).toMatch(/SET\s+LOCAL\s+statement_timeout/);
|
||||
expect(stripComments(fn)).not.toMatch(/SET\s+statement_timeout\s*=\s*['"]?0/);
|
||||
});
|
||||
|
||||
test('_searchKeywordCJK seals source-isolation on the fallback path (#861 parity)', () => {
|
||||
const fn = stripComments(extractCJKHelper(SRC));
|
||||
expect(fn).toMatch(/p\.source_id = ANY\(\$\$\{params\.length\}::text\[\]\)/);
|
||||
expect(fn).toMatch(/opts\?\.sourceId/);
|
||||
});
|
||||
});
|
||||
|
||||
function stripComments(s: string): string {
|
||||
return s
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
|
||||
}
|
||||
|
||||
// _searchKeywordCJK's signature contains an object-type literal (the ctx
|
||||
// param), so extractMethod's first-brace matcher would stop at the type.
|
||||
// Slice from the declaration to the method's 2-space-indent closing brace
|
||||
// instead (nested blocks all sit deeper).
|
||||
function extractCJKHelper(source: string): string {
|
||||
const start = source.indexOf('private async _searchKeywordCJK(');
|
||||
if (start < 0) throw new Error('_searchKeywordCJK not found in postgres-engine.ts');
|
||||
const end = source.indexOf('\n }', start);
|
||||
if (end < 0) throw new Error('no closing brace for _searchKeywordCJK');
|
||||
return source.slice(start, end + 4);
|
||||
}
|
||||
|
||||
// extractMethod grabs the body of a class method by brace-matching from
|
||||
// its opening line. Returns the method body up to the matching closing
|
||||
// brace. Good enough for the small number of methods in this file.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* the rule can't drift without the suite catching it.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { resolveBootstrapToken } from '../src/commands/serve-http.ts';
|
||||
import { resolveBootstrapToken, shouldSuppressBootstrapPrint } from '../src/commands/serve-http.ts';
|
||||
|
||||
describe('resolveBootstrapToken (v0.36.1.x #1024)', () => {
|
||||
test('unset env → generates a fresh token via the injected RNG', () => {
|
||||
@@ -72,3 +72,27 @@ describe('resolveBootstrapToken (v0.36.1.x #1024)', () => {
|
||||
expect(r.kind).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSuppressBootstrapPrint (#2624 log-leak default)', () => {
|
||||
const base = { suppress: false, fromEnv: false, forcePrint: false, isTty: true };
|
||||
|
||||
test('generated token on non-TTY (container) → hidden by default', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, isTty: false })).toBe(true);
|
||||
});
|
||||
|
||||
test('generated token on interactive TTY → printed', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, isTty: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('--print-admin-token forces raw value on non-TTY', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, isTty: false, forcePrint: true })).toBe(false);
|
||||
});
|
||||
|
||||
test('env-sourced token is never printed', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, fromEnv: true, isTty: true })).toBe(true);
|
||||
});
|
||||
|
||||
test('--suppress overrides even a forced print', () => {
|
||||
expect(shouldSuppressBootstrapPrint({ ...base, suppress: true, forcePrint: true, isTty: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Pins `maxOutputTokensFor` — the per-model output-token budget `runThink`
|
||||
* passes to `client.create`. Thinking-by-default Claude 5 models
|
||||
* (`anthropic:claude-*-5`) spend a large share of the budget on internal
|
||||
* reasoning before emitting an answer, so the 4000 default left `think` with
|
||||
* empty/truncated text. They now get 16000; everything else stays 4000.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { maxOutputTokensFor } from '../src/core/think/index.ts';
|
||||
|
||||
describe('maxOutputTokensFor — thinking-default headroom', () => {
|
||||
test('Claude 5 family gets 16000', () => {
|
||||
expect(maxOutputTokensFor('anthropic:claude-sonnet-5')).toBe(16000);
|
||||
expect(maxOutputTokensFor('anthropic:claude-opus-5')).toBe(16000);
|
||||
expect(maxOutputTokensFor('anthropic:claude-fable-5')).toBe(16000);
|
||||
expect(maxOutputTokensFor('anthropic:claude-haiku-5')).toBe(16000);
|
||||
expect(maxOutputTokensFor('anthropic/claude-sonnet-5')).toBe(16000); // slash form
|
||||
});
|
||||
|
||||
test('non-Claude-5 and non-Anthropic keep 4000', () => {
|
||||
expect(maxOutputTokensFor('anthropic:claude-opus-4-8')).toBe(4000);
|
||||
expect(maxOutputTokensFor('anthropic:claude-haiku-4-5')).toBe(4000);
|
||||
expect(maxOutputTokensFor('anthropic:claude-sonnet-4-6')).toBe(4000);
|
||||
expect(maxOutputTokensFor('anthropic:claude-3-haiku')).toBe(4000);
|
||||
expect(maxOutputTokensFor('openai:gpt-4o')).toBe(4000);
|
||||
expect(maxOutputTokensFor('deepseek:deepseek-reasoner')).toBe(4000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { TrajectoryPoint } from '../src/core/engine.ts';
|
||||
import {
|
||||
DEFAULT_REGRESSION_THRESHOLD,
|
||||
detectRegressions,
|
||||
} from '../src/core/trajectory.ts';
|
||||
|
||||
function point(args: {
|
||||
id: number;
|
||||
metric?: string;
|
||||
value: number;
|
||||
date: string;
|
||||
}): TrajectoryPoint {
|
||||
return {
|
||||
fact_id: args.id,
|
||||
valid_from: new Date(args.date),
|
||||
metric: args.metric ?? 'net_income',
|
||||
value: args.value,
|
||||
unit: 'USD',
|
||||
period: 'monthly',
|
||||
event_type: null,
|
||||
text: `${args.metric ?? 'net_income'} = ${args.value}`,
|
||||
source_session: null,
|
||||
source_markdown_slug: null,
|
||||
embedding: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectRegressions', () => {
|
||||
test('keeps existing positive-valued drop behavior', () => {
|
||||
const regs = detectRegressions([
|
||||
point({ id: 1, metric: 'mrr', value: 200000, date: '2026-01-01' }),
|
||||
point({ id: 2, metric: 'mrr', value: 150000, date: '2026-02-01' }),
|
||||
], DEFAULT_REGRESSION_THRESHOLD);
|
||||
|
||||
expect(regs).toHaveLength(1);
|
||||
expect(regs[0]).toMatchObject({
|
||||
metric: 'mrr',
|
||||
from_value: 200000,
|
||||
to_value: 150000,
|
||||
});
|
||||
expect(regs[0].delta_pct).toBeCloseTo(-0.25, 4);
|
||||
});
|
||||
|
||||
test('does not flag a negative-valued metric improving toward zero', () => {
|
||||
const regs = detectRegressions([
|
||||
point({ id: 1, value: -1000, date: '2026-01-01' }),
|
||||
point({ id: 2, value: -500, date: '2026-02-01' }),
|
||||
], DEFAULT_REGRESSION_THRESHOLD);
|
||||
|
||||
expect(regs).toEqual([]);
|
||||
});
|
||||
|
||||
test('flags a negative-valued metric worsening away from zero', () => {
|
||||
const regs = detectRegressions([
|
||||
point({ id: 1, value: -500, date: '2026-01-01' }),
|
||||
point({ id: 2, value: -1000, date: '2026-02-01' }),
|
||||
], DEFAULT_REGRESSION_THRESHOLD);
|
||||
|
||||
expect(regs).toHaveLength(1);
|
||||
expect(regs[0]).toMatchObject({
|
||||
metric: 'net_income',
|
||||
from_value: -500,
|
||||
to_value: -1000,
|
||||
from_date: '2026-01-01',
|
||||
to_date: '2026-02-01',
|
||||
});
|
||||
expect(regs[0].delta_pct).toBeCloseTo(-1.0, 4);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user