fix(dream): honor a configured 0 in synthesize + auto_think config resolution (stop coercing to the default) (#3552)

Co-Authored-By: Time Attakc <89218912+time-attack@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-08-01 07:40:48 +08:00
committed by Sina Matian
co-authored by Time Attakc
parent 69be8bb707
commit 241603aab8
4 changed files with 131 additions and 8 deletions
+19 -4
View File
@@ -56,8 +56,6 @@ async function loadConfig(engine: BrainEngine): Promise<AutoThinkConfig> {
const enabledStr = await engine.getConfig('dream.auto_think.enabled');
const questionsStr = await engine.getConfig('dream.auto_think.questions');
const maxPerStr = await engine.getConfig('dream.auto_think.max_per_cycle');
const budgetStr = await engine.getConfig('dream.auto_think.budget');
const cooldownStr = await engine.getConfig('dream.auto_think.cooldown_days');
const autoCommitStr = await engine.getConfig('dream.auto_think.auto_commit');
let questions: string[] = [];
@@ -68,16 +66,30 @@ async function loadConfig(engine: BrainEngine): Promise<AutoThinkConfig> {
} catch { /* ignore */ }
}
// getNumberConfig (not `parse* || N`) so a configured 0 is honored — a bare
// `|| N` coerces an explicit 0 back to the default (budget 0 = "spend nothing",
// cooldown 0 = "no cooldown"). max_per_cycle stays inline: its Math.max(1, ...)
// floor already makes 0 invalid there, so no configured value is lost.
const budgetUsd = Math.max(0, await getNumberConfig(engine, 'dream.auto_think.budget', 2.0));
const cooldownDays = Math.max(0, await getNumberConfig(engine, 'dream.auto_think.cooldown_days', 30));
return {
enabled: enabledStr === 'true',
questions,
maxPerCycle: maxPerStr ? Math.max(1, parseInt(maxPerStr, 10) || 5) : 5,
budgetUsd: budgetStr ? Math.max(0, parseFloat(budgetStr) || 2.0) : 2.0,
cooldownDays: cooldownStr ? Math.max(0, parseInt(cooldownStr, 10) || 30) : 30,
budgetUsd,
cooldownDays,
autoCommit: autoCommitStr === 'true',
};
}
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 isCoolingDown(engine: BrainEngine, days: number): Promise<boolean> {
if (days <= 0) return false;
const last = await engine.getConfig('dream.auto_think.last_completion_ts');
@@ -201,3 +213,6 @@ export async function runPhaseAutoThink(
duration_ms: Date.now() - start,
};
}
// Test-only export: pin config-resolution behavior at function granularity.
export const __testing = { loadConfig };
+7 -4
View File
@@ -804,7 +804,6 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
// Explicit enabled=false still wins for pausing synthesis without removing corpus config.
const enabled = enabledRaw === 'false' ? false : (enabledRaw === 'true' || !!corpusDir);
const meetingTranscriptsDir = await engine.getConfig('dream.synthesize.meeting_transcripts_dir');
const minCharsStr = await engine.getConfig('dream.synthesize.min_chars');
const excludeStr = await engine.getConfig('dream.synthesize.exclude_patterns');
// v0.28: resolveModel() unifies CLI flag > new key > deprecated key > models.default > env > fallback
const { resolveModel } = await import('../model-config.ts');
@@ -820,7 +819,10 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
tier: 'utility',
fallback: 'haiku',
});
const cooldownHoursStr = await engine.getConfig('dream.synthesize.cooldown_hours');
// getNumberConfig (not `parseInt(str, 10) || N`) so a configured 0 is honored — a bare
// `|| N` coerces an explicit 0 back to the default (cooldown 0 = "no cooldown").
const cooldownHours = Math.max(0, await getNumberConfig(engine, 'dream.synthesize.cooldown_hours', 12));
const minChars = Math.max(0, await getNumberConfig(engine, 'dream.synthesize.min_chars', 2000));
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(
@@ -863,11 +865,11 @@ async function loadSynthConfig(engine: BrainEngine): Promise<SynthConfig> {
enabled,
corpusDir: corpusDir ?? null,
meetingTranscriptsDir: meetingTranscriptsDir ?? null,
minChars: minCharsStr ? Math.max(0, parseInt(minCharsStr, 10) || 2000) : 2000,
minChars,
excludePatterns,
model,
verdictModel,
cooldownHours: cooldownHoursStr ? Math.max(0, parseInt(cooldownHoursStr, 10) || 12) : 12,
cooldownHours,
maxPromptTokens,
maxChunksPerTranscript,
outputRoot: await loadOutputRoot(engine),
@@ -1599,4 +1601,5 @@ export const __testing = {
stampDreamProvenance,
reverseWriteRefs,
runPgliteSubagentsInline,
loadSynthConfig,
};
+52
View File
@@ -0,0 +1,52 @@
import { describe, test, expect } from 'bun:test';
import { __testing } from '../src/core/cycle/auto-think.ts';
import type { BrainEngine } from '../src/core/engine.ts';
// A configured 0 must survive config resolution. The pre-fix
// `parse*(str) || <default>` coerced an explicit "0" back to the default
// (budget 0 = "spend nothing", cooldown 0 = "no cooldown"); loadConfig now
// routes budget + cooldown_days through getNumberConfig, which honors 0.
function stubEngine(config: Record<string, string>): BrainEngine {
return { getConfig: async (key: string) => config[key] ?? null } as unknown as BrainEngine;
}
describe('auto_think loadConfig honors a configured 0', () => {
test('budget = "0" resolves 0, not $2', async () => {
const cfg = await __testing.loadConfig(stubEngine({ 'dream.auto_think.budget': '0' }));
expect(cfg.budgetUsd).toBe(0);
});
test('cooldown_days = "0" resolves 0, not the 30d default', async () => {
const cfg = await __testing.loadConfig(stubEngine({ 'dream.auto_think.cooldown_days': '0' }));
expect(cfg.cooldownDays).toBe(0);
});
test('absent keys keep the defaults', async () => {
const cfg = await __testing.loadConfig(stubEngine({}));
expect(cfg.budgetUsd).toBe(2.0);
expect(cfg.cooldownDays).toBe(30);
});
test('unparseable values fall back to the defaults', async () => {
const cfg = await __testing.loadConfig(stubEngine({
'dream.auto_think.budget': 'abc',
'dream.auto_think.cooldown_days': 'xyz',
}));
expect(cfg.budgetUsd).toBe(2.0);
expect(cfg.cooldownDays).toBe(30);
});
test('positive values round-trip (budget accepts fractions)', async () => {
const cfg = await __testing.loadConfig(stubEngine({
'dream.auto_think.budget': '0.5',
'dream.auto_think.cooldown_days': '7',
}));
expect(cfg.budgetUsd).toBe(0.5);
expect(cfg.cooldownDays).toBe(7);
});
test('a negative value clamps to 0', async () => {
const cfg = await __testing.loadConfig(stubEngine({ 'dream.auto_think.budget': '-1' }));
expect(cfg.budgetUsd).toBe(0);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, test, expect } from 'bun:test';
import { __testing } from '../src/core/cycle/synthesize.ts';
import type { BrainEngine } from '../src/core/engine.ts';
// A configured 0 must survive config resolution. The pre-fix `parseInt(str, 10)
// || <default>` coerced an explicit "0" back to the default (cooldown 0 = "no
// cooldown"); loadSynthConfig now routes cooldown_hours + min_chars through
// getNumberConfig, which honors 0. Only engine.getConfig is exercised, so a
// stub engine is sufficient (no PGLite).
function stubEngine(config: Record<string, string>): BrainEngine {
return { getConfig: async (key: string) => config[key] ?? null } as unknown as BrainEngine;
}
describe('loadSynthConfig honors a configured 0', () => {
test('cooldown_hours = "0" resolves 0, not the 12h default', async () => {
const cfg = await __testing.loadSynthConfig(stubEngine({ 'dream.synthesize.cooldown_hours': '0' }));
expect(cfg.cooldownHours).toBe(0);
});
test('min_chars = "0" resolves 0, not 2000', async () => {
const cfg = await __testing.loadSynthConfig(stubEngine({ 'dream.synthesize.min_chars': '0' }));
expect(cfg.minChars).toBe(0);
});
test('absent keys keep the defaults', async () => {
const cfg = await __testing.loadSynthConfig(stubEngine({}));
expect(cfg.cooldownHours).toBe(12);
expect(cfg.minChars).toBe(2000);
});
test('unparseable values fall back to the defaults', async () => {
const cfg = await __testing.loadSynthConfig(stubEngine({
'dream.synthesize.cooldown_hours': 'abc',
'dream.synthesize.min_chars': 'xyz',
}));
expect(cfg.cooldownHours).toBe(12);
expect(cfg.minChars).toBe(2000);
});
test('positive values round-trip', async () => {
const cfg = await __testing.loadSynthConfig(stubEngine({
'dream.synthesize.cooldown_hours': '6',
'dream.synthesize.min_chars': '500',
}));
expect(cfg.cooldownHours).toBe(6);
expect(cfg.minChars).toBe(500);
});
test('a negative value clamps to 0', async () => {
const cfg = await __testing.loadSynthConfig(stubEngine({ 'dream.synthesize.cooldown_hours': '-5' }));
expect(cfg.cooldownHours).toBe(0);
});
});