mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat: add plugin text transforms
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,6 +175,28 @@ API key auth, and dynamic model resolution.
|
||||
`openclaw onboard --acme-ai-api-key <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:
|
||||
|
||||
@@ -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<string, FallbackCliBackendPolicy> = {};
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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,
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
159
src/agents/plugin-text-transforms.test.ts
Normal file
159
src/agents/plugin-text-transforms.test.ts
Normal file
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
173
src/agents/plugin-text-transforms.ts
Normal file
173
src/agents/plugin-text-transforms.ts
Normal file
@@ -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>
|
||||
): 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<string, unknown> {
|
||||
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<StreamFn>[1],
|
||||
replacements?: PluginTextReplacement[],
|
||||
options?: { systemPrompt?: boolean },
|
||||
): Parameters<StreamFn>[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<StreamFn>[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<typeof streamSimple>,
|
||||
replacements?: PluginTextReplacement[],
|
||||
): ReturnType<typeof streamSimple> {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -3369,6 +3369,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
},
|
||||
],
|
||||
},
|
||||
jsonlDialect: {
|
||||
type: "string",
|
||||
const: "claude-stream-json",
|
||||
},
|
||||
input: {
|
||||
anyOf: [
|
||||
{
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -78,6 +78,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry {
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
textTransforms: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodScopes: {},
|
||||
|
||||
@@ -18,6 +18,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerCliBackend() {},
|
||||
registerTextTransforms() {},
|
||||
registerService() {},
|
||||
registerReload() {},
|
||||
registerNodeHostCommand() {},
|
||||
|
||||
Reference in New Issue
Block a user