Files
openclaw/src/agents/cli-backends.ts
2026-08-04 02:08:23 -05:00

490 lines
18 KiB
TypeScript

/**
* Resolves CLI runtime backends registered by plugins or setup metadata.
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ContextEngineHostCapability } from "../context-engine/types.js";
import type {
CliBackendConfig,
CliBackendLiveSessionRequirement,
CliBackendRuntimeArtifactPolicy,
} from "../plugins/cli-backend.types.js";
import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js";
import {
resolvePluginSetupCliBackend,
resolvePluginSetupRegistry,
} from "../plugins/setup-registry.js";
import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js";
import type {
CliBackendAuthEpochMode,
CliBackendNormalizeConfigContext,
CliBundleMcpMode,
CliBackendPlugin,
CliBackendNativeToolMode,
CliBackendSideQuestionToolMode,
CliBackendToolAvailabilityEnforcement,
PluginTextTransforms,
} from "../plugins/types.js";
import { mergePluginTextTransforms } from "./plugin-text-transforms.js";
type CliBackendsDeps = {
resolvePluginSetupCliBackend: typeof resolvePluginSetupCliBackend;
resolvePluginSetupRegistry: typeof resolvePluginSetupRegistry;
resolveRuntimeCliBackends: typeof resolveRuntimeCliBackends;
};
const defaultCliBackendsDeps: CliBackendsDeps = {
resolvePluginSetupCliBackend,
resolvePluginSetupRegistry,
resolveRuntimeCliBackends,
};
let cliBackendsDeps: CliBackendsDeps = defaultCliBackendsDeps;
/** Fully merged CLI backend definition used by agent runner execution. */
export type ResolvedCliBackend = {
id: string;
modelProvider?: string;
config: CliBackendConfig;
bundleMcp: boolean;
bundleMcpMode?: CliBundleMcpMode;
pluginId?: string;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
autoSelectAuthProfile?: boolean;
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
ownsNativeCompaction?: boolean;
prepareExecution?: CliBackendPlugin["prepareExecution"];
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
parseJsonlEvent?: CliBackendPlugin["parseJsonlEvent"];
toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement;
nativeToolMode?: CliBackendNativeToolMode;
sideQuestionToolMode?: CliBackendSideQuestionToolMode;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
liveSessionRequirement?: CliBackendLiveSessionRequirement;
};
type ResolvedCliBackendLiveTest = {
defaultModelRef?: string;
defaultImageProbe: boolean;
defaultMcpProbe: boolean;
dockerNpmPackage?: string;
dockerBinaryName?: string;
};
/** Binding between a model provider and the CLI runtime that serves it. */
type CliRuntimeModelBackendBinding = {
provider: string;
runtime: string;
pluginId?: string;
};
type FallbackCliBackendPolicy = {
modelProvider?: string;
bundleMcp: boolean;
bundleMcpMode?: CliBundleMcpMode;
baseConfig?: CliBackendConfig;
normalizeConfig?: (
config: CliBackendConfig,
context?: CliBackendNormalizeConfigContext,
) => CliBackendConfig;
transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"];
textTransforms?: PluginTextTransforms;
defaultAuthProfileId?: string;
authEpochMode?: CliBackendAuthEpochMode;
autoSelectAuthProfile?: boolean;
contextEngineHostCapabilities?: readonly ContextEngineHostCapability[];
ownsNativeCompaction?: boolean;
prepareExecution?: CliBackendPlugin["prepareExecution"];
resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"];
parseJsonlEvent?: CliBackendPlugin["parseJsonlEvent"];
toolAvailabilityEnforcement?: CliBackendToolAvailabilityEnforcement;
nativeToolMode?: CliBackendNativeToolMode;
sideQuestionToolMode?: CliBackendSideQuestionToolMode;
runtimeArtifact?: CliBackendRuntimeArtifactPolicy;
liveSessionRequirement?: CliBackendLiveSessionRequirement;
};
const FALLBACK_CLI_BACKEND_POLICIES: Record<string, FallbackCliBackendPolicy> = {};
function normalizeBundleMcpMode(
mode: CliBundleMcpMode | undefined,
enabled: boolean,
): CliBundleMcpMode | undefined {
if (!enabled) {
return undefined;
}
return mode ?? "claude-config-file";
}
function resolveToolAvailabilityEnforcement(
backend: Pick<
CliBackendPlugin,
"nativeToolMode" | "resolveExecutionArgs" | "toolAvailabilityEnforcement"
> & { builtWithOpenClawVersion?: string },
): CliBackendToolAvailabilityEnforcement | undefined {
if (backend.toolAvailabilityEnforcement) {
return backend.toolAvailabilityEnforcement;
}
// v2026.7.2-beta.1 through .3 made selectable + resolveExecutionArgs the
// public enforcement contract. Require matching package build provenance so
// a new no-op hook cannot be mistaken for that shipped SDK path.
const builtWith = backend.builtWithOpenClawVersion?.replace(/^v/u, "");
const isShippedBetaContract = /^2026\.7\.2-beta\.[123]$/u.test(builtWith ?? "");
return isShippedBetaContract &&
backend.nativeToolMode === "selectable" &&
backend.resolveExecutionArgs
? "execution-args"
: undefined;
}
function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolicy | undefined {
const entry = cliBackendsDeps.resolvePluginSetupCliBackend({
backend: provider,
});
if (!entry) {
return undefined;
}
return {
// Setup-registered backends keep narrow CLI paths generic even when the
// runtime plugin registry has not booted yet.
bundleMcp: entry.backend.bundleMcp === true,
modelProvider: resolveCliBackendModelProvider(entry.backend),
bundleMcpMode: normalizeBundleMcpMode(
entry.backend.bundleMcpMode,
entry.backend.bundleMcp === true,
),
baseConfig: entry.backend.config,
normalizeConfig: entry.backend.normalizeConfig,
transformSystemPrompt: entry.backend.transformSystemPrompt,
textTransforms: entry.backend.textTransforms,
defaultAuthProfileId: entry.backend.defaultAuthProfileId,
authEpochMode: entry.backend.authEpochMode,
autoSelectAuthProfile: entry.backend.autoSelectAuthProfile,
contextEngineHostCapabilities: entry.backend.contextEngineHostCapabilities,
ownsNativeCompaction: entry.backend.ownsNativeCompaction,
prepareExecution: entry.backend.prepareExecution,
resolveExecutionArgs: entry.backend.resolveExecutionArgs,
parseJsonlEvent: entry.backend.parseJsonlEvent,
toolAvailabilityEnforcement: entry.backend.toolAvailabilityEnforcement,
nativeToolMode: entry.backend.nativeToolMode,
sideQuestionToolMode: entry.backend.sideQuestionToolMode,
runtimeArtifact: entry.backend.runtimeArtifact,
liveSessionRequirement: entry.backend.liveSessionRequirement,
};
}
function resolveFallbackCliBackendPolicy(provider: string): FallbackCliBackendPolicy | undefined {
return FALLBACK_CLI_BACKEND_POLICIES[provider] ?? resolveSetupCliBackendPolicy(provider);
}
function normalizeBackendKey(key: string): string {
return normalizeProviderId(key);
}
function resolveRegisteredBackend(provider: string) {
const normalized = normalizeBackendKey(provider);
return cliBackendsDeps
.resolveRuntimeCliBackends()
.find((entry) => normalizeBackendKey(entry.id) === normalized);
}
function resolveCliBackendModelProvider(
backend: Pick<CliBackendPlugin, "modelProvider">,
): string | undefined {
const provider = backend.modelProvider?.trim();
return provider ? normalizeProviderId(provider) : undefined;
}
function addCliRuntimeModelBinding(
bindings: Map<string, CliRuntimeModelBackendBinding>,
params: { backend: CliBackendPlugin; pluginId?: string },
): void {
const provider = resolveCliBackendModelProvider(params.backend);
const runtime = normalizeBackendKey(params.backend.id);
if (!provider || !runtime) {
return;
}
bindings.set(`${provider}:${runtime}`, {
provider,
runtime,
...(params.pluginId ? { pluginId: params.pluginId } : {}),
});
}
/** Lists model-provider to CLI-runtime bindings from runtime and optional setup registries. */
export function listCliRuntimeModelBackendBindings(
params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includeSetupRegistry?: boolean;
} = {},
): CliRuntimeModelBackendBinding[] {
const bindings = new Map<string, CliRuntimeModelBackendBinding>();
for (const backend of cliBackendsDeps.resolveRuntimeCliBackends()) {
addCliRuntimeModelBinding(bindings, {
backend,
...(backend.pluginId ? { pluginId: backend.pluginId } : {}),
});
}
if (params.includeSetupRegistry === true) {
for (const entry of cliBackendsDeps.resolvePluginSetupRegistry({
config: params.config,
env: params.env,
}).cliBackends) {
addCliRuntimeModelBinding(bindings, {
backend: entry.backend,
pluginId: entry.pluginId,
});
}
}
return [...bindings.values()].toSorted((left, right) =>
left.provider === right.provider
? left.runtime.localeCompare(right.runtime)
: left.provider.localeCompare(right.provider),
);
}
/** Lists CLI runtime ids that alias canonical model providers. */
export function listCliRuntimeProviderIds(
params: {
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includeSetupRegistry?: boolean;
} = {},
): string[] {
// Only CLI backends with a canonical modelProvider are runtime aliases that
// should be hidden from model-provider pickers. Standalone CLI backends own
// direct refs such as acme-cli/model and must remain selectable.
return [
...new Set(
listCliRuntimeModelBackendBindings(params)
.map((binding) => normalizeBackendKey(binding.runtime))
.filter(Boolean),
),
].toSorted();
}
/** Resolves the canonical model provider served by a CLI runtime id. */
export function resolveCliRuntimeCanonicalProvider(params: {
runtime: string | undefined;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includeSetupRegistry?: boolean;
}): string | undefined {
const runtime = normalizeBackendKey(params.runtime ?? "");
if (!runtime) {
return undefined;
}
const runtimeBinding = listCliRuntimeModelBackendBindings().find(
(binding) => binding.runtime === runtime,
);
if (runtimeBinding) {
return runtimeBinding.provider;
}
if (params.includeSetupRegistry !== true) {
return undefined;
}
const setupBackend = cliBackendsDeps.resolvePluginSetupCliBackend({
backend: runtime,
config: params.config,
env: params.env,
});
return setupBackend ? resolveCliBackendModelProvider(setupBackend.backend) : undefined;
}
/** Resolves the binding for one provider/runtime pair when registered. */
export function resolveCliRuntimeModelBackendBinding(params: {
provider: string | undefined;
runtime: string | undefined;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): CliRuntimeModelBackendBinding | undefined {
const provider = normalizeProviderId(params.provider ?? "");
const runtime = normalizeBackendKey(params.runtime ?? "");
if (!provider || !runtime) {
return undefined;
}
const runtimeBinding = listCliRuntimeModelBackendBindings().find(
(binding) => binding.provider === provider && binding.runtime === runtime,
);
if (runtimeBinding) {
return runtimeBinding;
}
const includeSetupRegistry = params.config !== undefined || params.env !== undefined;
if (!includeSetupRegistry) {
return undefined;
}
const setupBackend = cliBackendsDeps.resolvePluginSetupCliBackend({
backend: runtime,
config: params.config,
env: params.env,
});
if (!setupBackend) {
return undefined;
}
const setupProvider = resolveCliBackendModelProvider(setupBackend.backend);
return setupProvider === provider
? {
provider,
runtime,
...(setupBackend.pluginId ? { pluginId: setupBackend.pluginId } : {}),
}
: undefined;
}
/** Checks whether a runtime is registered to serve a model provider. */
export function isCliRuntimeModelBackendForProvider(params: {
provider: string | undefined;
runtime: string | undefined;
config?: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
return resolveCliRuntimeModelBackendBinding(params) !== undefined;
}
/** Resolves live-test defaults advertised by a CLI backend plugin. */
export function resolveCliBackendLiveTest(provider: string): ResolvedCliBackendLiveTest | null {
const normalized = normalizeBackendKey(provider);
const entry =
cliBackendsDeps.resolvePluginSetupCliBackend({ backend: normalized }) ??
cliBackendsDeps
.resolveRuntimeCliBackends()
.find((backend) => normalizeBackendKey(backend.id) === normalized);
if (!entry) {
return null;
}
const backend = "backend" in entry ? entry.backend : entry;
return {
defaultModelRef: backend.liveTest?.defaultModelRef,
defaultImageProbe: backend.liveTest?.defaultImageProbe === true,
defaultMcpProbe: backend.liveTest?.defaultMcpProbe === true,
dockerNpmPackage: backend.liveTest?.docker?.npmPackage,
dockerBinaryName: backend.liveTest?.docker?.binaryName,
};
}
/** Resolves setup-safe live-session protocol metadata without normalizing runtime config. */
export function resolveCliBackendLiveSessionRequirement(
provider: string,
): CliBackendLiveSessionRequirement | null {
const normalized = normalizeBackendKey(provider);
const entry =
cliBackendsDeps.resolvePluginSetupCliBackend({ backend: normalized }) ??
cliBackendsDeps
.resolveRuntimeCliBackends()
.find((backend) => normalizeBackendKey(backend.id) === normalized);
if (!entry) {
return null;
}
const backend = "backend" in entry ? entry.backend : entry;
return backend.liveSessionRequirement ?? null;
}
/** Resolves the executable CLI backend registered by its owning plugin. */
export function resolveCliBackendConfig(
provider: string,
cfg?: OpenClawConfig,
options: { agentId?: string } = {},
): ResolvedCliBackend | null {
const normalized = normalizeBackendKey(provider);
const normalizeContext: CliBackendNormalizeConfigContext = {
backendId: normalized,
...(options.agentId ? { agentId: options.agentId } : {}),
...(cfg ? { config: cfg } : {}),
};
const runtimeTextTransforms = resolveRuntimeTextTransforms();
const registered = resolveRegisteredBackend(normalized);
if (registered) {
const registeredConfig = { ...registered.config };
const config = registered.normalizeConfig
? registered.normalizeConfig(registeredConfig, normalizeContext)
: registeredConfig;
const command = config.command?.trim();
if (!command) {
return null;
}
return {
id: normalized,
...(registered.modelProvider
? { modelProvider: normalizeProviderId(registered.modelProvider) }
: {}),
config: { ...config, command },
bundleMcp: registered.bundleMcp === true,
bundleMcpMode: normalizeBundleMcpMode(
registered.bundleMcpMode,
registered.bundleMcp === true,
),
pluginId: registered.pluginId,
transformSystemPrompt: registered.transformSystemPrompt,
textTransforms: mergePluginTextTransforms(runtimeTextTransforms, registered.textTransforms),
defaultAuthProfileId: registered.defaultAuthProfileId,
authEpochMode: registered.authEpochMode,
autoSelectAuthProfile: registered.autoSelectAuthProfile,
contextEngineHostCapabilities: registered.contextEngineHostCapabilities,
ownsNativeCompaction: registered.ownsNativeCompaction,
prepareExecution: registered.prepareExecution,
resolveExecutionArgs: registered.resolveExecutionArgs,
parseJsonlEvent: registered.parseJsonlEvent,
toolAvailabilityEnforcement: resolveToolAvailabilityEnforcement(registered),
nativeToolMode: registered.nativeToolMode,
sideQuestionToolMode: registered.sideQuestionToolMode,
runtimeArtifact: registered.runtimeArtifact,
liveSessionRequirement: registered.liveSessionRequirement,
};
}
const fallbackPolicy = resolveFallbackCliBackendPolicy(normalized);
if (!fallbackPolicy?.baseConfig) {
return null;
}
const config = fallbackPolicy.normalizeConfig
? fallbackPolicy.normalizeConfig(fallbackPolicy.baseConfig, normalizeContext)
: fallbackPolicy.baseConfig;
const command = config.command?.trim();
if (!command) {
return null;
}
return {
id: normalized,
...(fallbackPolicy.modelProvider ? { modelProvider: fallbackPolicy.modelProvider } : {}),
config: { ...config, command },
bundleMcp: fallbackPolicy.bundleMcp,
bundleMcpMode: fallbackPolicy.bundleMcpMode,
transformSystemPrompt: fallbackPolicy.transformSystemPrompt,
textTransforms: mergePluginTextTransforms(runtimeTextTransforms, fallbackPolicy.textTransforms),
defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId,
authEpochMode: fallbackPolicy.authEpochMode,
autoSelectAuthProfile: fallbackPolicy.autoSelectAuthProfile,
contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities,
ownsNativeCompaction: fallbackPolicy.ownsNativeCompaction,
prepareExecution: fallbackPolicy.prepareExecution,
resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs,
parseJsonlEvent: fallbackPolicy.parseJsonlEvent,
toolAvailabilityEnforcement: fallbackPolicy.toolAvailabilityEnforcement,
nativeToolMode: fallbackPolicy.nativeToolMode,
sideQuestionToolMode: fallbackPolicy.sideQuestionToolMode,
runtimeArtifact: fallbackPolicy.runtimeArtifact,
liveSessionRequirement: fallbackPolicy.liveSessionRequirement,
};
}
/** Test-only dependency controls for CLI backend registry resolution. */
const testing = {
resetDepsForTest(): void {
cliBackendsDeps = defaultCliBackendsDeps;
},
setDepsForTest(deps: Partial<CliBackendsDeps>): void {
cliBackendsDeps = {
...defaultCliBackendsDeps,
...deps,
};
},
} as const;
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.cliBackendsTestApi")] = testing;
}