diff --git a/AGENTS.md b/AGENTS.md index f41103d56b2..999aff02a5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index e3bd7f2416d..7e5b8adec2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 9773d23f4e4..8a74d275704 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/scripts/lib/plugin-sdk-doc-metadata.ts b/scripts/lib/plugin-sdk-doc-metadata.ts index 7ce8e43d4c0..6e57d5f9ed0 100644 --- a/scripts/lib/plugin-sdk-doc-metadata.ts +++ b/scripts/lib/plugin-sdk-doc-metadata.ts @@ -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", }, diff --git a/src/agents/model-catalog-scope.ts b/src/agents/model-catalog-scope.ts new file mode 100644 index 00000000000..e1c57f9e552 --- /dev/null +++ b/src/agents/model-catalog-scope.ts @@ -0,0 +1,51 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; + +function dedupeCatalogScopeRefs(values: Array): string[] { + const refs = new Set(); + 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; +} diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index dc158732456..07fe1faf575 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -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; model?: ProviderRuntimeModel; resolvedTransport?: SupportedTransport; + providerRuntimeHandle?: ProviderRuntimePluginHandle; }): Record { 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, diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 659b679968a..b02df51c4bb 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/tool-schema-runtime.ts b/src/agents/pi-embedded-runner/tool-schema-runtime.ts index 3150473f6ab..91ec528588e 100644 --- a/src/agents/pi-embedded-runner/tool-schema-runtime.ts +++ b/src/agents/pi-embedded-runner/tool-schema-runtime.ts @@ -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( @@ -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)) { diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 8cb4c517c93..628e8b860ae 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -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, + }); + }); }); diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index 334534b2b00..a24f59c7f98 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -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 | undefined; + let memoizedTransportExtraParams: ReturnType | 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( tools: AgentTool[], 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: { diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index 479ebdcca08..eec8648234a 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -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( tools: AgentTool[], 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; resolvedTransport?: AgentRuntimeTransport; + providerRuntimeHandle?: AgentRuntimeProviderHandle; }; diff --git a/src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts b/src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts index d3fe667f582..8701afb73fc 100644 --- a/src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/pi-embedded-runner-e2e-mocks.ts @@ -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), diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 74c53c8c4bb..5107249312c 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -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, diff --git a/src/config/model-refs.ts b/src/config/model-refs.ts index d4e0c7e266e..ffdaa4cd66b 100644 --- a/src/config/model-refs.ts +++ b/src/config/model-refs.ts @@ -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)); +} diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 2b377079f83..33f897cd223 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -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; + +export type ChannelPromptRuntime = { + messageToolHints?: ChannelAgentPromptAdapter["messageToolHints"]; + messageToolCapabilities?: ChannelAgentPromptAdapter["messageToolCapabilities"]; + reactionGuidance?: ChannelAgentPromptAdapter["reactionGuidance"]; + hasNativeApprovalPromptUi?: boolean; +}; + +export type OutboundChannelRuntime = { + id: string; + label: string; + chatTypes: NonNullable; + preferSessionLookupForAnnounceTarget?: ChannelPlugin["meta"]["preferSessionLookupForAnnounceTarget"]; + actions?: ChannelMessageActionAdapter; + approvalCapability?: ChannelPlugin["approvalCapability"]; + conversationBindings?: ChannelConversationBindingSupport; + allowlist?: ChannelAllowlistAdapter; + pairing?: ChannelPairingAdapter; + commands?: ChannelCommandAdapter; + defaultAccountId?: ChannelConfigAdapter["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["resolveAllowFrom"]; + resolveDefaultTo?: ChannelConfigAdapter["resolveDefaultTo"]; + formatAllowFrom?: ChannelPlugin["config"]["formatAllowFrom"]; + allowFromFallback?: NonNullable["allowFromFallback"]; + resolveGroupRequireMention?: ChannelGroupAdapter["resolveRequireMention"]; + resolveGroupToolPolicy?: ChannelGroupAdapter["resolveToolPolicy"]; + queueDebounceMs?: NonNullable["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[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[0]) ?? + resolveDirectFromActiveRegistry(normalized) + ); +} diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index 3a689433964..3c127510108 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -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"; diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index cc3f6bdc7e4..c762bacd111 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -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 = { description: string; importMetaUrl: string; plugin: BundledEntryModuleRef; + outbound?: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; configSchema?: ChannelEntryConfigSchema | (() => ChannelEntryConfigSchema); runtime?: BundledEntryModuleRef; @@ -108,6 +110,9 @@ export type BundledChannelEntryContract = { 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({ description, importMetaUrl, plugin, + outbound, secrets, configSchema, runtime, @@ -449,6 +455,14 @@ export function defineBundledChannelEntry({ : ((configSchema ?? emptyChannelConfigSchema()) as ChannelEntryConfigSchema); const loadChannelPlugin = (options?: BundledEntryModuleLoadOptions) => loadBundledEntryExportSync(importMetaUrl, plugin, options); + const loadChannelOutbound = outbound + ? (options?: BundledEntryModuleLoadOptions) => + loadBundledEntryExportSync( + importMetaUrl, + outbound, + options, + ) + : undefined; const loadChannelSecrets = secrets ? (options?: BundledEntryModuleLoadOptions) => loadBundledEntryExportSync( @@ -511,6 +525,7 @@ export function defineBundledChannelEntry({ profile("bundled-register:registerFull", () => registerFull?.(api)); }, loadChannelPlugin, + ...(loadChannelOutbound ? { loadChannelOutbound } : {}), ...(loadChannelSecrets ? { loadChannelSecrets } : {}), ...(loadChannelAccountInspector ? { loadChannelAccountInspector } : {}), ...(setChannelRuntime ? { setChannelRuntime } : {}), diff --git a/src/plugin-sdk/speech-core.ts b/src/plugin-sdk/speech-core.ts index 370c92d19fe..1f837e42158 100644 --- a/src/plugin-sdk/speech-core.ts +++ b/src/plugin-sdk/speech-core.ts @@ -37,6 +37,7 @@ export { parseTtsDirectives } from "../tts/directives.js"; export { canonicalizeSpeechProviderId, getSpeechProvider, + listLoadedSpeechProviders, listSpeechProviders, normalizeSpeechProviderId, } from "../tts/provider-registry.js"; diff --git a/src/plugin-sdk/tts-runtime.ts b/src/plugin-sdk/tts-runtime.ts index 460abe7273e..5761f3b5d17 100644 --- a/src/plugin-sdk/tts-runtime.ts +++ b/src/plugin-sdk/tts-runtime.ts @@ -33,6 +33,10 @@ function loadFacadeModule(): FacadeModule { }); } +export function prewarmTtsRuntimeFacade(): void { + loadFacadeModule(); +} + export const _test: FacadeModule["_test"] = createLazyFacadeObjectValue( () => loadFacadeModule()._test, ); diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index a758bf6a1ad..bc57db75717 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -22,8 +22,9 @@ import type { } from "./types.js"; const providerRuntimePluginCache: ConfigScopedRuntimeCache = 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 + ); } diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 09467e24e57..3f3d71e137b 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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: { diff --git a/src/tts/provider-registry.ts b/src/tts/provider-registry.ts index b673c21790e..308b158f56c 100644 --- a/src/tts/provider-registry.ts +++ b/src/tts/provider-registry.ts @@ -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;