From eb4ecca00774082ad7ded5e27216843477367400 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sun, 18 Oct 2026 01:21:25 -0400 Subject: [PATCH 1/4] fix: nine boolean config switches take only true or false create() and the Agent constructor read nine boolean switches by truthiness: subconscious.enabled, subconscious.allowChannelSpeech, watchdog.enabled and processLogging.persist, and each agent's promptCaching, thinking.enabled (which membrane reads), refusalHandling.autoRewind, toolResultGuard and toolWrapperProseGuard. So a quoted "false", as a JSON config or a ${VAR} substitution can write it, turned the switch on, and a falsy non-boolean such as 0 turned it off without a word. Each now takes true or false, or can be left out, with null counting as left out, and anything else is refused, naming the value's kind rather than the value, with a sentence for what the switch does. A settings object holding them (subconscious, watchdog, processLogging, an agent's thinking or refusalHandling) that isn't an object is refused too, since every switch in it would read as left out. A subconscious that isn't on has its allowChannelSpeech left unchecked, since nothing reads it then. create() checks the framework's switches and each of its agents' before anything opens; createEphemeralAgent checks the config it's handed before it reserves the name. The other two paths in src that build an Agent copy one of create()'s agents: the subconscious resident spreads the primary agent's config, and a conversation agent its template's. The switches are then read as === true, and promptCaching, which defaults to on, as !== false. The refusal is one helper, assertBooleanSetting, with holdsSettings for the settings objects, in a new src/config-checks.ts beside kindOf, which moves there from mcpl/transport.ts. The MCPL server booleans' refusal and codeExecution's use the same helpers, with their messages unchanged; validateCodeExecutionConfig, which create() calls only for a codeExecution that isn't null or left out, now treats a null it's handed as not set. Tests: create() refuses each framework switch and each agent switch, and each settings object that isn't an object, with its whole message and no store left behind; createEphemeralAgent refuses a bad switch and leaves the name free; null switches count as left out, and an off subconscious has a non-boolean allowChannelSpeech left unchecked. Co-Authored-By: Claude Opus 5.5 --- .../fix-config-switch-booleans.fixed.md | 1 + src/agent.ts | 51 ++++++- src/code-execution/tool-definition.ts | 26 ++-- src/config-checks.ts | 47 ++++++ src/framework.ts | 56 ++++++- src/mcpl/transport.ts | 18 +-- test/config-switches.test.ts | 142 ++++++++++++++++++ 7 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 changelog.d/fix-config-switch-booleans.fixed.md create mode 100644 src/config-checks.ts create mode 100644 test/config-switches.test.ts diff --git a/changelog.d/fix-config-switch-booleans.fixed.md b/changelog.d/fix-config-switch-booleans.fixed.md new file mode 100644 index 000000000..b1926507e --- /dev/null +++ b/changelog.d/fix-config-switch-booleans.fixed.md @@ -0,0 +1 @@ +- The framework's and each agent's boolean switches take `true` or `false`, or can be left out, and `null` counts as left out. `create()` refuses any other value, and a settings object holding them that isn't an object, naming the value's kind, not the value. The switches are `subconscious.enabled`, `subconscious.allowChannelSpeech` (checked once the subconscious is on), `watchdog.enabled` and `processLogging.persist`, and each agent's `promptCaching`, `thinking.enabled`, `refusalHandling.autoRewind`, `toolResultGuard` and `toolWrapperProseGuard`, which `createEphemeralAgent` refuses too. Each was read by truthiness, so a quoted `"false"`, as a JSON config or a `${VAR}` substitution can write it, turned it on, and a falsy non-boolean such as `0` turned it off without a word. `promptCaching` is still on unless it's `false`. A host that passes these on from its own configuration without checking them now gets this refusal from `create()` where such a value used to start. diff --git a/src/agent.ts b/src/agent.ts index af0872904..54ef32348 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -3,6 +3,7 @@ import { isAbortedResponse } from '@animalabs/membrane'; import { createHash } from 'node:crypto'; import type { CacheWireReceipt, KvUnifiedRequestHooks } from './kv-unified-wire.js'; import { ToolResultGuard } from './tool-result-guard.js'; +import { assertBooleanSetting, holdsSettings } from './config-checks.js'; import { indexSilentTickRows, separateSilentHeartbeatTicks, @@ -75,6 +76,50 @@ const DEFAULT_CONTEXT_BUDGET_TOKENS = 100_000; const DEFAULT_TRANSITION_PACE_TOKENS = 16_000; const DEFAULT_SAME_ROUND_THINK_TEXT_POLICY: SameRoundThinkTextPolicy = 'public'; +/** + * Refuse an agent config whose `thinking` or `refusalHandling` isn't an object, + * or whose switch holds anything but true or false (assertBooleanSetting). An + * Agent reads these switches as given, so `create()` checks each of its agents + * before anything opens, and `createEphemeralAgent` checks the config it's + * handed. Membrane reads `thinking.enabled`, and the refusal is here because + * the setting is AgentConfig's. + */ +export function assertAgentSwitches(config: AgentConfig): void { + const agent = `Agent "${config.name}"`; + assertBooleanSetting( + `${agent} promptCaching`, + config.promptCaching, + 'true or leaving it out marks requests for prompt caching, and false leaves the markers off, for transports that refuse them', + ); + if (holdsSettings(`${agent} thinking`, config.thinking, '{ enabled: true }')) { + assertBooleanSetting( + `${agent} thinking.enabled`, + config.thinking.enabled, + 'true asks the provider for extended thinking, and false or leaving it out doesn\'t', + ); + } + if (holdsSettings(`${agent} refusalHandling`, config.refusalHandling, '{ autoRewind: true }')) { + assertBooleanSetting( + `${agent} refusalHandling.autoRewind`, + config.refusalHandling.autoRewind, + 'true redacts the turn that tripped a refusal and runs again, up to maxRewinds times, ' + + 'and false or leaving it out doesn\'t', + ); + } + assertBooleanSetting( + `${agent} toolResultGuard`, + config.toolResultGuard, + 'true turns on tool-output admission, with one guarded retry after a provider refusal, ' + + 'and false or leaving it out doesn\'t', + ); + assertBooleanSetting( + `${agent} toolWrapperProseGuard`, + config.toolWrapperProseGuard, + 'true keeps a response whose whole prose is a tool-call-shaped wrapper from running or entering the agent\'s history, ' + + 'and false or leaving it out doesn\'t', + ); +} + /** * An agent wraps a context manager and manages inference state. */ @@ -230,11 +275,11 @@ export class Agent { this.temperature = config.temperature; this.thinking = config.thinking; this.refusalHandling = config.refusalHandling; - this.toolResultGuard = new ToolResultGuard(config.name, contextManager, config.toolResultGuard); + this.toolResultGuard = new ToolResultGuard(config.name, contextManager, config.toolResultGuard === true); this.proseRouting = config.proseRouting ?? 'locus'; - this.toolWrapperProseGuard = config.toolWrapperProseGuard ?? false; + this.toolWrapperProseGuard = config.toolWrapperProseGuard === true; this.cacheTtl = config.cacheTtl ?? '1h'; - this.promptCaching = config.promptCaching ?? true; + this.promptCaching = config.promptCaching !== false; this.prefillUserMessage = config.prefillUserMessage; this.providerParams = config.providerParams; this.sameRoundThinkTextPolicy = config.sameRoundThinkTextPolicy; diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index 7a0fea14e..4a7ea73ff 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -9,7 +9,7 @@ */ import type { ToolDefinition } from '../types/index.js'; -import { kindOf } from '../mcpl/transport.js'; +import { assertBooleanSetting, holdsSettings } from '../config-checks.js'; import { formatLimit, MAX_TIMER_MS } from './py-runner.js'; export const CODE_EXECUTION_TOOL_NAME = 'code_execution'; @@ -121,26 +121,22 @@ export function scriptTimeLimits(opts?: { * contradict each other. A JSON config, or a `${VAR}` substituted as a string, * can carry a quoted "false" where the type says boolean, and only `true` turns * code execution on, so the quotes would hide what the value does; a bare - * `codeExecution: true` would be read as off. A null switch counts as not set, - * as a left-out one does. The refusal names the value's kind, not the value. - * A configuration that isn't on has nothing else read. + * `codeExecution: true` would be read as off. A null codeExecution or switch + * counts as not set, as a left-out one does. The refusal names the value's + * kind, not the value. A configuration that isn't on has nothing else read. */ export function validateCodeExecutionConfig(cfg: { enabled?: unknown; scriptTimeoutMs?: number; maxScriptTimeoutMs?: number; }): void { - if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) { - throw new Error(`codeExecution must be an object, such as { enabled: true }, not ${cfg === null ? 'null' : kindOf(cfg)}`); - } - if (cfg.enabled !== undefined && cfg.enabled !== null && typeof cfg.enabled !== 'boolean') { - const wanted = typeof cfg.enabled === 'string' ? 'true or false, unquoted,' : 'true or false,'; - throw new Error( - `codeExecution.enabled must be ${wanted} not ${kindOf(cfg.enabled)}: ` + - 'true gives agents the code_execution tool, which runs their scripts in a python interpreter on the host, ' + - 'and false or leaving it out doesn\'t', - ); - } + if (!holdsSettings('codeExecution', cfg, '{ enabled: true }')) return; + assertBooleanSetting( + 'codeExecution.enabled', + cfg.enabled, + 'true gives agents the code_execution tool, which runs their scripts in a python interpreter on the host, ' + + 'and false or leaving it out doesn\'t', + ); if (cfg.enabled !== true) return; const defaultMs = cfg.scriptTimeoutMs ?? 600_000; if (cfg.maxScriptTimeoutMs !== undefined && !(cfg.maxScriptTimeoutMs >= defaultMs)) { diff --git a/src/config-checks.ts b/src/config-checks.ts new file mode 100644 index 000000000..dca1937bc --- /dev/null +++ b/src/config-checks.ts @@ -0,0 +1,47 @@ +/** + * Refusals shared by the configuration checks. A setting read by truthiness + * turns on for a quoted "false", and a settings object that isn't an object + * has every setting in it read as left out. A JSON config, or a `${VAR}` + * substituted as a string, can carry either where the type says otherwise, + * so each refusal says what the value would have done. Each names the + * setting and the value's kind, never the value, which could be a secret + * pasted into the wrong field. + */ + +/** The kind of a value that isn't undefined, null or a boolean, as a refusal + * names it in place of the value. */ +export function kindOf(value: unknown): string { + if (Array.isArray(value)) return 'an array'; + if (typeof value !== 'object') return `a ${typeof value}`; + const prototype = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null; + if (prototype === null || prototype === Object.prototype) return 'an object'; + const name = prototype.constructor?.name; + if (!name) return 'an object that isn\'t plain'; + return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`; +} + +/** + * Refuse a boolean setting that holds anything but true or false. null counts + * as not set, as leaving it out does, so a generated config that writes an + * unset setting as null keeps working. `whatItDoes` ends the refusal: what + * true does, and what false or leaving it out does. + */ +export function assertBooleanSetting(setting: string, value: unknown, whatItDoes: string): void { + if (value === undefined || value === null || typeof value === 'boolean') return; + const wanted = typeof value === 'string' ? 'true or false, unquoted,' : 'true or false,'; + throw new Error(`${setting} must be ${wanted} not ${kindOf(value)}: ${whatItDoes}`); +} + +/** + * Whether a settings object holds settings to check: false when it's null or + * left out, which count as not set. Anything else that isn't an object, an + * array included, is refused, since every setting in it would read as left + * out. + */ +export function holdsSettings(setting: string, value: T, example: string): value is NonNullable { + if (value === undefined || value === null) return false; + if (typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${setting} must be an object, such as ${example}, not ${kindOf(value)}`); + } + return true; +} diff --git a/src/framework.ts b/src/framework.ts index 4c12b2002..3d4a01413 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -74,7 +74,8 @@ import type { import { ProcessQueueImpl } from './queue.js'; import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js'; import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; -import { Agent } from './agent.js'; +import { Agent, assertAgentSwitches } from './agent.js'; +import { assertBooleanSetting, holdsSettings } from './config-checks.js'; import { ModuleRegistry, checkedToolResult, ensureStateRegistered, storableToolResult, thrownResult } from './module-registry.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; import { logValue } from './mcpl/log-value.js'; @@ -821,6 +822,45 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number { ); } +/** + * Refuse a config whose `subconscious`, `watchdog` or `processLogging` isn't an + * object, or whose switch in one holds anything but true or false + * (assertBooleanSetting). A subconscious that isn't on has its + * `allowChannelSpeech` left unchecked, since nothing reads it then. + */ +function assertFrameworkSwitches(config: FrameworkConfig): void { + if (holdsSettings('subconscious', config.subconscious, '{ enabled: true }')) { + assertBooleanSetting( + 'subconscious.enabled', + config.subconscious.enabled, + 'true creates the subconscious resident, a side agent that reads the residents\' timeline and delivers into their window, ' + + 'and false or leaving it out doesn\'t', + ); + if (config.subconscious.enabled === true) { + assertBooleanSetting( + 'subconscious.allowChannelSpeech', + config.subconscious.allowChannelSpeech, + 'true lets the subconscious speak in channels with speak_in_channel, and false or leaving it out refuses it', + ); + } + } + if (holdsSettings('watchdog', config.watchdog, '{ enabled: true }')) { + assertBooleanSetting( + 'watchdog.enabled', + config.watchdog.enabled, + 'true arms the liveness watchdog, which ends the process when the main thread misses its heartbeat for thresholdMs, ' + + 'and false or leaving it out doesn\'t', + ); + } + if (holdsSettings('processLogging', config.processLogging, '{ persist: true }')) { + assertBooleanSetting( + 'processLogging.persist', + config.processLogging.persist, + 'true writes each process event and its responses to Chronicle, and false or leaving it out doesn\'t', + ); + } +} + /** Messages copied into another conversation (a fork's seed) without their * `thinking` and `redacted_thinking` blocks: a fork inherits the template's * conversation, not its reasoning. A signed block was minted under the @@ -2351,6 +2391,8 @@ export class AgentFramework { static async create(config: FrameworkConfig): Promise { // Before anything opens or starts: a refused config must leave nothing behind. if (config.codeExecution != null) validateCodeExecutionConfig(config.codeExecution); + assertFrameworkSwitches(config); + for (const agentConfig of config.agents) assertAgentSwitches(agentConfig); // Create or use existing store let store: JsStore; @@ -2468,7 +2510,7 @@ export class AgentFramework { } // Process logging config (default: disabled) - const processLoggingPersist = config.processLogging?.persist ?? false; + const processLoggingPersist = config.processLogging?.persist === true; const processLoggingBroadcast = config.processLogging?.broadcast ?? false; // Register process log state only if persistence is enabled @@ -2551,7 +2593,7 @@ export class AgentFramework { // The subconscious resident (issue #77) registers after the residents so // it can never become primary; it is excluded from every broadcast // fan-out and woken only by the tune-out coordinator's explicit pushes. - if (config.subconscious?.enabled) { + if (config.subconscious?.enabled === true) { await framework.createSubconsciousAgent(config.subconscious); } @@ -2652,7 +2694,7 @@ export class AgentFramework { } // Liveness watchdog: fail hard if the main thread wedges (opt-in). - if (config.watchdog?.enabled) { + if (config.watchdog?.enabled === true) { const { LivenessWatchdog } = await import('./runtime/liveness-watchdog.js'); framework.livenessWatchdog = new LivenessWatchdog({ enabled: true, @@ -2803,7 +2845,7 @@ export class AgentFramework { [...framework.conversationAgentHomes.values()].includes(channelId), isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false, - allowChannelSpeech: () => !!framework.subconsciousConfig?.allowChannelSpeech, + allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true, emitTrace: (e) => framework.emitTrace(e as never), }, ); @@ -6885,6 +6927,8 @@ export class AgentFramework { contextManager: ContextManager; cleanup: () => void; }> { + // Checked before the name is reserved, so a refused config leaves it free. + assertAgentSwitches(config); // Names are Chronicle namespaces and generation identities. Reserve before // opening the namespace so a second creation cannot append to an earlier // generation before runEphemeralToCompletion has a chance to reject it. @@ -14422,7 +14466,7 @@ export class AgentFramework { }, () => this.finishUnstick(agent.name, false, category), ); - } else if (rh?.autoRewind && !guardRefusal) { + } else if (rh?.autoRewind === true && !guardRefusal) { const cap = Math.max(1, rh.maxRewinds ?? 3); const used = this.refusalRewinds.get(agent.name) ?? 0; doRewind( diff --git a/src/mcpl/transport.ts b/src/mcpl/transport.ts index 8be8747ed..82d2fe262 100644 --- a/src/mcpl/transport.ts +++ b/src/mcpl/transport.ts @@ -23,6 +23,7 @@ import { StringDecoder } from 'node:string_decoder'; import WebSocket from 'ws'; import type { McplServerConfig } from './types.js'; +import { assertBooleanSetting } from '../config-checks.js'; /** Why/how a transport closed. `code`/`signal` are populated for stdio child * exits; `reason` carries a WebSocket close reason or an error message. */ @@ -511,25 +512,10 @@ const SERVER_CONFIG_BOOLEANS: Record = { export function assertServerConfigBooleans(config: McplServerConfig): void { for (const [setting, whatTrueDoes] of Object.entries(SERVER_CONFIG_BOOLEANS) as Array<[BooleanSetting, string]>) { if (setting === 'inheritEnv' && isWebSocketTransport(config)) continue; - const value: unknown = config[setting]; - if (value === undefined || value === null || typeof value === 'boolean') continue; - const wanted = typeof value === 'string' ? 'true or false, unquoted,' : 'true or false,'; - throw new Error(`MCPL server "${config.id}" ${setting} must be ${wanted} not ${kindOf(value)}: ${whatTrueDoes}`); + assertBooleanSetting(`MCPL server "${config.id}" ${setting}`, config[setting], whatTrueDoes); } } -/** The kind of a value that isn't undefined, null or a boolean, as a refusal - * names it in place of the value. */ -export function kindOf(value: unknown): string { - if (Array.isArray(value)) return 'an array'; - if (typeof value !== 'object') return `a ${typeof value}`; - const prototype = Object.getPrototypeOf(value) as { constructor?: { name?: string } } | null; - if (prototype === null || prototype === Object.prototype) return 'an object'; - const name = prototype.constructor?.name; - if (!name) return 'an object that isn\'t plain'; - return `${/^[aeiou]/i.test(name) ? 'an' : 'a'} ${name}`; -} - /** * Open the transport selected by `config` (WebSocket when `transport` is * 'websocket' or a bare `url` is given; stdio otherwise). The returned transport diff --git a/test/config-switches.test.ts b/test/config-switches.test.ts new file mode 100644 index 000000000..474e751cb --- /dev/null +++ b/test/config-switches.test.ts @@ -0,0 +1,142 @@ +/** + * The framework's and each agent's boolean switches take true or false, or + * can be left out, with null counting as left out. Each was read by + * truthiness, so a quoted "false", as a JSON config or a `${VAR}` + * substitution can write it, turned it on, and a falsy non-boolean such as 0 + * turned it off without a word. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AgentFramework } from '../src/index.js'; +import { MockMembrane } from './helpers/mock-membrane.js'; + +function tempStorePath(prefix: string): { tempDir: string; storePath: string } { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); + return { tempDir, storePath: join(tempDir, 'store.chronicle') }; +} + +const createWith = (storePath: string, config: Record) => + AgentFramework.create({ + storePath, + membrane: new MockMembrane().asMembrane(), + agents: [], + modules: [], + syncIntervalMs: 0, + ...config, + } as never); + +const agentWith = (settings: Record) => ({ + name: 'agent', + model: 'test-model', + systemPrompt: 'test', + ...settings, +}); + +/** create() refuses each config with exactly its message, before anything + * opens: no store is left at storePath. */ +async function assertRefused(cases: ReadonlyArray, string]>): Promise { + const { tempDir, storePath } = tempStorePath('config-switches-refused-'); + try { + for (const [config, message] of cases) { + await assert.rejects(createWith(storePath, config), (error: Error) => { + assert.equal(error.message, message); + return true; + }); + assert.equal(existsSync(storePath), false, `the refused config opened no store: ${message}`); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +const doesnt = 'and false or leaving it out doesn\'t'; +const SUBCONSCIOUS_ENABLED = 'true creates the subconscious resident, a side agent that reads the residents\' timeline and delivers into their window, ' + doesnt; +const ALLOW_CHANNEL_SPEECH = 'true lets the subconscious speak in channels with speak_in_channel, and false or leaving it out refuses it'; +const WATCHDOG_ENABLED = 'true arms the liveness watchdog, which ends the process when the main thread misses its heartbeat for thresholdMs, ' + doesnt; +const PERSIST = 'true writes each process event and its responses to Chronicle, ' + doesnt; +const PROMPT_CACHING = 'true or leaving it out marks requests for prompt caching, and false leaves the markers off, for transports that refuse them'; +const THINKING_ENABLED = 'true asks the provider for extended thinking, ' + doesnt; +const AUTO_REWIND = 'true redacts the turn that tripped a refusal and runs again, up to maxRewinds times, ' + doesnt; +const TOOL_RESULT_GUARD = 'true turns on tool-output admission, with one guarded retry after a provider refusal, ' + doesnt; +const TOOL_WRAPPER_PROSE_GUARD = 'true keeps a response whose whole prose is a tool-call-shaped wrapper from running or entering the agent\'s history, ' + doesnt; + +describe('config switches', () => { + it('create() refuses a framework switch that is not true or false, and a settings object that is not an object', async () => { + await assertRefused([ + [{ subconscious: { enabled: 'false', systemPrompt: 'x' } }, `subconscious.enabled must be true or false, unquoted, not a string: ${SUBCONSCIOUS_ENABLED}`], + [{ subconscious: { enabled: true, systemPrompt: 'x', allowChannelSpeech: 'false' } }, `subconscious.allowChannelSpeech must be true or false, unquoted, not a string: ${ALLOW_CHANNEL_SPEECH}`], + [{ subconscious: true }, 'subconscious must be an object, such as { enabled: true }, not a boolean'], + [{ watchdog: { enabled: 1 } }, `watchdog.enabled must be true or false, not a number: ${WATCHDOG_ENABLED}`], + [{ watchdog: 'on' }, 'watchdog must be an object, such as { enabled: true }, not a string'], + [{ processLogging: { persist: 'sk-secret-token' } }, `processLogging.persist must be true or false, unquoted, not a string: ${PERSIST}`], + [{ processLogging: [] }, 'processLogging must be an object, such as { persist: true }, not an array'], + ]); + }); + + it('create() refuses an agent switch that is not true or false, and a settings object that is not an object', async () => { + const refused = (settings: Record, message: string) => + [{ agents: [agentWith(settings)] }, `Agent "agent" ${message}`] as const; + await assertRefused([ + refused({ promptCaching: 'false' }, `promptCaching must be true or false, unquoted, not a string: ${PROMPT_CACHING}`), + refused({ thinking: { enabled: 'false' } }, `thinking.enabled must be true or false, unquoted, not a string: ${THINKING_ENABLED}`), + refused({ thinking: true }, 'thinking must be an object, such as { enabled: true }, not a boolean'), + refused({ refusalHandling: { autoRewind: 'false' } }, `refusalHandling.autoRewind must be true or false, unquoted, not a string: ${AUTO_REWIND}`), + refused({ refusalHandling: 'on' }, 'refusalHandling must be an object, such as { autoRewind: true }, not a string'), + refused({ toolResultGuard: 0 }, `toolResultGuard must be true or false, not a number: ${TOOL_RESULT_GUARD}`), + refused({ toolWrapperProseGuard: { on: true } }, `toolWrapperProseGuard must be true or false, not an object: ${TOOL_WRAPPER_PROSE_GUARD}`), + ]); + }); + + it('createEphemeralAgent refuses an agent switch that is not true or false, and leaves the name free', async () => { + const { tempDir, storePath } = tempStorePath('config-switches-ephemeral-'); + let framework: AgentFramework | undefined; + try { + framework = await createWith(storePath, {}); + const worker = { name: 'worker', model: 'test-model', systemPrompt: 'Do the task.' }; + await assert.rejects(framework.createEphemeralAgent({ ...worker, toolWrapperProseGuard: 'false' } as never), (error: Error) => { + assert.equal(error.message, `Agent "worker" toolWrapperProseGuard must be true or false, unquoted, not a string: ${TOOL_WRAPPER_PROSE_GUARD}`); + return true; + }); + const created = await framework.createEphemeralAgent(worker); + created.cleanup(); + } finally { + await framework?.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('a null switch counts as left out, and a subconscious that is off has allowChannelSpeech left unchecked', async () => { + for (const enabled of [null, false]) { + const { tempDir, storePath } = tempStorePath('config-switches-unset-'); + let framework: AgentFramework | undefined; + try { + framework = await createWith(storePath, { + subconscious: { enabled, systemPrompt: 'x', allowChannelSpeech: 'yes' }, + watchdog: { enabled: null }, + processLogging: { persist: null }, + agents: [agentWith({ + promptCaching: null, + thinking: { enabled: null }, + refusalHandling: { autoRewind: null }, + toolResultGuard: null, + toolWrapperProseGuard: null, + })], + }); + assert.equal(framework.getAgent('Subconscious'), null, 'no subconscious resident'); + assert.equal(framework.isProcessLoggingEnabled().persist, false); + const agent = framework.getAgent('agent'); + assert.ok(agent); + assert.equal(agent.promptCaching, true, 'promptCaching left out is on'); + assert.equal(agent.toolResultGuard.enabled, false); + assert.equal(agent.toolWrapperProseGuard, false); + } finally { + await framework?.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + } + }); +}); From 0c3520813966297dfb19a29027c3bd831b3c54ba Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sun, 18 Oct 2026 04:44:16 -0400 Subject: [PATCH 2/4] fix: the Agent constructor checks its switches; a settings object of false counts as left out The Agent class is exported from the package entry, and its constructor read an agent's switches with no check, so a caller building an Agent directly kept the misread: new Agent({ ..., promptCaching: "false" }) left caching on, and toolResultGuard: "true" read as off without a word. The constructor now runs assertAgentSwitches first, so the rule holds wherever an Agent is built. create() and createEphemeralAgent still check first as well, so a refusal there leaves no store open and no state registered under the agent's name. A settings object of false (subconscious, watchdog, processLogging, an agent's thinking or refusalHandling, and codeExecution) was refused as not an object. Every reader of these goes through ?., so false was read as left out, which is what it says, and it now counts as left out, as null does. true and every other non-object are still refused, since every switch in them reads as left out whatever they say. validateCodeExecutionConfig, which create() calls only for a codeExecution that isn't null or left out, likewise treats a null or false it's handed as not set. Tests: new Agent() refuses a quoted promptCaching; promptCaching false is sent in the activation request as false; settings objects of null and false start with their switches off; the null test checks that the watchdog never printed that it armed; createEphemeralAgent's test checks that a refusal registers no state under the name, which the constructor's later refusal would; and code-execution's off test takes codeExecution: false. The changelog fragment names the nine switches it covers, and the codeExecution fragment says a codeExecution of null or false counts as left out. Co-Authored-By: Claude Opus 5.5 --- .../473-code-execution-enabled.fixed.md | 2 +- .../fix-config-switch-booleans.fixed.md | 2 +- src/agent.ts | 15 ++-- src/code-execution/tool-definition.ts | 7 +- src/config-checks.ts | 21 ++--- src/framework.ts | 12 +-- test/code-execution.test.ts | 5 +- test/config-switches.test.ts | 78 ++++++++++++++++--- 8 files changed, 105 insertions(+), 37 deletions(-) diff --git a/changelog.d/473-code-execution-enabled.fixed.md b/changelog.d/473-code-execution-enabled.fixed.md index fa4728cbb..2afc69f41 100644 --- a/changelog.d/473-code-execution-enabled.fixed.md +++ b/changelog.d/473-code-execution-enabled.fixed.md @@ -1 +1 @@ -- `codeExecution.enabled` takes `true` or `false`, or can be left out, and `null` counts as left out (#473). `create()` refuses any other value, and a `codeExecution` that isn't an object, naming the value's kind, not the value. The switch was read by truthiness, so `"enabled": "false"`, as a JSON config or a `${VAR}` substitution can write it, gave agents the `code_execution` tool and a python interpreter on the host, and a bare `"codeExecution": true` was read as off. Only `true` turns it on now. As before, a configuration that isn't on has its other settings left unchecked. A host that passes `codeExecution` on from its own configuration without checking it now gets this refusal from `create()` where such a value used to start. +- `codeExecution.enabled` takes `true` or `false`, or can be left out, and `null` counts as left out (#473). `create()` refuses any other value, and a `codeExecution` that isn't an object, `null` or `false`, naming the value's kind, not the value. The switch was read by truthiness, so `"enabled": "false"`, as a JSON config or a `${VAR}` substitution can write it, gave agents the `code_execution` tool and a python interpreter on the host, and a bare `"codeExecution": true` was read as off. Only `true` turns it on now. As before, a configuration that isn't on has its other settings left unchecked. A host that passes `codeExecution` on from its own configuration without checking it now gets this refusal from `create()` where such a value used to start. diff --git a/changelog.d/fix-config-switch-booleans.fixed.md b/changelog.d/fix-config-switch-booleans.fixed.md index b1926507e..cfba43cec 100644 --- a/changelog.d/fix-config-switch-booleans.fixed.md +++ b/changelog.d/fix-config-switch-booleans.fixed.md @@ -1 +1 @@ -- The framework's and each agent's boolean switches take `true` or `false`, or can be left out, and `null` counts as left out. `create()` refuses any other value, and a settings object holding them that isn't an object, naming the value's kind, not the value. The switches are `subconscious.enabled`, `subconscious.allowChannelSpeech` (checked once the subconscious is on), `watchdog.enabled` and `processLogging.persist`, and each agent's `promptCaching`, `thinking.enabled`, `refusalHandling.autoRewind`, `toolResultGuard` and `toolWrapperProseGuard`, which `createEphemeralAgent` refuses too. Each was read by truthiness, so a quoted `"false"`, as a JSON config or a `${VAR}` substitution can write it, turned it on, and a falsy non-boolean such as `0` turned it off without a word. `promptCaching` is still on unless it's `false`. A host that passes these on from its own configuration without checking them now gets this refusal from `create()` where such a value used to start. +- Nine boolean switches take `true` or `false`, or can be left out, and `null` counts as left out: `subconscious.enabled`, `subconscious.allowChannelSpeech` (checked once the subconscious is on), `watchdog.enabled` and `processLogging.persist`, and each agent's `promptCaching`, `thinking.enabled`, `refusalHandling.autoRewind`, `toolResultGuard` and `toolWrapperProseGuard`. `create()` refuses any other value, naming the value's kind, not the value, and `createEphemeralAgent` and the `Agent` constructor refuse it in an agent's. Each was read by truthiness, so a quoted `"false"`, as a JSON config or a `${VAR}` substitution can write it, turned it on, and a falsy non-boolean such as `0` turned it off without a word. A settings object holding them (`subconscious`, `watchdog`, `processLogging`, an agent's `thinking` or `refusalHandling`) counts as left out when it's `null` or `false`, and anything else that isn't an object, such as `true`, is refused, since every switch in it was read as left out. `promptCaching` is still on unless it's `false`. A host that passes these on from its own configuration without checking them now gets this refusal from `create()` where such a value used to start. diff --git a/src/agent.ts b/src/agent.ts index 54ef32348..708d8c03b 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -77,12 +77,14 @@ const DEFAULT_TRANSITION_PACE_TOKENS = 16_000; const DEFAULT_SAME_ROUND_THINK_TEXT_POLICY: SameRoundThinkTextPolicy = 'public'; /** - * Refuse an agent config whose `thinking` or `refusalHandling` isn't an object, - * or whose switch holds anything but true or false (assertBooleanSetting). An - * Agent reads these switches as given, so `create()` checks each of its agents - * before anything opens, and `createEphemeralAgent` checks the config it's - * handed. Membrane reads `thinking.enabled`, and the refusal is here because - * the setting is AgentConfig's. + * Refuse an agent config whose `thinking` or `refusalHandling` is anything but + * an object, null or false (holdsSettings), or whose switch holds anything but + * true or false (assertBooleanSetting). The constructor runs it first, so the + * rule holds wherever an Agent is built. `create()` also runs it for each of + * its agents before anything opens, and `createEphemeralAgent` before it + * reserves the name, so a refusal there leaves nothing behind. Membrane reads + * `thinking.enabled`, and the refusal is here because the setting is + * AgentConfig's. */ export function assertAgentSwitches(config: AgentConfig): void { const agent = `Agent "${config.name}"`; @@ -266,6 +268,7 @@ export class Agent { contextManager: ContextManager, membrane: Membrane ) { + assertAgentSwitches(config); this.name = config.name; this.model = config.model; this.systemPrompt = config.systemPrompt; diff --git a/src/code-execution/tool-definition.ts b/src/code-execution/tool-definition.ts index 4a7ea73ff..4001aae33 100644 --- a/src/code-execution/tool-definition.ts +++ b/src/code-execution/tool-definition.ts @@ -121,9 +121,10 @@ export function scriptTimeLimits(opts?: { * contradict each other. A JSON config, or a `${VAR}` substituted as a string, * can carry a quoted "false" where the type says boolean, and only `true` turns * code execution on, so the quotes would hide what the value does; a bare - * `codeExecution: true` would be read as off. A null codeExecution or switch - * counts as not set, as a left-out one does. The refusal names the value's - * kind, not the value. A configuration that isn't on has nothing else read. + * `codeExecution: true` would be read as off. A null or false codeExecution, + * and a null switch, count as not set, as left-out ones do. The refusal names + * the value's kind, not the value. A configuration that isn't on has nothing + * else read. */ export function validateCodeExecutionConfig(cfg: { enabled?: unknown; diff --git a/src/config-checks.ts b/src/config-checks.ts index dca1937bc..06fb75635 100644 --- a/src/config-checks.ts +++ b/src/config-checks.ts @@ -1,10 +1,10 @@ /** * Refusals shared by the configuration checks. A setting read by truthiness - * turns on for a quoted "false", and a settings object that isn't an object - * has every setting in it read as left out. A JSON config, or a `${VAR}` - * substituted as a string, can carry either where the type says otherwise, - * so each refusal says what the value would have done. Each names the - * setting and the value's kind, never the value, which could be a secret + * turns on for a quoted "false", and a settings object such as `true`, which + * isn't an object, has every setting in it read as left out. A JSON config, + * or a `${VAR}` substituted as a string, can carry either where the type says + * otherwise, so each refusal says what the value would have done. Each names + * the setting and the value's kind, never the value, which could be a secret * pasted into the wrong field. */ @@ -33,13 +33,14 @@ export function assertBooleanSetting(setting: string, value: unknown, whatItDoes } /** - * Whether a settings object holds settings to check: false when it's null or - * left out, which count as not set. Anything else that isn't an object, an - * array included, is refused, since every setting in it would read as left - * out. + * Whether a settings object holds settings to check: false when it's null, + * false or left out, which count as not set, since every setting in it reads + * as left out and those say as much. Anything else that isn't an object, + * `true` and an array included, is refused, since every setting in it would + * read as left out whatever it says. */ export function holdsSettings(setting: string, value: T, example: string): value is NonNullable { - if (value === undefined || value === null) return false; + if (value === undefined || value === null || value === false) return false; if (typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${setting} must be an object, such as ${example}, not ${kindOf(value)}`); } diff --git a/src/framework.ts b/src/framework.ts index 3d4a01413..d13ff3b25 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -823,10 +823,11 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number { } /** - * Refuse a config whose `subconscious`, `watchdog` or `processLogging` isn't an - * object, or whose switch in one holds anything but true or false - * (assertBooleanSetting). A subconscious that isn't on has its - * `allowChannelSpeech` left unchecked, since nothing reads it then. + * Refuse a config whose `subconscious`, `watchdog` or `processLogging` is + * anything but an object, null or false (holdsSettings), or whose switch in + * one holds anything but true or false (assertBooleanSetting). A subconscious + * that isn't on has its `allowChannelSpeech` left unchecked, since nothing + * reads it then. */ function assertFrameworkSwitches(config: FrameworkConfig): void { if (holdsSettings('subconscious', config.subconscious, '{ enabled: true }')) { @@ -6927,7 +6928,8 @@ export class AgentFramework { contextManager: ContextManager; cleanup: () => void; }> { - // Checked before the name is reserved, so a refused config leaves it free. + // Checked before anything happens: the constructor checks too, but only + // after the name is reserved and the agent's states are registered. assertAgentSwitches(config); // Names are Chronicle namespaces and generation identities. Reserve before // opening the namespace so a second creation cannot append to an earlier diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index db3ae45a5..2799809b6 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -1578,7 +1578,7 @@ describe('code_execution per-call time limit (time_limit_ms)', () => { ]); }); - it('create() refuses a codeExecution that is not an object, which would read as off', async () => { + it('create() refuses a codeExecution that is not an object, null or false, since it would read as off', async () => { await assertRefused([ [true, 'codeExecution must be an object, such as { enabled: true }, not a boolean'], ['true', 'codeExecution must be an object, such as { enabled: true }, not a string'], @@ -1586,11 +1586,12 @@ describe('code_execution per-call time limit (time_limit_ms)', () => { ]); }); - it('a null or left-out codeExecution.enabled counts as off, and a configuration that is off has nothing else checked', async () => { + it('a null or false codeExecution, or a null or left-out codeExecution.enabled, counts as off, and a configuration that is off has nothing else checked', async () => { // Contradictory limits are refused only once code execution is on. const contradictory = { scriptTimeoutMs: 600_000, maxScriptTimeoutMs: 60_000 }; for (const codeExecution of [ null, + false, { enabled: null, ...contradictory }, { enabled: false, ...contradictory }, { ...contradictory }, diff --git a/test/config-switches.test.ts b/test/config-switches.test.ts index 474e751cb..ae9614017 100644 --- a/test/config-switches.test.ts +++ b/test/config-switches.test.ts @@ -1,17 +1,20 @@ /** - * The framework's and each agent's boolean switches take true or false, or - * can be left out, with null counting as left out. Each was read by - * truthiness, so a quoted "false", as a JSON config or a `${VAR}` + * Nine boolean switches in the framework's and each agent's config take true + * or false, or can be left out, with null counting as left out, and a settings + * object holding them takes an object, or null or false for none. Each switch + * was read by truthiness, so a quoted "false", as a JSON config or a `${VAR}` * substitution can write it, turned it on, and a falsy non-boolean such as 0 * turned it off without a word. */ -import { describe, it } from 'node:test'; +import { describe, it, type TestContext } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { ContextManager } from '@animalabs/context-manager'; +import type { Membrane } from '@animalabs/membrane'; -import { AgentFramework } from '../src/index.js'; +import { Agent, AgentFramework } from '../src/index.js'; import { MockMembrane } from './helpers/mock-membrane.js'; function tempStorePath(prefix: string): { tempDir: string; storePath: string } { @@ -36,6 +39,22 @@ const agentWith = (settings: Record) => ({ ...settings, }); +/** An Agent built directly, as a caller importing the class can. Building an + * activation request needs only compile(), and membrane is never touched. */ +const stubContextManager = { + compile: async () => ({ messages: [], systemInjections: [] }), +} as unknown as ContextManager; +const newAgent = (settings: Record) => + new Agent(agentWith(settings) as never, stubContextManager, {} as Membrane); + +/** Records console.error for the rest of the test, so the test can say + * whether the liveness watchdog printed the line it prints when it arms. */ +function watchdogLines(t: TestContext): () => string[] { + const lines: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { lines.push(args.map(String).join(' ')); }); + return () => lines.filter((line) => line.startsWith('[liveness-watchdog]')); +} + /** create() refuses each config with exactly its message, before anything * opens: no store is left at storePath. */ async function assertRefused(cases: ReadonlyArray, string]>): Promise { @@ -65,7 +84,7 @@ const TOOL_RESULT_GUARD = 'true turns on tool-output admission, with one guarded const TOOL_WRAPPER_PROSE_GUARD = 'true keeps a response whose whole prose is a tool-call-shaped wrapper from running or entering the agent\'s history, ' + doesnt; describe('config switches', () => { - it('create() refuses a framework switch that is not true or false, and a settings object that is not an object', async () => { + it('create() refuses a framework switch that is not true or false, and a settings object that is not an object, null or false', async () => { await assertRefused([ [{ subconscious: { enabled: 'false', systemPrompt: 'x' } }, `subconscious.enabled must be true or false, unquoted, not a string: ${SUBCONSCIOUS_ENABLED}`], [{ subconscious: { enabled: true, systemPrompt: 'x', allowChannelSpeech: 'false' } }, `subconscious.allowChannelSpeech must be true or false, unquoted, not a string: ${ALLOW_CHANNEL_SPEECH}`], @@ -77,7 +96,7 @@ describe('config switches', () => { ]); }); - it('create() refuses an agent switch that is not true or false, and a settings object that is not an object', async () => { + it('create() refuses an agent switch that is not true or false, and a settings object that is not an object, null or false', async () => { const refused = (settings: Record, message: string) => [{ agents: [agentWith(settings)] }, `Agent "agent" ${message}`] as const; await assertRefused([ @@ -91,17 +110,21 @@ describe('config switches', () => { ]); }); - it('createEphemeralAgent refuses an agent switch that is not true or false, and leaves the name free', async () => { + it('createEphemeralAgent refuses an agent switch that is not true or false, before it registers anything for the name', async () => { const { tempDir, storePath } = tempStorePath('config-switches-ephemeral-'); let framework: AgentFramework | undefined; try { framework = await createWith(storePath, {}); + const store = framework.getStore(); + const workerStates = () => store.listStates().map((state) => state.id).filter((id) => id.startsWith('subagent/worker/')); const worker = { name: 'worker', model: 'test-model', systemPrompt: 'Do the task.' }; await assert.rejects(framework.createEphemeralAgent({ ...worker, toolWrapperProseGuard: 'false' } as never), (error: Error) => { assert.equal(error.message, `Agent "worker" toolWrapperProseGuard must be true or false, unquoted, not a string: ${TOOL_WRAPPER_PROSE_GUARD}`); return true; }); + assert.deepEqual(workerStates(), [], 'the refused config registered no state under its name'); const created = await framework.createEphemeralAgent(worker); + assert.notDeepEqual(workerStates(), [], 'a created agent registers its states under its name'); created.cleanup(); } finally { await framework?.stop(); @@ -109,7 +132,43 @@ describe('config switches', () => { } }); - it('a null switch counts as left out, and a subconscious that is off has allowChannelSpeech left unchecked', async () => { + it('new Agent() refuses a switch that is not true or false, so the rule holds wherever an agent is built', () => { + assert.throws(() => newAgent({ promptCaching: 'false' }), (error: Error) => { + assert.equal(error.message, `Agent "agent" promptCaching must be true or false, unquoted, not a string: ${PROMPT_CACHING}`); + return true; + }); + }); + + it('promptCaching false leaves the prompt-caching markers off', async () => { + const request = await newAgent({ promptCaching: false }).buildActivationRequest([]); + assert.equal(request.promptCaching, false); + }); + + it('a settings object set to null or false counts as left out', async (t) => { + const watchdog = watchdogLines(t); + for (const unset of [null, false]) { + const { tempDir, storePath } = tempStorePath('config-switches-no-settings-'); + let framework: AgentFramework | undefined; + try { + framework = await createWith(storePath, { + subconscious: unset, + watchdog: unset, + processLogging: unset, + agents: [agentWith({ thinking: unset, refusalHandling: unset })], + }); + assert.equal(framework.getAgent('Subconscious'), null, 'no subconscious resident'); + assert.equal(framework.isProcessLoggingEnabled().persist, false); + assert.ok(framework.getAgent('agent')); + } finally { + await framework?.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + } + assert.deepEqual(watchdog(), [], 'the watchdog never armed'); + }); + + it('a null switch counts as left out, and a subconscious that is off has allowChannelSpeech left unchecked', async (t) => { + const watchdog = watchdogLines(t); for (const enabled of [null, false]) { const { tempDir, storePath } = tempStorePath('config-switches-unset-'); let framework: AgentFramework | undefined; @@ -138,5 +197,6 @@ describe('config switches', () => { rmSync(tempDir, { recursive: true, force: true }); } } + assert.deepEqual(watchdog(), [], 'the watchdog never armed'); }); }); From 4448c6167071ff2fc797d3ae8e33cfbe79f9b3c3 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sun, 18 Oct 2026 05:13:00 -0400 Subject: [PATCH 3/4] docs, test: say exactly what the checks and their tests cover The test names and holdsSettings' doc no longer read as refusing a settings object of null or false. FrameworkConfig.codeExecution's doc says null and false count as omitted. assertAgentSwitches' doc names every switch it checks, and createEphemeralAgent's comment says what the constructor's own check would come after. The promptCaching test's name says what it checks: the flag the request carries to membrane. A test now turns the watchdog on and checks that it prints the line the two never-armed checks look for, so those checks can fail. Co-Authored-By: Claude Opus 5.5 --- src/agent.ts | 17 +++++++++-------- src/config-checks.ts | 8 ++++---- src/framework.ts | 3 ++- src/types/framework.ts | 5 +++-- test/config-switches.test.ts | 19 ++++++++++++++++--- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index 708d8c03b..1af452155 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -77,14 +77,15 @@ const DEFAULT_TRANSITION_PACE_TOKENS = 16_000; const DEFAULT_SAME_ROUND_THINK_TEXT_POLICY: SameRoundThinkTextPolicy = 'public'; /** - * Refuse an agent config whose `thinking` or `refusalHandling` is anything but - * an object, null or false (holdsSettings), or whose switch holds anything but - * true or false (assertBooleanSetting). The constructor runs it first, so the - * rule holds wherever an Agent is built. `create()` also runs it for each of - * its agents before anything opens, and `createEphemeralAgent` before it - * reserves the name, so a refusal there leaves nothing behind. Membrane reads - * `thinking.enabled`, and the refusal is here because the setting is - * AgentConfig's. + * Refuse an agent config whose `promptCaching`, `toolResultGuard`, + * `toolWrapperProseGuard`, `thinking.enabled` or `refusalHandling.autoRewind` + * holds anything but true or false (assertBooleanSetting), or whose `thinking` + * or `refusalHandling` is anything but an object, null or false + * (holdsSettings). The constructor runs it first, so the rule holds wherever + * an Agent is built. `create()` also runs it for each of its agents before + * anything opens, and `createEphemeralAgent` before it reserves the name, so a + * refusal there leaves nothing behind. Membrane reads `thinking.enabled`, and + * the refusal is here because the setting is AgentConfig's. */ export function assertAgentSwitches(config: AgentConfig): void { const agent = `Agent "${config.name}"`; diff --git a/src/config-checks.ts b/src/config-checks.ts index 06fb75635..87889424a 100644 --- a/src/config-checks.ts +++ b/src/config-checks.ts @@ -34,10 +34,10 @@ export function assertBooleanSetting(setting: string, value: unknown, whatItDoes /** * Whether a settings object holds settings to check: false when it's null, - * false or left out, which count as not set, since every setting in it reads - * as left out and those say as much. Anything else that isn't an object, - * `true` and an array included, is refused, since every setting in it would - * read as left out whatever it says. + * false or left out, which count as not set. Each of those has every setting + * in it read as left out, which is what null and false ask for. Anything else + * that isn't an object, `true` and an array included, is refused, since every + * setting in it would read as left out too, whatever it says. */ export function holdsSettings(setting: string, value: T, example: string): value is NonNullable { if (value === undefined || value === null || value === false) return false; diff --git a/src/framework.ts b/src/framework.ts index d13ff3b25..ba9c52492 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -6929,7 +6929,8 @@ export class AgentFramework { cleanup: () => void; }> { // Checked before anything happens: the constructor checks too, but only - // after the name is reserved and the agent's states are registered. + // after the name is reserved and ContextManager.open has registered the + // agent's message and context states in the store. assertAgentSwitches(config); // Names are Chronicle namespaces and generation identities. Reserve before // opening the namespace so a second creation cannot append to an earlier diff --git a/src/types/framework.ts b/src/types/framework.ts index a58cdf41d..1818a2789 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -212,8 +212,9 @@ export interface FrameworkConfig { /** * Client-side programmatic tool calling (`code_execution` tool). Omitted or - * `enabled: false` → tool absent, no python is ever spawned. A value that - * isn't an object is refused at `create()`. + * `enabled: false` → tool absent, no python is ever spawned. `null` and + * `false` count as omitted, and any other value that isn't an object is + * refused at `create()`. */ codeExecution?: CodeExecutionConfig; diff --git a/test/config-switches.test.ts b/test/config-switches.test.ts index ae9614017..00c789761 100644 --- a/test/config-switches.test.ts +++ b/test/config-switches.test.ts @@ -84,7 +84,7 @@ const TOOL_RESULT_GUARD = 'true turns on tool-output admission, with one guarded const TOOL_WRAPPER_PROSE_GUARD = 'true keeps a response whose whole prose is a tool-call-shaped wrapper from running or entering the agent\'s history, ' + doesnt; describe('config switches', () => { - it('create() refuses a framework switch that is not true or false, and a settings object that is not an object, null or false', async () => { + it('create() refuses a framework switch that is not true or false, and a settings object other than an object, null or false', async () => { await assertRefused([ [{ subconscious: { enabled: 'false', systemPrompt: 'x' } }, `subconscious.enabled must be true or false, unquoted, not a string: ${SUBCONSCIOUS_ENABLED}`], [{ subconscious: { enabled: true, systemPrompt: 'x', allowChannelSpeech: 'false' } }, `subconscious.allowChannelSpeech must be true or false, unquoted, not a string: ${ALLOW_CHANNEL_SPEECH}`], @@ -96,7 +96,7 @@ describe('config switches', () => { ]); }); - it('create() refuses an agent switch that is not true or false, and a settings object that is not an object, null or false', async () => { + it('create() refuses an agent switch that is not true or false, and a settings object other than an object, null or false', async () => { const refused = (settings: Record, message: string) => [{ agents: [agentWith(settings)] }, `Agent "agent" ${message}`] as const; await assertRefused([ @@ -139,7 +139,7 @@ describe('config switches', () => { }); }); - it('promptCaching false leaves the prompt-caching markers off', async () => { + it('promptCaching false is sent in the request as false, the flag membrane reads for its caching markers', async () => { const request = await newAgent({ promptCaching: false }).buildActivationRequest([]); assert.equal(request.promptCaching, false); }); @@ -199,4 +199,17 @@ describe('config switches', () => { } assert.deepEqual(watchdog(), [], 'the watchdog never armed'); }); + + it('a watchdog that is on prints that it armed, the line the tests above look for', async (t) => { + const watchdog = watchdogLines(t); + const { tempDir, storePath } = tempStorePath('config-switches-watchdog-'); + let framework: AgentFramework | undefined; + try { + framework = await createWith(storePath, { watchdog: { enabled: true, thresholdMs: 600_000, action: 'exit' } }); + assert.ok(watchdog().some((line) => line.startsWith('[liveness-watchdog] armed')), `watchdog lines: ${JSON.stringify(watchdog())}`); + } finally { + await framework?.stop(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); From 976e6e87fd6dbe5aca7b00dd325fee54f2f8e795 Mon Sep 17 00:00:00 2001 From: Effie-2756 Date: Sun, 18 Oct 2026 06:28:30 -0400 Subject: [PATCH 4/4] docs(types): codeExecution's null and false are for untyped configs FrameworkConfig.codeExecution's type is still CodeExecutionConfig or left out, so a TypeScript caller can't write null or false there. The doc now says those two count as omitted at runtime, from an untyped config. Co-Authored-By: Claude Opus 5.5 --- src/types/framework.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/types/framework.ts b/src/types/framework.ts index 4d7441f3d..64e8c6a95 100644 --- a/src/types/framework.ts +++ b/src/types/framework.ts @@ -212,9 +212,9 @@ export interface FrameworkConfig { /** * Client-side programmatic tool calling (`code_execution` tool). Omitted or - * `enabled: false` → tool absent, no python is ever spawned. `null` and - * `false` count as omitted, and any other value that isn't an object is - * refused at `create()`. + * `enabled: false` → tool absent, no python is ever spawned. At runtime, + * untyped configs may also use `null` or `false` to omit this setting. + * Other values that aren't objects are refused at `create()`. */ codeExecution?: CodeExecutionConfig;