mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* 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.
280 lines
9.5 KiB
TypeScript
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")),
|
|
];
|
|
}
|