From 202f80792ed193f1d76fd6d2eb29d5add48bb328 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Apr 2026 02:03:11 +0100 Subject: [PATCH] feat: add plugin text transforms --- docs/.generated/config-baseline.sha256 | 4 +- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/gateway/cli-backends.md | 25 +++ docs/plugins/sdk-provider-plugins.md | 22 +++ src/agents/cli-backends.ts | 23 ++- src/agents/cli-output.test.ts | 68 ++++++- src/agents/cli-output.ts | 17 +- src/agents/cli-runner/execute.ts | 29 ++- src/agents/cli-runner/prepare.ts | 17 +- src/agents/pi-embedded-runner/compact.ts | 101 ++++++---- src/agents/pi-embedded-runner/run/attempt.ts | 39 +++- src/agents/plugin-text-transforms.test.ts | 159 ++++++++++++++++ src/agents/plugin-text-transforms.ts | 173 ++++++++++++++++++ src/config/schema.base.generated.ts | 4 + src/config/types.agent-defaults.ts | 2 + src/config/zod-schema.core.ts | 1 + src/gateway/server-plugins.test.ts | 1 + src/gateway/test-helpers.plugin-registry.ts | 1 + src/plugins/api-builder.ts | 3 + src/plugins/bundled-capability-runtime.ts | 9 + src/plugins/captured-registration.test.ts | 6 + src/plugins/captured-registration.ts | 7 + src/plugins/loader.test.ts | 31 ++++ src/plugins/provider-runtime.ts | 36 ++++ src/plugins/registry-empty.ts | 1 + src/plugins/registry-types.ts | 10 + src/plugins/registry.ts | 29 +++ src/plugins/status.test-helpers.ts | 1 + src/plugins/text-transforms.runtime.ts | 33 ++++ src/plugins/types.ts | 58 ++++++ src/test-utils/channel-plugins.ts | 1 + test/helpers/plugins/plugin-api.ts | 1 + 32 files changed, 866 insertions(+), 50 deletions(-) create mode 100644 src/agents/plugin-text-transforms.test.ts create mode 100644 src/agents/plugin-text-transforms.ts create mode 100644 src/plugins/text-transforms.runtime.ts diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 7525a77eb11..5dacc28faff 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -995d3ffac3a4a982f9a97d7583c081eb8b48e2d1ed80fa4db5633f2b5c0f543a config-baseline.json -44b2f2fc7fe0092346d33a16936c576e8767b83d13808491e0cb418cd69ecf1b config-baseline.core.json +228031f16ad06580bfd137f092d70d03f2796515e723b8b6618ed69d285465fa config-baseline.json +bad0a5bb247a62b8fb9ed9fc2b2720eacf3e0913077ac351b5d26ae2723335ad config-baseline.core.json e1f94346a8507ce3dec763b598e79f3bb89ff2e33189ce977cc87d3b05e71c1d config-baseline.channel.json 6c19997f1fb2aff4315f2cb9c7d9e299b403fbc0f9e78e3412cc7fe1c655f222 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index dd4c1f0598e..caa2045fe36 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -2256ba1237c3608ca981bce3a7c66b6880b12d05025f260d5c086b69038f408b plugin-sdk-api-baseline.json -6360529513280140c122020466f0821a9acc83aba64612cf90656c2af0261ab3 plugin-sdk-api-baseline.jsonl +4d6ea7dac2bcb51c3e99d36ac974bc417dd65117316492a605f2fc9762d1e722 plugin-sdk-api-baseline.json +836ee5cd995a2fd4e4dbcab702f59d749c0c142767fa40d736d095cba4a83749 plugin-sdk-api-baseline.jsonl diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 773d87546cd..1bf28d4fe70 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -263,6 +263,31 @@ CLI backend defaults are now part of the plugin surface: - Backend-specific config cleanup stays plugin-owned through the optional `normalizeConfig` hook. +Plugins that need tiny prompt/message compatibility shims can declare +bidirectional text transforms without replacing a provider or CLI backend: + +```typescript +api.registerTextTransforms({ + input: [ + { from: /red basket/g, to: "blue basket" }, + { from: /paper ticket/g, to: "digital ticket" }, + { from: /left shelf/g, to: "right shelf" }, + ], + output: [ + { from: /blue basket/g, to: "red basket" }, + { from: /digital ticket/g, to: "paper ticket" }, + { from: /right shelf/g, to: "left shelf" }, + ], +}); +``` + +`input` rewrites the system prompt and user prompt passed to the CLI. `output` +rewrites streamed assistant deltas and parsed final text before OpenClaw handles +its own control markers and channel delivery. + +For CLIs that emit Claude Code stream-json compatible JSONL, set +`jsonlDialect: "claude-stream-json"` on that backend's config. + ## Bundle MCP overlays CLI backends do **not** receive OpenClaw tool calls directly, but a backend can diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 3f8dc76c72f..b55847ef5e1 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -175,6 +175,28 @@ API key auth, and dynamic model resolution. `openclaw onboard --acme-ai-api-key ` and select `acme-ai/acme-large` as their model. + If the upstream provider uses different control tokens than OpenClaw, add a + small bidirectional text transform instead of replacing the stream path: + + ```typescript + api.registerTextTransforms({ + input: [ + { from: /red basket/g, to: "blue basket" }, + { from: /paper ticket/g, to: "digital ticket" }, + { from: /left shelf/g, to: "right shelf" }, + ], + output: [ + { from: /blue basket/g, to: "red basket" }, + { from: /digital ticket/g, to: "paper ticket" }, + { from: /right shelf/g, to: "left shelf" }, + ], + }); + ``` + + `input` rewrites the final system prompt and text message content before + transport. `output` rewrites assistant text deltas and final text before + OpenClaw parses its own control markers or channel delivery. + For bundled providers that only register one text provider with API-key auth plus a single catalog-backed runtime, prefer the narrower `defineSingleProviderPluginEntry(...)` helper: diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 8b526afde6d..a8fe8f6e463 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -2,9 +2,11 @@ import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js"; -import type { CliBundleMcpMode } from "../plugins/types.js"; +import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js"; +import type { CliBundleMcpMode, CliBackendPlugin, PluginTextTransforms } from "../plugins/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { normalizeProviderId } from "./model-selection.js"; +import { mergePluginTextTransforms } from "./plugin-text-transforms.js"; type CliBackendsDeps = { resolvePluginSetupCliBackend: typeof resolvePluginSetupCliBackend; @@ -24,6 +26,8 @@ export type ResolvedCliBackend = { bundleMcp: boolean; bundleMcpMode?: CliBundleMcpMode; pluginId?: string; + transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"]; + textTransforms?: PluginTextTransforms; }; export type ResolvedCliBackendLiveTest = { @@ -44,6 +48,8 @@ type FallbackCliBackendPolicy = { bundleMcpMode?: CliBundleMcpMode; baseConfig?: CliBackendConfig; normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; + transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"]; + textTransforms?: PluginTextTransforms; }; const FALLBACK_CLI_BACKEND_POLICIES: Record = {}; @@ -75,6 +81,8 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic ), baseConfig: entry.backend.config, normalizeConfig: entry.backend.normalizeConfig, + transformSystemPrompt: entry.backend.transformSystemPrompt, + textTransforms: entry.backend.textTransforms, }; } @@ -185,6 +193,7 @@ export function resolveCliBackendConfig( cfg?: OpenClawConfig, ): ResolvedCliBackend | null { const normalized = normalizeBackendKey(provider); + const runtimeTextTransforms = resolveRuntimeTextTransforms(); const configured = cfg?.agents?.defaults?.cliBackends ?? {}; const override = pickBackendConfig(configured, normalized); const registered = resolveRegisteredBackend(normalized); @@ -204,6 +213,8 @@ export function resolveCliBackendConfig( registered.bundleMcp === true, ), pluginId: registered.pluginId, + transformSystemPrompt: registered.transformSystemPrompt, + textTransforms: mergePluginTextTransforms(runtimeTextTransforms, registered.textTransforms), }; } @@ -224,6 +235,11 @@ export function resolveCliBackendConfig( config: { ...baseConfig, command }, bundleMcp: fallbackPolicy.bundleMcp, bundleMcpMode: fallbackPolicy.bundleMcpMode, + transformSystemPrompt: fallbackPolicy.transformSystemPrompt, + textTransforms: mergePluginTextTransforms( + runtimeTextTransforms, + fallbackPolicy.textTransforms, + ), }; } const mergedFallback = fallbackPolicy?.baseConfig @@ -241,6 +257,11 @@ export function resolveCliBackendConfig( config: { ...config, command }, bundleMcp: fallbackPolicy?.bundleMcp === true, bundleMcpMode: fallbackPolicy?.bundleMcpMode, + transformSystemPrompt: fallbackPolicy?.transformSystemPrompt, + textTransforms: mergePluginTextTransforms( + runtimeTextTransforms, + fallbackPolicy?.textTransforms, + ), }; } diff --git a/src/agents/cli-output.test.ts b/src/agents/cli-output.test.ts index 2eed0fb02eb..3aebd685e0d 100644 --- a/src/agents/cli-output.test.ts +++ b/src/agents/cli-output.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { extractCliErrorMessage, parseCliJson, parseCliJsonl } from "./cli-output.js"; +import { + createCliJsonlStreamingParser, + extractCliErrorMessage, + parseCliJson, + parseCliJsonl, +} from "./cli-output.js"; describe("parseCliJson", () => { it("recovers mixed-output Claude session metadata from embedded JSON objects", () => { @@ -157,6 +162,33 @@ describe("parseCliJsonl", () => { }); }); + it("parses Claude stream-json result events for an explicit backend dialect", () => { + const result = parseCliJsonl( + [ + JSON.stringify({ type: "init", session_id: "session-dialect" }), + JSON.stringify({ + type: "result", + session_id: "session-dialect", + result: "dialect says hello", + usage: { input_tokens: 5, output_tokens: 2 }, + }), + ].join("\n"), + { + command: "local-cli", + output: "jsonl", + jsonlDialect: "claude-stream-json", + sessionIdFields: ["session_id"], + }, + "local-cli", + ); + + expect(result).toMatchObject({ + text: "dialect says hello", + sessionId: "session-dialect", + usage: { input: 5, output: 2 }, + }); + }); + it("preserves Claude cache creation tokens instead of flattening them to zero", () => { const result = parseCliJsonl( [ @@ -284,3 +316,37 @@ describe("parseCliJsonl", () => { expect(result).toBe(message); }); }); + +describe("createCliJsonlStreamingParser", () => { + it("streams Claude stream-json deltas for an explicit backend dialect", () => { + const deltas: Array<{ text: string; delta: string; sessionId?: string }> = []; + const parser = createCliJsonlStreamingParser({ + backend: { + command: "local-cli", + output: "jsonl", + jsonlDialect: "claude-stream-json", + sessionIdFields: ["session_id"], + }, + providerId: "local-cli", + onAssistantDelta: (delta) => deltas.push(delta), + }); + + parser.push( + [ + JSON.stringify({ type: "init", session_id: "session-stream" }), + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "hello" }, + }, + }), + ].join("\n"), + ); + parser.finish(); + + expect(deltas).toEqual([ + { text: "hello", delta: "hello", sessionId: "session-stream", usage: undefined }, + ]); + }); +}); diff --git a/src/agents/cli-output.ts b/src/agents/cli-output.ts index 16a344521e2..6bc096a249d 100644 --- a/src/agents/cli-output.ts +++ b/src/agents/cli-output.ts @@ -27,6 +27,15 @@ function isClaudeCliProvider(providerId: string): boolean { return normalizeLowercaseStringOrEmpty(providerId) === "claude-cli"; } +function usesClaudeStreamJsonDialect(params: { + backend: CliBackendConfig; + providerId: string; +}): boolean { + return ( + params.backend.jsonlDialect === "claude-stream-json" || isClaudeCliProvider(params.providerId) + ); +} + function extractJsonObjectCandidates(raw: string): string[] { const candidates: string[] = []; let depth = 0; @@ -295,12 +304,13 @@ export function parseCliJson(raw: string, backend: CliBackendConfig): CliOutput } function parseClaudeCliJsonlResult(params: { + backend: CliBackendConfig; providerId: string; parsed: Record; sessionId?: string; usage?: CliUsage; }): CliOutput | null { - if (!isClaudeCliProvider(params.providerId)) { + if (!usesClaudeStreamJsonDialect(params)) { return null; } if ( @@ -320,13 +330,14 @@ function parseClaudeCliJsonlResult(params: { } function parseClaudeCliStreamingDelta(params: { + backend: CliBackendConfig; providerId: string; parsed: Record; textSoFar: string; sessionId?: string; usage?: CliUsage; }): CliStreamingDelta | null { - if (!isClaudeCliProvider(params.providerId)) { + if (!usesClaudeStreamJsonDialect(params)) { return null; } if (params.parsed.type !== "stream_event" || !isRecord(params.parsed.event)) { @@ -371,6 +382,7 @@ export function createCliJsonlStreamingParser(params: { } const delta = parseClaudeCliStreamingDelta({ + backend: params.backend, providerId: params.providerId, parsed, textSoFar: assistantText, @@ -452,6 +464,7 @@ export function parseCliJsonl( usage = readCliUsage(parsed) ?? usage; const claudeResult = parseClaudeCliJsonlResult({ + backend, providerId, parsed, sessionId, diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 6ae01c21390..5597d9d9dc7 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -15,6 +15,7 @@ import { } from "../cli-output.js"; import { FailoverError, resolveFailoverStatus } from "../failover-error.js"; import { classifyFailoverReason } from "../pi-embedded-helpers.js"; +import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { applySkillEnvOverridesFromSnapshot } from "../skills.js"; import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; import { @@ -192,9 +193,12 @@ export async function executePreparedCliRun( }) : undefined; - let prompt = prependBootstrapPromptWarning(params.prompt, context.bootstrapPromptWarningLines, { - preserveExactPrompt: context.heartbeatPrompt, - }); + let prompt = applyPluginTextReplacements( + prependBootstrapPromptWarning(params.prompt, context.bootstrapPromptWarningLines, { + preserveExactPrompt: context.heartbeatPrompt, + }), + context.backendResolved.textTransforms?.input, + ); const { prompt: promptWithImages, imagePaths, @@ -317,8 +321,14 @@ export async function executePreparedCliRun( runId: params.runId, stream: "assistant", data: { - text, - delta, + text: applyPluginTextReplacements( + text, + context.backendResolved.textTransforms?.output, + ), + delta: applyPluginTextReplacements( + delta, + context.backendResolved.textTransforms?.output, + ), }, }); }, @@ -445,13 +455,20 @@ export async function executePreparedCliRun( }); } - return parseCliOutput({ + const parsed = parseCliOutput({ raw: stdout, backend, providerId: context.backendResolved.id, outputMode: useResume ? (backend.resumeOutput ?? backend.output) : backend.output, fallbackSessionId: resolvedSessionId, }); + return { + ...parsed, + text: applyPluginTextReplacements( + parsed.text, + context.backendResolved.textTransforms?.output, + ), + }; } finally { restoreSkillEnv?.(); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 01984f261a2..ffdc6d4b0d0 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -22,6 +22,7 @@ import { resolveBootstrapPromptTruncationWarningMode, resolveBootstrapTotalMaxChars, } from "../pi-embedded-helpers.js"; +import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { resolveSkillsPromptForRun } from "../skills.js"; import { resolveSystemPromptOverride } from "../system-prompt-override.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; @@ -170,7 +171,7 @@ export async function prepareCliRunContext( config: params.config, agentId: sessionAgentId, }); - const systemPrompt = + const builtSystemPrompt = resolveSystemPromptOverride({ config: params.config, agentId: sessionAgentId, @@ -189,6 +190,20 @@ export async function prepareCliRunContext( modelDisplay, agentId: sessionAgentId, }); + const transformedSystemPrompt = + backendResolved.transformSystemPrompt?.({ + config: params.config, + workspaceDir, + provider: params.provider, + modelId, + modelDisplay, + agentId: sessionAgentId, + systemPrompt: builtSystemPrompt, + }) ?? builtSystemPrompt; + const systemPrompt = applyPluginTextReplacements( + transformedSystemPrompt, + backendResolved.textTransforms?.input, + ); const systemPromptReport = buildSystemPromptReport({ source: "run", generatedAt: Date.now(), diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 31d22fc8091..9de6e023823 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -29,6 +29,8 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth, resolveProviderSystemPromptContribution, + resolveProviderTextTransforms, + transformProviderSystemPrompt, } from "../../plugins/provider-runtime.js"; import type { ProviderRuntimeModel } from "../../plugins/types.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; @@ -77,6 +79,7 @@ import { } from "../pi-hooks/compaction-safeguard-runtime.js"; import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js"; import { createOpenClawCodingTools } from "../pi-tools.js"; +import { wrapStreamFnTextTransforms } from "../plugin-text-transforms.js"; import { registerProviderStreamForModel } from "../provider-stream.js"; import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; @@ -252,6 +255,19 @@ function prepareCompactionSessionAgent(params: { resolvedApiKey: params.resolvedApiKey, authStorage: params.authStorage as never, }); + const providerTextTransforms = resolveProviderTextTransforms({ + provider: params.provider, + config: params.config, + workspaceDir: params.effectiveWorkspace, + }); + if (providerTextTransforms) { + params.session.agent.streamFn = wrapStreamFnTextTransforms({ + streamFn: params.session.agent.streamFn as never, + input: providerTextTransforms.input, + output: providerTextTransforms.output, + transformSystemPrompt: false, + }) as never; + } return applyExtraParamsToAgent( params.session.agent as never, params.config, @@ -746,45 +762,64 @@ export async function compactEmbeddedPiSessionDirect( agentId: sessionAgentId, }, }); - const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => - createSystemPromptOverride( + const buildSystemPromptOverride = (defaultThinkLevel: ThinkLevel) => { + const builtSystemPrompt = resolveSystemPromptOverride({ config: params.config, agentId: sessionAgentId, }) ?? - buildEmbeddedSystemPrompt({ - workspaceDir: effectiveWorkspace, - defaultThinkLevel, - reasoningLevel: params.reasoningLevel ?? "off", - extraSystemPrompt: params.extraSystemPrompt, - ownerNumbers: params.ownerNumbers, - ownerDisplay: ownerDisplay.ownerDisplay, - ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, - reasoningTagHint, - heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({ - config: params.config, - agentId: sessionAgentId, - defaultAgentId, - }), - skillsPrompt, - docsPath: docsPath ?? undefined, - ttsHint, - promptMode, - acpEnabled: params.config?.acp?.enabled !== false, - runtimeInfo, - reactionGuidance, - messageToolHints, - sandboxInfo, - tools: effectiveTools, - modelAliasLines: buildModelAliasLines(params.config), - userTimezone, - userTime, - userTimeFormat, - contextFiles, - memoryCitationsMode: params.config?.memory?.citations, - promptContribution, + buildEmbeddedSystemPrompt({ + workspaceDir: effectiveWorkspace, + defaultThinkLevel, + reasoningLevel: params.reasoningLevel ?? "off", + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + ownerDisplay: ownerDisplay.ownerDisplay, + ownerDisplaySecret: ownerDisplay.ownerDisplaySecret, + reasoningTagHint, + heartbeatPrompt: resolveHeartbeatPromptForSystemPrompt({ + config: params.config, + agentId: sessionAgentId, + defaultAgentId, }), + skillsPrompt, + docsPath: docsPath ?? undefined, + ttsHint, + promptMode, + acpEnabled: params.config?.acp?.enabled !== false, + runtimeInfo, + reactionGuidance, + messageToolHints, + sandboxInfo, + tools: effectiveTools, + modelAliasLines: buildModelAliasLines(params.config), + userTimezone, + userTime, + userTimeFormat, + contextFiles, + memoryCitationsMode: params.config?.memory?.citations, + promptContribution, + }); + return createSystemPromptOverride( + transformProviderSystemPrompt({ + provider, + config: params.config, + workspaceDir: effectiveWorkspace, + context: { + config: params.config, + agentDir, + workspaceDir: effectiveWorkspace, + provider, + modelId, + promptMode, + runtimeChannel, + runtimeCapabilities, + agentId: sessionAgentId, + systemPrompt: builtSystemPrompt, + }, + }), ); + }; const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config); const sessionLock = await acquireSessionWriteLock({ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d6669819815..46526a526c1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -24,7 +24,11 @@ import { } from "../../../plugin-sdk/ollama-runtime.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js"; -import { resolveProviderSystemPromptContribution } from "../../../plugins/provider-runtime.js"; +import { + resolveProviderSystemPromptContribution, + resolveProviderTextTransforms, + transformProviderSystemPrompt, +} from "../../../plugins/provider-runtime.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js"; import { normalizeOptionalString } from "../../../shared/string-coerce.js"; @@ -84,6 +88,7 @@ import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settin import { applyPiAutoCompactionGuard } from "../../pi-settings.js"; import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js"; import { createOpenClawCodingTools, resolveToolLoopDetectionConfig } from "../../pi-tools.js"; +import { wrapStreamFnTextTransforms } from "../../plugin-text-transforms.js"; import { registerProviderStreamForModel } from "../../provider-stream.js"; import { resolveSandboxContext } from "../../sandbox.js"; import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; @@ -733,7 +738,7 @@ export async function runEmbeddedAttempt( }, }); - const appendPrompt = + const builtAppendPrompt = resolveSystemPromptOverride({ config: params.config, agentId: sessionAgentId, @@ -768,6 +773,23 @@ export async function runEmbeddedAttempt( memoryCitationsMode: params.config?.memory?.citations, promptContribution, }); + const appendPrompt = transformProviderSystemPrompt({ + provider: params.provider, + config: params.config, + workspaceDir: effectiveWorkspace, + context: { + config: params.config, + agentDir: params.agentDir, + workspaceDir: effectiveWorkspace, + provider: params.provider, + modelId: params.modelId, + promptMode: effectivePromptMode, + runtimeChannel, + runtimeCapabilities, + agentId: sessionAgentId, + systemPrompt: builtAppendPrompt, + }, + }); const systemPromptReport = buildSystemPromptReport({ source: "run", generatedAt: Date.now(), @@ -1029,6 +1051,19 @@ export async function runEmbeddedAttempt( resolvedApiKey: params.resolvedApiKey, authStorage: params.authStorage, }); + const providerTextTransforms = resolveProviderTextTransforms({ + provider: params.provider, + config: params.config, + workspaceDir: effectiveWorkspace, + }); + if (providerTextTransforms) { + activeSession.agent.streamFn = wrapStreamFnTextTransforms({ + streamFn: activeSession.agent.streamFn, + input: providerTextTransforms.input, + output: providerTextTransforms.output, + transformSystemPrompt: false, + }); + } const { effectiveExtraParams } = applyExtraParamsToAgent( activeSession.agent, diff --git a/src/agents/plugin-text-transforms.test.ts b/src/agents/plugin-text-transforms.test.ts new file mode 100644 index 00000000000..f1f2e2aecf9 --- /dev/null +++ b/src/agents/plugin-text-transforms.test.ts @@ -0,0 +1,159 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { + createAssistantMessageEventStream, + type AssistantMessage, + type Context, + type Model, +} from "@mariozechner/pi-ai"; +import { describe, expect, it } from "vitest"; +import { + applyPluginTextReplacements, + mergePluginTextTransforms, + transformStreamContextText, + wrapStreamFnTextTransforms, +} from "./plugin-text-transforms.js"; + +const model = { + api: "openai-responses", + provider: "test", + id: "test-model", +} as Model<"openai-responses">; + +function makeAssistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + stopReason: "stop", + api: "openai-responses", + provider: "test", + model: "test-model", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: 0, + }; +} + +describe("plugin text transforms", () => { + it("merges registered transform groups in order", () => { + const merged = mergePluginTextTransforms( + { input: [{ from: /red basket/g, to: "blue basket" }] }, + { output: [{ from: /blue basket/g, to: "red basket" }] }, + { input: [{ from: /paper ticket/g, to: "digital ticket" }] }, + ); + + expect(merged?.input).toHaveLength(2); + expect(merged?.output).toHaveLength(1); + expect(applyPluginTextReplacements("red basket paper ticket", merged?.input)).toBe( + "blue basket digital ticket", + ); + }); + + it("applies ordered string and regexp replacements", () => { + expect( + applyPluginTextReplacements("paper ticket on the left shelf", [ + { from: /paper ticket/g, to: "digital ticket" }, + { from: /left shelf/g, to: "right shelf" }, + { from: "digital ticket", to: "counter receipt" }, + ]), + ).toBe("counter receipt on the right shelf"); + }); + + it("rewrites system prompt and message text content before transport", () => { + const context = transformStreamContextText( + { + systemPrompt: "Use orchid mailbox inside north tower", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Please use the red basket" }, + { type: "image", url: "data:image/png;base64,abc" }, + ], + }, + ], + } as Context, + [ + { + from: /orchid mailbox/g, + to: "pine mailbox", + }, + { from: /red basket/g, to: "blue basket" }, + ], + ) as unknown as { systemPrompt: string; messages: Array<{ content: unknown[] }> }; + + expect(context.systemPrompt).toBe("Use pine mailbox inside north tower"); + expect(context.messages[0]?.content[0]).toMatchObject({ + type: "text", + text: "Please use the blue basket", + }); + expect(context.messages[0]?.content[1]).toMatchObject({ + type: "image", + url: "data:image/png;base64,abc", + }); + }); + + it("wraps stream functions with inbound and outbound replacements", async () => { + let capturedContext: Context | undefined; + const baseStreamFn: StreamFn = (_model, context) => { + capturedContext = context; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + const partial = makeAssistantMessage("blue basket on the right shelf"); + stream.push({ + type: "text_delta", + contentIndex: 0, + delta: "blue basket on the right shelf", + partial, + }); + stream.push({ + type: "done", + reason: "stop", + message: makeAssistantMessage("final blue basket on the right shelf"), + }); + stream.end(); + }); + return stream; + }; + + const wrapped = wrapStreamFnTextTransforms({ + streamFn: baseStreamFn, + input: [{ from: /red basket/g, to: "blue basket" }], + output: [ + { from: /blue basket/g, to: "red basket" }, + { from: /right shelf/g, to: "left shelf" }, + ], + transformSystemPrompt: false, + }); + const stream = await Promise.resolve( + wrapped( + model, + { + systemPrompt: "Keep red basket untouched here", + messages: [{ role: "user", content: "Use red basket" }], + } as Context, + undefined, + ), + ); + const events = []; + for await (const event of stream) { + events.push(event); + } + const result = await stream.result(); + + expect(capturedContext?.systemPrompt).toBe("Keep red basket untouched here"); + expect(capturedContext?.messages).toMatchObject([{ role: "user", content: "Use blue basket" }]); + expect(events[0]).toMatchObject({ + type: "text_delta", + delta: "red basket on the left shelf", + }); + expect(result.content).toMatchObject([ + { type: "text", text: "final red basket on the left shelf" }, + ]); + }); +}); diff --git a/src/agents/plugin-text-transforms.ts b/src/agents/plugin-text-transforms.ts new file mode 100644 index 00000000000..db0cd71d903 --- /dev/null +++ b/src/agents/plugin-text-transforms.ts @@ -0,0 +1,173 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple, type AssistantMessageEvent } from "@mariozechner/pi-ai"; +import type { PluginTextReplacement, PluginTextTransforms } from "../plugins/types.js"; + +export function mergePluginTextTransforms( + ...transforms: Array +): PluginTextTransforms | undefined { + const input = transforms.flatMap((entry) => entry?.input ?? []); + const output = transforms.flatMap((entry) => entry?.output ?? []); + if (input.length === 0 && output.length === 0) { + return undefined; + } + return { + ...(input.length > 0 ? { input } : {}), + ...(output.length > 0 ? { output } : {}), + }; +} + +export function applyPluginTextReplacements( + text: string, + replacements?: PluginTextReplacement[], +): string { + if (!replacements || replacements.length === 0 || !text) { + return text; + } + let next = text; + for (const replacement of replacements) { + next = next.replace(replacement.from, replacement.to); + } + return next; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function transformContentText(content: unknown, replacements?: PluginTextReplacement[]): unknown { + if (typeof content === "string") { + return applyPluginTextReplacements(content, replacements); + } + if (Array.isArray(content)) { + return content.map((entry) => transformContentText(entry, replacements)); + } + if (!isRecord(content)) { + return content; + } + const next = { ...content }; + if (typeof next.text === "string") { + next.text = applyPluginTextReplacements(next.text, replacements); + } + if (Object.hasOwn(next, "content")) { + next.content = transformContentText(next.content, replacements); + } + return next; +} + +function transformMessageText(message: unknown, replacements?: PluginTextReplacement[]): unknown { + if (!isRecord(message)) { + return message; + } + const next = { ...message }; + if (Object.hasOwn(next, "content")) { + next.content = transformContentText(next.content, replacements); + } + if (typeof next.errorMessage === "string") { + next.errorMessage = applyPluginTextReplacements(next.errorMessage, replacements); + } + return next; +} + +export function transformStreamContextText( + context: Parameters[1], + replacements?: PluginTextReplacement[], + options?: { systemPrompt?: boolean }, +): Parameters[1] { + if (!replacements || replacements.length === 0) { + return context; + } + return { + ...context, + systemPrompt: + options?.systemPrompt !== false && typeof context.systemPrompt === "string" + ? applyPluginTextReplacements(context.systemPrompt, replacements) + : context.systemPrompt, + messages: Array.isArray(context.messages) + ? context.messages.map((message) => transformMessageText(message, replacements)) + : context.messages, + } as Parameters[1]; +} + +function transformAssistantEventText( + event: unknown, + replacements?: PluginTextReplacement[], +): AssistantMessageEvent { + if (!isRecord(event) || !replacements || replacements.length === 0) { + return event as AssistantMessageEvent; + } + const next = { ...event }; + if (next.type === "text_delta" && typeof next.delta === "string") { + next.delta = applyPluginTextReplacements(next.delta, replacements); + } + if (next.type === "text_end" && typeof next.content === "string") { + next.content = applyPluginTextReplacements(next.content, replacements); + } + if (Object.hasOwn(next, "partial")) { + next.partial = transformMessageText(next.partial, replacements); + } + if (Object.hasOwn(next, "message")) { + next.message = transformMessageText(next.message, replacements); + } + if (Object.hasOwn(next, "error")) { + next.error = transformMessageText(next.error, replacements); + } + return next as AssistantMessageEvent; +} + +function wrapStreamTextTransforms( + stream: ReturnType, + replacements?: PluginTextReplacement[], +): ReturnType { + if (!replacements || replacements.length === 0) { + return stream; + } + const originalResult = stream.result.bind(stream); + stream.result = async () => transformMessageText(await originalResult(), replacements) as never; + + const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream); + (stream as { [Symbol.asyncIterator]: typeof originalAsyncIterator })[Symbol.asyncIterator] = + function () { + const iterator = originalAsyncIterator(); + return { + async next() { + const result = await iterator.next(); + return result.done + ? result + : { + done: false as const, + value: transformAssistantEventText(result.value, replacements), + }; + }, + async return(value?: unknown) { + return iterator.return?.(value) ?? { done: true as const, value: undefined }; + }, + async throw(error?: unknown) { + return iterator.throw?.(error) ?? { done: true as const, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + }; + return stream; +} + +export function wrapStreamFnTextTransforms(params: { + streamFn: StreamFn; + input?: PluginTextReplacement[]; + output?: PluginTextReplacement[]; + transformSystemPrompt?: boolean; +}): StreamFn { + return (model, context, options) => { + const nextContext = transformStreamContextText(context, params.input, { + systemPrompt: params.transformSystemPrompt, + }); + const maybeStream = params.streamFn(model, nextContext, options); + if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) { + return Promise.resolve(maybeStream).then((stream) => + wrapStreamTextTransforms(stream, params.output), + ); + } + return wrapStreamTextTransforms(maybeStream, params.output); + }; +} diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 228f7c487b1..f71abf70eb9 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3369,6 +3369,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, ], }, + jsonlDialect: { + type: "string", + const: "claude-stream-json", + }, input: { anyOf: [ { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 2ec2ff014b0..1b3abf65627 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -59,6 +59,8 @@ export type CliBackendConfig = { output?: "json" | "text" | "jsonl"; /** Output parsing mode when resuming a CLI session. */ resumeOutput?: "json" | "text" | "jsonl"; + /** JSONL event dialect for CLIs with provider-specific stream formats. */ + jsonlDialect?: "claude-stream-json"; /** Prompt input mode (default: arg). */ input?: "arg" | "stdin"; /** Max prompt length for arg mode (if exceeded, stdin is used). */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index ff832a196c0..4e36f047105 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -529,6 +529,7 @@ export const CliBackendSchema = z args: z.array(z.string()).optional(), output: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), resumeOutput: z.union([z.literal("json"), z.literal("text"), z.literal("jsonl")]).optional(), + jsonlDialect: z.literal("claude-stream-json").optional(), input: z.union([z.literal("arg"), z.literal("stdin")]).optional(), maxPromptArgChars: z.number().int().positive().optional(), env: z.record(z.string(), z.string()).optional(), diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 36851373655..45374c82b60 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -78,6 +78,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + textTransforms: [], agentHarnesses: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index d52ebf9c237..e495104a601 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry { webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + textTransforms: [], agentHarnesses: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index f719979b5ee..c4db7ed195e 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -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, diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 0374d20cde2..49e9f69bc77 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -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, diff --git a/src/plugins/captured-registration.test.ts b/src/plugins/captured-registration.test.ts index 844be720a3e..0352f47148a 100644 --- a/src/plugins/captured-registration.test.ts +++ b/src/plugins/captured-registration.test.ts @@ -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"); }); }); diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index be8c4e7bcbd..c7cb9f72769 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -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); }, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 1304555b371..9b03f7fb985 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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", diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index b59f66ab21e..b0ef09139ca 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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; diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 100d24f1a8e..d3fae089f94 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -10,6 +10,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { channelSetups: [], providers: [], cliBackends: [], + textTransforms: [], speechProviders: [], realtimeTranscriptionProviders: [], realtimeVoiceProviders: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 1f7dcfb3ae9..4ed300754bc 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -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 = { 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[]; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b3950d47ad0..4fc4b97ce14 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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, @@ -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, diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 5ef110c6d36..d4b0d764dd6 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -128,6 +128,7 @@ export function createPluginLoadResult( webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + textTransforms: [], agentHarnesses: [], tools: [], hooks: [], diff --git a/src/plugins/text-transforms.runtime.ts b/src/plugins/text-transforms.runtime.ts new file mode 100644 index 00000000000..4949d781b8f --- /dev/null +++ b/src/plugins/text-transforms.runtime.ts @@ -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; + +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) ?? []), + ); +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 0fe038ff24f..52f670a764f 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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. */ diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index e4f5093c8d1..32d2ea12808 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + textTransforms: [], agentHarnesses: [], gatewayHandlers: {}, gatewayMethodScopes: {}, diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index fe56e55e941..df12b26f983 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -18,6 +18,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerGatewayMethod() {}, registerCli() {}, registerCliBackend() {}, + registerTextTransforms() {}, registerService() {}, registerReload() {}, registerNodeHostCommand() {},