fix(subagent): orchestration fix-wave G — configurable timeouts/caps, honest child outcomes, fenced timeline writes

Four verified-open issues in the subagent-orchestration family, one PR:

- #1594: dream synthesize subagent job/wait timeouts promoted from
  hardcoded 30/35-min constants to config keys
  dream.synthesize.subagent_timeout_ms / subagent_wait_timeout_ms.
  Approach ported from stale PR #1596 (credit @ai920wisco).
- #2778: add_timeline_entry joins the subagent brain-tool allowlist,
  fenced fail-closed by the shared enforceSubagentSlugFence (extracted
  from put_page's inline check — same trusted-workspace allow-list /
  wiki/agents/<id>/ namespace policy). The per-turn output cap is now
  resolveMaxOutputTokens (data.max_tokens → agent.max_output_tokens →
  8192, was hardcoded 4096); a max_tokens stop surfaces as
  stop_reason 'max_tokens' instead of a silent end_turn, and a
  mid-tool-round cap hit injects a truncation note so the model
  re-issues the dropped call.
- #2782: patterns phase status now reflects the child outcome —
  non-complete outcome with zero writes → fail (PATTERNS_CHILD_<OUTCOME>),
  partial writes → warn. Patterns timeouts get the same config-key pair
  (dream.patterns.subagent_timeout_ms / subagent_wait_timeout_ms).
- #2113: facts extraction cap is config facts.extraction_max_tokens
  (default 4000, was hardcoded 1500); stopReason 'length' is checked,
  retried once at 2x the cap, and surfaced on stderr instead of
  silently extracting zero facts.

Co-authored-by: ai920wisco <ai920wisco@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-17 14:18:08 -07:00
co-authored by ai920wisco Claude Fable 5
parent 3aeb622dc7
commit 251f35d490
17 changed files with 825 additions and 60 deletions
File diff suppressed because one or more lines are too long
+26
View File
@@ -271,6 +271,8 @@ export interface GBrainConfig {
verdict_model?: string;
max_prompt_tokens?: number;
max_chunks_per_transcript?: number;
subagent_timeout_ms?: number;
subagent_wait_timeout_ms?: number;
};
patterns?: {
lookback_days?: number;
@@ -710,6 +712,12 @@ export async function loadConfigWithEngine(
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
async function dbNum(key: string): Promise<number | undefined> {
const v = await dbStr(key);
if (v === undefined) return undefined;
const n = Number(v);
return Number.isNaN(n) ? undefined : n;
}
const dbWarnBytes = await dbInt('content_sanity.bytes_warn');
const dbBlockBytes = await dbInt('content_sanity.bytes_block');
const dbJunkEnabled = await dbBool('content_sanity.junk_patterns_enabled');
@@ -759,6 +767,8 @@ export async function loadConfigWithEngine(
const dbVerdictModel = await dbStr('dream.synthesize.verdict_model');
const dbMaxPromptTokens = await dbInt('dream.synthesize.max_prompt_tokens');
const dbMaxChunksPerTranscript = await dbInt('dream.synthesize.max_chunks_per_transcript');
const dbSubagentTimeoutMs = await dbNum('dream.synthesize.subagent_timeout_ms');
const dbSubagentWaitTimeoutMs = await dbNum('dream.synthesize.subagent_wait_timeout_ms');
const dbLookbackDays = await dbInt('dream.patterns.lookback_days');
const dbMinEvidence = await dbInt('dream.patterns.min_evidence');
@@ -783,6 +793,12 @@ export async function loadConfigWithEngine(
if (mergedSynth.max_chunks_per_transcript === undefined && dbMaxChunksPerTranscript !== undefined) {
mergedSynth.max_chunks_per_transcript = dbMaxChunksPerTranscript;
}
if (mergedSynth.subagent_timeout_ms === undefined && dbSubagentTimeoutMs !== undefined) {
mergedSynth.subagent_timeout_ms = dbSubagentTimeoutMs;
}
if (mergedSynth.subagent_wait_timeout_ms === undefined && dbSubagentWaitTimeoutMs !== undefined) {
mergedSynth.subagent_wait_timeout_ms = dbSubagentWaitTimeoutMs;
}
if (mergedPatterns.lookback_days === undefined && dbLookbackDays !== undefined) {
mergedPatterns.lookback_days = dbLookbackDays;
}
@@ -854,6 +870,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// subagent handler's error message tells users to `config set` this, so it
// must be a known key or `config set` rejects it without --force.
'agent.use_gateway_loop',
// #2778: per-turn output-token cap for the subagent loop (default 8192).
'agent.max_output_tokens',
// DB-plane (v0.32.3 search modes + related)
'search.mode',
'search.cache.enabled',
@@ -888,6 +906,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'models.chat',
'models.eval.longmemeval',
'facts.extraction_model',
// #2113: output-token cap for the per-turn facts extractor (default 4000).
'facts.extraction_max_tokens',
// Dream cycle config
'dream.synthesize.session_corpus_dir',
'dream.synthesize.meeting_transcripts_dir',
@@ -895,8 +915,14 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'dream.synthesize.verdict_model',
'dream.synthesize.max_prompt_tokens',
'dream.synthesize.max_chunks_per_transcript',
'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',
+58 -4
View File
@@ -83,7 +83,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,
@@ -92,7 +92,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;
@@ -113,13 +113,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)));
@@ -135,6 +169,20 @@ interface PatternsConfig {
lookbackDays: number;
minEvidence: number;
model: 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> {
@@ -155,6 +203,12 @@ async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig>
lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30,
minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3,
model,
subagentTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS,
),
subagentWaitTimeoutMs: await getNumberConfig(
engine, 'dream.patterns.subagent_wait_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_WAIT_TIMEOUT_MS,
),
};
}
+29 -2
View File
@@ -75,6 +75,8 @@ const MIN_PROMPT_TOKENS = 100_000;
const DEFAULT_MAX_CHUNKS = 24;
/** Conservative default budget when model is unknown (200K × HEADROOM_RATIO). */
const UNKNOWN_MODEL_BUDGET_TOKENS = 180_000;
const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS = 35 * 60 * 1000;
/**
* Compute per-chunk character budget for the resolved model + config override.
@@ -478,7 +480,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',
@@ -499,7 +501,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 });
@@ -593,6 +595,8 @@ interface SynthConfig {
* `dream.synthesize.max_chunks_per_transcript`.
*/
maxChunksPerTranscript: number;
subagentTimeoutMs: number;
subagentWaitTimeoutMs: number;
}
async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
@@ -621,6 +625,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) {
@@ -658,9 +672,22 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
cooldownHours: cooldownHoursStr ? Math.max(0, parseInt(cooldownHoursStr, 10) || 12) : 12,
maxPromptTokens,
maxChunksPerTranscript,
subagentTimeoutMs,
subagentWaitTimeoutMs,
};
}
async function getNumberConfig(
engine: BrainEngine,
key: string,
fallback: number,
): Promise<number> {
const raw = await engine.getConfig(key);
if (raw === undefined || raw === null) return fallback;
const value = Number(raw);
return Number.isNaN(value) ? fallback : value;
}
async function checkCooldown(
engine: BrainEngine,
hours: number,
+51 -12
View File
@@ -67,6 +67,23 @@ export async function getFactsExtractionModel(engine?: BrainEngine): Promise<str
return normalizeModelId(resolved);
}
/**
* #2113: output-token cap for the extractor call. The pre-fix hardcoded 1500
* silently truncated output on mandatory-reasoning models (thinking tokens
* count toward the cap), so the JSON never parsed and extraction returned
* zero facts with no signal. Configurable via
* `gbrain config set facts.extraction_max_tokens <n>`; default 4000.
*/
export const DEFAULT_EXTRACTION_MAX_TOKENS = 4000;
export async function getFactsExtractionMaxTokens(engine?: BrainEngine): Promise<number> {
if (!engine) return DEFAULT_EXTRACTION_MAX_TOKENS;
const raw = await engine.getConfig('facts.extraction_max_tokens').catch(() => null);
if (raw == null || raw.trim() === '') return DEFAULT_EXTRACTION_MAX_TOKENS;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_EXTRACTION_MAX_TOKENS;
}
export const ALL_EXTRACT_KINDS: readonly FactKind[] = [
'event', 'preference', 'commitment', 'belief', 'fact',
] as const;
@@ -164,24 +181,46 @@ export async function extractFactsFromTurn(input: ExtractInput): Promise<Extract
const cap = Math.max(1, Math.min(input.maxFactsPerTurn ?? 10, 25));
const defaultModel = await getFactsExtractionModel(input.engine);
const maxTokens = await getFactsExtractionMaxTokens(input.engine);
const model = input.model ?? defaultModel;
const userContent = `<turn>\n${cleaned}\n</turn>\n\nExtract up to ${cap} facts.${
input.entityHints && input.entityHints.length
? ` Known entity slugs the user already mentioned: ${input.entityHints.slice(0, 5).join(', ')}.`
: ''
}`;
let result: ChatResult;
try {
result = await chat({
model: input.model ?? defaultModel,
model,
system: EXTRACTOR_SYSTEM,
messages: [
{
role: 'user',
content: `<turn>\n${cleaned}\n</turn>\n\nExtract up to ${cap} facts.${
input.entityHints && input.entityHints.length
? ` Known entity slugs the user already mentioned: ${input.entityHints.slice(0, 5).join(', ')}.`
: ''
}`,
},
],
maxTokens: 1500,
messages: [{ role: 'user', content: userContent }],
maxTokens,
abortSignal: input.abortSignal,
});
// #2113: never checked pre-fix — a truncated response (stopReason
// 'length', e.g. reasoning tokens eating the cap on mandatory-reasoning
// models) produced unparseable JSON and silently extracted zero facts.
// Retry ONCE at double the cap, then surface the truncation loudly.
if (result.stopReason === 'length') {
process.stderr.write(
`[facts-extract] WARN: extractor output truncated at maxTokens=${maxTokens} ` +
`(model=${model}); retrying once at ${maxTokens * 2}\n`,
);
result = await chat({
model,
system: EXTRACTOR_SYSTEM,
messages: [{ role: 'user', content: userContent }],
maxTokens: maxTokens * 2,
abortSignal: input.abortSignal,
});
if (result.stopReason === 'length') {
process.stderr.write(
`[facts-extract] WARN: extractor output STILL truncated at maxTokens=${maxTokens * 2} ` +
`(model=${model}); facts for this turn are likely lost. ` +
`Raise the cap: gbrain config set facts.extraction_max_tokens <n>\n`,
);
}
}
} catch (err) {
// Re-throw aborts; absorb other errors as "no extraction" — caller's
// `put_page` backstop will still record the page itself.
+54 -3
View File
@@ -58,8 +58,29 @@ import { randomUUIDv7 } from 'bun';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
const DEFAULT_MAX_TURNS = 20;
const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
const DEFAULT_RATE_KEY = 'anthropic:messages';
/**
* Resolve the per-turn output-token cap (#2778). Per-job data wins, then the
* `agent.max_output_tokens` config row, then the 8192 default (was a
* hardcoded 4096 that made pages >~12KB unwritable via put_page). Invalid
* values (NaN / zero / negative) fall through to the next tier.
*/
export function resolveMaxOutputTokens(
perJob: number | undefined,
configRaw: string | null | undefined,
): number {
if (typeof perJob === 'number' && Number.isFinite(perJob) && perJob > 0) {
return Math.floor(perJob);
}
if (typeof configRaw === 'string' && configRaw.trim() !== '') {
const n = Number(configRaw);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
return DEFAULT_MAX_OUTPUT_TOKENS;
}
/**
* Resolve the rate-lease cap from the env var.
*
@@ -212,6 +233,11 @@ export function makeSubagentHandler(deps: SubagentDeps) {
fallback: TIER_DEFAULTS.subagent,
});
const maxTurns = data.max_turns ?? DEFAULT_MAX_TURNS;
// #2778: per-turn output cap — data.max_tokens → config → 8192 default.
const maxOutputTokens = resolveMaxOutputTokens(
data.max_tokens,
await engine.getConfig('agent.max_output_tokens').catch(() => null),
);
// v0.41 Approach C: systemPrompt is now built AFTER toolDefs (a few
// lines below) so the renderer can splice a tool-usage preamble
// listing each available tool's usage_hint. The renderer is
@@ -277,6 +303,7 @@ export function makeSubagentHandler(deps: SubagentDeps) {
systemPrompt,
toolDefs,
maxTurns,
maxOutputTokens,
});
}
@@ -535,7 +562,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,
@@ -628,7 +655,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')
@@ -741,6 +771,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++;
@@ -776,6 +824,8 @@ interface GatewayRunArgs {
systemPrompt: string;
toolDefs: ToolDef[];
maxTurns: number;
/** #2778: per-turn output-token cap (resolved by resolveMaxOutputTokens). */
maxOutputTokens: number;
}
/**
@@ -793,7 +843,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.
@@ -917,6 +967,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
@@ -61,6 +61,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
@@ -97,6 +103,7 @@ export const BRAIN_TOOL_USAGE_HINTS: Readonly<Record<string, string>> = {
resolve_slugs: 'Resolve free-form entity names to canonical slugs (e.g. "Alice" → `people/alice-example`). Use before any tool that takes a slug if the user gave a name not a slug.',
get_ingest_log: 'Read the brain ingestion log for diagnostic / verification queries.',
put_page: 'Write a markdown page to the gbrain DATABASE (NOT the local filesystem). Page becomes searchable + linkable. Slug must match the agent\'s allowed namespace.',
add_timeline_entry: 'Append a dated timeline entry to an existing page (the canonical timeline write). Use over rewriting the page body when recording a dated event. Slug must match the agent\'s allowed namespace.',
get_recent_salience: 'Read pages ranked by emotional + activity salience over a recency window. Use for "what\'s been on my mind lately".',
find_anomalies: 'Read cohort-level activity outliers (e.g. tag-cohort or type-cohort with unusual recent volume). Use for "what\'s unusual lately".',
};
+7
View File
@@ -411,6 +411,12 @@ export interface SubagentHandlerData {
model?: string;
/** Max assistant turns before the loop fails with stop_reason='max_turns'. */
max_turns?: number;
/**
* Per-turn max output tokens (#2778). Resolution: this field →
* `agent.max_output_tokens` config → 8192 default. The pre-#2778
* hardcoded 4096 made pages >~12KB unwritable via put_page.
*/
max_tokens?: number;
/**
* Whitelist of tool names the agent may call. MUST be a subset of the
* derived registry names — invalid entries are rejected at tool-dispatch
@@ -562,6 +568,7 @@ export type ContentBlock =
export type SubagentStopReason =
| 'end_turn' // Anthropic says end_turn and last message has no tool_use
| 'max_turns' // hit max_turns budget before end_turn
| 'max_tokens' // final turn hit the output-token cap — result text is TRUNCATED (#2778)
| 'refusal' // detected via stop_reason + content shape
| 'error'; // unrecoverable (empty response retry exhausted, etc.)
+41 -31
View File
@@ -193,6 +193,39 @@ export function matchesSlugAllowList(slug: string, prefixes: readonly string[]):
return false;
}
/**
* Subagent slug-fence enforcement, shared by every mutating op a subagent
* can reach (put_page, add_timeline_entry). FAIL-CLOSED: `viaSubagent=true`
* enforces the check even if the dispatcher forgot to populate `subagentId`.
*
* - Trusted-workspace path (ctx.allowedSlugPrefixes set by cycle.ts under
* PROTECTED_JOB_NAMES \u2014 MCP cannot reach it): slug must match the
* allow-list globs.
* - Legacy default: slug must live under `wiki/agents/<subagentId>/...`
* (anchored, slash-boundary \u2014 `wiki/agents/12evil/*` can't impersonate
* subagent 12).
*/
function enforceSubagentSlugFence(ctx: OperationContext, slug: string, opName: string): void {
if (ctx.viaSubagent !== true) return;
if (typeof ctx.subagentId !== 'number' || Number.isNaN(ctx.subagentId)) {
throw new OperationError('permission_denied', `${opName} via subagent requires ctx.subagentId`);
}
const allowList = ctx.allowedSlugPrefixes;
if (allowList && allowList.length > 0) {
if (!matchesSlugAllowList(slug, allowList)) {
throw new OperationError(
'permission_denied',
`${opName} slug '${slug}' is not within the trusted-workspace allow-list (${allowList.join(', ')})`
);
}
} else {
const prefix = `wiki/agents/${ctx.subagentId}/`;
if (!slug.startsWith(prefix) || slug.length === prefix.length) {
throw new OperationError('permission_denied', `${opName} via subagent must write under '${prefix}...'`);
}
}
}
/**
* Allowlist validator for uploaded file basenames. Rejects control chars, backslashes,
* RTL overrides (\u202E), leading dot (hidden files) and leading dash (CLI flag confusion).
@@ -784,37 +817,9 @@ const put_page: Operation = {
}
// Subagent namespace enforcement (v0.15+). Runs BEFORE the dry-run
// short-circuit so preview calls surface the same rejection. Confines
// LLM-driven writes to wiki/agents/<subagentId>/... — no leading slash
// (slug grammar rejects that), anchored, slash-boundary to defeat prefix
// collisions like `wiki/agents/12evil/*` impersonating subagent 12.
//
// FAIL-CLOSED: `viaSubagent=true` enforces the check even if the
// dispatcher forgot to populate `subagentId`. Agent-originated writes
// without an owning subagent id are rejected outright.
if (ctx.viaSubagent === true) {
if (typeof ctx.subagentId !== 'number' || Number.isNaN(ctx.subagentId)) {
throw new OperationError('permission_denied', 'put_page via subagent requires ctx.subagentId');
}
const allowList = ctx.allowedSlugPrefixes;
if (allowList && allowList.length > 0) {
// Trusted-workspace path: explicit allow-list bounds writes.
// Set only by cycle.ts (synthesize/patterns) which submits subagent
// jobs under PROTECTED_JOB_NAMES — MCP cannot reach this branch.
if (!matchesSlugAllowList(slug, allowList)) {
throw new OperationError(
'permission_denied',
`put_page slug '${slug}' is not within the trusted-workspace allow-list (${allowList.join(', ')})`
);
}
} else {
// Legacy default: agent-namespace confinement.
const prefix = `wiki/agents/${ctx.subagentId}/`;
if (!slug.startsWith(prefix) || slug.length === prefix.length) {
throw new OperationError('permission_denied', `put_page via subagent must write under '${prefix}...'`);
}
}
}
// short-circuit so preview calls surface the same rejection. See
// enforceSubagentSlugFence for the fail-closed policy.
enforceSubagentSlugFence(ctx, slug, 'put_page');
if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug };
// Skip embedding when the AI gateway has no embedding provider configured.
@@ -2149,6 +2154,11 @@ const add_timeline_entry: Operation = {
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
// #2778: same fail-closed slug fence as put_page. add_timeline_entry is
// subagent-allowlisted (brain-allowlist.ts), so timeline writes must be
// confined to the same namespace/allow-list as page writes. Runs before
// the dry-run short-circuit so preview calls surface the same rejection.
enforceSubagentSlugFence(ctx, p.slug as string, 'add_timeline_entry');
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
const date = p.date as string;
// Reject anything that isn't a strict YYYY-MM-DD with year 1900-2199 and
+4 -1
View File
@@ -50,7 +50,10 @@ describe('BRAIN_TOOL_ALLOWLIST', () => {
// have ctx.remote=true, and the v0.29 trust gate rejects remote callers.
// v114 (#1941) added list_link_sources (read-only provenance discovery);
// the edge-WRITE ops add_link/remove_link stay out (separate trust call).
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(14);
// #2778 added add_timeline_entry (write, fenced like put_page via
// operations.ts:enforceSubagentSlugFence).
expect(BRAIN_TOOL_ALLOWLIST.size).toBe(15);
expect(BRAIN_TOOL_ALLOWLIST.has('add_timeline_entry')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('query')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('search')).toBe(true);
expect(BRAIN_TOOL_ALLOWLIST.has('get_page')).toBe(true);
+5
View File
@@ -30,6 +30,11 @@ describe('KNOWN_CONFIG_KEYS', () => {
expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent');
});
test('contains the dream synthesize timeout keys (#1594)', () => {
expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_timeout_ms');
expect(KNOWN_CONFIG_KEYS).toContain('dream.synthesize.subagent_wait_timeout_ms');
});
test('contains the spend-control keys (v0.42.42.0, #2139) — no --force archaeology', () => {
expect(KNOWN_CONFIG_KEYS).toContain('spend.posture');
expect(KNOWN_CONFIG_KEYS).toContain('sync.cost_gate_min_usd');
+103
View File
@@ -0,0 +1,103 @@
/**
* #2782 — patterns phase status must reflect the child subagent outcome.
*
* Pre-fix, runPhasePatterns returned status:ok with child_outcome:timeout and
* zero pattern pages written (e.g. when no subagent-capable worker slot was
* free for the whole wait window) — a silent no-op for days.
*
* Drives the real phase against PGLite with the (#1594-family) configurable
* wait timeout set to 1ms and NO worker running, so the child job never
* completes: waitForCompletion throws TimeoutError → outcome 'timeout' →
* nothing written → the phase must report status 'fail', not 'ok'.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { runPhasePatterns } from '../src/core/cycle/patterns.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
let schemaVersion: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ database_url: '' });
await engine.initSchema();
// resetPgliteState truncates `config`, wiping the `version` row that
// MinionQueue.ensureSchema checks. Capture it so beforeEach can restore.
schemaVersion = (await engine.getConfig('version')) ?? '7';
}, 60_000);
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', schemaVersion);
});
async function seedReflections(): Promise<void> {
// Enough recent reflections to clear min_evidence (default 3).
for (let i = 0; i < 3; i++) {
await engine.executeRaw(
`INSERT INTO pages (slug, type, title, compiled_truth)
VALUES ($1, 'note', $2, $3)`,
[
`wiki/personal/reflections/2026-07-0${i + 1}-reflection`,
`Reflection ${i + 1}`,
`Recurring theme fixture number ${i + 1}.`,
],
);
}
}
describe('runPhasePatterns child-outcome status (#2782)', () => {
test('child timeout with zero writes → status fail (was silent ok)', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-'));
try {
await seedReflections();
// #1594-family knob: make the wait window elapse immediately. No
// minion worker runs in this test, so the child job stays queued.
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
expect(result.status).toBe('fail');
expect(result.details.child_outcome).toBe('timeout');
expect(result.details.patterns_written).toBe(0);
expect(result.error?.code).toBe('PATTERNS_CHILD_TIMEOUT');
expect(result.error?.class).toBe('Timeout');
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
}, 60_000);
test('dream.patterns.subagent_timeout_ms flows to the submitted job', async () => {
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-timeout-'));
try {
await seedReflections();
await engine.setConfig('dream.patterns.subagent_timeout_ms', '600000');
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
runPhasePatterns(engine, { brainDir, dryRun: false }),
);
const jobs = await engine.executeRaw<{ timeout_ms: string | number | null }>(
`SELECT timeout_ms FROM minion_jobs WHERE name = 'subagent' ORDER BY id DESC LIMIT 1`,
);
expect(jobs).toHaveLength(1);
expect(Number(jobs[0]!.timeout_ms)).toBe(600000);
} finally {
rmSync(brainDir, { recursive: true, force: true });
}
}, 60_000);
});
@@ -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);
});
+132
View File
@@ -0,0 +1,132 @@
/**
* #2113 — facts extraction must not silently extract zero facts on truncation.
*
* Pre-fix, extractFactsFromTurn hardcoded maxTokens:1500 and never checked
* the finish reason. Mandatory-reasoning models spend thinking tokens inside
* the same cap, so the JSON payload got cut off, parse failed, and extraction
* returned [] with no signal.
*
* Post-fix: the cap is configurable (`facts.extraction_max_tokens`, default
* 4000), a stopReason:'length' response is retried once at double the cap,
* and a still-truncated retry is surfaced on stderr.
*
* Uses the gateway chat-transport test seam — no API key, no network.
*/
import { afterAll, describe, test, expect, beforeEach } from 'bun:test';
import {
configureGateway,
resetGateway,
__setChatTransportForTests,
} from '../src/core/ai/gateway.ts';
import type { ChatOpts, ChatResult } from '../src/core/ai/gateway.ts';
import {
extractFactsFromTurn,
getFactsExtractionMaxTokens,
DEFAULT_EXTRACTION_MAX_TOKENS,
} from '../src/core/facts/extract.ts';
import type { BrainEngine } from '../src/core/engine.ts';
beforeEach(() => {
resetGateway();
__setChatTransportForTests(null);
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
});
});
// Shard hygiene (same rationale as facts-extract-silent-no-op.test.ts):
// restore the legacy 1536-d embedding pin so later fresh-schema files in
// this shard don't inherit a dimensionless gateway.
afterAll(() => {
__setChatTransportForTests(null);
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
});
});
function chatResult(text: string, stopReason: ChatResult['stopReason']): ChatResult {
return {
text,
blocks: [{ type: 'text', text }],
stopReason,
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} as ChatResult;
}
const GOOD_JSON = '{"facts":[{"fact":"user gave up alcohol","kind":"commitment",' +
'"entity":null,"confidence":1.0,"notability":"high",' +
'"metric":null,"value":null,"unit":null,"period":null}]}';
describe('getFactsExtractionMaxTokens (#2113)', () => {
test('defaults to 4000 without an engine', async () => {
expect(await getFactsExtractionMaxTokens()).toBe(DEFAULT_EXTRACTION_MAX_TOKENS);
expect(DEFAULT_EXTRACTION_MAX_TOKENS).toBe(4000);
});
test('reads facts.extraction_max_tokens from the engine', async () => {
const engine = { getConfig: async () => '9000' } as unknown as BrainEngine;
expect(await getFactsExtractionMaxTokens(engine)).toBe(9000);
});
test('invalid config values fall back to the default', async () => {
for (const bad of ['garbage', '0', '-5', '']) {
const engine = { getConfig: async () => bad } as unknown as BrainEngine;
expect(await getFactsExtractionMaxTokens(engine)).toBe(DEFAULT_EXTRACTION_MAX_TOKENS);
}
});
});
describe('extractFactsFromTurn truncation handling (#2113)', () => {
test('default call carries maxTokens=4000 (was hardcoded 1500)', async () => {
const seen: ChatOpts[] = [];
__setChatTransportForTests(async (opts) => {
seen.push(opts);
return chatResult(GOOD_JSON, 'end');
});
const facts = await extractFactsFromTurn({
turnText: 'I gave up alcohol.',
source: 'test:truncation',
});
expect(seen).toHaveLength(1);
expect(seen[0]!.maxTokens).toBe(4000);
expect(facts).toHaveLength(1);
});
test("stopReason 'length' retries ONCE at double the cap and recovers the facts", async () => {
const seen: ChatOpts[] = [];
__setChatTransportForTests(async (opts) => {
seen.push(opts);
// First call: truncated garbage. Retry: full JSON.
return seen.length === 1
? chatResult('{"facts":[{"fact":"user gave up alco', 'length')
: chatResult(GOOD_JSON, 'end');
});
const facts = await extractFactsFromTurn({
turnText: 'I gave up alcohol.',
source: 'test:truncation',
});
expect(seen).toHaveLength(2);
expect(seen[1]!.maxTokens).toBe(seen[0]!.maxTokens! * 2);
expect(facts).toHaveLength(1);
expect(facts[0]!.fact).toContain('alcohol');
});
test('still-truncated retry does not retry again (bounded at one retry)', async () => {
let calls = 0;
__setChatTransportForTests(async () => {
calls++;
return chatResult('{"facts":[{"fac', 'length');
});
const facts = await extractFactsFromTurn({
turnText: 'I gave up alcohol.',
source: 'test:truncation',
});
expect(calls).toBe(2);
expect(facts).toEqual([]);
});
});
+5 -1
View File
@@ -178,7 +178,7 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
// dream.* — adding env shadows is a separate PR (out of scope for the
// fix wave). These tests pin that contract.
describe('dream.* DB-plane merge (v0.41.2.1)', () => {
test('DB value fills in for all 5 dream.synthesize.* keys when base unset', async () => {
test('DB value fills in for dream.synthesize.* keys when base unset', async () => {
const base: GBrainConfig = { engine: 'pglite' };
const engine = makeEngine({
'dream.synthesize.session_corpus_dir': '/tmp/sessions',
@@ -186,6 +186,8 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
'dream.synthesize.verdict_model': 'anthropic:claude-haiku-4-5',
'dream.synthesize.max_prompt_tokens': '180000',
'dream.synthesize.max_chunks_per_transcript': '32',
'dream.synthesize.subagent_timeout_ms': '600000',
'dream.synthesize.subagent_wait_timeout_ms': '900000',
});
const merged = await loadConfigWithEngine(engine, base);
expect(merged?.dream?.synthesize?.session_corpus_dir).toBe('/tmp/sessions');
@@ -193,6 +195,8 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
expect(merged?.dream?.synthesize?.verdict_model).toBe('anthropic:claude-haiku-4-5');
expect(merged?.dream?.synthesize?.max_prompt_tokens).toBe(180000);
expect(merged?.dream?.synthesize?.max_chunks_per_transcript).toBe(32);
expect(merged?.dream?.synthesize?.subagent_timeout_ms).toBe(600000);
expect(merged?.dream?.synthesize?.subagent_wait_timeout_ms).toBe(900000);
});
test('DB value fills in for both dream.patterns.* keys when base unset', async () => {
+112
View File
@@ -596,3 +596,115 @@ describe('makeSubagentHandler default client construction', () => {
expect(result.result).toBe('ok');
});
});
// ── #2778: per-turn output-token cap + max_tokens stop handling ─────
import { resolveMaxOutputTokens } from '../src/core/minions/handlers/subagent.ts';
describe('resolveMaxOutputTokens (#2778)', () => {
test('defaults to 8192 when nothing set', () => {
expect(resolveMaxOutputTokens(undefined, null)).toBe(8192);
expect(resolveMaxOutputTokens(undefined, undefined)).toBe(8192);
});
test('per-job value wins over config', () => {
expect(resolveMaxOutputTokens(2048, '5000')).toBe(2048);
});
test('config value used when per-job unset', () => {
expect(resolveMaxOutputTokens(undefined, '5000')).toBe(5000);
});
test('invalid values fall through to next tier', () => {
expect(resolveMaxOutputTokens(0, '5000')).toBe(5000);
expect(resolveMaxOutputTokens(-1, null)).toBe(8192);
expect(resolveMaxOutputTokens(Number.NaN, 'garbage')).toBe(8192);
expect(resolveMaxOutputTokens(undefined, '')).toBe(8192);
expect(resolveMaxOutputTokens(undefined, '0')).toBe(8192);
});
});
describe('subagent handler output-token cap (#2778)', () => {
test('default: SDK call carries max_tokens=8192 (was hardcoded 4096)', async () => {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
await handler(ctx);
expect(client.calls[0]!.max_tokens).toBe(8192);
});
test('data.max_tokens flows to the SDK call', async () => {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi', max_tokens: 2048 });
await handler(ctx);
expect(client.calls[0]!.max_tokens).toBe(2048);
});
test('agent.max_output_tokens config flows to the SDK call', async () => {
await engine.setConfig('agent.max_output_tokens', '5000');
try {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ok' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
await handler(ctx);
expect(client.calls[0]!.max_tokens).toBe(5000);
} finally {
await engine.executeRaw(`DELETE FROM config WHERE key = 'agent.max_output_tokens'`);
}
});
test('final turn hitting the cap surfaces stop_reason=max_tokens, not a silent end_turn', async () => {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'truncated tex' }] as any, stop_reason: 'max_tokens' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
const result = await handler(ctx);
expect(result.stop_reason).toBe('max_tokens');
expect(result.result).toBe('truncated tex');
});
test('max_tokens stop with tool_use: truncation note injected so the model re-issues the dropped call', async () => {
const tool = makeEchoTool();
const client = new FakeMessagesClient([
{
// A complete tool_use survived, but the turn stopped on max_tokens —
// the API dropped whatever came after (e.g. a big put_page call).
content: [{ type: 'tool_use', id: 'tu_1', name: 'echo', input: { value: 'v1' } } as any],
stop_reason: 'max_tokens' as any,
},
{ content: [{ type: 'text', text: 'recovered' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [tool] });
const ctx = await makeCtx({ prompt: 'go' });
const result = await handler(ctx);
expect(result.stop_reason).toBe('end_turn');
expect(result.result).toBe('recovered');
// The synthesized user turn (persisted + fed to the second call) must
// carry the truncation note alongside the tool_result. Assert on the
// persisted row — client.calls[].messages is the live array the loop
// keeps mutating, so positional checks there are unreliable.
const rows = await engine.executeRaw<{ content_blocks: unknown }>(
`SELECT content_blocks FROM subagent_messages
WHERE job_id = $1 AND role = 'user' AND message_idx > 0
ORDER BY message_idx ASC`,
[ctx.id],
);
expect(rows.length).toBe(1);
const blocks = (typeof rows[0]!.content_blocks === 'string'
? JSON.parse(rows[0]!.content_blocks as string)
: rows[0]!.content_blocks) as Array<{ type: string; text?: string }>;
expect(blocks.some(b => b.type === 'tool_result')).toBe(true);
const texts = blocks.filter(b => b.type === 'text').map(b => b.text ?? '');
expect(texts.some(t => t.includes('truncated') && t.includes('DROPPED'))).toBe(true);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* #2778 — add_timeline_entry subagent slug fence.
*
* add_timeline_entry joined the subagent brain-tool allowlist, so it must be
* confined exactly like put_page: when ctx.viaSubagent=true the target slug
* must match the trusted-workspace allow-list (when set) or the legacy
* wiki/agents/<subagentId>/ namespace, fail-closed on a missing subagentId.
* Non-subagent callers (CLI, plain MCP) are unchanged.
*
* Uses dryRun ctxs — the fence runs BEFORE the dry-run short-circuit, so no
* engine is needed (same pattern as test/put-page-namespace.test.ts).
*/
import { describe, test, expect } from 'bun:test';
import { operations, OperationError } from '../src/core/operations.ts';
import type { OperationContext, Operation } from '../src/core/operations.ts';
import type { BrainEngine } from '../src/core/engine.ts';
const add_timeline_entry = operations.find(o => o.name === 'add_timeline_entry') as Operation;
if (!add_timeline_entry) throw new Error('add_timeline_entry op missing');
const ENTRY = { date: '2026-07-01', summary: 'test entry' };
function makeCtx(overrides: Partial<OperationContext> = {}): OperationContext {
const engine = {} as BrainEngine; // dry_run short-circuits before touching the engine
return {
engine,
config: { engine: 'postgres' } as any,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: true,
remote: true,
sourceId: 'default',
...overrides,
};
}
describe('add_timeline_entry subagent fence (#2778)', () => {
describe('regression: non-subagent callers unchanged', () => {
test('local CLI write (viaSubagent undefined) accepts arbitrary slug', async () => {
const ctx = makeCtx({ remote: false });
const result = await add_timeline_entry.handler(ctx, { slug: 'people/alice-example', ...ENTRY });
expect(result).toMatchObject({ dry_run: true, action: 'add_timeline_entry', slug: 'people/alice-example' });
});
test('MCP write (remote=true, viaSubagent=undefined) accepts arbitrary slug', async () => {
const ctx = makeCtx({ remote: true });
const result = await add_timeline_entry.handler(ctx, { slug: 'companies/acme-example', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
test('viaSubagent=false is the same as unset', async () => {
const ctx = makeCtx({ viaSubagent: false, subagentId: 42 });
const result = await add_timeline_entry.handler(ctx, { slug: 'anything/goes', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
});
describe('legacy namespace confinement', () => {
test('accepts wiki/agents/<subagentId>/ prefix', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 42 });
const result = await add_timeline_entry.handler(ctx, { slug: 'wiki/agents/42/notes', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
test('rejects a slug outside the namespace', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 42 });
const p = add_timeline_entry.handler(ctx, { slug: 'people/alice-example', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/add_timeline_entry/);
});
test('rejects prefix-collision attempt (wiki/agents/12evil/* with subagentId=12)', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 12 });
const p = add_timeline_entry.handler(ctx, { slug: 'wiki/agents/12evil/foo', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
});
test('FAIL-CLOSED: viaSubagent=true with undefined subagentId rejects any slug', async () => {
const ctx = makeCtx({ viaSubagent: true });
const p = add_timeline_entry.handler(ctx, { slug: 'wiki/agents/42/foo', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/subagentId/);
});
});
describe('trusted-workspace allow-list', () => {
const allow = ['wiki/personal/patterns/*', 'wiki/originals/*'];
test('accepts a slug inside the allow-list', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 7, allowedSlugPrefixes: allow });
const result = await add_timeline_entry.handler(ctx, { slug: 'wiki/personal/patterns/topic-x', ...ENTRY });
expect(result).toMatchObject({ dry_run: true });
});
test('rejects a slug outside the allow-list (even inside the legacy namespace)', async () => {
const ctx = makeCtx({ viaSubagent: true, subagentId: 7, allowedSlugPrefixes: allow });
const p = add_timeline_entry.handler(ctx, { slug: 'wiki/agents/7/notes', ...ENTRY });
await expect(p).rejects.toBeInstanceOf(OperationError);
await expect(p).rejects.toThrow(/allow-list/);
});
});
});