mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-15 19:21:08 +00:00
feat: add plugin text transforms
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
channelSetups: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
textTransforms: [],
|
||||
speechProviders: [],
|
||||
realtimeTranscriptionProviders: [],
|
||||
realtimeVoiceProviders: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,6 +128,7 @@ export function createPluginLoadResult(
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
|
||||
33
src/plugins/text-transforms.runtime.ts
Normal file
33
src/plugins/text-transforms.runtime.ts
Normal 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) ?? []),
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user