openclaw/src/agents/code-mode-runtime.ts
Peter Steinberger d5e6038577
feat: enable automatic Code Mode for preferred models (#155614)
* feat: enable automatic Code Mode for preferred models

* test: preserve parser boundary and automatic default expectations

* ci: refresh merge proof after upstream tooling type repair

Refresh the merge ref after main restored the tsx CLI shim declaration in 80b9608a25. No source changes.
2026-09-22 04:19:15 -07:00

280 lines
9.5 KiB
TypeScript

import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
import { normalizeAgentModelRefForConfig } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { modelKey } from "../shared/model-key.js";
import { clampNumber } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope-config.js";
import type { CodeModeFailureCode } from "./code-mode-executor-types.js";
import type { CodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import { CODE_MODE_RESULTS_API_FILE } from "./code-mode-results-api.js";
import {
MAX_CODE_MODE_PENDING_TOOL_CALLS,
type CodeModeConfig as CodeModeWorkerConfig,
} from "./code-mode-worker-types.js";
import type { ToolSearchConfig, ToolSearchToolContext } from "./tool-search.js";
import { asToolParamsRecord, ToolInputError } from "./tools/common.js";
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_MEMORY_LIMIT_BYTES = 64 * 1024 * 1024;
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
const DEFAULT_MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_PENDING_TOOL_CALLS = 16;
const DEFAULT_SNAPSHOT_TTL_SECONDS = 900;
const DEFAULT_SEARCH_LIMIT = 8;
const DEFAULT_MAX_SEARCH_LIMIT = 50;
export { CODE_MODE_WORKER_WATCHDOG_GRACE_MS } from "./code-mode-worker-types.js";
export const DEFAULT_HEADLESS_WALL_CLOCK_MS = 30_000;
// Cron script payloads persist caps of 900 seconds and 200 tool calls.
// The shared executor must not silently lower those accepted job limits.
export const MAX_HEADLESS_WALL_CLOCK_MS = 900_000;
export const DEFAULT_HEADLESS_TOOL_CALLS = 5;
export const MAX_HEADLESS_TOOL_CALLS = 200;
/** Resolved Code Mode runtime limits. */
export type CodeModeConfig = CodeModeWorkerConfig & {
/** Effective activation policy; "auto" follows the model catalog flag. */
enabled: boolean | "auto";
executor: "node" | "quickjs";
mode: "only";
snapshotTtlSeconds: number;
searchDefaultLimit: number;
maxSearchLimit: number;
};
export type {
CodeModeSettlementMode,
PendingBridgeRequest,
SettledBridgeRequest,
} from "./code-mode-worker-types.js";
export type { CodeModeFailureCode, CodeModeWorkerResult } from "./code-mode-executor-types.js";
export { codeModeFailureCode, codeModeFailureMessage } from "./code-mode-errors.js";
export type CodeModeHeadlessResult =
| {
status: "completed";
value: unknown;
output: unknown[];
toolCallCount: number;
}
| {
status: "failed";
code: CodeModeFailureCode | "tool_budget_exceeded";
error: string;
output: unknown[];
toolCallCount: number;
};
function normalizeCodeModeRawConfig(value: unknown): Record<string, unknown> | undefined {
const codeMode = value;
if (codeMode === true) {
return { enabled: true };
}
if (codeMode === false) {
return { enabled: false };
}
if (codeMode === "auto") {
return { enabled: "auto" };
}
return isRecord(codeMode) ? codeMode : undefined;
}
function readCodeModeRawConfig(
config?: OpenClawConfig,
agentId?: string,
model?: { provider: string; modelId: string },
): Record<string, unknown> {
const tools = isRecord(config?.tools) ? config.tools : undefined;
const globalRaw = normalizeCodeModeRawConfig(tools?.codeMode) ?? { enabled: "auto" };
const agent = config && agentId ? resolveAgentConfig(config, agentId) : undefined;
const agentRaw = normalizeCodeModeRawConfig(agent?.tools?.codeMode);
const key = model
? normalizeAgentModelRefForConfig(modelKey(model.provider, model.modelId))
: undefined;
// An options-only agent object inherits activation; it must not hide a model
// override. Explicit false at either scope remains an authored choice.
return {
...globalRaw,
...agentRaw,
enabled:
(key ? agent?.models?.[key]?.codeMode : undefined) ??
agentRaw?.enabled ??
(key ? config?.agents?.defaults?.models?.[key]?.codeMode : undefined) ??
globalRaw.enabled,
};
}
function readEnabled(value: unknown): boolean | "auto" {
// Authored option-bearing objects keep their historical opt-in behavior.
return typeof value === "boolean" || value === "auto" ? value : false;
}
function readExecutor(value: unknown): CodeModeConfig["executor"] {
if (value === undefined) {
return "node";
}
if (value === "node" || value === "quickjs") {
return value;
}
throw new ToolInputError('Code Mode executor must be "node" or "quickjs".');
}
export function readPositiveInteger(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
}
/** Resolves Code Mode runtime limits from config. */
export function resolveCodeModeConfig(
config?: OpenClawConfig,
agentId?: string,
model?: { provider: string; modelId: string },
): CodeModeConfig {
const raw = readCodeModeRawConfig(config, agentId, model);
const maxSearchLimit = clampNumber(
readPositiveInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT),
1,
DEFAULT_MAX_SEARCH_LIMIT,
);
return {
enabled: readEnabled(raw.enabled),
executor: readExecutor(raw.executor),
mode: "only",
timeoutMs: clampNumber(readPositiveInteger(raw.timeoutMs, DEFAULT_TIMEOUT_MS), 100, 60_000),
memoryLimitBytes: clampNumber(
readPositiveInteger(raw.memoryLimitBytes, DEFAULT_MEMORY_LIMIT_BYTES),
1024 * 1024,
1024 * 1024 * 1024,
),
maxOutputBytes: clampNumber(
readPositiveInteger(raw.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES),
1024,
10 * 1024 * 1024,
),
maxSnapshotBytes: clampNumber(
readPositiveInteger(raw.maxSnapshotBytes, DEFAULT_MAX_SNAPSHOT_BYTES),
1024,
256 * 1024 * 1024,
),
maxPendingToolCalls: clampNumber(
readPositiveInteger(raw.maxPendingToolCalls, DEFAULT_MAX_PENDING_TOOL_CALLS),
1,
MAX_CODE_MODE_PENDING_TOOL_CALLS,
),
snapshotTtlSeconds: clampNumber(
readPositiveInteger(raw.snapshotTtlSeconds, DEFAULT_SNAPSHOT_TTL_SECONDS),
1,
24 * 60 * 60,
),
searchDefaultLimit: clampNumber(
readPositiveInteger(raw.searchDefaultLimit, DEFAULT_SEARCH_LIMIT),
1,
maxSearchLimit,
),
maxSearchLimit,
};
}
/**
* Resolves the effective activation policy against one model's catalog flag.
* `true`/`false` are absolute; `"auto"` engages only for models whose catalog
* compat declares `codeMode: "preferred"`. This gates the model-facing tool
* surface only; runs that route to a provider-native harness (for example the
* default OpenAI Codex surface) never reach this embedded-runtime gate.
*/
export function isCodeModeEngagedForModel(
config: Pick<CodeModeConfig, "enabled">,
model: { compat?: unknown } | undefined,
): boolean {
if (config.enabled !== "auto") {
return config.enabled;
}
const compat =
model?.compat && typeof model.compat === "object"
? (model.compat as { codeMode?: unknown })
: undefined;
return compat?.codeMode === "preferred";
}
export function toToolSearchConfig(config: CodeModeConfig): ToolSearchConfig {
return {
enabled: true,
mode: "tools",
codeTimeoutMs: config.timeoutMs,
searchDefaultLimit: config.searchDefaultLimit,
maxSearchLimit: config.maxSearchLimit,
};
}
export function resolveCodeModeHeadlessConfig(
ctx: ToolSearchToolContext,
overrides?: Partial<
Pick<
CodeModeConfig,
| "timeoutMs"
| "memoryLimitBytes"
| "maxOutputBytes"
| "maxSnapshotBytes"
| "maxPendingToolCalls"
>
>,
): CodeModeConfig {
const base = resolveCodeModeConfig(ctx.runtimeConfig ?? ctx.config, ctx.agentId);
const definedOverrides = Object.fromEntries(
Object.entries(overrides ?? {}).filter(([, value]) => value !== undefined),
);
return resolveCodeModeConfig({
tools: { codeMode: { ...base, ...definedOverrides } },
} as OpenClawConfig);
}
export function readCode(args: unknown): {
code: string;
restartSafe: boolean;
} {
const params = asToolParamsRecord(args);
// Full-schema tool calls can materialize an unused alias as blank.
// Only nonblank aliases participate in divergence checks.
const codeAlias = readNonBlankString(params.code);
const commandAlias = readNonBlankString(params.command);
if (codeAlias !== undefined && commandAlias !== undefined && codeAlias !== commandAlias) {
throw new ToolInputError("code and command must match when both are provided.");
}
const code = commandAlias ?? codeAlias;
if (code === undefined) {
throw new ToolInputError("code or command must be a non-empty string.");
}
if (params.language !== undefined || params.typecheck !== undefined) {
throw new ToolInputError(
"Code Mode accepts JavaScript only. Remove language and typecheck; use API.read(...) for tool types.",
);
}
const restartSafe = params.restartSafe;
if (restartSafe !== undefined && typeof restartSafe !== "boolean") {
throw new ToolInputError("restartSafe must be a boolean.");
}
return {
code,
restartSafe: restartSafe === true,
};
}
export function readRunId(args: unknown): string {
const params = asToolParamsRecord(args);
const runId = params.runId ?? params.run_id;
if (typeof runId !== "string" || !runId.trim()) {
throw new ToolInputError("runId must be a non-empty string.");
}
return runId.trim();
}
export function createCodeModeApiFilesForRun(
namespaceRuntime: CodeModeNamespaceRuntime,
swarmEnabled: boolean,
) {
const { apiFiles: files } = namespaceRuntime;
return [
CODE_MODE_RESULTS_API_FILE,
...(swarmEnabled ? files : files.filter((file) => file.path !== "agents.d.ts")),
];
}