Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog.d/473-code-execution-enabled.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/fix-config-switch-booleans.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 52 additions & 3 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
27 changes: 12 additions & 15 deletions src/code-execution/tool-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)) {
Expand Down
48 changes: 48 additions & 0 deletions src/config-checks.ts
Original file line number Diff line number Diff line change
@@ -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<T>(setting: string, value: T, example: string): value is NonNullable<T> {
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;
}
59 changes: 53 additions & 6 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2393,6 +2434,8 @@ export class AgentFramework {
static async create(config: FrameworkConfig): Promise<AgentFramework> {
// 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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
},
);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
18 changes: 2 additions & 16 deletions src/mcpl/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -578,25 +579,10 @@ const SERVER_CONFIG_BOOLEANS: Record<BooleanSetting, string> = {
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
Expand Down
5 changes: 3 additions & 2 deletions src/types/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading