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 new file mode 100644 index 000000000..cfba43cec --- /dev/null +++ b/changelog.d/fix-config-switch-booleans.fixed.md @@ -0,0 +1 @@ +- 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 8d976d250..06168e883 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,53 @@ 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 `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}"`; + 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. */ @@ -221,6 +269,7 @@ export class Agent { contextManager: ContextManager, membrane: Membrane ) { + assertAgentSwitches(config); this.name = config.name; this.model = config.model; this.systemPrompt = config.systemPrompt; @@ -230,11 +279,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..4001aae33 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,23 @@ 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 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; 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..87889424a --- /dev/null +++ b/src/config-checks.ts @@ -0,0 +1,48 @@ +/** + * Refusals shared by the configuration checks. A setting read by truthiness + * 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. + */ + +/** 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, + * 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; + 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 394ccd81e..98544379b 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 { logDetail, logValue } from './mcpl/log-value.js'; @@ -932,6 +933,46 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number { ); } +/** + * 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 }')) { + 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 @@ -2393,6 +2434,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; @@ -2510,7 +2553,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 @@ -2593,7 +2636,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); } @@ -2694,7 +2737,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, @@ -2845,7 +2888,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), }, ); @@ -7105,6 +7148,10 @@ export class AgentFramework { contextManager: ContextManager; cleanup: () => void; }> { + // Checked before anything happens: the constructor checks too, but only + // 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 // generation before runEphemeralToCompletion has a chance to reject it. @@ -16114,7 +16161,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 b1a4b6e84..5b6fbc50c 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'; import { logDetail } from './log-value.js'; /** Why/how a transport closed. `code`/`signal` are populated for stdio child @@ -578,25 +579,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/src/types/framework.ts b/src/types/framework.ts index c29bd7e6b..64e8c6a95 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. 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; diff --git a/test/code-execution.test.ts b/test/code-execution.test.ts index 1a5b844f7..4a92ad2f3 100644 --- a/test/code-execution.test.ts +++ b/test/code-execution.test.ts @@ -1623,7 +1623,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'], @@ -1631,11 +1631,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 new file mode 100644 index 000000000..00c789761 --- /dev/null +++ b/test/config-switches.test.ts @@ -0,0 +1,215 @@ +/** + * 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, 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 { Agent, 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, +}); + +/** 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 { + 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 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}`], + [{ 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 other than an object, null or false', 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, 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(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + 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 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); + }); + + 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; + 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 }); + } + } + 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 }); + } + }); +});