feat: add plugin text transforms

This commit is contained in:
Peter Steinberger
2026-04-11 02:03:11 +01:00
parent a2dbc1b63c
commit 202f80792e
32 changed files with 866 additions and 50 deletions

View File

@@ -29,6 +29,7 @@ export type BuildPluginApiParams = {
| "registerSecurityAuditCollector"
| "registerService"
| "registerCliBackend"
| "registerTextTransforms"
| "registerConfigMigration"
| "registerAutoEnableProbe"
| "registerProvider"
@@ -71,6 +72,7 @@ const noopRegisterSecurityAuditCollector: OpenClawPluginApi["registerSecurityAud
() => {};
const noopRegisterService: OpenClawPluginApi["registerService"] = () => {};
const noopRegisterCliBackend: OpenClawPluginApi["registerCliBackend"] = () => {};
const noopRegisterTextTransforms: OpenClawPluginApi["registerTextTransforms"] = () => {};
const noopRegisterConfigMigration: OpenClawPluginApi["registerConfigMigration"] = () => {};
const noopRegisterAutoEnableProbe: OpenClawPluginApi["registerAutoEnableProbe"] = () => {};
const noopRegisterProvider: OpenClawPluginApi["registerProvider"] = () => {};
@@ -134,6 +136,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi
handlers.registerSecurityAuditCollector ?? noopRegisterSecurityAuditCollector,
registerService: handlers.registerService ?? noopRegisterService,
registerCliBackend: handlers.registerCliBackend ?? noopRegisterCliBackend,
registerTextTransforms: handlers.registerTextTransforms ?? noopRegisterTextTransforms,
registerConfigMigration: handlers.registerConfigMigration ?? noopRegisterConfigMigration,
registerAutoEnableProbe: handlers.registerAutoEnableProbe ?? noopRegisterAutoEnableProbe,
registerProvider: handlers.registerProvider ?? noopRegisterProvider,

View File

@@ -341,6 +341,15 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
rootDir: record.rootDir,
})),
);
registry.textTransforms.push(
...captured.textTransforms.map((transforms) => ({
pluginId: record.id,
pluginName: record.name,
transforms,
source: record.source,
rootDir: record.rootDir,
})),
);
registry.providers.push(
...captured.providers.map((provider) => ({
pluginId: record.id,

View File

@@ -18,6 +18,10 @@ describe("captured plugin registration", () => {
label: "Captured Provider",
auth: [],
});
api.registerTextTransforms({
input: [{ from: /red basket/g, to: "blue basket" }],
output: [{ from: /blue basket/g, to: "red basket" }],
});
api.registerChannel({
plugin: {
id: "captured-channel",
@@ -47,6 +51,8 @@ describe("captured plugin registration", () => {
expect(captured.tools.map((tool) => tool.name)).toEqual(["captured-tool"]);
expect(captured.providers.map((provider) => provider.id)).toEqual(["captured-provider"]);
expect(captured.textTransforms).toHaveLength(1);
expect(captured.textTransforms[0]?.input).toHaveLength(1);
expect(captured.api.registerMemoryEmbeddingProvider).toBeTypeOf("function");
});
});

View File

@@ -12,6 +12,7 @@ import type {
OpenClawPluginApi,
OpenClawPluginCliCommandDescriptor,
OpenClawPluginCliRegistrar,
PluginTextTransformRegistration,
ProviderPlugin,
RealtimeTranscriptionProviderPlugin,
RealtimeVoiceProviderPlugin,
@@ -33,6 +34,7 @@ export type CapturedPluginRegistration = {
agentHarnesses: AgentHarness[];
cliRegistrars: CapturedPluginCliRegistration[];
cliBackends: CliBackendPlugin[];
textTransforms: PluginTextTransformRegistration[];
speechProviders: SpeechProviderPlugin[];
realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[];
realtimeVoiceProviders: RealtimeVoiceProviderPlugin[];
@@ -54,6 +56,7 @@ export function createCapturedPluginRegistration(params?: {
const agentHarnesses: AgentHarness[] = [];
const cliRegistrars: CapturedPluginCliRegistration[] = [];
const cliBackends: CliBackendPlugin[] = [];
const textTransforms: PluginTextTransformRegistration[] = [];
const speechProviders: SpeechProviderPlugin[] = [];
const realtimeTranscriptionProviders: RealtimeTranscriptionProviderPlugin[] = [];
const realtimeVoiceProviders: RealtimeVoiceProviderPlugin[] = [];
@@ -77,6 +80,7 @@ export function createCapturedPluginRegistration(params?: {
agentHarnesses,
cliRegistrars,
cliBackends,
textTransforms,
speechProviders,
realtimeTranscriptionProviders,
realtimeVoiceProviders,
@@ -130,6 +134,9 @@ export function createCapturedPluginRegistration(params?: {
registerCliBackend(backend: CliBackendPlugin) {
cliBackends.push(backend);
},
registerTextTransforms(transforms: PluginTextTransformRegistration) {
textTransforms.push(transforms);
},
registerSpeechProvider(provider: SpeechProviderPlugin) {
speechProviders.push(provider);
},

View File

@@ -719,6 +719,37 @@ describe("loadOpenClawPlugins", () => {
expect(bundled?.status).toBe("disabled");
});
it("registers standalone text transforms", () => {
useNoBundledPlugins();
const plugin = writePlugin({
id: "text-shim",
filename: "text-shim.cjs",
body: `module.exports = {
id: "text-shim",
register(api) {
api.registerTextTransforms({
input: [{ from: /red basket/g, to: "blue basket" }],
output: [{ from: /blue basket/g, to: "red basket" }],
});
},
};`,
});
const registry = loadRegistryFromSinglePlugin({
plugin,
pluginConfig: { allow: ["text-shim"] },
});
expect(registry.textTransforms).toHaveLength(1);
expect(registry.textTransforms[0]).toMatchObject({
pluginId: "text-shim",
transforms: {
input: expect.any(Array),
output: expect.any(Array),
},
});
});
it.each([
{
name: "loads bundled telegram plugin when enabled",

View File

@@ -1,4 +1,8 @@
import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-profiles/types.js";
import {
applyPluginTextReplacements,
mergePluginTextTransforms,
} from "../agents/plugin-text-transforms.js";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js";
import type { OpenClawConfig } from "../config/config.js";
@@ -9,6 +13,7 @@ import { resolveCatalogHookProviderPluginIds } from "./providers.js";
import { isPluginProvidersLoadInFlight, resolvePluginProviders } from "./providers.runtime.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js";
import type {
ProviderAuthDoctorHintContext,
ProviderAugmentModelCatalogContext,
@@ -49,12 +54,14 @@ import type {
ProviderResolveTransportTurnStateContext,
ProviderResolveWebSocketSessionPolicyContext,
ProviderSystemPromptContributionContext,
ProviderTransformSystemPromptContext,
ProviderRuntimeModel,
ProviderThinkingPolicyContext,
ProviderTransportTurnState,
ProviderValidateReplayTurnsContext,
ProviderWebSocketSessionPolicy,
ProviderWrapStreamFnContext,
PluginTextTransforms,
} from "./types.js";
function matchesProviderId(provider: ProviderPlugin, providerId: string): boolean {
@@ -242,6 +249,35 @@ export function resolveProviderSystemPromptContribution(params: {
);
}
export function transformProviderSystemPrompt(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderTransformSystemPromptContext;
}): string {
const plugin = resolveProviderRuntimePlugin(params);
const textTransforms = mergePluginTextTransforms(
resolveRuntimeTextTransforms(),
plugin?.textTransforms,
);
const transformed =
plugin?.transformSystemPrompt?.(params.context) ?? params.context.systemPrompt;
return applyPluginTextReplacements(transformed, textTransforms?.input);
}
export function resolveProviderTextTransforms(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): PluginTextTransforms | undefined {
return mergePluginTextTransforms(
resolveRuntimeTextTransforms(),
resolveProviderRuntimePlugin(params)?.textTransforms,
);
}
export async function prepareProviderDynamicModel(params: {
provider: string;
config?: OpenClawConfig;

View File

@@ -10,6 +10,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
channelSetups: [],
providers: [],
cliBackends: [],
textTransforms: [],
speechProviders: [],
realtimeTranscriptionProviders: [],
realtimeVoiceProviders: [],

View File

@@ -33,6 +33,7 @@ import type {
PluginKind,
PluginLogger,
PluginOrigin,
PluginTextTransformRegistration,
ProviderPlugin,
RealtimeTranscriptionProviderPlugin,
RealtimeVoiceProviderPlugin,
@@ -105,6 +106,14 @@ export type PluginCliBackendRegistration = {
rootDir?: string;
};
export type PluginTextTransformsRegistration = {
pluginId: string;
pluginName?: string;
transforms: PluginTextTransformRegistration;
source: string;
rootDir?: string;
};
type PluginOwnedProviderRegistration<T extends { id: string }> = {
pluginId: string;
pluginName?: string;
@@ -259,6 +268,7 @@ export type PluginRegistry = {
channelSetups: PluginChannelSetupRegistration[];
providers: PluginProviderRegistration[];
cliBackends?: PluginCliBackendRegistration[];
textTransforms: PluginTextTransformsRegistration[];
speechProviders: PluginSpeechProviderRegistration[];
realtimeTranscriptionProviders: PluginRealtimeTranscriptionProviderRegistration[];
realtimeVoiceProviders: PluginRealtimeVoiceProviderRegistration[];

View File

@@ -63,6 +63,7 @@ import type {
PluginReloadRegistration,
PluginSecurityAuditCollectorRegistration,
PluginServiceRegistration,
PluginTextTransformsRegistration,
} from "./registry-types.js";
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
import type { PluginRuntime } from "./runtime/types.js";
@@ -136,6 +137,7 @@ export type {
PluginReloadRegistration,
PluginSecurityAuditCollectorRegistration,
PluginServiceRegistration,
PluginTextTransformsRegistration,
PluginToolRegistration,
PluginSpeechProviderRegistration,
PluginRealtimeTranscriptionProviderRegistration,
@@ -614,6 +616,31 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.cliBackendIds.push(id);
};
const registerTextTransforms = (
record: PluginRecord,
transforms: PluginTextTransformsRegistration["transforms"],
) => {
if (
(!transforms.input || transforms.input.length === 0) &&
(!transforms.output || transforms.output.length === 0)
) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: "text transform registration has no input or output replacements",
});
return;
}
registry.textTransforms.push({
pluginId: record.id,
pluginName: record.name,
transforms,
source: record.source,
rootDir: record.rootDir,
});
};
const registerUniqueProviderLike = <
T extends { id: string },
R extends PluginOwnedProviderRegistration<T>,
@@ -1151,6 +1178,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod(record, method, handler, opts),
registerService: (service) => registerService(record, service),
registerCliBackend: (backend) => registerCliBackend(record, backend),
registerTextTransforms: (transforms) => registerTextTransforms(record, transforms),
registerReload: (registration) => registerReload(record, registration),
registerNodeHostCommand: (command) => registerNodeHostCommand(record, command),
registerSecurityAuditCollector: (collector) =>
@@ -1394,6 +1422,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerProvider,
registerAgentHarness,
registerCliBackend,
registerTextTransforms,
registerSpeechProvider,
registerRealtimeTranscriptionProvider,
registerRealtimeVoiceProvider,

View File

@@ -128,6 +128,7 @@ export function createPluginLoadResult(
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
textTransforms: [],
agentHarnesses: [],
tools: [],
hooks: [],

View File

@@ -0,0 +1,33 @@
import { createRequire } from "node:module";
import { mergePluginTextTransforms } from "../agents/plugin-text-transforms.js";
import type { PluginTextTransforms } from "./types.js";
type PluginRuntimeModule = Pick<typeof import("./runtime.js"), "getActivePluginRegistry">;
const require = createRequire(import.meta.url);
const RUNTIME_MODULE_CANDIDATES = ["./runtime.js", "./runtime.ts"] as const;
let pluginRuntimeModule: PluginRuntimeModule | undefined;
function loadPluginRuntime(): PluginRuntimeModule | null {
if (pluginRuntimeModule) {
return pluginRuntimeModule;
}
for (const candidate of RUNTIME_MODULE_CANDIDATES) {
try {
pluginRuntimeModule = require(candidate) as PluginRuntimeModule;
return pluginRuntimeModule;
} catch {
// Try source/runtime candidates in order.
}
}
return null;
}
export function resolveRuntimeTextTransforms(): PluginTextTransforms | undefined {
return mergePluginTextTransforms(
...(loadPluginRuntime()
?.getActivePluginRegistry()
?.textTransforms.map((entry) => entry.transforms) ?? []),
);
}

View File

@@ -1095,6 +1095,24 @@ export type ProviderSystemPromptContributionContext = {
agentId?: string;
};
export type ProviderTransformSystemPromptContext = ProviderSystemPromptContributionContext & {
systemPrompt: string;
};
export type PluginTextReplacement = {
from: string | RegExp;
to: string;
};
export type PluginTextTransforms = {
/** Rewrites applied to outbound prompt text before provider/CLI transport. */
input?: PluginTextReplacement[];
/** Rewrites applied to inbound assistant text before OpenClaw consumes it. */
output?: PluginTextReplacement[];
};
export type PluginTextTransformRegistration = PluginTextTransforms;
/** Text-inference provider capability registered by a plugin. */
export type ProviderPlugin = {
id: string;
@@ -1467,6 +1485,22 @@ export type ProviderPlugin = {
resolveSystemPromptContribution?: (
ctx: ProviderSystemPromptContributionContext,
) => ProviderSystemPromptContribution | null | undefined;
/**
* Provider-owned final system-prompt transform.
*
* Use this sparingly when a provider transport needs small compatibility
* rewrites after OpenClaw has assembled the complete prompt. Return
* `undefined`/`null` to leave the prompt unchanged.
*/
transformSystemPrompt?: (ctx: ProviderTransformSystemPromptContext) => string | null | undefined;
/**
* Provider-owned bidirectional text replacements.
*
* `input` applies to system prompts and text message content before transport.
* `output` applies to assistant text deltas/final text before OpenClaw handles
* its own control markers or channel delivery.
*/
textTransforms?: PluginTextTransforms;
/**
* Provider-owned global config defaults.
*
@@ -2091,6 +2125,28 @@ export type CliBackendPlugin = {
* shapes need to stay working.
*/
normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig;
/**
* Backend-owned final system-prompt transform.
*
* Use this for tiny CLI-specific compatibility rewrites without replacing
* the generic CLI runner or prompt builder.
*/
transformSystemPrompt?: (ctx: {
config?: OpenClawConfig;
workspaceDir?: string;
provider: string;
modelId: string;
modelDisplay: string;
agentId?: string;
systemPrompt: string;
}) => string | null | undefined;
/**
* Backend-owned bidirectional text replacements.
*
* `input` applies to the system prompt and user prompt passed to the CLI.
* `output` applies to parsed/streamed assistant text from the CLI.
*/
textTransforms?: PluginTextTransforms;
};
export type OpenClawPluginChannelRegistration = {
@@ -2199,6 +2255,8 @@ export type OpenClawPluginApi = {
registerService: (service: OpenClawPluginService) => void;
/** Register a text-only CLI backend used by the local CLI runner. */
registerCliBackend: (backend: CliBackendPlugin) => void;
/** Register plugin-owned prompt/message compatibility text transforms. */
registerTextTransforms: (transforms: PluginTextTransformRegistration) => void;
/** Register a lightweight config migration that can run before plugin runtime loads. */
registerConfigMigration: (migrate: PluginConfigMigration) => void;
/** Register a lightweight config probe that can auto-enable this plugin generically. */