Merge origin/master (waves G #2937 + E #2938) into fix-wave/f-dream-synthesize

Conflicts resolved keeping BOTH waves:
- src/core/config.ts: KNOWN_CONFIG_KEYS carries dream.synthesize.output_root (F) AND dream.synthesize.subagent_{timeout,wait_timeout}_ms (G)
- src/core/cycle/synthesize.ts: SynthConfig has outputRoot (F) + subagent timeout fields (G)
- src/core/cycle/patterns.ts: shared loadOutputRoot/loadAllowedSlugPrefixes imports (F) + probeChatModel gate, timeouts (G)
- docs/architecture/KEY_FILES.md: per-entry word-merge of F's #1586/#2415/#2569 clauses with G's timeout/status clauses

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-17 14:31:44 -07:00
co-authored by Claude Fable 5
29 changed files with 1335 additions and 119 deletions
File diff suppressed because one or more lines are too long
+10
View File
@@ -131,6 +131,16 @@ hashes match. If both a cron and `--watch` fire simultaneously, no conflict.
history rewrite still hard-blocks even with `--skip-failed`. Run
`gbrain sync --skip-failed` to acknowledge a known-bad set yourself.
5. **Import checkpoints name the import target, not the caller's CWD.**
Interrupted `gbrain import <dir>` runs may leave
`~/.gbrain/import-checkpoint.json` so the next import can resume. The
checkpoint `dir` is the absolute, resolved import target captured when
import starts. It is not a cleanup instruction and it must not be
re-derived from the process working directory. Checkpoints written by
gbrain include `schema_version: 1`, `owner: "gbrain"`, and
`kind: "import"` so downstream tools can validate the contract before
deciding whether to resume.
## How to Verify
1. **Edit a file and search for the change.** Edit a brain markdown file,
+10
View File
@@ -2767,6 +2767,16 @@ hashes match. If both a cron and `--watch` fire simultaneously, no conflict.
history rewrite still hard-blocks even with `--skip-failed`. Run
`gbrain sync --skip-failed` to acknowledge a known-bad set yourself.
5. **Import checkpoints name the import target, not the caller's CWD.**
Interrupted `gbrain import <dir>` runs may leave
`~/.gbrain/import-checkpoint.json` so the next import can resume. The
checkpoint `dir` is the absolute, resolved import target captured when
import starts. It is not a cleanup instruction and it must not be
re-derived from the process working directory. Checkpoints written by
gbrain include `schema_version: 1`, `owner: "gbrain"`, and
`kind: "import"` so downstream tools can validate the contract before
deciding whether to resume.
## How to Verify
1. **Edit a file and search for the change.** Edit a brain markdown file,
+17 -1
View File
@@ -20,6 +20,7 @@ import {
loadCheckpoint,
saveCheckpoint,
clearCheckpoint,
resolveImportTargetDir,
resumeFilter,
} from '../core/import-checkpoint.ts';
@@ -168,7 +169,19 @@ export async function runImport(
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--source-id <id>] [--json]');
process.exit(1);
}
const dir: string = dirArg; // narrowed; survives closure capture
// #1728: capture the import target ONCE as an absolute real path. Every
// downstream consumer of `dir` (collection, checkpoint load/save, resume
// filtering) sees the same canonical identity — never the caller's `.`/
// relative spelling, which would make the persisted checkpoint `dir`
// resolve against whatever CWD a later process happens to run from.
let dir: string;
try {
dir = resolveImportTargetDir(dirArg);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Import target is not readable: ${dirArg} (${msg})`);
process.exit(1);
}
// v0.31.2: collect under the right strategy. Pre-fix this called
// collectMarkdownFiles unconditionally — code-strategy first sync
@@ -288,6 +301,9 @@ export async function runImport(
catch { /* non-fatal */ }
}
saveCheckpoint(checkpointPath, {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir,
completedPaths: Array.from(completed),
timestamp: new Date().toISOString(),
+39
View File
@@ -23,6 +23,7 @@
import { embed as aiEmbed, embedMany, generateObject, generateText, jsonSchema } from 'ai';
import { AsyncLocalStorage } from 'node:async_hooks';
import { createHash } from 'node:crypto';
import { listRecipes } from './recipes/index.ts';
import { createOpenAI } from '@ai-sdk/openai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
@@ -2849,6 +2850,30 @@ async function classifyGatewayGuardrail(input: {
}
}
/**
* Derive OpenAI's `prompt_cache_key` (the AI SDK's `providerOptions.openai.
* promptCacheKey`). It's a ROUTING hint, not a cache breakpoint: OpenAI caches
* prefixes automatically, and a stable key makes requests sharing a prefix
* land on the same engine, raising the hit rate (OpenAI cites 60%→87%).
*
* Hash the system prompt + sorted tool names — that's the stable prefix
* gbrain's repeated loops (enrich, page-summary, skillopt, subagent) actually
* share. Returns undefined when there's no system prompt (nothing stable to
* key on), so one-off requests don't get pinned to a single engine. An
* explicit key can still be set per provider/model via
* `provider_chat_options` config, which overrides the derived key.
*
* @internal exported for tests; not part of the public gateway API.
*/
export function openAIPromptCacheKey(args: {
system?: string;
toolNames?: string[];
}): string | undefined {
if (!args.system) return undefined;
const basis = `${args.system} ${(args.toolNames ?? []).slice().sort().join(',')}`;
return `gbrain:${createHash('sha256').update(basis).digest('hex').slice(0, 32)}`;
}
export function toAISDKTools(tools: ChatToolDef[] | undefined): Record<string, any> | undefined {
if (!tools || tools.length === 0) return undefined;
return tools.reduce((acc, t) => {
@@ -2959,6 +2984,20 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
if (useCache) {
providerOptions.anthropic = { cacheControl: { type: 'ephemeral' } };
}
// OpenAI prompt_cache_key (native-openai only): a stable per-prefix routing
// hint that keeps requests sharing a system prompt + tool set on the same
// inference engine, lifting OpenAI's automatic prefix-cache hit rate. The
// openai-compatible path (litellm/azure/groq/...) ignores
// providerOptions.openai, so it gets nothing. Applied BEFORE the configured
// provider options so `provider_chat_options.openai.promptCacheKey` from
// config still overrides the derived key.
if (recipe.implementation === 'native-openai') {
const promptCacheKey = openAIPromptCacheKey({
system: opts.system,
toolNames: (opts.tools ?? []).map(t => t.name),
});
if (promptCacheKey) providerOptions.openai = { promptCacheKey };
}
applyConfiguredChatProviderOptions(providerOptions, cfg, recipe.id, modelId);
let _budgetRecorded = false;
+26
View File
@@ -271,6 +271,8 @@ export interface GBrainConfig {
verdict_model?: string;
max_prompt_tokens?: number;
max_chunks_per_transcript?: number;
subagent_timeout_ms?: number;
subagent_wait_timeout_ms?: number;
};
patterns?: {
lookback_days?: number;
@@ -710,6 +712,12 @@ export async function loadConfigWithEngine(
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
async function dbNum(key: string): Promise<number | undefined> {
const v = await dbStr(key);
if (v === undefined) return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
}
const dbWarnBytes = await dbInt('content_sanity.bytes_warn');
const dbBlockBytes = await dbInt('content_sanity.bytes_block');
const dbJunkEnabled = await dbBool('content_sanity.junk_patterns_enabled');
@@ -759,6 +767,8 @@ export async function loadConfigWithEngine(
const dbVerdictModel = await dbStr('dream.synthesize.verdict_model');
const dbMaxPromptTokens = await dbInt('dream.synthesize.max_prompt_tokens');
const dbMaxChunksPerTranscript = await dbInt('dream.synthesize.max_chunks_per_transcript');
const dbSubagentTimeoutMs = await dbNum('dream.synthesize.subagent_timeout_ms');
const dbSubagentWaitTimeoutMs = await dbNum('dream.synthesize.subagent_wait_timeout_ms');
const dbLookbackDays = await dbInt('dream.patterns.lookback_days');
const dbMinEvidence = await dbInt('dream.patterns.min_evidence');
@@ -783,6 +793,12 @@ export async function loadConfigWithEngine(
if (mergedSynth.max_chunks_per_transcript === undefined && dbMaxChunksPerTranscript !== undefined) {
mergedSynth.max_chunks_per_transcript = dbMaxChunksPerTranscript;
}
if (mergedSynth.subagent_timeout_ms === undefined && dbSubagentTimeoutMs !== undefined) {
mergedSynth.subagent_timeout_ms = dbSubagentTimeoutMs;
}
if (mergedSynth.subagent_wait_timeout_ms === undefined && dbSubagentWaitTimeoutMs !== undefined) {
mergedSynth.subagent_wait_timeout_ms = dbSubagentWaitTimeoutMs;
}
if (mergedPatterns.lookback_days === undefined && dbLookbackDays !== undefined) {
mergedPatterns.lookback_days = dbLookbackDays;
}
@@ -854,6 +870,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// 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',
// #2778: per-turn output-token cap for the subagent loop (default 8192).
'agent.max_output_tokens',
// DB-plane (v0.32.3 search modes + related)
'search.mode',
'search.cache.enabled',
@@ -888,6 +906,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'models.chat',
'models.eval.longmemeval',
'facts.extraction_model',
// #2113: output-token cap for the per-turn facts extractor (default 4000).
'facts.extraction_max_tokens',
// Dream cycle config
'dream.synthesize.session_corpus_dir',
'dream.synthesize.meeting_transcripts_dir',
@@ -897,8 +917,14 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'dream.synthesize.max_chunks_per_transcript',
// #2415: top-level namespace for synthesize/patterns output (default 'wiki').
'dream.synthesize.output_root',
'dream.synthesize.subagent_timeout_ms',
'dream.synthesize.subagent_wait_timeout_ms',
'dream.patterns.lookback_days',
'dream.patterns.min_evidence',
// #2782-family: patterns-phase subagent timeouts (mirror of the
// dream.synthesize.* pair from #1594).
'dream.patterns.subagent_timeout_ms',
'dream.patterns.subagent_wait_timeout_ms',
// Emotional weight (v0.29)
'emotional_weight.high_tags',
'emotional_weight.user_holder',
+74 -7
View File
@@ -30,6 +30,8 @@ import type { Page, PageType } from '../types.ts';
// #2415: allow-list + output-root resolution shared with the synthesize
// phase — both phases must agree on the configured namespace.
import { loadAllowedSlugPrefixes, loadOutputRoot } from './synthesize.ts';
import { probeChatModel } from '../ai/gateway.ts';
import { normalizeModelId } from '../model-id.ts';
export interface PatternsPhaseOpts {
brainDir: string;
@@ -66,9 +68,20 @@ export async function runPhasePatterns(
});
}
// Submit one subagent for pattern detection.
if (!process.env.ANTHROPIC_API_KEY) {
return skipped('no_api_key', 'ANTHROPIC_API_KEY unset; pattern detection skipped');
// Submit one subagent for pattern detection. The subagent dispatches via
// the gateway model-tier resolver, so gate on "is the resolved model's
// provider reachable" rather than ANTHROPIC_API_KEY specifically — a
// hardcoded env gate misclassified non-Anthropic stacks (litellm,
// deepseek, openrouter, ...) as "no upstream" even though the subagent
// routes them through the gateway (agent.use_gateway_loop), and it missed
// Anthropic keys set via `gbrain config set anthropic_api_key`. Same
// probe semantics as think/index.ts + synthesize's makeJudgeClient:
// unknown provider/model or Anthropic-without-key skips cheaply; other
// providers' auth is checked lazily at dispatch and surfaces in the job
// outcome. (Takeover of PR #2279's intent by @brettdavies.)
const probe = probeChatModel(normalizeModelId(config.model));
if (!probe.ok) {
return skipped('no_provider', `pattern detection skipped: ${probe.detail}`);
}
const allowedSlugPrefixes = await loadAllowedSlugPrefixes(config.outputRoot);
@@ -86,7 +99,7 @@ export async function runPhasePatterns(
};
const submitOpts: Partial<MinionJobInput> = {
max_stalled: 3,
timeout_ms: 30 * 60 * 1000,
timeout_ms: config.subagentTimeoutMs,
};
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
@@ -95,7 +108,7 @@ export async function runPhasePatterns(
let outcome: string;
try {
const final = await waitForCompletion(queue, job.id, {
timeoutMs: 35 * 60 * 1000,
timeoutMs: config.subagentWaitTimeoutMs,
pollMs: 5 * 1000,
});
outcome = final.status;
@@ -116,13 +129,47 @@ export async function runPhasePatterns(
// Reverse-write to fs.
const reverseWriteCount = await reverseWriteRefs(engine, opts.brainDir, writtenRefs);
return ok(`${writtenRefs.length} pattern page(s) written/updated (${outcome})`, {
const details = {
reflections_considered: reflections.length,
patterns_written: writtenRefs.length,
reverse_write_count: reverseWriteCount,
child_outcome: outcome,
job_id: job.id,
});
};
// #2782: the phase status must reflect the child outcome. Pre-fix this
// returned status:ok even when the subagent timed out (e.g. no
// subagent-capable worker slot free for the whole wait window) and zero
// pattern pages were written — a silent no-op for days.
if (outcome !== 'complete') {
if (writtenRefs.length === 0) {
return {
phase: 'patterns',
status: 'fail',
duration_ms: 0,
summary: `pattern-detection subagent job ${job.id} ended '${outcome}'; nothing was written`,
details,
error: makeError(
outcome === 'timeout' ? 'Timeout' : 'InternalError',
`PATTERNS_CHILD_${outcome.toUpperCase()}`,
`subagent job ${job.id} outcome '${outcome}' with zero pattern pages written`,
outcome === 'timeout'
? 'A timeout with zero writes usually means no subagent-capable worker claimed the job. Check `gbrain jobs list` and worker capacity.'
: undefined,
),
};
}
// Partial: the child died/timed out but some pages landed first.
return {
phase: 'patterns',
status: 'warn',
duration_ms: 0,
summary: `${writtenRefs.length} pattern page(s) written but subagent job ${job.id} ended '${outcome}'`,
details,
};
}
return ok(`${writtenRefs.length} pattern page(s) written/updated (${outcome})`, details);
} catch (e) {
return failed(makeError('InternalError', 'PATTERNS_PHASE_FAIL',
e instanceof Error ? (e.message || 'patterns phase threw') : String(e)));
@@ -140,6 +187,20 @@ interface PatternsConfig {
model: string;
/** #2415: shared output namespace (dream.synthesize.output_root, default 'wiki'). */
outputRoot: string;
/** #1594-family: subagent job timeout, config `dream.patterns.subagent_timeout_ms`. */
subagentTimeoutMs: number;
/** #1594-family: waitForCompletion timeout, config `dream.patterns.subagent_wait_timeout_ms`. */
subagentWaitTimeoutMs: number;
}
const DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_PATTERNS_SUBAGENT_WAIT_TIMEOUT_MS = 35 * 60 * 1000;
async function getNumberConfig(engine: BrainEngine, key: string, fallback: number): Promise<number> {
const raw = await engine.getConfig(key);
if (raw === undefined || raw === null) return fallback;
const value = Number(raw);
return Number.isNaN(value) ? fallback : value;
}
async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig> {
@@ -161,6 +222,12 @@ async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig>
minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3,
model,
outputRoot: await loadOutputRoot(engine),
subagentTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS,
),
subagentWaitTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_wait_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_WAIT_TIMEOUT_MS,
),
};
}
+29 -2
View File
@@ -75,6 +75,8 @@ const MIN_PROMPT_TOKENS = 100_000;
const DEFAULT_MAX_CHUNKS = 24;
/** Conservative default budget when model is unknown (200K × HEADROOM_RATIO). */
const UNKNOWN_MODEL_BUDGET_TOKENS = 180_000;
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS = 35 * 60 * 1000;
/**
* Compute per-chunk character budget for the resolved model + config override.
@@ -489,7 +491,7 @@ export async function runPhaseSynthesize(
max_stalled: 3,
on_child_fail: 'continue',
idempotency_key,
timeout_ms: 30 * 60 * 1000, // 30 min per chunk
timeout_ms: config.subagentTimeoutMs,
};
const child = await queue.add(
'subagent',
@@ -510,7 +512,7 @@ export async function runPhaseSynthesize(
for (const jobId of childIds) {
try {
const job = await waitForCompletion(queue, jobId, {
timeoutMs: 35 * 60 * 1000,
timeoutMs: config.subagentWaitTimeoutMs,
pollMs: 5 * 1000,
});
childOutcomes.push({ jobId, status: job.status });
@@ -620,6 +622,8 @@ interface SynthConfig {
* grammar; invalid values fall back to 'wiki' with a stderr warning.
*/
outputRoot: string;
subagentTimeoutMs: number;
subagentWaitTimeoutMs: number;
}
/** #2415: shared output-root resolution (synthesize + patterns phases). */
@@ -660,6 +664,16 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
const cooldownHoursStr = await engine.getConfig('dream.synthesize.cooldown_hours');
const maxPromptTokensStr = await engine.getConfig('dream.synthesize.max_prompt_tokens');
const maxChunksStr = await engine.getConfig('dream.synthesize.max_chunks_per_transcript');
const subagentTimeoutMs = await getNumberConfig(
engine,
'dream.synthesize.subagent_timeout_ms',
DEFAULT_SUBAGENT_TIMEOUT_MS,
);
const subagentWaitTimeoutMs = await getNumberConfig(
engine,
'dream.synthesize.subagent_wait_timeout_ms',
DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS,
);
let excludePatterns: string[] = ['medical', 'therapy'];
if (excludeStr) {
@@ -698,9 +712,22 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
maxPromptTokens,
maxChunksPerTranscript,
outputRoot: await loadOutputRoot(engine),
subagentTimeoutMs,
subagentWaitTimeoutMs,
};
}
async function getNumberConfig(
engine: BrainEngine,
key: string,
fallback: number,
): Promise<number> {
const raw = await engine.getConfig(key);
if (raw === undefined || raw === null) return fallback;
const value = Number(raw);
return Number.isNaN(value) ? fallback : value;
}
async function checkCooldown(
engine: BrainEngine,
hours: number,
+51 -12
View File
@@ -67,6 +67,23 @@ export async function getFactsExtractionModel(engine?: BrainEngine): Promise<str
return normalizeModelId(resolved);
}
/**
* #2113: output-token cap for the extractor call. The pre-fix hardcoded 1500
* silently truncated output on mandatory-reasoning models (thinking tokens
* count toward the cap), so the JSON never parsed and extraction returned
* zero facts with no signal. Configurable via
* `gbrain config set facts.extraction_max_tokens <n>`; default 4000.
*/
export const DEFAULT_EXTRACTION_MAX_TOKENS = 4000;
export async function getFactsExtractionMaxTokens(engine?: BrainEngine): Promise<number> {
if (!engine) return DEFAULT_EXTRACTION_MAX_TOKENS;
const raw = await engine.getConfig('facts.extraction_max_tokens').catch(() => null);
if (raw == null || raw.trim() === '') return DEFAULT_EXTRACTION_MAX_TOKENS;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_EXTRACTION_MAX_TOKENS;
}
export const ALL_EXTRACT_KINDS: readonly FactKind[] = [
'event', 'preference', 'commitment', 'belief', 'fact',
] as const;
@@ -164,24 +181,46 @@ export async function extractFactsFromTurn(input: ExtractInput): Promise<Extract
const cap = Math.max(1, Math.min(input.maxFactsPerTurn ?? 10, 25));
const defaultModel = await getFactsExtractionModel(input.engine);
const maxTokens = await getFactsExtractionMaxTokens(input.engine);
const model = input.model ?? defaultModel;
const userContent = `<turn>\n${cleaned}\n</turn>\n\nExtract up to ${cap} facts.${
input.entityHints && input.entityHints.length
? ` Known entity slugs the user already mentioned: ${input.entityHints.slice(0, 5).join(', ')}.`
: ''
}`;
let result: ChatResult;
try {
result = await chat({
model: input.model ?? defaultModel,
model,
system: EXTRACTOR_SYSTEM,
messages: [
{
role: 'user',
content: `<turn>\n${cleaned}\n</turn>\n\nExtract up to ${cap} facts.${
input.entityHints && input.entityHints.length
? ` Known entity slugs the user already mentioned: ${input.entityHints.slice(0, 5).join(', ')}.`
: ''
}`,
},
],
maxTokens: 1500,
messages: [{ role: 'user', content: userContent }],
maxTokens,
abortSignal: input.abortSignal,
});
// #2113: never checked pre-fix — a truncated response (stopReason
// 'length', e.g. reasoning tokens eating the cap on mandatory-reasoning
// models) produced unparseable JSON and silently extracted zero facts.
// Retry ONCE at double the cap, then surface the truncation loudly.
if (result.stopReason === 'length') {
process.stderr.write(
`[facts-extract] WARN: extractor output truncated at maxTokens=${maxTokens} ` +
`(model=${model}); retrying once at ${maxTokens * 2}\n`,
);
result = await chat({
model,
system: EXTRACTOR_SYSTEM,
messages: [{ role: 'user', content: userContent }],
maxTokens: maxTokens * 2,
abortSignal: input.abortSignal,
});
if (result.stopReason === 'length') {
process.stderr.write(
`[facts-extract] WARN: extractor output STILL truncated at maxTokens=${maxTokens * 2} ` +
`(model=${model}); facts for this turn are likely lost. ` +
`Raise the cap: gbrain config set facts.extraction_max_tokens <n>\n`,
);
}
}
} catch (err) {
// Re-throw aborts; absorb other errors as "no extraction" — caller's
// `put_page` backstop will still record the page itself.
+36 -2
View File
@@ -1,5 +1,5 @@
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
import { relative, isAbsolute } from 'path';
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, realpathSync } from 'fs';
import { relative, isAbsolute, resolve } from 'path';
/**
* Path-based import checkpoint.
@@ -25,6 +25,12 @@ import { relative, isAbsolute } from 'path';
* enter the set.
*/
export interface ImportCheckpoint {
/** Checkpoint payload schema. v1 is path-based with explicit producer metadata. */
schema_version: 1;
/** Producer marker for downstream consumers that validate before acting. */
owner: 'gbrain';
/** Checkpoint kind. Prevents unrelated checkpoint files from being treated as import state. */
kind: 'import';
/** Absolute brain directory the checkpoint was created against. Mismatch on resume → discard. */
dir: string;
/**
@@ -37,6 +43,21 @@ export interface ImportCheckpoint {
}
const OLD_FORMAT_LOG = 'Older checkpoint format detected — re-walking (cheap via content_hash)';
export const IMPORT_CHECKPOINT_SCHEMA_VERSION = 1;
export const IMPORT_CHECKPOINT_OWNER = 'gbrain';
export const IMPORT_CHECKPOINT_KIND = 'import';
/**
* Capture the import target once at run start. `resolve()` removes caller
* spelling such as `.` or `../staging`; `realpathSync()` collapses symlinks
* and proves the target exists. The returned value is the only directory
* identity import checkpoints should ever persist (#1728 — a raw `.` here
* made the checkpoint `dir` resolve to whatever CWD the NEXT consumer ran
* from, which downstream tooling treated as an owned staging directory).
*/
export function resolveImportTargetDir(dir: string): string {
return realpathSync(resolve(dir));
}
/**
* Load a checkpoint and verify it's compatible with the current run.
@@ -72,11 +93,21 @@ export function loadCheckpoint(path: string, currentDir: string): ImportCheckpoi
}
if (typeof obj.dir !== 'string') return null;
if (!isAbsolute(obj.dir)) return null;
if (obj.dir !== currentDir) return null;
// Self-describing metadata (#1728): absent fields are tolerated (legacy
// path-based checkpoints predate them), but present-and-wrong means the
// file was written by something else — don't resume from it.
if (obj.schema_version !== undefined && obj.schema_version !== IMPORT_CHECKPOINT_SCHEMA_VERSION) return null;
if (obj.owner !== undefined && obj.owner !== IMPORT_CHECKPOINT_OWNER) return null;
if (obj.kind !== undefined && obj.kind !== IMPORT_CHECKPOINT_KIND) return null;
if (typeof obj.timestamp !== 'string') return null;
if (!obj.completedPaths.every((p): p is string => typeof p === 'string')) return null;
return {
schema_version: IMPORT_CHECKPOINT_SCHEMA_VERSION,
owner: IMPORT_CHECKPOINT_OWNER,
kind: IMPORT_CHECKPOINT_KIND,
dir: obj.dir,
completedPaths: obj.completedPaths,
timestamp: obj.timestamp,
@@ -98,6 +129,9 @@ export function saveCheckpoint(path: string, cp: ImportCheckpoint): void {
// Sort for stable serialization — keeps diffs across snapshots minimal
// and tests deterministic.
const payload: ImportCheckpoint = {
schema_version: IMPORT_CHECKPOINT_SCHEMA_VERSION,
owner: IMPORT_CHECKPOINT_OWNER,
kind: IMPORT_CHECKPOINT_KIND,
dir: cp.dir,
completedPaths: [...cp.completedPaths].sort(),
timestamp: cp.timestamp,
+54 -3
View File
@@ -58,8 +58,29 @@ import { randomUUIDv7 } from 'bun';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
const DEFAULT_MAX_TURNS = 20;
const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
const DEFAULT_RATE_KEY = 'anthropic:messages';
/**
* Resolve the per-turn output-token cap (#2778). Per-job data wins, then the
* `agent.max_output_tokens` config row, then the 8192 default (was a
* hardcoded 4096 that made pages >~12KB unwritable via put_page). Invalid
* values (NaN / zero / negative) fall through to the next tier.
*/
export function resolveMaxOutputTokens(
perJob: number | undefined,
configRaw: string | null | undefined,
): number {
if (typeof perJob === 'number' && Number.isFinite(perJob) && perJob > 0) {
return Math.floor(perJob);
}
if (typeof configRaw === 'string' && configRaw.trim() !== '') {
const n = Number(configRaw);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
return DEFAULT_MAX_OUTPUT_TOKENS;
}
/**
* Resolve the rate-lease cap from the env var.
*
@@ -212,6 +233,11 @@ export function makeSubagentHandler(deps: SubagentDeps) {
fallback: TIER_DEFAULTS.subagent,
});
const maxTurns = data.max_turns ?? DEFAULT_MAX_TURNS;
// #2778: per-turn output cap — data.max_tokens → config → 8192 default.
const maxOutputTokens = resolveMaxOutputTokens(
data.max_tokens,
await engine.getConfig('agent.max_output_tokens').catch(() => null),
);
// v0.41 Approach C: systemPrompt is now built AFTER toolDefs (a few
// lines below) so the renderer can splice a tool-usage preamble
// listing each available tool's usage_hint. The renderer is
@@ -279,6 +305,7 @@ export function makeSubagentHandler(deps: SubagentDeps) {
systemPrompt,
toolDefs,
maxTurns,
maxOutputTokens,
});
}
@@ -537,7 +564,7 @@ export function makeSubagentHandler(deps: SubagentDeps) {
// `model` stays qualified everywhere else (persistence, recipe
// lookup at recipeIdFromModel(), capability gate).
model: stripProviderPrefix(model),
max_tokens: 4096,
max_tokens: maxOutputTokens,
system: [
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
] as any,
@@ -630,7 +657,10 @@ export function makeSubagentHandler(deps: SubagentDeps) {
b.type === 'tool_use',
);
if (toolUses.length === 0) {
stopReason = 'end_turn';
// #2778: an output-cap hit is NOT end_turn — the text (and possibly a
// dropped trailing tool_use block) is truncated. Surface it as its own
// stop_reason instead of silently reporting a clean end_turn.
stopReason = assistantMsg.stop_reason === 'max_tokens' ? 'max_tokens' : 'end_turn';
// Concatenate text blocks as the final answer.
finalText = blocks
.filter(b => b.type === 'text' && typeof b.text === 'string')
@@ -743,6 +773,24 @@ export function makeSubagentHandler(deps: SubagentDeps) {
}
}
// #2778: a max_tokens stop with tool_use blocks means the API dropped an
// incomplete trailing block (e.g. a large put_page body that overflowed
// the cap). Tell the model so it re-issues the cut-off call (split, or
// smaller pages) instead of assuming the write happened.
if (assistantMsg.stop_reason === 'max_tokens') {
toolResults.push({
type: 'text',
text: `[system] Your previous response hit the ${maxOutputTokens}-token output cap and was truncated; ` +
`any tool call cut off by the cap was DROPPED and did not execute. Re-issue it, splitting large content if needed.`,
} as ContentBlock);
logSubagentHeartbeat({
job_id: ctx.id,
event: 'llm_call_completed',
turn_idx: turnIdx,
error: `stop_reason=max_tokens at cap ${maxOutputTokens}; truncation note injected`,
});
}
// 6. Append the synthesized user turn (tool_result wrappers) to the
// conversation and persist it so replay picks it up.
const userIdx = nextMessageIdx++;
@@ -778,6 +826,8 @@ interface GatewayRunArgs {
systemPrompt: string;
toolDefs: ToolDef[];
maxTurns: number;
/** #2778: per-turn output-token cap (resolved by resolveMaxOutputTokens). */
maxOutputTokens: number;
}
/**
@@ -795,7 +845,7 @@ interface GatewayRunArgs {
* reconciler sees both shapes uniformly.
*/
async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResult> {
const { engine, ctx, data, model, systemPrompt, toolDefs, maxTurns } = args;
const { engine, ctx, data, model, systemPrompt, toolDefs, maxTurns, maxOutputTokens } = args;
// Map ToolDef → ChatToolDef (gateway shape). The gateway's chat() bridges
// this to provider-specific tool definitions via the Vercel AI SDK.
@@ -919,6 +969,7 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
tools: chatTools,
toolHandlers,
maxTurns,
maxTokens: maxOutputTokens,
abortSignal: ctx.signal,
cacheSystem,
// ALWAYS pass replayState (even on fresh runs) so the gateway loop's
@@ -62,6 +62,12 @@ export const BRAIN_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
'resolve_slugs',
'get_ingest_log',
'put_page',
// #2778: the canonical timeline-write op. Fenced exactly like put_page —
// operations.ts:enforceSubagentSlugFence confines the target slug to the
// trusted-workspace allow-list (or the wiki/agents/<id>/ namespace) when
// ctx.viaSubagent=true, so a subagent can only append timeline entries to
// pages it could have written anyway.
'add_timeline_entry',
// v0.29 — Salience + Anomaly Detection. Both read-only. `get_recent_transcripts`
// is intentionally NOT included: subagent calls always have ctx.remote=true,
// and the v0.29 trust gate rejects remote callers — adding it here would be
@@ -98,6 +104,7 @@ export const BRAIN_TOOL_USAGE_HINTS: Readonly<Record<string, string>> = {
resolve_slugs: 'Resolve free-form entity names to canonical slugs (e.g. "Alice" → `people/alice-example`). Use before any tool that takes a slug if the user gave a name not a slug.',
get_ingest_log: 'Read the brain ingestion log for diagnostic / verification queries.',
put_page: 'Write a markdown page to the gbrain DATABASE (NOT the local filesystem). Page becomes searchable + linkable. Slug must match the agent\'s allowed namespace.',
add_timeline_entry: 'Append a dated timeline entry to an existing page (the canonical timeline write). Use over rewriting the page body when recording a dated event. Slug must match the agent\'s allowed namespace.',
get_recent_salience: 'Read pages ranked by emotional + activity salience over a recency window. Use for "what\'s been on my mind lately".',
find_anomalies: 'Read cohort-level activity outliers (e.g. tag-cohort or type-cohort with unusual recent volume). Use for "what\'s unusual lately".',
};
+7
View File
@@ -411,6 +411,12 @@ export interface SubagentHandlerData {
model?: string;
/** Max assistant turns before the loop fails with stop_reason='max_turns'. */
max_turns?: number;
/**
* Per-turn max output tokens (#2778). Resolution: this field →
* `agent.max_output_tokens` config → 8192 default. The pre-#2778
* hardcoded 4096 made pages >~12KB unwritable via put_page.
*/
max_tokens?: number;
/**
* Whitelist of tool names the agent may call. MUST be a subset of the
* derived registry names — invalid entries are rejected at tool-dispatch
@@ -573,6 +579,7 @@ export type ContentBlock =
export type SubagentStopReason =
| 'end_turn' // Anthropic says end_turn and last message has no tool_use
| 'max_turns' // hit max_turns budget before end_turn
| 'max_tokens' // final turn hit the output-token cap — result text is TRUNCATED (#2778)
| 'refusal' // detected via stop_reason + content shape
| 'error'; // unrecoverable (empty response retry exhausted, etc.)
+41 -31
View File
@@ -193,6 +193,39 @@ export function matchesSlugAllowList(slug: string, prefixes: readonly string[]):
return false;
}
/**
* Subagent slug-fence enforcement, shared by every mutating op a subagent
* can reach (put_page, add_timeline_entry). FAIL-CLOSED: `viaSubagent=true`
* enforces the check even if the dispatcher forgot to populate `subagentId`.
*
* - Trusted-workspace path (ctx.allowedSlugPrefixes set by cycle.ts under
* PROTECTED_JOB_NAMES \u2014 MCP cannot reach it): slug must match the
* allow-list globs.
* - Legacy default: slug must live under `wiki/agents/<subagentId>/...`
* (anchored, slash-boundary \u2014 `wiki/agents/12evil/*` can't impersonate
* subagent 12).
*/
function enforceSubagentSlugFence(ctx: OperationContext, slug: string, opName: string): void {
if (ctx.viaSubagent !== true) return;
if (typeof ctx.subagentId !== 'number' || Number.isNaN(ctx.subagentId)) {
throw new OperationError('permission_denied', `${opName} via subagent requires ctx.subagentId`);
}
const allowList = ctx.allowedSlugPrefixes;
if (allowList && allowList.length > 0) {
if (!matchesSlugAllowList(slug, allowList)) {
throw new OperationError(
'permission_denied',
`${opName} slug '${slug}' is not within the trusted-workspace allow-list (${allowList.join(', ')})`
);
}
} else {
const prefix = `wiki/agents/${ctx.subagentId}/`;
if (!slug.startsWith(prefix) || slug.length === prefix.length) {
throw new OperationError('permission_denied', `${opName} via subagent must write under '${prefix}...'`);
}
}
}
/**
* Allowlist validator for uploaded file basenames. Rejects control chars, backslashes,
* RTL overrides (\u202E), leading dot (hidden files) and leading dash (CLI flag confusion).
@@ -784,37 +817,9 @@ const put_page: Operation = {
}
// Subagent namespace enforcement (v0.15+). Runs BEFORE the dry-run
// short-circuit so preview calls surface the same rejection. Confines
// LLM-driven writes to wiki/agents/<subagentId>/... — no leading slash
// (slug grammar rejects that), anchored, slash-boundary to defeat prefix
// collisions like `wiki/agents/12evil/*` impersonating subagent 12.
//
// FAIL-CLOSED: `viaSubagent=true` enforces the check even if the
// dispatcher forgot to populate `subagentId`. Agent-originated writes
// without an owning subagent id are rejected outright.
if (ctx.viaSubagent === true) {
if (typeof ctx.subagentId !== 'number' || Number.isNaN(ctx.subagentId)) {
throw new OperationError('permission_denied', 'put_page via subagent requires ctx.subagentId');
}
const allowList = ctx.allowedSlugPrefixes;
if (allowList && allowList.length > 0) {
// Trusted-workspace path: explicit allow-list bounds writes.
// Set only by cycle.ts (synthesize/patterns) which submits subagent
// jobs under PROTECTED_JOB_NAMES — MCP cannot reach this branch.
if (!matchesSlugAllowList(slug, allowList)) {
throw new OperationError(
'permission_denied',
`put_page slug '${slug}' is not within the trusted-workspace allow-list (${allowList.join(', ')})`
);
}
} else {
// Legacy default: agent-namespace confinement.
const prefix = `wiki/agents/${ctx.subagentId}/`;
if (!slug.startsWith(prefix) || slug.length === prefix.length) {
throw new OperationError('permission_denied', `put_page via subagent must write under '${prefix}...'`);
}
}
}
// short-circuit so preview calls surface the same rejection. See
// enforceSubagentSlugFence for the fail-closed policy.
enforceSubagentSlugFence(ctx, slug, 'put_page');
if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug };
// Skip embedding when the AI gateway has no embedding provider configured.
@@ -2149,6 +2154,11 @@ const add_timeline_entry: Operation = {
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
// #2778: same fail-closed slug fence as put_page. add_timeline_entry is
// subagent-allowlisted (brain-allowlist.ts), so timeline writes must be
// confined to the same namespace/allow-list as page writes. Runs before
// the dry-run short-circuit so preview calls surface the same rejection.
enforceSubagentSlugFence(ctx, p.slug as string, 'add_timeline_entry');
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
const date = p.date as string;
// Reject anything that isn't a strict YYYY-MM-DD with year 1900-2199 and
+60 -32
View File
@@ -5244,52 +5244,80 @@ export class PostgresEngine implements BrainEngine {
}
// Config
/**
* Single-statement sibling of {@link batchRetry} for the NON-batch config
* accessors that touch `this.sql` directly (#1603 / PR #1593 follow-up,
* PR #1891 by @jalagrange).
*
* Why not `batchRetry`: a config accessor is not a sized batch routing it
* through `batchRetry` would emit bogus batch-retry audit JSONL (inflating
* the `batch_retry_health` doctor metric) and demand a fake `BatchAuditSite`
* enum member. This keeps the SAME retry + reconnect posture with no audit.
*
* Why it exists: the `sql` getter throws a RETRYABLE "No database
* connection" by design when an instance pool was torn down mid-cycle
* (#1678), precisely so a withRetry+reconnect caller rebuilds the pool and
* self-heals. `getConfig` got that wrapper in #1603; the sibling accessors
* did not so the first config write/list after a mid-cycle disconnect
* threw unhandled (e.g. crashing the worker into a respawn loop).
*
* `fn` MUST re-read `this.sql` per invocation `reconnect()` rebuilds the
* pool between attempts. Safe for the writes too: `withRetry` only retries
* connection-class failures (statement never committed), and both writes
* are idempotent (upsert / delete), so even a lost-ack replay converges.
*/
private async connRetry<T>(fn: () => Promise<T>): Promise<T> {
const opts = this.getBulkRetryOpts();
return withRetry(fn, {
maxRetries: opts.maxRetries,
delayMs: opts.delayMs,
delayMaxMs: opts.delayMaxMs,
jitter: BULK_RETRY_OPTS.jitter,
// Same reconnect posture as batchRetry: rebuild a dead instance pool
// before the next attempt. Race-safe via the engine's `_reconnecting`
// guard; fail-loud — a reconnect throw propagates as the real cause.
reconnect: (ctx) => this.reconnect(ctx),
});
}
async getConfig(key: string): Promise<string | null> {
// #1603: a transient pooler drop on this read used to throw / fall through
// to defaults silently — which on remote Postgres surfaces as the wrong
// search mode/knobs and empty-stdout queries. Retry-with-reconnect using the
// same tuned opts as the bulk writers. No auditSite: this is a single-row
// read, not a bulk write, so it must not emit batch-retry audit rows.
// `this.sql` is a getter, so each attempt sees the pool rebuilt by reconnect.
const opts = this.getBulkRetryOpts();
return withRetry(
async () => {
const rows = await this.sql`SELECT value FROM config WHERE key = ${key}`;
return rows.length > 0 ? (rows[0].value as string) : null;
},
{
maxRetries: opts.maxRetries,
delayMs: opts.delayMs,
delayMaxMs: opts.delayMaxMs,
jitter: BULK_RETRY_OPTS.jitter,
reconnect: (ctx) => this.reconnect(ctx),
},
);
// search mode/knobs and empty-stdout queries.
return this.connRetry(async () => {
const rows = await this.sql`SELECT value FROM config WHERE key = ${key}`;
return rows.length > 0 ? (rows[0].value as string) : null;
});
}
async setConfig(key: string, value: string): Promise<void> {
const sql = this.sql;
await sql`
INSERT INTO config (key, value) VALUES (${key}, ${value})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
return this.connRetry(async () => {
await this.sql`
INSERT INTO config (key, value) VALUES (${key}, ${value})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
});
}
async unsetConfig(key: string): Promise<number> {
const sql = this.sql;
const result = await sql`DELETE FROM config WHERE key = ${key}` as unknown as { count: number };
return result.count ?? 0;
return this.connRetry(async () => {
const result = await this.sql`DELETE FROM config WHERE key = ${key}` as unknown as { count: number };
return result.count ?? 0;
});
}
async listConfigKeys(prefix: string): Promise<string[]> {
const sql = this.sql;
// LIKE-escape literal % and _ so a config key with those chars resolves correctly.
// LIKE-escape literal % and _ so a config key with those chars resolves
// correctly. Pure string work — stays outside the retried thunk.
const escaped = prefix.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
const pattern = `${escaped}%`;
const rows = await sql<{ key: string }[]>`
SELECT key FROM config WHERE key LIKE ${pattern} ESCAPE '\\' ORDER BY key
`;
return rows.map(r => r.key);
return this.connRetry(async () => {
const rows = await this.sql<{ key: string }[]>`
SELECT key FROM config WHERE key LIKE ${pattern} ESCAPE '\\' ORDER BY key
`;
return rows.map(r => r.key);
});
}
// Migration support
@@ -0,0 +1,123 @@
/**
* OpenAI `prompt_cache_key` routing hint (takeover of PR #2442's remaining
* half, originally by @CoachRyanNguyen).
*
* OpenAI caches prompt prefixes automatically; a stable `prompt_cache_key`
* keeps requests that share a prefix on the same inference engine, lifting the
* automatic-cache hit rate. `chat()` derives one from the system prompt + tool
* names for native-OpenAI models and passes it via
* `providerOptions.openai.promptCacheKey` (which @ai-sdk/openai maps to the
* request's `prompt_cache_key`).
*
* Pins:
* - key derivation is stable (tool ORDER doesn't matter), sensitive to
* system/tool-set changes, and absent without a system prompt
* - the chat() wiring only fires for native-openai (anthropic/compat get
* nothing), and config `provider_chat_options` overrides the derived key
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import {
chat,
configureGateway,
openAIPromptCacheKey,
resetGateway,
__setGenerateTextTransportForTests,
} from '../../src/core/ai/gateway.ts';
describe('openAIPromptCacheKey — derivation', () => {
test('same system + same tools → identical stable key (sticky routing)', () => {
const a = openAIPromptCacheKey({ system: 'SYS', toolNames: ['search', 'put_page'] });
const b = openAIPromptCacheKey({ system: 'SYS', toolNames: ['put_page', 'search'] });
expect(a).toBe(b as string); // tool ORDER must not change the key
expect(a).toMatch(/^gbrain:[0-9a-f]{32}$/);
});
test('different system → different key', () => {
const a = openAIPromptCacheKey({ system: 'SYS A', toolNames: [] });
const b = openAIPromptCacheKey({ system: 'SYS B', toolNames: [] });
expect(a).not.toBe(b as string);
});
test('different tool set → different key', () => {
const a = openAIPromptCacheKey({ system: 'SYS', toolNames: ['search'] });
const b = openAIPromptCacheKey({ system: 'SYS', toolNames: ['search', 'put_page'] });
expect(a).not.toBe(b as string);
});
test('no system prompt → undefined (do not pin one-off requests)', () => {
expect(openAIPromptCacheKey({ system: undefined, toolNames: ['search'] })).toBeUndefined();
});
});
describe('chat() wiring — prompt_cache_key per provider', () => {
beforeEach(() => {
resetGateway();
__setGenerateTextTransportForTests(null);
});
async function captureProviderOptions(
config: Parameters<typeof configureGateway>[0],
opts: Partial<Parameters<typeof chat>[0]> = {},
): Promise<Record<string, any> | undefined> {
let captured: Record<string, any> | undefined;
__setGenerateTextTransportForTests(async (args: any) => {
captured = args.providerOptions;
return {
content: [{ type: 'text', text: 'ok' }],
finishReason: 'stop',
usage: { inputTokens: 1, outputTokens: 1 },
} as any;
});
configureGateway(config);
await chat({
model: config.chat_model ?? 'anthropic:claude-sonnet-4-6',
messages: [{ role: 'user', content: 'hello' }],
...opts,
});
return captured;
}
test('native-openai with a system prompt → providerOptions.openai.promptCacheKey', async () => {
const providerOptions = await captureProviderOptions(
{ chat_model: 'openai:gpt-4o-mini', env: { OPENAI_API_KEY: 'fake' } },
{ system: 'SYS' },
);
expect(providerOptions?.openai?.promptCacheKey).toMatch(/^gbrain:[0-9a-f]{32}$/);
});
test('native-openai without a system prompt → no providerOptions at all', async () => {
const providerOptions = await captureProviderOptions(
{ chat_model: 'openai:gpt-4o-mini', env: { OPENAI_API_KEY: 'fake' } },
);
expect(providerOptions).toBeUndefined();
});
test('native-anthropic never gets an openai promptCacheKey', async () => {
const providerOptions = await captureProviderOptions(
{ chat_model: 'anthropic:claude-sonnet-4-6', env: { ANTHROPIC_API_KEY: 'fake' } },
{ system: 'SYS' },
);
expect(providerOptions?.openai).toBeUndefined();
});
test('openai-compatible (deepseek) never gets promptCacheKey (provider ignores providerOptions.openai)', async () => {
const providerOptions = await captureProviderOptions(
{ chat_model: 'deepseek:deepseek-chat', env: { DEEPSEEK_API_KEY: 'fake' } },
{ system: 'SYS' },
);
expect(providerOptions?.openai).toBeUndefined();
});
test('config provider_chat_options overrides the derived key', async () => {
const providerOptions = await captureProviderOptions(
{
chat_model: 'openai:gpt-4o-mini',
env: { OPENAI_API_KEY: 'fake' },
provider_chat_options: { openai: { promptCacheKey: 'session-42' } },
},
{ system: 'SYS' },
);
expect(providerOptions?.openai?.promptCacheKey).toBe('session-42');
});
});
+4 -1
View File
@@ -50,7 +50,10 @@ describe('BRAIN_TOOL_ALLOWLIST', () => {
// have ctx.remote=true, and the v0.29 trust gate rejects remote callers.
// v114 (#1941) added list_link_sources (read-only provenance discovery);
// the edge-WRITE ops add_link/remove_link stay out (separate trust call).
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(14);
// #2778 added add_timeline_entry (write, fenced like put_page via
// operations.ts:enforceSubagentSlugFence).
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(15);
expect(BRAIN_TOOL_ALLOWLIST.has('add_timeline_entry')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('query')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('search')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('get_page')).toBe(true);
+5
View File
@@ -30,6 +30,11 @@ describe('KNOWN_CONFIG_KEYS', () => {
expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent');
});
test('contains the dream synthesize timeout keys (#1594)', () => {
expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_timeout_ms');
expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_wait_timeout_ms');
});
test('contains the spend-control keys (v0.42.42.0, #2139) — no --force archaeology', () => {
expect(KNOWN_CONFIG_KEYS).toContain('spend.posture');
expect(KNOWN_CONFIG_KEYS).toContain('sync.cost_gate_min_usd');
+103
View File
@@ -0,0 +1,103 @@
/**
* #2782 — patterns phase status must reflect the child subagent outcome.
*
* Pre-fix, runPhasePatterns returned status:ok with child_outcome:timeout and
* zero pattern pages written (e.g. when no subagent-capable worker slot was
* free for the whole wait window) — a silent no-op for days.
*
* Drives the real phase against PGLite with the (#1594-family) configurable
* wait timeout set to 1ms and NO worker running, so the child job never
* completes: waitForCompletion throws TimeoutError → outcome 'timeout' →
* nothing written → the phase must report status 'fail', not 'ok'.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { runPhasePatterns } from '../src/core/cycle/patterns.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
let schemaVersion: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
// resetPgliteState truncates `config`, wiping the `version` row that
// MinionQueue.ensureSchema checks. Capture it so beforeEach can restore.
schemaVersion = (await engine.getConfig('version')) ?? '7';
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', schemaVersion);
});
async function seedReflections(): Promise<void> {
// Enough recent reflections to clear min_evidence (default 3).
for (let i = 0; i < 3; i++) {
await engine.executeRaw(
`INSERT INTO pages (slug, type, title, compiled_truth)
VALUES ($1, 'note', $2, $3)`,
[
`wiki/personal/reflections/2026-07-0${i + 1}-reflection`,
`Reflection ${i + 1}`,
`Recurring theme fixture number ${i + 1}.`,
],
);
}
}
describe('runPhasePatterns child-outcome status (#2782)', () => {
test('child timeout with zero writes → status fail (was silent ok)', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-'));
try {
await seedReflections();
// #1594-family knob: make the wait window elapse immediately. No
// minion worker runs in this test, so the child job stays queued.
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
expect(result.status).toBe('fail');
expect(result.details.child_outcome).toBe('timeout');
expect(result.details.patterns_written).toBe(0);
expect(result.error?.code).toBe('PATTERNS_CHILD_TIMEOUT');
expect(result.error?.class).toBe('Timeout');
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
}, 60_000);
test('dream.patterns.subagent_timeout_ms flows to the submitted job', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-timeout-'));
try {
await seedReflections();
await engine.setConfig('dream.patterns.subagent_timeout_ms', '600000');
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
const jobs = await engine.executeRaw<{ timeout_ms: string | number | null }>(
`SELECT timeout_ms FROM minion_jobs WHERE name = 'subagent' ORDER BY id DESC LIMIT 1`,
);
expect(jobs).toHaveLength(1);
expect(Number(jobs[0]!.timeout_ms)).toBe(600000);
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
}, 60_000);
});
+8 -3
View File
@@ -39,9 +39,14 @@ describe('patterns phase wiring', () => {
expect(patternsSrc).toContain("tool_name = 'brain_put_page'");
});
test('skips when ANTHROPIC_API_KEY missing', () => {
expect(patternsSrc).toContain('ANTHROPIC_API_KEY');
expect(patternsSrc).toContain('no_api_key');
test('gates on gateway provider reachability, not ANTHROPIC_API_KEY (PR #2279)', () => {
// The gate must probe the RESOLVED patterns model through the gateway
// (any configured provider can run patterns), not hardcode the Anthropic
// env var — that misclassified non-Anthropic stacks as "no upstream".
expect(patternsSrc).toContain('probeChatModel');
expect(patternsSrc).toContain('normalizeModelId');
expect(patternsSrc).toContain('no_provider');
expect(patternsSrc).not.toContain('process.env.ANTHROPIC_API_KEY');
});
test('skips when reflections below min_evidence', () => {
@@ -0,0 +1,83 @@
/**
* #1594 — dream synthesize subagent timeouts are config keys, not hardcoded
* 30/35-minute constants. Approach ported from PR #1596 (@ai920wisco).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { runPhaseSynthesize } from '../src/core/cycle/synthesize.ts';
let engine: PGLiteEngine;
let schemaVersion: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
// resetPgliteState truncates `config`, wiping the `version` row that
// MinionQueue.ensureSchema checks. Capture it so beforeEach can restore.
schemaVersion = (await engine.getConfig('version')) ?? '7';
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', schemaVersion);
});
async function seedWorthProcessingVerdict(
filePath: string,
content: string,
): Promise<void> {
const contentHash = createHash('sha256').update(content, 'utf8').digest('hex');
await engine.putDreamVerdict(filePath, contentHash, {
worth_processing: true,
reasons: ['seeded for timeout config test'],
});
}
describe('runPhaseSynthesize subagent timeout config', () => {
test('dream.synthesize.subagent_timeout_ms flows to submitted subagent job', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-synth-timeout-brain-'));
const corpusDir = mkdtempSync(join(tmpdir(), 'gbrain-synth-timeout-corpus-'));
try {
await engine.setConfig('dream.synthesize.enabled', 'true');
await engine.setConfig('dream.synthesize.session_corpus_dir', corpusDir);
await engine.setConfig('dream.synthesize.subagent_timeout_ms', '600000');
await engine.setConfig('dream.synthesize.subagent_wait_timeout_ms', '1');
const filePath = join(corpusDir, '2026-05-28-dense-transcript.txt');
const content = 'dense transcript line\n'.repeat(250);
writeFileSync(filePath, content);
await seedWorthProcessingVerdict(filePath, content);
const result = await runPhaseSynthesize(engine, {
brainDir,
dryRun: false,
});
expect(result.status).toBe('ok');
const jobs = await engine.executeRaw<{ timeout_ms: string | number | null }>(
`SELECT timeout_ms
FROM minion_jobs
WHERE name = 'subagent'
ORDER BY id DESC
LIMIT 1`,
);
expect(jobs).toHaveLength(1);
expect(Number(jobs[0]!.timeout_ms)).toBe(600000);
} finally {
rmSync(brainDir, { recursive: true, force: true });
rmSync(corpusDir, { recursive: true, force: true });
}
}, 30_000);
});
+12 -15
View File
@@ -9,7 +9,9 @@
* Anthropic call:
* - disabled: dream.patterns.enabled=false → skipped
* - insufficient_evidence: <min_evidence reflections → skipped
* - no_api_key: enough reflections, no ANTHROPIC_API_KEY → skipped
* - no_provider: enough reflections, no reachable provider for the
* resolved patterns model (default: Anthropic with no key in env OR
* config) → skipped
* - dry-run: passes through with reflections_considered + zero pages
*
* The Sonnet detection path is structurally covered in
@@ -23,6 +25,7 @@
import { describe, test, expect } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { runPhasePatterns } from '../../src/core/cycle/patterns.ts';
import { withoutAnthropicKey } from '../helpers/no-anthropic-key.ts';
interface TestRig {
engine: PGLiteEngine;
@@ -43,17 +46,6 @@ async function setupRig(): Promise<TestRig> {
};
}
async function withoutAnthropicKey<T>(body: () => Promise<T>): Promise<T> {
const saved = process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
try {
return await body();
} finally {
if (saved === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = saved;
}
}
/**
* Insert N reflection pages directly via engine.putPage so the patterns
* gather query has data without going through the synthesize phase.
@@ -141,18 +133,23 @@ describe('E2E patterns — insufficient_evidence', () => {
}, 30_000);
});
describe('E2E patterns — no API key', () => {
test('enough reflections, no ANTHROPIC_API_KEY → skipped no_api_key', async () => {
describe('E2E patterns — no reachable provider', () => {
test('enough reflections, no Anthropic key in env OR config → skipped no_provider', async () => {
const rig = await setupRig();
try {
await seedReflections(rig.engine, 5); // above default min_evidence (3)
// Default patterns model resolves to Anthropic; with no key reachable
// from EITHER source (env + config file — the shared helper neuters
// both) the gateway probe reports the provider unavailable. A
// non-Anthropic stack (litellm, deepseek, ...) passes this gate and
// dispatches through the gateway instead (PR #2279).
await withoutAnthropicKey(async () => {
const result = await runPhasePatterns(rig.engine, {
brainDir: rig.brainDir,
dryRun: false,
});
expect(result.status).toBe('skipped');
expect((result.details as { reason?: string }).reason).toBe('no_api_key');
expect((result.details as { reason?: string }).reason).toBe('no_provider');
});
} finally {
await rig.cleanup();
+132
View File
@@ -0,0 +1,132 @@
/**
* #2113 — facts extraction must not silently extract zero facts on truncation.
*
* Pre-fix, extractFactsFromTurn hardcoded maxTokens:1500 and never checked
* the finish reason. Mandatory-reasoning models spend thinking tokens inside
* the same cap, so the JSON payload got cut off, parse failed, and extraction
* returned [] with no signal.
*
* Post-fix: the cap is configurable (`facts.extraction_max_tokens`, default
* 4000), a stopReason:'length' response is retried once at double the cap,
* and a still-truncated retry is surfaced on stderr.
*
* Uses the gateway chat-transport test seam — no API key, no network.
*/
import { afterAll, describe, test, expect, beforeEach } from 'bun:test';
import {
configureGateway,
resetGateway,
__setChatTransportForTests,
} from '../src/core/ai/gateway.ts';
import type { ChatOpts, ChatResult } from '../src/core/ai/gateway.ts';
import {
extractFactsFromTurn,
getFactsExtractionMaxTokens,
DEFAULT_EXTRACTION_MAX_TOKENS,
} from '../src/core/facts/extract.ts';
import type { BrainEngine } from '../src/core/engine.ts';
beforeEach(() => {
resetGateway();
__setChatTransportForTests(null);
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
});
});
// Shard hygiene (same rationale as facts-extract-silent-no-op.test.ts):
// restore the legacy 1536-d embedding pin so later fresh-schema files in
// this shard don't inherit a dimensionless gateway.
afterAll(() => {
__setChatTransportForTests(null);
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
});
function chatResult(text: string, stopReason: ChatResult['stopReason']): ChatResult {
return {
text,
blocks: [{ type: 'text', text }],
stopReason,
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} as ChatResult;
}
const GOOD_JSON = '{"facts":[{"fact":"user gave up alcohol","kind":"commitment",' +
'"entity":null,"confidence":1.0,"notability":"high",' +
'"metric":null,"value":null,"unit":null,"period":null}]}';
describe('getFactsExtractionMaxTokens (#2113)', () => {
test('defaults to 4000 without an engine', async () => {
expect(await getFactsExtractionMaxTokens()).toBe(DEFAULT_EXTRACTION_MAX_TOKENS);
expect(DEFAULT_EXTRACTION_MAX_TOKENS).toBe(4000);
});
test('reads facts.extraction_max_tokens from the engine', async () => {
const engine = { getConfig: async () => '9000' } as unknown as BrainEngine;
expect(await getFactsExtractionMaxTokens(engine)).toBe(9000);
});
test('invalid config values fall back to the default', async () => {
for (const bad of ['garbage', '0', '-5', '']) {
const engine = { getConfig: async () => bad } as unknown as BrainEngine;
expect(await getFactsExtractionMaxTokens(engine)).toBe(DEFAULT_EXTRACTION_MAX_TOKENS);
}
});
});
describe('extractFactsFromTurn truncation handling (#2113)', () => {
test('default call carries maxTokens=4000 (was hardcoded 1500)', async () => {
const seen: ChatOpts[] = [];
__setChatTransportForTests(async (opts) => {
seen.push(opts);
return chatResult(GOOD_JSON, 'end');
});
const facts = await extractFactsFromTurn({
turnText: 'I gave up alcohol.',
source: 'test:truncation',
});
expect(seen).toHaveLength(1);
expect(seen[0]!.maxTokens).toBe(4000);
expect(facts).toHaveLength(1);
});
test("stopReason 'length' retries ONCE at double the cap and recovers the facts", async () => {
const seen: ChatOpts[] = [];
__setChatTransportForTests(async (opts) => {
seen.push(opts);
// First call: truncated garbage. Retry: full JSON.
return seen.length === 1
? chatResult('{"facts":[{"fact":"user gave up alco', 'length')
: chatResult(GOOD_JSON, 'end');
});
const facts = await extractFactsFromTurn({
turnText: 'I gave up alcohol.',
source: 'test:truncation',
});
expect(seen).toHaveLength(2);
expect(seen[1]!.maxTokens).toBe(seen[0]!.maxTokens! * 2);
expect(facts).toHaveLength(1);
expect(facts[0]!.fact).toContain('alcohol');
});
test('still-truncated retry does not retry again (bounded at one retry)', async () => {
let calls = 0;
__setChatTransportForTests(async () => {
calls++;
return chatResult('{"facts":[{"fac', 'length');
});
const facts = await extractFactsFromTurn({
turnText: 'I gave up alcohol.',
source: 'test:truncation',
});
expect(calls).toBe(2);
expect(facts).toEqual([]);
});
});
+89 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync } from 'fs';
import { writeFileSync, readFileSync, existsSync, mkdtempSync, rmSync, mkdirSync, symlinkSync, realpathSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
@@ -7,6 +7,7 @@ import {
saveCheckpoint,
resumeFilter,
clearCheckpoint,
resolveImportTargetDir,
type ImportCheckpoint,
} from '../src/core/import-checkpoint.ts';
@@ -52,6 +53,9 @@ describe('loadCheckpoint', () => {
test('returns null when dir mismatches the current run', () => {
const cp: ImportCheckpoint = {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '/other/brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T00:00:00Z',
@@ -86,6 +90,30 @@ describe('loadCheckpoint', () => {
expect(stderrCaptured).not.toContain('Older checkpoint format');
});
test('returns null when dir is relative (#1728 — CWD-dependent identity)', () => {
writeFileSync(cpPath, JSON.stringify({
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '.',
completedPaths: ['a.md'],
timestamp: '2026-01-01T00:00:00Z',
}));
expect(loadCheckpoint(cpPath, '.')).toBeNull();
});
test('returns null when self-describing metadata is wrong', () => {
writeFileSync(cpPath, JSON.stringify({
schema_version: 99,
owner: 'some-tool',
kind: 'other',
dir: '/tmp/example-brain',
completedPaths: ['a.md'],
timestamp: '2026-01-01T00:00:00Z',
}));
expect(loadCheckpoint(cpPath, '/tmp/example-brain')).toBeNull();
});
test('returns null when completedPaths contains non-strings', () => {
writeFileSync(cpPath, JSON.stringify({
dir: '/tmp/example-brain',
@@ -97,6 +125,9 @@ describe('loadCheckpoint', () => {
test('returns the checkpoint for valid v0.33.2 payload', () => {
const cp: ImportCheckpoint = {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '/tmp/example-brain',
completedPaths: ['meetings/2026-05-13.md', 'concepts/foo.md'],
timestamp: '2026-05-14T12:34:56Z',
@@ -107,12 +138,31 @@ describe('loadCheckpoint', () => {
expect(loaded?.dir).toBe('/tmp/example-brain');
expect(loaded?.completedPaths).toEqual(['meetings/2026-05-13.md', 'concepts/foo.md']);
expect(loaded?.timestamp).toBe('2026-05-14T12:34:56Z');
expect(loaded?.schema_version).toBe(1);
expect(loaded?.owner).toBe('gbrain');
expect(loaded?.kind).toBe('import');
});
test('returns legacy path-based checkpoint without metadata as v1 in memory', () => {
writeFileSync(cpPath, JSON.stringify({
dir: '/tmp/example-brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T12:34:56Z',
}));
const loaded = loadCheckpoint(cpPath, '/tmp/example-brain');
expect(loaded?.schema_version).toBe(1);
expect(loaded?.owner).toBe('gbrain');
expect(loaded?.kind).toBe('import');
expect(loaded?.dir).toBe('/tmp/example-brain');
});
});
describe('saveCheckpoint', () => {
test('round-trips through loadCheckpoint', () => {
const cp: ImportCheckpoint = {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '/tmp/example-brain',
completedPaths: ['a.md', 'b.md', 'c.md'],
timestamp: '2026-05-14T00:00:00Z',
@@ -125,16 +175,25 @@ describe('saveCheckpoint', () => {
test('serializes completedPaths sorted (deterministic output)', () => {
saveCheckpoint(cpPath, {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '/tmp/example-brain',
completedPaths: ['z.md', 'a.md', 'm.md'],
timestamp: '2026-05-14T00:00:00Z',
});
const onDisk = JSON.parse(readFileSync(cpPath, 'utf-8'));
expect(onDisk.schema_version).toBe(1);
expect(onDisk.owner).toBe('gbrain');
expect(onDisk.kind).toBe('import');
expect(onDisk.completedPaths).toEqual(['a.md', 'm.md', 'z.md']);
});
test('atomic-ish write — no stray .tmp file after success', () => {
saveCheckpoint(cpPath, {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '/tmp/example-brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T00:00:00Z',
@@ -148,6 +207,9 @@ describe('saveCheckpoint', () => {
const badPath = join(workDir, 'does-not-exist', 'cp.json');
expect(() =>
saveCheckpoint(badPath, {
schema_version: 1,
owner: 'gbrain',
kind: 'import',
dir: '/tmp/example-brain',
completedPaths: ['a.md'],
timestamp: '2026-05-14T00:00:00Z',
@@ -157,6 +219,32 @@ describe('saveCheckpoint', () => {
});
});
describe('resolveImportTargetDir', () => {
test('captures a relative import target as an absolute real path', () => {
const target = join(workDir, 'staging');
mkdirSync(target);
const cwd = process.cwd();
try {
process.chdir(workDir);
expect(resolveImportTargetDir('staging')).toBe(realpathSync(target));
} finally {
process.chdir(cwd);
}
});
test('collapses symlink spelling to the real import target', () => {
const target = join(workDir, 'real-staging');
const link = join(workDir, 'linked-staging');
mkdirSync(target);
symlinkSync(target, link);
expect(resolveImportTargetDir(link)).toBe(realpathSync(target));
});
test('throws when the target does not exist', () => {
expect(() => resolveImportTargetDir(join(workDir, 'nope'))).toThrow();
});
});
describe('resumeFilter', () => {
test('empty completed set returns all files unchanged', () => {
const all = ['a.md', 'b.md', 'c.md'];
+4 -2
View File
@@ -20,7 +20,7 @@
* `afterAll`) per CLAUDE.md test-isolation rules R3 + R4.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'fs';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync, realpathSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
@@ -52,7 +52,9 @@ beforeEach(async () => {
gbrainHomeDir = join(workspace, '.gbrain');
mkdirSync(gbrainHomeDir, { recursive: true });
cpPath = join(gbrainHomeDir, 'import-checkpoint.json');
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-import-resume-brain-'));
// #1728: realpath so planted checkpoints match runImport's canonicalized
// dir (macOS tmpdir is a /var → /private/var symlink).
brainDir = realpathSync(mkdtempSync(join(tmpdir(), 'gbrain-import-resume-brain-')));
});
afterEach(() => {
+5 -1
View File
@@ -178,7 +178,7 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
// dream.* — adding env shadows is a separate PR (out of scope for the
// fix wave). These tests pin that contract.
describe('dream.* DB-plane merge (v0.41.2.1)', () => {
test('DB value fills in for all 5 dream.synthesize.* keys when base unset', async () => {
test('DB value fills in for dream.synthesize.* keys when base unset', async () => {
const base: GBrainConfig = { engine: 'pglite' };
const engine = makeEngine({
'dream.synthesize.session_corpus_dir': '/tmp/sessions',
@@ -186,6 +186,8 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
'dream.synthesize.verdict_model': 'anthropic:claude-haiku-4-5',
'dream.synthesize.max_prompt_tokens': '180000',
'dream.synthesize.max_chunks_per_transcript': '32',
'dream.synthesize.subagent_timeout_ms': '600000',
'dream.synthesize.subagent_wait_timeout_ms': '900000',
});
const merged = await loadConfigWithEngine(engine, base);
expect(merged?.dream?.synthesize?.session_corpus_dir).toBe('/tmp/sessions');
@@ -193,6 +195,8 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
expect(merged?.dream?.synthesize?.verdict_model).toBe('anthropic:claude-haiku-4-5');
expect(merged?.dream?.synthesize?.max_prompt_tokens).toBe(180000);
expect(merged?.dream?.synthesize?.max_chunks_per_transcript).toBe(32);
expect(merged?.dream?.synthesize?.subagent_timeout_ms).toBe(600000);
expect(merged?.dream?.synthesize?.subagent_wait_timeout_ms).toBe(900000);
});
test('DB value fills in for both dream.patterns.* keys when base unset', async () => {
@@ -0,0 +1,86 @@
/**
* Non-batch config accessors must self-heal the same way the batch path does
* (takeover of PR #1891 by @jalagrange; #1593 follow-up).
*
* The config accessors touch `this.sql` directly. When an instance pool is
* torn down mid-cycle the getter throws a RETRYABLE "No database connection"
* (issue #1678) by design, so a withRetry+reconnect caller can rebuild the
* pool and recover. `getConfig` got that wrapper in #1603; `setConfig`,
* `unsetConfig`, and `listConfigKeys` did not — so the first config write or
* list after a mid-cycle disconnect threw unhandled. This pins that ALL four
* accessors now reconnect + retry, and that non-retryable errors are NOT
* masked by a reconnect.
*
* Pure: pokes private fields and stubs `reconnect` to simulate the pool
* rebuild; no real DB.
*/
import { describe, it, expect } from 'bun:test';
import { PostgresEngine } from '../src/core/postgres-engine.ts';
// A tagged-template-callable fake `sql` that resolves to the given value.
function fakeSql(result: unknown) {
return (..._args: unknown[]) => Promise.resolve(result);
}
// Near-instant retry delays so the inter-attempt sleep does not slow the test.
// Shape matches `resolveBulkRetryOpts()` (the getBulkRetryOpts cache type).
const FAST_RETRY = { maxRetries: 3, delayMs: 1, delayMaxMs: 1, jitter: 'none' as const };
/** Engine with a torn-down instance pool; reconnect installs `poolResult`. */
function makeTornDownEngine(poolResult: unknown): { engine: PostgresEngine; reconnects: () => number } {
const e = new PostgresEngine();
(e as unknown as { _connectionStyle: string })._connectionStyle = 'instance';
(e as unknown as { _sql: unknown })._sql = null; // instance pool torn down → getter throws retryable
(e as unknown as { _bulkRetryOptsCache: unknown })._bulkRetryOptsCache = FAST_RETRY;
let reconnectCalls = 0;
(e as unknown as { reconnect: () => Promise<void> }).reconnect = async () => {
reconnectCalls++;
(e as unknown as { _sql: unknown })._sql = fakeSql(poolResult);
};
return { engine: e, reconnects: () => reconnectCalls };
}
describe('PostgresEngine non-batch config accessors self-heal (PR #1891 takeover)', () => {
it('getConfig reconnects + retries a null instance pool, then returns the value', async () => {
const { engine, reconnects } = makeTornDownEngine([{ value: 'live-value' }]);
expect(await engine.getConfig('some.key')).toBe('live-value');
expect(reconnects()).toBe(1); // exactly one reconnect closed the gap
});
it('setConfig reconnects + retries a null instance pool (idempotent upsert)', async () => {
const { engine, reconnects } = makeTornDownEngine([]);
await engine.setConfig('some.key', 'v');
expect(reconnects()).toBe(1);
});
it('unsetConfig reconnects + retries a null instance pool, then returns the count', async () => {
const { engine, reconnects } = makeTornDownEngine({ count: 2 });
expect(await engine.unsetConfig('some.key')).toBe(2);
expect(reconnects()).toBe(1);
});
it('listConfigKeys reconnects + retries a null instance pool, then returns keys', async () => {
const { engine, reconnects } = makeTornDownEngine([{ key: 'a.one' }, { key: 'a.two' }]);
expect(await engine.listConfigKeys('a.')).toEqual(['a.one', 'a.two']);
expect(reconnects()).toBe(1);
});
it('surfaces a non-retryable error without reconnecting (no masking)', async () => {
const e = new PostgresEngine();
(e as unknown as { _connectionStyle: string })._connectionStyle = 'instance';
(e as unknown as { _bulkRetryOptsCache: unknown })._bulkRetryOptsCache = FAST_RETRY;
// A live pool whose query throws a NON-retryable (non-connection) error.
(e as unknown as { _sql: unknown })._sql = () =>
Promise.reject(new Error('syntax error at or near "SLECT"'));
let reconnectCalls = 0;
(e as unknown as { reconnect: () => Promise<void> }).reconnect = async () => {
reconnectCalls++;
};
await expect(e.getConfig('k')).rejects.toThrow('syntax error');
await expect(e.setConfig('k', 'v')).rejects.toThrow('syntax error');
expect(reconnectCalls).toBe(0); // non-retryable → no reconnect, error not masked
});
});
+112
View File
@@ -596,3 +596,115 @@ describe('makeSubagentHandler default client construction', () => {
expect(result.result).toBe('ok');
});
});
// ── #2778: per-turn output-token cap + max_tokens stop handling ─────
import { resolveMaxOutputTokens } from '../src/core/minions/handlers/subagent.ts';
describe('resolveMaxOutputTokens (#2778)', () => {
test('defaults to 8192 when nothing set', () => {
expect(resolveMaxOutputTokens(undefined, null)).toBe(8192);
expect(resolveMaxOutputTokens(undefined, undefined)).toBe(8192);
});
test('per-job value wins over config', () => {
expect(resolveMaxOutputTokens(2048, '5000')).toBe(2048);
});
test('config value used when per-job unset', () => {
expect(resolveMaxOutputTokens(undefined, '5000')).toBe(5000);
});
test('invalid values fall through to next tier', () => {
expect(resolveMaxOutputTokens(0, '5000')).toBe(5000);
expect(resolveMaxOutputTokens(-1, null)).toBe(8192);
expect(resolveMaxOutputTokens(Number.NaN, 'garbage')).toBe(8192);
expect(resolveMaxOutputTokens(undefined, '')).toBe(8192);
expect(resolveMaxOutputTokens(undefined, '0')).toBe(8192);
});
});
describe('subagent handler output-token cap (#2778)', () => {
test('default: SDK call carries max_tokens=8192 (was hardcoded 4096)', async () => {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
await handler(ctx);
expect(client.calls[0]!.max_tokens).toBe(8192);
});
test('data.max_tokens flows to the SDK call', async () => {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi', max_tokens: 2048 });
await handler(ctx);
expect(client.calls[0]!.max_tokens).toBe(2048);
});
test('agent.max_output_tokens config flows to the SDK call', async () => {
await engine.setConfig('agent.max_output_tokens', '5000');
try {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
await handler(ctx);
expect(client.calls[0]!.max_tokens).toBe(5000);
} finally {
await engine.executeRaw(`DELETE FROM config WHERE key = 'agent.max_output_tokens'`);
}
});
test('final turn hitting the cap surfaces stop_reason=max_tokens, not a silent end_turn', async () => {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'truncated tex' }] as any, stop_reason: 'max_tokens' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
const result = await handler(ctx);
expect(result.stop_reason).toBe('max_tokens');
expect(result.result).toBe('truncated tex');
});
test('max_tokens stop with tool_use: truncation note injected so the model re-issues the dropped call', async () => {
const tool = makeEchoTool();
const client = new FakeMessagesClient([
{
// A complete tool_use survived, but the turn stopped on max_tokens —
// the API dropped whatever came after (e.g. a big put_page call).
content: [{ type: 'tool_use', id: 'tu_1', name: 'echo', input: { value: 'v1' } } as any],
stop_reason: 'max_tokens' as any,
},
{ content: [{ type: 'text', text: 'recovered' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
const ctx = await makeCtx({ prompt: 'go' });
const result = await handler(ctx);
expect(result.stop_reason).toBe('end_turn');
expect(result.result).toBe('recovered');
// The synthesized user turn (persisted + fed to the second call) must
// carry the truncation note alongside the tool_result. Assert on the
// persisted row — client.calls[].messages is the live array the loop
// keeps mutating, so positional checks there are unreliable.
const rows = await engine.executeRaw<{ content_blocks: unknown }>(
`SELECT content_blocks FROM subagent_messages
WHERE job_id = $1 AND role = 'user' AND message_idx > 0
ORDER BY message_idx ASC`,
[ctx.id],
);
expect(rows.length).toBe(1);
const blocks = (typeof rows[0]!.content_blocks === 'string'
? JSON.parse(rows[0]!.content_blocks as string)
: rows[0]!.content_blocks) as Array<{ type: string; text?: string }>;
expect(blocks.some(b => b.type === 'tool_result')).toBe(true);
const texts = blocks.filter(b => b.type === 'text').map(b => b.text ?? '');
expect(texts.some(t => t.includes('truncated') && t.includes('DROPPED'))).toBe(true);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* #2778 — add_timeline_entry subagent slug fence.
*
* add_timeline_entry joined the subagent brain-tool allowlist, so it must be
* confined exactly like put_page: when ctx.viaSubagent=true the target slug
* must match the trusted-workspace allow-list (when set) or the legacy
* wiki/agents/<subagentId>/ namespace, fail-closed on a missing subagentId.
* Non-subagent callers (CLI, plain MCP) are unchanged.
*
* Uses dryRun ctxs — the fence runs BEFORE the dry-run short-circuit, so no
* engine is needed (same pattern as test/put-page-namespace.test.ts).
*/
import { describe, test, expect } from 'bun:test';
import { operations, OperationError } from '../src/core/operations.ts';
import type { OperationContext, Operation } from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
const add_timeline_entry = operations.find(o => o.name === 'add_timeline_entry') as Operation;
if (!add_timeline_entry) throw new Error('add_timeline_entry op missing');
const ENTRY = { date: '2026-07-01', summary: 'test entry' };
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
const engine = {} as BrainEngine; // dry_run short-circuits before touching the engine
return {
engine,
config: { engine: 'postgres' } as any,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: true,
remote: true,
sourceId: 'default',
...overrides,
};
}
describe('add_timeline_entry subagent fence (#2778)', () => {
describe('regression: non-subagent callers unchanged', () => {
test('local CLI write (viaSubagent undefined) accepts arbitrary slug', async () => {
const ctx = makeCtx({ remote: false });
const result = await add_timeline_entry.handler(ctx, { slug: 'people/alice-example', ...ENTRY });
expect(result).toMatchObject({ dry_run: true, action: 'add_timeline_entry', slug: 'people/alice-example' });
});
test('MCP write (remote=true, viaSubagent=undefined) accepts arbitrary slug', async () => {
const ctx = makeCtx({ remote: true });
const result = await add_timeline_entry.handler(ctx, { slug: 'companies/acme-example', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
test('viaSubagent=false is the same as unset', async () => {
const ctx = makeCtx({ viaSubagent: false, subagentId: 42 });
const result = await add_timeline_entry.handler(ctx, { slug: 'anything/goes', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
});
describe('legacy namespace confinement', () => {
test('accepts wiki/agents/<subagentId>/ prefix', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 42 });
const result = await add_timeline_entry.handler(ctx, { slug: 'wiki/agents/42/notes', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
test('rejects a slug outside the namespace', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 42 });
const p = add_timeline_entry.handler(ctx, { slug: 'people/alice-example', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/add_timeline_entry/);
});
test('rejects prefix-collision attempt (wiki/agents/12evil/* with subagentId=12)', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 12 });
const p = add_timeline_entry.handler(ctx, { slug: 'wiki/agents/12evil/foo', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
});
test('FAIL-CLOSED: viaSubagent=true with undefined subagentId rejects any slug', async () => {
const ctx = makeCtx({ viaSubagent: true });
const p = add_timeline_entry.handler(ctx, { slug: 'wiki/agents/42/foo', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/subagentId/);
});
});
describe('trusted-workspace allow-list', () => {
const allow = ['wiki/personal/patterns/*', 'wiki/originals/*'];
test('accepts a slug inside the allow-list', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 7, allowedSlugPrefixes: allow });
const result = await add_timeline_entry.handler(ctx, { slug: 'wiki/personal/patterns/topic-x', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
test('rejects a slug outside the allow-list (even inside the legacy namespace)', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 7, allowedSlugPrefixes: allow });
const p = add_timeline_entry.handler(ctx, { slug: 'wiki/agents/7/notes', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/allow-list/);
});
});
});