mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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".',
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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'];
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user