refactor(runtime): add prepared runtime foundation (#78248)

* docs(runtime): document prepared runtime guidance

* refactor(provider-runtime): thread prepared provider handles

* refactor(runtime-plan): add prepared runtime foundation

* refactor(outbound): add prepared channel runtime facts

* refactor(models): add scoped model reference helpers

* refactor(plugin-sdk): expose prepared runtime helper surfaces
This commit is contained in:
Marcus Castro
2026-05-07 18:49:42 -03:00
committed by GitHub
parent 70eabd3b08
commit 5df08201ff
22 changed files with 824 additions and 66 deletions

View File

@@ -37,6 +37,11 @@ Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
- New seams: backwards-compatible, documented, versioned. Third-party plugins exist.
- Channels: `src/channels/**` is implementation; plugin authors get SDK seams.
- Providers: core owns generic loop; provider plugins own auth/catalog/runtime hooks.
- Request-time runtime resolution: when a path already knows the provider id, model ref, channel id, outbound target, capability family, or attachment class, carry that as a prepared runtime fact instead of rediscovering it later.
- Prepared runtime facts should be small typed values produced once near startup, reply dispatch, model selection, tool planning, or channel resolution, then passed through context to consumers. Prefer `AgentRuntimePlan`, `ProviderRuntimePluginHandle`, scoped model/catalog helpers, active/runtime registries, manifest/public-artifact lookups, single-provider resolvers, and lazy registry construction.
- Avoid broad request-time rediscovery: hot reply/tool/outbound/media paths should not call broad plugin/provider/channel/capability loaders such as `loadOpenClawPlugins`, `resolveProviderPluginsForHooks`, `resolvePluginCapabilityProviders`, `resolvePluginDiscoveryProvidersRuntime`, `getChannelPlugin`, or broad model/tool/media registry builders just to answer a question the caller already knows. Do not build multimodal/provider registries for document-only or otherwise non-participating paths.
- Compatibility fallbacks are allowed only for startup/setup/admin/standalone/legacy callers that genuinely lack prepared facts. Keep them explicit, tested, and outside migrated hot reply/tool/outbound paths.
- Do not fix repeated request-time discovery by adding scattered cache layers. Move the canonical fact earlier, reuse the existing prepared-runtime object, and delete duplicate lookup branches when the last migrated caller stops needing them.
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor.
- Direction: manifest-first control plane; targeted runtime loaders; no hidden contract bypasses; broad mutable registries transitional.

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: add an agent-first filter to the chat session picker, keep chat controls/composer responsive across phone/tablet/desktop widths, keep desktop chat controls on one row, avoid duplicate avatar refreshes during initial chat load, and hide that row while scrolling down the transcript. Thanks @BunsDev.
- Control UI/chat: collapse consecutive duplicate text messages into one bubble with a count so repeated text-only messages stay compact without hiding nearby context.
- Control UI/chat and Sessions: label inherited thinking defaults separately from explicit overrides while preserving provider-supplied option labels. Fixes #77581. Thanks @BunsDev and @Beandon13.
- Agents/runtime: add prepared runtime foundation contracts for carrying provider, model, tool, TTS, and outbound runtime facts through later reply-path migrations. Thanks @mcaxtr.
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.

View File

@@ -1,2 +1,2 @@
259c4d22d5fe84ca6b27f9d061dddca4459e4eddd516729564f250d823dbd819 plugin-sdk-api-baseline.json
b2bf16d3c5859b06240b5f41ed5803af8e82338da3e56ace1569520ac0732a5a plugin-sdk-api-baseline.jsonl
28e280d21693216c99cfa8da553589b41741d37c0ada956e316ee01d3d6c202c plugin-sdk-api-baseline.json
633dae33da97f6a073c5561709c57d5c0b7ff67af0512d0261f05455c24b38de plugin-sdk-api-baseline.jsonl

View File

@@ -101,6 +101,15 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
"agent-runtime": {
category: "runtime",
},
"speech-core": {
category: "provider",
},
"tts-runtime": {
category: "runtime",
},
"allow-from": {
category: "utilities",
},

View File

@@ -0,0 +1,51 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js";
function dedupeCatalogScopeRefs(values: Array<string | undefined>): string[] {
const refs = new Set<string>();
for (const value of values) {
const trimmed = value?.trim();
if (trimmed) {
refs.add(trimmed);
}
}
return [...refs];
}
function providerFromModelRef(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const slash = trimmed.indexOf("/");
if (slash <= 0) {
return undefined;
}
const provider = normalizeProviderId(trimmed.slice(0, slash));
return provider || undefined;
}
export function resolveModelCatalogScope(params: {
cfg?: OpenClawConfig;
provider: string;
model: string;
}): { providerRefs: string[]; modelRefs: string[] } {
const provider = params.provider.trim();
const model = params.model.trim();
const providerConfig = findNormalizedProviderValue(params.cfg?.models?.providers, provider);
return {
providerRefs: dedupeCatalogScopeRefs([provider, providerConfig?.api]),
modelRefs: dedupeCatalogScopeRefs([provider && model ? `${provider}/${model}` : model, model]),
};
}
export function resolveProviderDiscoveryProviderIdsForCatalogScope(params: {
providerRefs?: readonly string[];
modelRefs?: readonly string[];
}): string[] | undefined {
const providerIds = dedupeCatalogScopeRefs([
...(params.providerRefs ?? []),
...(params.modelRefs ?? []).map(providerFromModelRef),
]);
return providerIds.length > 0 ? providerIds : undefined;
}

View File

@@ -6,6 +6,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
prepareProviderExtraParams as prepareProviderExtraParamsRuntime,
type ProviderRuntimePluginHandle,
resolveProviderExtraParamsForTransport as resolveProviderExtraParamsForTransportRuntime,
wrapProviderStreamFn as wrapProviderStreamFnRuntime,
} from "../../plugins/provider-hook-runtime.js";
@@ -207,6 +208,7 @@ export function resolvePreparedExtraParams(params: {
resolvedExtraParams?: Record<string, unknown>;
model?: ProviderRuntimeModel;
resolvedTransport?: SupportedTransport;
providerRuntimeHandle?: ProviderRuntimePluginHandle;
}): Record<string, unknown> {
const resolvedExtraParams =
params.resolvedExtraParams ??
@@ -253,6 +255,7 @@ export function resolvePreparedExtraParams(params: {
provider: params.provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
runtimeHandle: params.providerRuntimeHandle,
context: {
config: params.cfg,
agentDir: params.agentDir,
@@ -267,6 +270,7 @@ export function resolvePreparedExtraParams(params: {
provider: params.provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
runtimeHandle: params.providerRuntimeHandle,
context: {
config: params.cfg,
agentDir: params.agentDir,

View File

@@ -105,6 +105,7 @@ function makeForwardedRuntimePlan(overrides: RuntimePlanOverrides = {}): AgentRu
provider: "anthropic",
modelId: "test-model",
resolveSystemPromptContribution: vi.fn(),
transformSystemPrompt: vi.fn((context) => context.systemPrompt),
},
transcript: {
policy: transcriptPolicy,

View File

@@ -1,6 +1,7 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { TSchema } from "typebox";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import {
inspectProviderToolSchemasWithPlugin,
@@ -19,6 +20,7 @@ type ProviderToolSchemaParams<TSchemaType extends TSchema = TSchema, TResult = u
modelId?: string;
modelApi?: string | null;
model?: ProviderRuntimeModel;
runtimeHandle?: ProviderRuntimePluginHandle;
};
function buildProviderToolSchemaContext<TSchemaType extends TSchema = TSchema, TResult = unknown>(
@@ -51,6 +53,7 @@ export function normalizeProviderToolSchemas<
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
runtimeHandle: params.runtimeHandle,
context: buildProviderToolSchemaContext(params, provider),
});
return Array.isArray(pluginNormalized)
@@ -68,6 +71,7 @@ export function logProviderToolSchemaDiagnostics(params: ProviderToolSchemaParam
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
runtimeHandle: params.runtimeHandle,
context: buildProviderToolSchemaContext(params, provider),
});
if (!Array.isArray(diagnostics)) {

View File

@@ -1,20 +1,80 @@
import { createParameterFreeTool } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { describe, expect, it, vi } from "vitest";
import { buildAgentRuntimePlan } from "./build.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetConfigRuntimeState, setRuntimeConfigSnapshot } from "../../config/config.js";
import {
resolveProviderRuntimePluginHandle,
prepareProviderExtraParams,
resolveProviderFollowupFallbackRoute,
type ProviderRuntimePluginHandle,
} from "../../plugins/provider-hook-runtime.js";
import { buildAgentRuntimeDeliveryPlan, buildAgentRuntimePlan } from "./build.js";
const manifestMocks = vi.hoisted(() => ({
loadManifestMetadataSnapshot: vi.fn(() => ({}) as never),
}));
vi.mock("../../plugins/manifest-contract-eligibility.js", () => ({
loadManifestMetadataSnapshot: manifestMocks.loadManifestMetadataSnapshot,
}));
vi.mock("../../plugins/provider-hook-runtime.js", () => ({
__testing: {},
ensureProviderRuntimePluginHandle: vi.fn(
(params) => params.runtimeHandle ?? { provider: "openai" },
),
prepareProviderExtraParams: vi.fn(() => undefined),
resolveProviderAuthProfileId: vi.fn(() => undefined),
resolveProviderExtraParamsForTransport: vi.fn(() => undefined),
resolveProviderFollowupFallbackRoute: vi.fn(() => undefined),
resolveProviderHookPlugin: vi.fn(() => undefined),
resolveProviderPluginsForHooks: vi.fn(() => []),
resolveProviderRuntimePlugin: vi.fn(() => undefined),
resolveProviderRuntimePluginHandle: vi.fn(() => ({ provider: "openai" })),
wrapProviderStreamFn: vi.fn(() => undefined),
}));
const gpt54Model = {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
} as const;
describe("AgentRuntimePlan", () => {
afterEach(() => {
resetConfigRuntimeState();
manifestMocks.loadManifestMetadataSnapshot.mockClear();
vi.mocked(resolveProviderRuntimePluginHandle).mockClear();
});
it("defers default transport extra params until they are read", () => {
const prepareProviderExtraParamsMock = vi.mocked(prepareProviderExtraParams);
prepareProviderExtraParamsMock.mockClear();
const plan = buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
modelApi: "openai-responses",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
model: gpt54Model,
});
expect(prepareProviderExtraParamsMock).not.toHaveBeenCalled();
expect(plan.transport.extraParams).toMatchObject({
parallel_tool_calls: true,
text_verbosity: "low",
openaiWsWarmup: false,
});
expect(prepareProviderExtraParamsMock).toHaveBeenCalledTimes(1);
void plan.transport.extraParams;
expect(prepareProviderExtraParamsMock).toHaveBeenCalledTimes(1);
});
it("records resolved model, auth, transport, tool, delivery, and observability policy", () => {
const plan = buildAgentRuntimePlan({
provider: "openai",
@@ -27,16 +87,8 @@ describe("AgentRuntimePlan", () => {
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
model: {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
...gpt54Model,
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
},
});
@@ -95,16 +147,8 @@ describe("AgentRuntimePlan", () => {
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
model: {
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
provider: "openai",
...gpt54Model,
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8_192,
},
});
@@ -155,4 +199,178 @@ describe("AgentRuntimePlan", () => {
forwardedAuthProfileId: "openai-codex:work",
});
});
it("resolves follow-up routes with the prepared provider handle", () => {
const resolveProviderFollowupFallbackRouteMock = vi.mocked(
resolveProviderFollowupFallbackRoute,
);
resolveProviderFollowupFallbackRouteMock.mockClear();
resolveProviderFollowupFallbackRouteMock.mockReturnValueOnce({
route: "dispatcher" as const,
reason: "prepared-route",
});
const providerRuntimeHandle: ProviderRuntimePluginHandle = {
provider: "openai",
};
const plan = buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
providerRuntimeHandle,
});
expect(
plan.delivery.resolveFollowupRoute({
payload: { text: "hello" },
originRoutable: false,
dispatcherAvailable: true,
}),
).toEqual({
route: "dispatcher",
reason: "prepared-route",
});
expect(resolveProviderFollowupFallbackRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
runtimeHandle: providerRuntimeHandle,
context: expect.objectContaining({
provider: "openai",
modelId: "gpt-5.4",
originRoutable: false,
dispatcherAvailable: true,
}),
}),
);
});
it("resolves incomplete supplied provider handles before invoking runtime hooks", () => {
const resolveProviderRuntimePluginHandleMock = vi.mocked(resolveProviderRuntimePluginHandle);
const resolveProviderFollowupFallbackRouteMock = vi.mocked(
resolveProviderFollowupFallbackRoute,
);
resolveProviderRuntimePluginHandleMock.mockClear();
resolveProviderFollowupFallbackRouteMock.mockClear();
const suppliedHandle = {
provider: "openai",
config: { plugins: { allow: ["openai"] } },
};
const resolvedHandle: ProviderRuntimePluginHandle = {
...suppliedHandle,
workspaceDir: "/tmp/openclaw-runtime-plan",
env: process.env,
plugin: {} as never,
};
resolveProviderRuntimePluginHandleMock.mockReturnValueOnce(resolvedHandle);
const plan = buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
providerRuntimeHandle: suppliedHandle,
});
expect(plan.providerRuntimeHandle).toBe(resolvedHandle);
plan.delivery.resolveFollowupRoute({
payload: { text: "hello" },
originRoutable: false,
dispatcherAvailable: true,
});
expect(resolveProviderRuntimePluginHandleMock).toHaveBeenCalledWith({
provider: "openai",
config: suppliedHandle.config,
workspaceDir: "/tmp/openclaw-runtime-plan",
env: process.env,
applyAutoEnable: undefined,
bundledProviderAllowlistCompat: undefined,
bundledProviderVitestCompat: undefined,
});
expect(resolveProviderFollowupFallbackRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
runtimeHandle: resolvedHandle,
}),
);
});
it("resolves incomplete supplied delivery handles before follow-up routing", () => {
const resolveProviderRuntimePluginHandleMock = vi.mocked(resolveProviderRuntimePluginHandle);
const resolveProviderFollowupFallbackRouteMock = vi.mocked(
resolveProviderFollowupFallbackRoute,
);
resolveProviderRuntimePluginHandleMock.mockClear();
resolveProviderFollowupFallbackRouteMock.mockClear();
const suppliedHandle = {
provider: "openai",
};
const resolvedHandle: ProviderRuntimePluginHandle = {
provider: "openai",
workspaceDir: "/tmp/openclaw-runtime-plan",
env: process.env,
plugin: {} as never,
};
resolveProviderRuntimePluginHandleMock.mockReturnValueOnce(resolvedHandle);
const delivery = buildAgentRuntimeDeliveryPlan({
provider: "openai",
modelId: "gpt-5.4",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
providerRuntimeHandle: suppliedHandle,
});
delivery.resolveFollowupRoute({
payload: { text: "hello" },
originRoutable: false,
dispatcherAvailable: true,
});
expect(resolveProviderRuntimePluginHandleMock).toHaveBeenCalledWith({
provider: "openai",
config: {},
workspaceDir: "/tmp/openclaw-runtime-plan",
env: process.env,
applyAutoEnable: undefined,
bundledProviderAllowlistCompat: undefined,
bundledProviderVitestCompat: undefined,
});
expect(resolveProviderFollowupFallbackRouteMock).toHaveBeenCalledWith(
expect.objectContaining({
runtimeHandle: resolvedHandle,
}),
);
});
it("plans tool metadata against the runtime source snapshot lazily", () => {
const sourceConfig = { channels: { telegram: { botToken: "token" } } };
const runtimeConfig = {
...sourceConfig,
plugins: { allow: ["telegram"] },
};
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const plan = buildAgentRuntimePlan({
provider: "openai",
modelId: "gpt-5.4",
config: runtimeConfig,
workspaceDir: "/tmp/openclaw-runtime-plan",
});
expect(manifestMocks.loadManifestMetadataSnapshot).not.toHaveBeenCalled();
plan.tools.preparedPlanning?.loadMetadataSnapshot?.();
expect(manifestMocks.loadManifestMetadataSnapshot).toHaveBeenCalledWith({
config: sourceConfig,
workspaceDir: "/tmp/openclaw-runtime-plan",
env: process.env,
});
});
});

View File

@@ -3,11 +3,20 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay
import type { TSchema } from "typebox";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
import {
resolveProviderRuntimePluginHandle,
type ProviderRuntimePluginHandle,
} from "../../plugins/provider-hook-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import {
resolveProviderFollowupFallbackRoute,
resolveProviderSystemPromptContribution,
resolveProviderTextTransforms,
transformProviderSystemPrompt,
} from "../../plugins/provider-runtime.js";
import { resolvePreparedExtraParams } from "../pi-embedded-runner/extra-params.js";
import { classifyEmbeddedPiRunResultForModelFallback } from "../pi-embedded-runner/result-fallback-classifier.js";
@@ -49,10 +58,46 @@ function asThinkLevel(value: BuildAgentRuntimePlanParams["thinkingLevel"]): Thin
return value !== undefined ? (value as ThinkLevel) : undefined;
}
function isProviderRuntimePluginHandle(
value: BuildAgentRuntimePlanParams["providerRuntimeHandle"] | ProviderRuntimePluginHandle,
): value is ProviderRuntimePluginHandle {
return value !== undefined && "plugin" in value;
}
function resolveProviderRuntimeHandleForPlugins(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
runtimeHandle?: BuildAgentRuntimePlanParams["providerRuntimeHandle"];
resolveWhenMissing?: boolean;
}): ProviderRuntimePluginHandle | undefined {
if (isProviderRuntimePluginHandle(params.runtimeHandle)) {
return params.runtimeHandle;
}
if (!params.runtimeHandle && !params.resolveWhenMissing) {
return undefined;
}
return resolveProviderRuntimePluginHandle({
provider: params.runtimeHandle?.provider ?? params.provider,
config: asOpenClawConfig(params.runtimeHandle?.config) ?? params.config,
workspaceDir: params.runtimeHandle?.workspaceDir ?? params.workspaceDir,
env: params.runtimeHandle?.env ?? process.env,
applyAutoEnable: params.runtimeHandle?.applyAutoEnable,
bundledProviderAllowlistCompat: params.runtimeHandle?.bundledProviderAllowlistCompat,
bundledProviderVitestCompat: params.runtimeHandle?.bundledProviderVitestCompat,
});
}
export function buildAgentRuntimeDeliveryPlan(
params: BuildAgentRuntimeDeliveryPlanParams,
): AgentRuntimeDeliveryPlan {
const config = asOpenClawConfig(params.config);
const providerRuntimeHandle = resolveProviderRuntimeHandleForPlugins({
provider: params.provider,
config,
workspaceDir: params.workspaceDir,
runtimeHandle: params.providerRuntimeHandle,
});
return {
isSilentPayload(payload): boolean {
return isSilentReplyPayloadText(payload.text, SILENT_REPLY_TOKEN) && !hasMedia(payload);
@@ -62,6 +107,7 @@ export function buildAgentRuntimeDeliveryPlan(
provider: params.provider,
config,
workspaceDir: params.workspaceDir,
runtimeHandle: providerRuntimeHandle,
context: {
config,
agentDir: params.agentDir,
@@ -90,6 +136,23 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
const model = asProviderRuntimeModel(params.model);
const modelApi = params.modelApi ?? params.model?.api ?? undefined;
const transport = params.resolvedTransport;
const toolPlanningConfig = config ? projectConfigOntoRuntimeSourceSnapshot(config) : undefined;
let toolPlanningMetadataSnapshot: PluginMetadataSnapshot | undefined;
const loadToolPlanningMetadataSnapshot = () => {
toolPlanningMetadataSnapshot ??= loadManifestMetadataSnapshot({
config: toolPlanningConfig,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
env: process.env,
});
return toolPlanningMetadataSnapshot;
};
const providerRuntimeHandleForPlugins = resolveProviderRuntimeHandleForPlugins({
provider: params.provider,
config,
workspaceDir: params.workspaceDir,
runtimeHandle: params.providerRuntimeHandle,
resolveWhenMissing: true,
});
const auth = buildAgentRuntimeAuthPlan({
provider: params.provider,
authProfileProvider: params.authProfileProvider,
@@ -112,6 +175,7 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
config,
workspaceDir: params.workspaceDir,
env: process.env,
runtimeHandle: providerRuntimeHandleForPlugins,
modelId: params.modelId,
modelApi,
model,
@@ -137,6 +201,7 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
config,
workspaceDir: overrides?.workspaceDir ?? params.workspaceDir,
env: process.env,
runtimeHandle: providerRuntimeHandleForPlugins,
modelApi: overrides?.modelApi ?? modelApi,
model: asProviderRuntimeModel(overrides?.model) ?? model,
});
@@ -154,19 +219,52 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
agentId: overrides.agentId ?? params.agentId,
model: asProviderRuntimeModel(overrides.model) ?? model,
resolvedTransport: overrides.resolvedTransport ?? transport,
providerRuntimeHandle: providerRuntimeHandleForPlugins,
});
let memoizedTranscriptPolicy: ReturnType<typeof resolveTranscriptRuntimePolicy> | undefined;
let memoizedTransportExtraParams: ReturnType<typeof resolveTransportExtraParams> | undefined;
const resolveDefaultTranscriptPolicy = () => {
memoizedTranscriptPolicy ??= resolveTranscriptRuntimePolicy();
return memoizedTranscriptPolicy;
};
const resolveDefaultTransportExtraParams = () => {
memoizedTransportExtraParams ??= resolveTransportExtraParams();
return memoizedTransportExtraParams;
};
const providerTextTransforms = resolveProviderTextTransforms({
provider: params.provider,
config,
workspaceDir: params.workspaceDir,
env: process.env,
runtimeHandle: providerRuntimeHandleForPlugins,
});
return {
resolvedRef,
providerRuntimeHandle: providerRuntimeHandleForPlugins,
auth,
prompt: {
provider: params.provider,
modelId: params.modelId,
textTransforms: providerTextTransforms,
resolveSystemPromptContribution(context) {
return resolveProviderSystemPromptContribution({
provider: params.provider,
config,
workspaceDir: context.workspaceDir ?? params.workspaceDir,
runtimeHandle: providerRuntimeHandleForPlugins,
context: {
...context,
config: asOpenClawConfig(context.config),
},
});
},
transformSystemPrompt(context) {
return transformProviderSystemPrompt({
provider: params.provider,
config,
workspaceDir: context.workspaceDir ?? params.workspaceDir,
runtimeHandle: providerRuntimeHandleForPlugins,
context: {
...context,
config: asOpenClawConfig(context.config),
@@ -175,6 +273,9 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
},
},
tools: {
preparedPlanning: {
loadMetadataSnapshot: loadToolPlanningMetadataSnapshot,
},
normalize<TSchemaType extends TSchema = TSchema, TResult = unknown>(
tools: AgentTool<TSchemaType, TResult>[],
overrides?: {
@@ -203,13 +304,20 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
},
},
transcript: {
policy: resolveTranscriptRuntimePolicy(),
get policy() {
return resolveDefaultTranscriptPolicy();
},
resolvePolicy: resolveTranscriptRuntimePolicy,
},
delivery: buildAgentRuntimeDeliveryPlan(params),
delivery: buildAgentRuntimeDeliveryPlan({
...params,
providerRuntimeHandle: providerRuntimeHandleForPlugins,
}),
outcome: buildAgentRuntimeOutcomePlan(),
transport: {
extraParams: resolveTransportExtraParams(),
get extraParams() {
return resolveDefaultTransportExtraParams();
},
resolveExtraParams: resolveTransportExtraParams,
},
observability: {

View File

@@ -46,7 +46,7 @@ export type AgentRuntimeModel = {
provider?: string;
baseUrl?: string;
reasoning?: boolean;
input?: string[];
input?: readonly string[];
cost?: {
input: number;
output: number;
@@ -59,6 +59,26 @@ export type AgentRuntimeModel = {
compat?: unknown;
};
export type AgentRuntimeTextReplacement = {
from: string | RegExp;
to: string;
};
export type AgentRuntimeTextTransforms = {
input?: AgentRuntimeTextReplacement[];
output?: AgentRuntimeTextReplacement[];
};
export type AgentRuntimeProviderHandle = {
provider: string;
config?: AgentRuntimeConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
applyAutoEnable?: boolean;
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
};
export type AgentRuntimeInteractiveButtonStyle = "primary" | "secondary" | "success" | "danger";
export type AgentRuntimeInteractiveReplyButton = {
@@ -251,12 +271,27 @@ export type AgentRuntimeAuthPlan = {
export type AgentRuntimePromptPlan = {
provider: string;
modelId: string;
textTransforms?: AgentRuntimeTextTransforms;
resolveSystemPromptContribution(
context: AgentRuntimeSystemPromptContributionContext,
): AgentRuntimeSystemPromptContribution | undefined;
transformSystemPrompt(
context: AgentRuntimeSystemPromptContributionContext & {
systemPrompt: string;
},
): string;
};
// Keep the leaf runtime-plan contract decoupled from plugin metadata internals.
export type AgentRuntimePreparedMetadataSnapshot = object;
export type PreparedOpenClawToolPlanning = {
metadataSnapshot?: AgentRuntimePreparedMetadataSnapshot;
loadMetadataSnapshot?: () => AgentRuntimePreparedMetadataSnapshot;
};
export type AgentRuntimeToolPlan = {
preparedPlanning?: PreparedOpenClawToolPlanning;
normalize<TSchemaType extends TSchema = TSchema, TResult = unknown>(
tools: AgentTool<TSchemaType, TResult>[],
params?: {
@@ -306,6 +341,7 @@ export type AgentRuntimeTransportPlan = {
export type AgentRuntimePlan = {
resolvedRef: AgentRuntimeResolvedRef;
providerRuntimeHandle?: AgentRuntimeProviderHandle;
auth: AgentRuntimeAuthPlan;
prompt: AgentRuntimePromptPlan;
tools: AgentRuntimeToolPlan;
@@ -337,6 +373,7 @@ export type BuildAgentRuntimeDeliveryPlanParams = {
agentDir?: string;
provider: string;
modelId: string;
providerRuntimeHandle?: AgentRuntimeProviderHandle;
};
export type BuildAgentRuntimePlanParams = {
@@ -356,4 +393,5 @@ export type BuildAgentRuntimePlanParams = {
thinkingLevel?: AgentRuntimeThinkLevel;
extraParamsOverride?: Record<string, unknown>;
resolvedTransport?: AgentRuntimeTransport;
providerRuntimeHandle?: AgentRuntimeProviderHandle;
};

View File

@@ -81,6 +81,7 @@ export function installEmbeddedRunnerFastRunE2eMocks(
provider: params.provider,
modelId: params.modelId,
resolveSystemPromptContribution: vi.fn(() => undefined),
transformSystemPrompt: vi.fn((context) => context.systemPrompt),
},
tools: {
normalize: vi.fn((tools: unknown[]) => tools),

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
import type { ProviderRuntimePluginHandle } from "../plugins/provider-hook-runtime.js";
import { resolveProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js";
import { shouldPreserveThinkingBlocks } from "../plugins/provider-replay-helpers.js";
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
@@ -219,6 +220,7 @@ export function resolveTranscriptPolicy(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
model?: ProviderRuntimeModel;
runtimeHandle?: ProviderRuntimePluginHandle;
}): TranscriptPolicy {
const provider = normalizeProviderId(params.provider ?? "");
const cacheConfig = canCacheTranscriptPolicy(params) ? params.config : undefined;
@@ -231,14 +233,16 @@ export function resolveTranscriptPolicy(params: {
return cached;
}
}
const runtimePlugin = provider
? resolveProviderRuntimePlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
: undefined;
const runtimePlugin =
params.runtimeHandle?.plugin ??
(provider
? resolveProviderRuntimePlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
: undefined);
const context = {
config: params.config,
workspaceDir: params.workspaceDir,

View File

@@ -1,3 +1,4 @@
import { normalizeProviderId } from "../agents/provider-id.js";
import { isRecord } from "../utils.js";
export type ConfiguredModelRef = {
@@ -115,3 +116,19 @@ export function collectConfiguredModelRefs(
);
return refs;
}
export function collectConfiguredModelRefValues(
config: unknown,
options?: { includeChannelModelOverrides?: boolean },
): string[] {
return collectConfiguredModelRefs(config, options).map((ref) => ref.value);
}
export function extractProviderFromModelRef(value: string): string | null {
const trimmed = value.trim();
const slash = trimmed.indexOf("/");
if (slash <= 0) {
return null;
}
return normalizeProviderId(trimmed.slice(0, slash));
}

View File

@@ -1,6 +1,23 @@
import type { ChannelMessageAdapterShape } from "../../channels/message/types.js";
import { getChannelPlugin, getLoadedChannelPlugin } from "../../channels/plugins/index.js";
import { channelPluginHasNativeApprovalPromptUi } from "../../channels/plugins/native-approval-prompt.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type {
ChannelAgentPromptAdapter,
ChannelAllowlistAdapter,
ChannelCapabilities,
ChannelCommandAdapter,
ChannelConfigAdapter,
ChannelConversationBindingSupport,
ChannelDirectoryAdapter,
ChannelGroupAdapter,
ChannelMessageActionAdapter,
ChannelMessagingAdapter,
ChannelOutboundAdapter,
ChannelPairingAdapter,
ChannelStreamingAdapter,
ChannelThreadingAdapter,
} from "../../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getActivePluginRegistry } from "../../plugins/runtime.js";
import {
@@ -13,6 +30,60 @@ import {
resetOutboundChannelBootstrapStateForTests,
} from "./channel-bootstrap.runtime.js";
type ChannelTargetResolver = NonNullable<ChannelMessagingAdapter["targetResolver"]>;
export type ChannelPromptRuntime = {
messageToolHints?: ChannelAgentPromptAdapter["messageToolHints"];
messageToolCapabilities?: ChannelAgentPromptAdapter["messageToolCapabilities"];
reactionGuidance?: ChannelAgentPromptAdapter["reactionGuidance"];
hasNativeApprovalPromptUi?: boolean;
};
export type OutboundChannelRuntime = {
id: string;
label: string;
chatTypes: NonNullable<ChannelCapabilities["chatTypes"]>;
preferSessionLookupForAnnounceTarget?: ChannelPlugin["meta"]["preferSessionLookupForAnnounceTarget"];
actions?: ChannelMessageActionAdapter;
approvalCapability?: ChannelPlugin["approvalCapability"];
conversationBindings?: ChannelConversationBindingSupport;
allowlist?: ChannelAllowlistAdapter;
pairing?: ChannelPairingAdapter;
commands?: ChannelCommandAdapter;
defaultAccountId?: ChannelConfigAdapter<unknown>["defaultAccountId"];
directory?: ChannelDirectoryAdapter;
promptRuntime?: ChannelPromptRuntime;
inferTargetChatType?: ChannelMessagingAdapter["inferTargetChatType"];
normalizeTarget?: ChannelMessagingAdapter["normalizeTarget"];
looksLikeTargetId?: ChannelTargetResolver["looksLikeId"];
targetResolverHint?: string;
resolveMessagingTargetFallback?: ChannelTargetResolver["resolveTarget"];
resolveSessionTarget?: ChannelMessagingAdapter["resolveSessionTarget"];
formatTargetDisplay?: ChannelMessagingAdapter["formatTargetDisplay"];
resolveOutboundSessionRoute?: ChannelMessagingAdapter["resolveOutboundSessionRoute"];
buildCrossContextPresentation?: ChannelMessagingAdapter["buildCrossContextPresentation"];
transformReplyPayload?: ChannelMessagingAdapter["transformReplyPayload"];
resolveAllowFrom?: ChannelConfigAdapter<unknown>["resolveAllowFrom"];
resolveDefaultTo?: ChannelConfigAdapter<unknown>["resolveDefaultTo"];
formatAllowFrom?: ChannelPlugin["config"]["formatAllowFrom"];
allowFromFallback?: NonNullable<ChannelPlugin["elevated"]>["allowFromFallback"];
resolveGroupRequireMention?: ChannelGroupAdapter["resolveRequireMention"];
resolveGroupToolPolicy?: ChannelGroupAdapter["resolveToolPolicy"];
queueDebounceMs?: NonNullable<NonNullable<ChannelPlugin["defaults"]>["queue"]>["debounceMs"];
buildThreadingToolContext?: ChannelThreadingAdapter["buildToolContext"];
resolveAutoThreadId?: ChannelThreadingAdapter["resolveAutoThreadId"];
resolveReplyToMode?: ChannelThreadingAdapter["resolveReplyToMode"];
resolveReplyTransport?: ChannelThreadingAdapter["resolveReplyTransport"];
outbound?: ChannelOutboundAdapter;
resolveTarget?: ChannelOutboundAdapter["resolveTarget"];
textChunkLimit?: ChannelOutboundAdapter["textChunkLimit"];
shouldTreatDeliveredTextAsVisible?: ChannelOutboundAdapter["shouldTreatDeliveredTextAsVisible"];
shouldTreatRoutedTextAsVisible?: ChannelOutboundAdapter["shouldTreatRoutedTextAsVisible"];
targetsMatchForReplySuppression?: ChannelOutboundAdapter["targetsMatchForReplySuppression"];
hasStructuredReplyPayload?: ChannelMessagingAdapter["hasStructuredReplyPayload"];
blockStreamingCoalesceDefaults?: ChannelStreamingAdapter["blockStreamingCoalesceDefaults"];
};
export function resetOutboundChannelResolutionStateForTest(): void {
resetOutboundChannelBootstrapStateForTests();
}
@@ -34,9 +105,7 @@ function maybeBootstrapChannelPlugin(params: {
bootstrapOutboundChannelPlugin(params);
}
function resolveDirectFromActiveRegistry(
channel: DeliverableMessageChannel,
): ChannelPlugin | undefined {
function resolveDirectFromActiveRegistry(channel: string): ChannelPlugin | undefined {
const activeRegistry = getActivePluginRegistry();
if (!activeRegistry) {
return undefined;
@@ -50,6 +119,58 @@ function resolveDirectFromActiveRegistry(
return undefined;
}
function toOutboundChannelRuntime(plugin: ChannelPlugin): OutboundChannelRuntime {
return {
id: plugin.id,
label: plugin.meta.label,
chatTypes: plugin.capabilities.chatTypes,
preferSessionLookupForAnnounceTarget: plugin.meta.preferSessionLookupForAnnounceTarget,
actions: plugin.actions,
approvalCapability: plugin.approvalCapability,
conversationBindings: plugin.conversationBindings,
allowlist: plugin.allowlist,
pairing: plugin.pairing,
commands: plugin.commands,
defaultAccountId: plugin.config.defaultAccountId,
directory: plugin.directory,
promptRuntime: {
messageToolHints: plugin.agentPrompt?.messageToolHints,
messageToolCapabilities: plugin.agentPrompt?.messageToolCapabilities,
reactionGuidance: plugin.agentPrompt?.reactionGuidance,
hasNativeApprovalPromptUi: channelPluginHasNativeApprovalPromptUi(plugin),
},
inferTargetChatType: plugin.messaging?.inferTargetChatType,
normalizeTarget: plugin.messaging?.normalizeTarget,
looksLikeTargetId: plugin.messaging?.targetResolver?.looksLikeId,
targetResolverHint: plugin.messaging?.targetResolver?.hint,
resolveMessagingTargetFallback: plugin.messaging?.targetResolver?.resolveTarget,
resolveSessionTarget: plugin.messaging?.resolveSessionTarget,
formatTargetDisplay: plugin.messaging?.formatTargetDisplay,
resolveOutboundSessionRoute: plugin.messaging?.resolveOutboundSessionRoute,
buildCrossContextPresentation: plugin.messaging?.buildCrossContextPresentation,
transformReplyPayload: plugin.messaging?.transformReplyPayload,
resolveAllowFrom: plugin.config?.resolveAllowFrom,
resolveDefaultTo: plugin.config?.resolveDefaultTo,
formatAllowFrom: plugin.config?.formatAllowFrom,
allowFromFallback: plugin.elevated?.allowFromFallback,
resolveGroupRequireMention: plugin.groups?.resolveRequireMention,
resolveGroupToolPolicy: plugin.groups?.resolveToolPolicy,
queueDebounceMs: plugin.defaults?.queue?.debounceMs,
buildThreadingToolContext: plugin.threading?.buildToolContext,
resolveAutoThreadId: plugin.threading?.resolveAutoThreadId,
resolveReplyToMode: plugin.threading?.resolveReplyToMode,
resolveReplyTransport: plugin.threading?.resolveReplyTransport,
outbound: plugin.outbound,
resolveTarget: plugin.outbound?.resolveTarget,
textChunkLimit: plugin.outbound?.textChunkLimit,
shouldTreatDeliveredTextAsVisible: plugin.outbound?.shouldTreatDeliveredTextAsVisible,
shouldTreatRoutedTextAsVisible: plugin.outbound?.shouldTreatRoutedTextAsVisible,
targetsMatchForReplySuppression: plugin.outbound?.targetsMatchForReplySuppression,
hasStructuredReplyPayload: plugin.messaging?.hasStructuredReplyPayload,
blockStreamingCoalesceDefaults: plugin.streaming?.blockStreamingCoalesceDefaults,
};
}
export function resolveOutboundChannelPlugin(params: {
channel: string;
cfg?: OpenClawConfig;
@@ -86,3 +207,53 @@ export function resolveOutboundChannelMessageAdapter(params: {
}): ChannelMessageAdapterShape | undefined {
return resolveOutboundChannelPlugin(params)?.message;
}
export function resolveOutboundChannelPluginForRead(params: {
channel: string;
cfg?: OpenClawConfig;
}): ChannelPlugin | undefined {
const normalized = normalizeMessageChannel(params.channel) ?? params.channel.trim();
if (!normalized) {
return undefined;
}
const channelId = normalized as Parameters<typeof getLoadedChannelPlugin>[0];
const current = getLoadedChannelPlugin(channelId);
if (current) {
return current;
}
const directCurrent = resolveDirectFromActiveRegistry(normalized);
if (directCurrent) {
return directCurrent;
}
const deliverable = normalizeDeliverableOutboundChannel(normalized);
if (deliverable) {
maybeBootstrapChannelPlugin({ channel: deliverable, cfg: params.cfg });
return (
getLoadedChannelPlugin(deliverable) ??
resolveDirectFromActiveRegistry(deliverable) ??
getChannelPlugin(deliverable)
);
}
return getChannelPlugin(channelId);
}
export function resolveOutboundChannelRuntime(params: {
channel: string;
cfg?: OpenClawConfig;
}): OutboundChannelRuntime | undefined {
const plugin = resolveOutboundChannelPluginForRead(params);
return plugin ? toOutboundChannelRuntime(plugin) : undefined;
}
export function resolveLoadedOutboundChannelPluginForRead(params: {
channel: string;
}): ChannelPlugin | undefined {
const normalized = normalizeMessageChannel(params.channel) ?? params.channel.trim();
if (!normalized) {
return undefined;
}
return (
getLoadedChannelPlugin(normalized as Parameters<typeof getLoadedChannelPlugin>[0]) ??
resolveDirectFromActiveRegistry(normalized)
);
}

View File

@@ -10,6 +10,7 @@ export * from "../agents/identity.js";
export * from "../agents/model-auth-markers.js";
export * from "../agents/model-auth.js";
export * from "../agents/model-catalog.js";
export * from "../agents/model-catalog-scope.js";
export * from "../agents/model-selection.js";
export * from "../agents/simple-completion-runtime.js";
export * from "../agents/pi-embedded-block-chunker.js";

View File

@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/types.adapters.js";
import type { ChannelConfigSchema } from "../channels/plugins/types.config.js";
import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
@@ -52,6 +53,7 @@ type DefineBundledChannelEntryOptions<TPlugin = ChannelPlugin> = {
description: string;
importMetaUrl: string;
plugin: BundledEntryModuleRef;
outbound?: BundledEntryModuleRef;
secrets?: BundledEntryModuleRef;
configSchema?: ChannelEntryConfigSchema<TPlugin> | (() => ChannelEntryConfigSchema<TPlugin>);
runtime?: BundledEntryModuleRef;
@@ -108,6 +110,9 @@ export type BundledChannelEntryContract<TPlugin = ChannelPlugin> = {
features?: BundledChannelEntryFeatures;
register: (api: OpenClawPluginApi) => void;
loadChannelPlugin: (options?: BundledEntryModuleLoadOptions) => TPlugin;
loadChannelOutbound?: (
options?: BundledEntryModuleLoadOptions,
) => ChannelOutboundAdapter | undefined;
loadChannelSecrets?: (
options?: BundledEntryModuleLoadOptions,
) => ChannelPlugin["secrets"] | undefined;
@@ -435,6 +440,7 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
description,
importMetaUrl,
plugin,
outbound,
secrets,
configSchema,
runtime,
@@ -449,6 +455,14 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
: ((configSchema ?? emptyChannelConfigSchema()) as ChannelEntryConfigSchema<TPlugin>);
const loadChannelPlugin = (options?: BundledEntryModuleLoadOptions) =>
loadBundledEntryExportSync<TPlugin>(importMetaUrl, plugin, options);
const loadChannelOutbound = outbound
? (options?: BundledEntryModuleLoadOptions) =>
loadBundledEntryExportSync<ChannelOutboundAdapter | undefined>(
importMetaUrl,
outbound,
options,
)
: undefined;
const loadChannelSecrets = secrets
? (options?: BundledEntryModuleLoadOptions) =>
loadBundledEntryExportSync<ChannelPlugin["secrets"] | undefined>(
@@ -511,6 +525,7 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
profile("bundled-register:registerFull", () => registerFull?.(api));
},
loadChannelPlugin,
...(loadChannelOutbound ? { loadChannelOutbound } : {}),
...(loadChannelSecrets ? { loadChannelSecrets } : {}),
...(loadChannelAccountInspector ? { loadChannelAccountInspector } : {}),
...(setChannelRuntime ? { setChannelRuntime } : {}),

View File

@@ -37,6 +37,7 @@ export { parseTtsDirectives } from "../tts/directives.js";
export {
canonicalizeSpeechProviderId,
getSpeechProvider,
listLoadedSpeechProviders,
listSpeechProviders,
normalizeSpeechProviderId,
} from "../tts/provider-registry.js";

View File

@@ -33,6 +33,10 @@ function loadFacadeModule(): FacadeModule {
});
}
export function prewarmTtsRuntimeFacade(): void {
loadFacadeModule();
}
export const _test: FacadeModule["_test"] = createLazyFacadeObjectValue(
() => loadFacadeModule()._test,
);

View File

@@ -22,8 +22,9 @@ import type {
} from "./types.js";
const providerRuntimePluginCache: ConfigScopedRuntimeCache<ProviderPlugin | null> = new WeakMap();
const PREPARED_PROVIDER_RUNTIME_SURFACES = ["channel"] as const;
type ProviderRuntimePluginLookupParams = {
export type ProviderRuntimePluginLookupParams = {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
@@ -33,6 +34,14 @@ type ProviderRuntimePluginLookupParams = {
bundledProviderVitestCompat?: boolean;
};
export type ProviderRuntimePluginHandle = ProviderRuntimePluginLookupParams & {
plugin?: ProviderPlugin;
};
export type ProviderRuntimePluginHandleParams = ProviderRuntimePluginLookupParams & {
runtimeHandle?: ProviderRuntimePluginHandle;
};
function matchesProviderId(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
if (!normalized) {
@@ -68,13 +77,42 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string):
return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
}
function resolveCompatibleActiveProviderRegistry(
params: ProviderRuntimePluginLookupParams,
): PluginRegistry | undefined {
return getLoadedRuntimePluginRegistry({
env: params.env,
workspaceDir: params.workspaceDir,
function findProviderRuntimePluginInLoadedRegistries(params: {
lookup: ProviderRuntimePluginLookupParams;
apiOwnerHint?: string;
}): ProviderPlugin | undefined {
const activeRegistry = getLoadedRuntimePluginRegistry({
env: params.lookup.env,
workspaceDir: params.lookup.workspaceDir,
});
const activePlugin = activeRegistry
? findProviderRuntimePluginInRegistry({
registry: activeRegistry,
provider: params.lookup.provider,
apiOwnerHint: params.apiOwnerHint,
})
: undefined;
if (activePlugin) {
return activePlugin;
}
for (const surface of PREPARED_PROVIDER_RUNTIME_SURFACES) {
const registry = getLoadedRuntimePluginRegistry({
env: params.lookup.env,
workspaceDir: params.lookup.workspaceDir,
surface,
});
const plugin = registry
? findProviderRuntimePluginInRegistry({
registry,
provider: params.lookup.provider,
apiOwnerHint: params.apiOwnerHint,
})
: undefined;
if (plugin) {
return plugin;
}
}
return undefined;
}
function findProviderRuntimePluginInRegistry(params: {
@@ -100,7 +138,7 @@ export function resolveProviderPluginsForHooks(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
providerRefs?: string[];
providerRefs?: readonly string[];
applyAutoEnable?: boolean;
bundledProviderAllowlistCompat?: boolean;
bundledProviderVitestCompat?: boolean;
@@ -139,16 +177,12 @@ export function resolveProviderRuntimePlugin(
provider: params.provider,
config: params.config,
});
const activeRegistry = resolveCompatibleActiveProviderRegistry(params);
const activePlugin = activeRegistry
? findProviderRuntimePluginInRegistry({
registry: activeRegistry,
provider: params.provider,
apiOwnerHint,
})
: undefined;
if (activePlugin) {
return activePlugin;
const loadedPlugin = findProviderRuntimePluginInLoadedRegistries({
lookup: params,
apiOwnerHint,
});
if (loadedPlugin) {
return loadedPlugin;
}
const cacheConfig = params.env && params.env !== process.env ? undefined : params.config;
const plugin = resolveConfigScopedRuntimeCacheValue({
@@ -196,14 +230,43 @@ export function resolveProviderHookPlugin(params: {
);
}
export function resolveProviderRuntimePluginHandle(
params: ProviderRuntimePluginLookupParams,
): ProviderRuntimePluginHandle {
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const env = params.env;
const runtimePlugin = resolveProviderRuntimePlugin({
...params,
workspaceDir,
env,
});
return {
...params,
workspaceDir,
env,
plugin: runtimePlugin,
};
}
export function ensureProviderRuntimePluginHandle(
params: ProviderRuntimePluginHandleParams,
): ProviderRuntimePluginHandle {
return params.runtimeHandle ?? resolveProviderRuntimePluginHandle(params);
}
export function prepareProviderExtraParams(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderPrepareExtraParamsContext;
}) {
return resolveProviderRuntimePlugin(params)?.prepareExtraParams?.(params.context) ?? undefined;
return (
ensureProviderRuntimePluginHandle(params).plugin?.prepareExtraParams?.(params.context) ??
undefined
);
}
export function resolveProviderExtraParamsForTransport(params: {
@@ -211,10 +274,12 @@ export function resolveProviderExtraParamsForTransport(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderExtraParamsForTransportContext;
}) {
return (
resolveProviderRuntimePlugin(params)?.extraParamsForTransport?.(params.context) ?? undefined
ensureProviderRuntimePluginHandle(params).plugin?.extraParamsForTransport?.(params.context) ??
undefined
);
}
@@ -223,9 +288,12 @@ export function resolveProviderAuthProfileId(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderResolveAuthProfileIdContext;
}): string | undefined {
const resolved = resolveProviderRuntimePlugin(params)?.resolveAuthProfileId?.(params.context);
const resolved = ensureProviderRuntimePluginHandle(params).plugin?.resolveAuthProfileId?.(
params.context,
);
return typeof resolved === "string" && resolved.trim() ? resolved.trim() : undefined;
}
@@ -234,9 +302,13 @@ export function resolveProviderFollowupFallbackRoute(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderFollowupFallbackRouteContext;
}): ProviderFollowupFallbackRouteResult | undefined {
return resolveProviderHookPlugin(params)?.followupFallbackRoute?.(params.context) ?? undefined;
return (
ensureProviderRuntimePluginHandle(params).plugin?.followupFallbackRoute?.(params.context) ??
undefined
);
}
export function wrapProviderStreamFn(params: {
@@ -244,7 +316,10 @@ export function wrapProviderStreamFn(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderWrapStreamFnContext;
}) {
return resolveProviderRuntimePlugin(params)?.wrapStreamFn?.(params.context) ?? undefined;
return (
ensureProviderRuntimePluginHandle(params).plugin?.wrapStreamFn?.(params.context) ?? undefined
);
}

View File

@@ -18,9 +18,11 @@ import {
resolveProviderAuthProfileId,
resolveProviderExtraParamsForTransport,
resolveProviderFollowupFallbackRoute,
ensureProviderRuntimePluginHandle,
resolveProviderHookPlugin,
resolveProviderPluginsForHooks,
resolveProviderRuntimePlugin,
type ProviderRuntimePluginHandle,
wrapProviderStreamFn,
} from "./provider-hook-runtime.js";
import { resolveBundledProviderPolicySurface } from "./provider-public-artifacts.js";
@@ -188,9 +190,10 @@ export function resolveProviderSystemPromptContribution(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderSystemPromptContributionContext;
}): ProviderSystemPromptContribution | undefined {
const plugin = resolveProviderRuntimePlugin(params);
const plugin = ensureProviderRuntimePluginHandle(params).plugin;
const baseOverlay = resolveGpt5SystemPromptContribution({
config: params.context.config ?? params.config,
providerId: params.context.provider ?? params.provider,
@@ -240,9 +243,10 @@ export function transformProviderSystemPrompt(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderTransformSystemPromptContext;
}): string {
const plugin = resolveProviderRuntimePlugin(params);
const plugin = ensureProviderRuntimePluginHandle(params).plugin;
const textTransforms = mergePluginTextTransforms(
resolveRuntimeTextTransforms(),
plugin?.textTransforms,
@@ -257,10 +261,11 @@ export function resolveProviderTextTransforms(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
}): PluginTextTransforms | undefined {
return mergePluginTextTransforms(
resolveRuntimeTextTransforms(),
resolveProviderRuntimePlugin(params)?.textTransforms,
ensureProviderRuntimePluginHandle(params).plugin?.textTransforms,
);
}
@@ -554,9 +559,13 @@ export function normalizeProviderToolSchemasWithPlugin(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderNormalizeToolSchemasContext;
}) {
return resolveProviderRuntimePlugin(params)?.normalizeToolSchemas?.(params.context) ?? undefined;
return (
ensureProviderRuntimePluginHandle(params).plugin?.normalizeToolSchemas?.(params.context) ??
undefined
);
}
export function inspectProviderToolSchemasWithPlugin(params: {
@@ -564,9 +573,13 @@ export function inspectProviderToolSchemasWithPlugin(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
runtimeHandle?: ProviderRuntimePluginHandle;
context: ProviderNormalizeToolSchemasContext;
}) {
return resolveProviderRuntimePlugin(params)?.inspectToolSchemas?.(params.context) ?? undefined;
return (
ensureProviderRuntimePluginHandle(params).plugin?.inspectToolSchemas?.(params.context) ??
undefined
);
}
export function resolveProviderReasoningOutputModeWithPlugin(params: {

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "../config/types.js";
import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js";
import {
resolvePluginCapabilityProvider,
resolvePluginCapabilityProviders,
@@ -17,6 +18,10 @@ function resolveSpeechProviderPluginEntries(cfg?: OpenClawConfig): SpeechProvide
});
}
function resolveLoadedSpeechProviderPluginEntries(): SpeechProviderPlugin[] {
return (getActiveRuntimePluginRegistry()?.speechProviders ?? []).map((entry) => entry.provider);
}
const defaultSpeechProviderRegistryResolver: SpeechProviderRegistryResolver = {
getProvider: (providerId, cfg) =>
resolvePluginCapabilityProvider({
@@ -31,7 +36,19 @@ const defaultSpeechProviderRegistry = createSpeechProviderRegistry(
defaultSpeechProviderRegistryResolver,
);
const loadedSpeechProviderRegistry = createSpeechProviderRegistry({
getProvider: (providerId) =>
resolveLoadedSpeechProviderPluginEntries().find((provider) => {
if (provider.id === providerId) {
return true;
}
return provider.aliases?.includes(providerId) ?? false;
}),
listProviders: () => resolveLoadedSpeechProviderPluginEntries(),
});
export const listSpeechProviders = defaultSpeechProviderRegistry.listSpeechProviders;
export const listLoadedSpeechProviders = loadedSpeechProviderRegistry.listSpeechProviders;
export const getSpeechProvider = defaultSpeechProviderRegistry.getSpeechProvider;
export const canonicalizeSpeechProviderId =
defaultSpeechProviderRegistry.canonicalizeSpeechProviderId;