Files
openclaw/src/agents/prepared-model-runtime.owner.ts
T
Peter Steinberger 06f5f73e47 refactor: own model discovery by runtime lifecycle (#111173)
* refactor(agents): prepare model runtime catalogs

Build lifecycle-owned model and auth snapshots, carry prepared stores into hot agent paths, and serialize config/auth publication.

Credits @zeroaltitude's #90741 investigation and benchmark approach.

* refactor: finish lifecycle-owned model discovery

* fix: align prepared model catalog contracts

* test: align lifecycle catalog mocks

* test: fix prepared catalog type fixtures

* refactor: split prepared model runtime ownership

* fix: import prepared runtime replacement gate type

* test: split media runtime coverage

* test: preserve image auth fixture key types

* test: isolate lifecycle gate fixtures

* chore: keep release changelog owned

* refactor: finish prepared model catalog migration

* test: keep catalog review fixtures scanner-safe

* refactor: preserve lifecycle model runtime ownership

* fix: close prepared runtime lifecycle races

* fix: preserve compaction workspace fallback

* chore: document btw generation rebinding

* fix: preserve prepared generation boundaries

* fix: keep model-list discovery flag explicit

* fix: serialize standalone model runtime activation

* refactor: migrate subagent model catalog lookup

* refactor: clarify doctor catalog lookup seam

* chore: refresh plugin sdk api baseline

* test: migrate swarm catalog dependency

* refactor(telegram): rename runtime catalog seam

* refactor: extract model-aware tool context

* test(models): isolate lifecycle catalog fixtures

* refactor(agents): avoid btw parameter rebinding

* fix(net-policy): align root ipaddr dependency

* fix(build): keep net policy dependency bundled

* fix(deadcode): document net policy compile dependency
2026-07-19 05:30:54 -07:00

471 lines
17 KiB
TypeScript

/** Construction and owner identity for prepared model runtime generations. */
import path from "node:path";
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import { MODEL_APIS } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withTimeout } from "../node-host/with-timeout.js";
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
import {
listAgentIds,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentDir,
resolveDefaultAgentId,
} from "./agent-scope.js";
import { loadBundledProviderStaticCatalogContextModels } from "./embedded-agent-runner/model.static-catalog.js";
import { buildPreparedModelCatalogSnapshot, type ModelCatalogEntry } from "./model-catalog.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
import { AuthStorage } from "./sessions/auth-storage.js";
import type { ModelRegistry } from "./sessions/model-registry.js";
const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000;
export type PreparedModelRuntimeSnapshot = Readonly<{
agentId?: string;
agentDir: string;
inheritedAuthDir?: string;
workspaceDir?: string;
config: OpenClawConfig;
metadataSnapshot: PluginMetadataSnapshot;
modelCatalog: ModelCatalogSnapshot;
createStores: () => PreparedModelRuntimeStores;
}>;
export type PreparedModelRuntimeStores = {
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
};
export type PreparedModelRuntimeInput = {
agentId?: string;
agentDir: string;
inheritedAuthDir?: string;
workspaceDir?: string;
preserveWorkspaceDirOnRefresh?: boolean;
readOnly?: boolean;
skipCredentials?: boolean;
env?: NodeJS.ProcessEnv;
config: OpenClawConfig;
};
export type PreparedModelRuntimeLease = Readonly<{
snapshot: PreparedModelRuntimeSnapshot;
release: () => void;
}>;
export type PreparedModelRuntimeOwner = {
input: PreparedModelRuntimeInput;
environmentFingerprint: string;
provenance: "configured" | "standalone" | "explicit" | "run" | "ephemeral";
generation: number;
needsRefresh: boolean;
refreshError?: Error;
snapshot?: PreparedModelRuntimeSnapshot;
pending?: Promise<PreparedModelRuntimeSnapshot>;
buildCompletion?: Promise<void>;
leaseCount?: number;
};
export type PreparedModelRuntimeReplacement = {
gateId: PreparedModelRuntimeReplacementGateId;
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
};
export type PreparedModelRuntimeReplacementGateId = symbol;
export class PreparedModelRuntimeOwnerNotPublishedError extends Error {}
export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {}
export function rebindInputToCommittedConfiguredOwner(
owners: Map<string, PreparedModelRuntimeOwner>,
rawInput: PreparedModelRuntimeInput,
): PreparedModelRuntimeInput {
const input = normalizePreparedModelRuntimeInput(rawInput);
const candidates = [...owners.values()].filter(
(owner) =>
owner.provenance === "configured" &&
owner.snapshot &&
!owner.needsRefresh &&
!owner.pending &&
(input.agentId === undefined
? owner.input.agentDir === input.agentDir
: owner.input.agentId === input.agentId),
);
if (candidates.length !== 1) {
throw new PreparedModelRuntimeOwnerNotPublishedError(
`prepared model runtime owner was not committed after replacement for ${input.agentDir}`,
);
}
const owner = candidates[0]!;
const preserveWorkspaceDir =
input.preserveWorkspaceDirOnRefresh === true && input.workspaceDir !== undefined;
return normalizePreparedModelRuntimeInput({
...input,
...(owner.input.agentId ? { agentId: owner.input.agentId } : {}),
agentDir: owner.input.agentDir,
config: owner.input.config,
inheritedAuthDir: owner.input.inheritedAuthDir,
env: owner.input.env,
workspaceDir: preserveWorkspaceDir ? input.workspaceDir : owner.input.workspaceDir,
preserveWorkspaceDirOnRefresh: preserveWorkspaceDir,
});
}
/** Accepts canonical config clones without weakening projected-config isolation. */
export function preparedModelRuntimeConfigsMatch(
left: OpenClawConfig,
right: OpenClawConfig,
): boolean {
if (left === right) {
return true;
}
try {
return hashRuntimeConfigValue(left) === hashRuntimeConfigValue(right);
} catch {
return false;
}
}
export function normalizeOptionalDir(dirname: string | undefined): string | undefined {
return dirname ? path.resolve(dirname) : undefined;
}
export function normalizePreparedModelRuntimeInput(
input: PreparedModelRuntimeInput,
): PreparedModelRuntimeInput {
const {
inheritedAuthDir: _inheritedAuthDir,
readOnly,
skipCredentials,
workspaceDir: _workspaceDir,
...rest
} = input;
const inheritedAuthDir = normalizeOptionalDir(
input.inheritedAuthDir ?? resolveDefaultAgentDir(input.config, input.env),
);
const workspaceDir = normalizeOptionalDir(input.workspaceDir);
const env = input.env ? Object.freeze({ ...input.env }) : undefined;
return {
...rest,
agentDir: path.resolve(input.agentDir),
...(inheritedAuthDir ? { inheritedAuthDir } : {}),
...(readOnly === true ? { readOnly: true } : {}),
...(skipCredentials === true ? { skipCredentials: true } : {}),
...(workspaceDir ? { workspaceDir } : {}),
...(env ? { env } : {}),
};
}
function environmentFingerprint(env: NodeJS.ProcessEnv | undefined): string | undefined {
return env ? hashRuntimeConfigValue(env) : undefined;
}
export function effectiveEnvironmentFingerprint(input: PreparedModelRuntimeInput): string {
return hashRuntimeConfigValue(input.env ?? process.env);
}
function isCatalogModelApi(
value: string | undefined,
): value is NonNullable<ModelCatalogEntry["api"]> {
return value !== undefined && (MODEL_APIS as readonly string[]).includes(value);
}
function toStaticCatalogEntry(
model: Awaited<ReturnType<typeof loadBundledProviderStaticCatalogContextModels>>[number],
): ModelCatalogEntry {
return {
id: model.id,
name: model.name ?? model.id,
provider: model.provider,
...(isCatalogModelApi(model.api) ? { api: model.api } : {}),
...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
...(model.contextTokens ? { contextTokens: model.contextTokens } : {}),
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
...(model.input ? { input: model.input } : {}),
...(model.params ? { params: model.params } : {}),
...(model.compat ? { compat: model.compat } : {}),
...(model.mediaInput ? { mediaInput: model.mediaInput } : {}),
};
}
export function ownerKey(input: PreparedModelRuntimeInput): string {
return JSON.stringify({
agentId: input.agentId,
agentDir: input.agentDir,
inheritedAuthDir: input.inheritedAuthDir,
readOnly: input.readOnly === true,
skipCredentials: input.skipCredentials === true,
workspaceDir: input.workspaceDir,
env: environmentFingerprint(input.env),
config: input.readOnly ? hashRuntimeConfigValue(input.config) : undefined,
});
}
export function resolvePublishedOwner(
owners: Map<string, PreparedModelRuntimeOwner>,
input: PreparedModelRuntimeInput,
options: { allowConfiguredWorkspaceFallback?: boolean } = {},
): PreparedModelRuntimeOwner | undefined {
const exact = owners.get(ownerKey(input));
if (exact) {
return exact;
}
if (!options.allowConfiguredWorkspaceFallback) {
return undefined;
}
// Gateway launch may supply an authoritative workspace outside config. Request readers still
// resolve the one configured lifecycle owner by agent; standalone/explicit owners remain exact.
const candidates = [...owners.values()].filter(
(owner) =>
owner.provenance === "configured" &&
(input.agentId === undefined || owner.input.agentId === input.agentId) &&
owner.input.agentDir === input.agentDir &&
owner.input.inheritedAuthDir === input.inheritedAuthDir &&
owner.input.readOnly === input.readOnly &&
owner.input.skipCredentials === input.skipCredentials &&
(input.env === undefined ||
owner.environmentFingerprint === environmentFingerprint(input.env)) &&
(input.workspaceDir === undefined || owner.input.workspaceDir === input.workspaceDir),
);
return candidates.length === 1 ? candidates[0] : undefined;
}
export function hasSameLifecycleInput(
left: PreparedModelRuntimeInput,
right: PreparedModelRuntimeInput,
): boolean {
return (
left.config === right.config &&
left.agentId === right.agentId &&
left.inheritedAuthDir === right.inheritedAuthDir &&
left.readOnly === right.readOnly &&
left.skipCredentials === right.skipCredentials &&
left.workspaceDir === right.workspaceDir &&
environmentFingerprint(left.env) === environmentFingerprint(right.env) &&
left.preserveWorkspaceDirOnRefresh === right.preserveWorkspaceDirOnRefresh
);
}
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function createPreparedModelRuntimeReplacement(): PreparedModelRuntimeReplacement {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
// Readers await the original promise. This handler only prevents an unobserved rejected gate
// when a reload fails before any request reaches the stale generation.
void promise.catch(() => undefined);
return { gateId: Symbol("prepared-model-runtime-replacement"), promise, resolve, reject };
}
export function listConfiguredOwnerInputs(
config: OpenClawConfig,
defaultWorkspaceDir?: string,
): PreparedModelRuntimeInput[] {
const inheritedAuthDir = resolveDefaultAgentDir(config);
const defaultAgentId = resolveDefaultAgentId(config);
return listAgentIds(config).map((agentId) => {
const preserveWorkspaceDirOnRefresh = agentId === defaultAgentId && defaultWorkspaceDir;
const input: PreparedModelRuntimeInput = {
agentId,
agentDir: resolveAgentDir(config, agentId),
config,
inheritedAuthDir,
workspaceDir: preserveWorkspaceDirOnRefresh
? defaultWorkspaceDir
: resolveAgentWorkspaceDir(config, agentId),
};
if (preserveWorkspaceDirOnRefresh) {
input.preserveWorkspaceDirOnRefresh = true;
}
return input;
});
}
async function buildSnapshot(
input: PreparedModelRuntimeInput,
): Promise<PreparedModelRuntimeSnapshot> {
const env = input.env ?? process.env;
if (!input.readOnly) {
// Writable lifecycle publication owns process-global runtime plugin activation. Read-only
// drafts consume manifest metadata only and must not mutate live hooks outside that gate.
ensureRuntimePluginsLoaded({
config: input.config,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
});
}
const pluginMetadataSnapshot = resolvePluginMetadataSnapshot({
config: input.config,
env,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
});
if (!input.readOnly) {
await ensureOpenClawModelsJson(input.config, input.agentDir, {
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(input.env ? { env } : {}),
providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS,
});
}
const templateAuthStorage = discoverAuthStorage(input.agentDir, {
config: input.config,
// Snapshot construction never initializes, migrates, or externally syncs auth. A writable
// generation performs its file preparation above; ModelRegistry discovery only parses it.
readOnly: true,
...(input.skipCredentials ? { skipCredentials: true } : {}),
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(input.env ? { env } : {}),
});
const templateModelRegistry = discoverModels(templateAuthStorage, input.agentDir, {
config: input.config,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
});
const credentials = templateAuthStorage.getAll();
const modelCatalog = await buildPreparedModelCatalogSnapshot({
agentDir: input.agentDir,
authCredentials: credentials,
config: input.config,
modelRegistry: templateModelRegistry,
metadataSnapshot: pluginMetadataSnapshot,
...(input.env ? { env } : {}),
...(input.readOnly ? { readOnly: true } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
});
const staticEntries = (
await loadBundledProviderStaticCatalogContextModels({
cfg: input.config,
env,
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
})
).map(toStaticCatalogEntry);
const createStores = (): PreparedModelRuntimeStores => {
// Runtime API keys and session extensions mutate these objects. Fork them per run while the
// credential map and parsed catalog remain owned by the lifecycle snapshot.
const authStorage = AuthStorage.inMemory(credentials);
return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) };
};
return Object.freeze({
...(input.agentId ? { agentId: input.agentId } : {}),
agentDir: input.agentDir,
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
config: input.config,
metadataSnapshot: pluginMetadataSnapshot,
modelCatalog: { ...modelCatalog, staticEntries },
createStores,
});
}
export function startSerializedSnapshotBuild(
input: PreparedModelRuntimeInput,
agentBuildCompletions: Map<string, Promise<void>>,
buildTimeoutMs: number,
): {
pending: Promise<PreparedModelRuntimeSnapshot>;
completion: Promise<void>;
} {
const previousBuildCompletion = agentBuildCompletions.get(input.agentDir);
// Lifecycle events may overlap. The timeout covers queueing plus this build, while completion
// follows the real work so a timed-out generation can never overlap a replacement.
const startBuild = (async () => {
if (previousBuildCompletion) {
await previousBuildCompletion;
}
return { actualBuild: buildSnapshot(input) };
})();
const completion = startBuild
.then(async ({ actualBuild }) => await actualBuild)
.then(
() => undefined,
() => undefined,
);
agentBuildCompletions.set(input.agentDir, completion);
void completion.then(() => {
if (agentBuildCompletions.get(input.agentDir) === completion) {
agentBuildCompletions.delete(input.agentDir);
}
});
return {
pending: withTimeout(
async () => {
const { actualBuild } = await startBuild;
return await actualBuild;
},
buildTimeoutMs,
"prepared model runtime publication",
),
completion,
};
}
export async function publishModelRuntimeSnapshot(
input: PreparedModelRuntimeInput,
owners: Map<string, PreparedModelRuntimeOwner>,
agentBuildCompletions: Map<string, Promise<void>>,
buildTimeoutMs: number,
existing?: PreparedModelRuntimeOwner,
provenance: PreparedModelRuntimeOwner["provenance"] = "explicit",
): Promise<PreparedModelRuntimeSnapshot> {
const key = ownerKey(input);
const owner: PreparedModelRuntimeOwner = existing ?? {
input,
environmentFingerprint: effectiveEnvironmentFingerprint(input),
provenance,
generation: 0,
needsRefresh: false,
};
owner.input = input;
owner.environmentFingerprint = effectiveEnvironmentFingerprint(input);
owner.provenance = provenance;
owner.generation += 1;
owner.needsRefresh = true;
owner.refreshError = undefined;
const generation = owner.generation;
const build = startSerializedSnapshotBuild(input, agentBuildCompletions, buildTimeoutMs);
owner.buildCompletion = build.completion;
void build.completion.then(() => {
if (owner.buildCompletion === build.completion) {
owner.buildCompletion = undefined;
}
});
owners.set(key, owner);
const publication = (async () => {
try {
const snapshot = await build.pending;
if (owner.generation !== generation || owners.get(key) !== owner) {
throw new PreparedModelRuntimePublicationSupersededError(
`prepared model runtime publication was superseded for ${input.agentDir}`,
);
}
owner.snapshot = snapshot;
owner.pending = undefined;
owner.needsRefresh = false;
return snapshot;
} catch (error) {
const refreshError = toError(error);
if (owner.generation === generation && owners.get(key) === owner) {
owner.pending = undefined;
owner.needsRefresh = true;
owner.refreshError = refreshError;
}
throw refreshError;
}
})();
// Every waiter observes the publication guard, not the underlying discovery result. This keeps
// invalidated generations from escaping even when callers deduplicate against pending work.
owner.pending = publication;
return await publication;
}