mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 04:31:12 +00:00
* chore: remove dead sanitizeThinkingSignatures field from transcript policy
The sanitizeThinkingSignatures field on TranscriptPolicy and
AgentRuntimeTranscriptPolicy was orphaned when the google-antigravity
provider was removed in 382fe8009a. That commit deleted the only
consumer (sanitizeSessionHistory in google.ts) but left the field
declaration in both types and the default value, plus stale
references across test fixtures.
Remove the field from:
- TranscriptPolicy type and DEFAULT_TRANSCRIPT_POLICY (transcript-policy.ts)
- AgentRuntimeTranscriptPolicy (runtime-plan/types.ts)
- All test/mock/compact-harness references (14 sites)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore: remove blank lines from transcript policy cleanup
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Colin <colin@solvely.net>
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
/**
|
|
* Transcript replay policy resolution.
|
|
* Combines provider plugin replay hooks with core transport fallbacks so chat
|
|
* history sanitization, tool IDs, thinking blocks, and turn validation align.
|
|
*/
|
|
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { resolvePluginControlPlaneFingerprint } from "../plugins/plugin-control-plane-context.js";
|
|
import type { ProviderRuntimePluginHandle } from "../plugins/provider-hook-runtime.js";
|
|
import { resolveProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js";
|
|
import { shouldPreserveThinkingBlocks } from "../plugins/provider-replay-helpers.js";
|
|
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
|
import type { ProviderReplayPolicy } from "../plugins/types.js";
|
|
import { isGoogleModelApi } from "./embedded-agent-helpers/google.js";
|
|
import { normalizeProviderId } from "./model-selection.js";
|
|
import type { ToolCallIdMode } from "./tool-call-id.js";
|
|
|
|
/** Scope of transcript content sanitization before provider replay. */
|
|
type TranscriptSanitizeMode = "full" | "images-only";
|
|
|
|
/** Effective replay policy applied before sending transcript history to a provider. */
|
|
export type TranscriptPolicy = {
|
|
sanitizeMode: TranscriptSanitizeMode;
|
|
sanitizeToolCallIds: boolean;
|
|
toolCallIdMode?: ToolCallIdMode;
|
|
duplicateToolCallIdStyle?: "openai";
|
|
preserveNativeAnthropicToolUseIds: boolean;
|
|
repairToolUseResultPairing: boolean;
|
|
preserveSignatures: boolean;
|
|
sanitizeThoughtSignatures?: {
|
|
allowBase64Only?: boolean;
|
|
includeCamelCase?: boolean;
|
|
};
|
|
dropThinkingBlocks: boolean;
|
|
dropReasoningFromHistory?: boolean;
|
|
applyGoogleTurnOrdering: boolean;
|
|
validateGeminiTurns: boolean;
|
|
validateAnthropicTurns: boolean;
|
|
allowSyntheticToolResults: boolean;
|
|
};
|
|
|
|
const SIGNED_THINKING_PROVIDERS = new Set(["anthropic", "amazon-bedrock", "anthropic-vertex"]);
|
|
|
|
/** Return true when a provider family owns signed thinking blocks. */
|
|
export function providerRequiresSignedThinking(provider?: string | null): boolean {
|
|
return SIGNED_THINKING_PROVIDERS.has(normalizeProviderId(provider ?? ""));
|
|
}
|
|
|
|
/** Decide whether signed thinking can be replayed under the current provider policy. */
|
|
export function shouldAllowProviderOwnedThinkingReplay(params: {
|
|
modelApi?: string | null;
|
|
provider?: string | null;
|
|
policy: Pick<
|
|
TranscriptPolicy,
|
|
"validateAnthropicTurns" | "preserveSignatures" | "dropThinkingBlocks"
|
|
>;
|
|
}): boolean {
|
|
const hasProviderOwnedSignedThinking =
|
|
params.policy.preserveSignatures || providerRequiresSignedThinking(params.provider);
|
|
return (
|
|
isAnthropicApi(params.modelApi) &&
|
|
params.policy.validateAnthropicTurns &&
|
|
hasProviderOwnedSignedThinking &&
|
|
!params.policy.dropThinkingBlocks
|
|
);
|
|
}
|
|
|
|
const DEFAULT_TRANSCRIPT_POLICY: TranscriptPolicy = {
|
|
sanitizeMode: "images-only",
|
|
sanitizeToolCallIds: false,
|
|
toolCallIdMode: undefined,
|
|
duplicateToolCallIdStyle: undefined,
|
|
preserveNativeAnthropicToolUseIds: false,
|
|
repairToolUseResultPairing: true,
|
|
preserveSignatures: false,
|
|
sanitizeThoughtSignatures: undefined,
|
|
dropThinkingBlocks: false,
|
|
dropReasoningFromHistory: false,
|
|
applyGoogleTurnOrdering: false,
|
|
validateGeminiTurns: false,
|
|
validateAnthropicTurns: false,
|
|
allowSyntheticToolResults: false,
|
|
};
|
|
|
|
function isAnthropicApi(modelApi?: string | null): boolean {
|
|
return modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream";
|
|
}
|
|
|
|
function isOpenAiResponsesCompatibleApi(modelApi?: string | null): boolean {
|
|
return (
|
|
modelApi === "openai-responses" ||
|
|
modelApi === "openai-chatgpt-responses" ||
|
|
modelApi === "azure-openai-responses"
|
|
);
|
|
}
|
|
|
|
function isClaudeFamilyModelId(modelId?: string | null): boolean {
|
|
const id = normalizeLowercaseStringOrEmpty(modelId);
|
|
return /(?:^|[./:_-])claude(?:$|[./:_-])/.test(id);
|
|
}
|
|
|
|
function modelDisablesReasoningEffort(model?: ProviderRuntimeModel): boolean {
|
|
const compat = model?.compat as { supportsReasoningEffort?: boolean } | undefined;
|
|
return compat?.supportsReasoningEffort === false;
|
|
}
|
|
|
|
function shouldPreserveReasoningContentReplay(params: {
|
|
modelId?: string | null;
|
|
model?: ProviderRuntimeModel;
|
|
}): boolean {
|
|
return params.model?.reasoning === true || requiresReasoningContentReplay(params.modelId);
|
|
}
|
|
|
|
/**
|
|
* Provides a narrow replay-policy fallback for providers that do not have an
|
|
* owning runtime plugin.
|
|
*
|
|
* This exists to preserve generic custom-provider behavior. Bundled providers
|
|
* should express replay ownership through `buildReplayPolicy` instead.
|
|
*/
|
|
function buildUnownedProviderTransportReplayFallback(params: {
|
|
modelApi?: string | null;
|
|
modelId?: string | null;
|
|
model?: ProviderRuntimeModel;
|
|
}): ProviderReplayPolicy | undefined {
|
|
const isGoogle = isGoogleModelApi(params.modelApi);
|
|
const isAnthropic = isAnthropicApi(params.modelApi);
|
|
const isStrictOpenAiCompatible = params.modelApi === "openai-completions";
|
|
const requiresOpenAiCompatibleToolIdSanitization =
|
|
params.modelApi === "openai-completions" ||
|
|
params.modelApi === "openai-responses" ||
|
|
params.modelApi === "openai-chatgpt-responses" ||
|
|
params.modelApi === "azure-openai-responses";
|
|
|
|
if (
|
|
!isGoogle &&
|
|
!isAnthropic &&
|
|
!isStrictOpenAiCompatible &&
|
|
!requiresOpenAiCompatibleToolIdSanitization
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const modelId = normalizeLowercaseStringOrEmpty(params.modelId);
|
|
const isClaudeOpenAiResponses = isOpenAiResponsesCompatibleApi(params.modelApi)
|
|
? isClaudeFamilyModelId(modelId)
|
|
: false;
|
|
return {
|
|
...(isGoogle || isAnthropic ? { sanitizeMode: "full" as const } : {}),
|
|
...(isGoogle || isAnthropic || requiresOpenAiCompatibleToolIdSanitization
|
|
? {
|
|
sanitizeToolCallIds: true,
|
|
toolCallIdMode: "strict" as const,
|
|
}
|
|
: {}),
|
|
...(isAnthropic ? { preserveSignatures: true } : {}),
|
|
...(isGoogle
|
|
? {
|
|
sanitizeThoughtSignatures: {
|
|
allowBase64Only: true,
|
|
includeCamelCase: true,
|
|
},
|
|
}
|
|
: {}),
|
|
...(isAnthropic && modelId.includes("claude")
|
|
? { dropThinkingBlocks: !shouldPreserveThinkingBlocks(modelId) }
|
|
: {}),
|
|
...(isAnthropic && modelDisablesReasoningEffort(params.model)
|
|
? { dropThinkingBlocks: true }
|
|
: {}),
|
|
...(isStrictOpenAiCompatible
|
|
? { dropReasoningFromHistory: !shouldPreserveReasoningContentReplay(params) }
|
|
: {}),
|
|
...(isGoogle || isStrictOpenAiCompatible ? { applyAssistantFirstOrderingFix: true } : {}),
|
|
...(isGoogle || isStrictOpenAiCompatible ? { validateGeminiTurns: true } : {}),
|
|
...(isAnthropic || isStrictOpenAiCompatible || isClaudeOpenAiResponses
|
|
? { validateAnthropicTurns: true }
|
|
: {}),
|
|
...(isGoogle || isAnthropic || isOpenAiResponsesCompatibleApi(params.modelApi)
|
|
? { allowSyntheticToolResults: true }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
const REASONING_CONTENT_REPLAY_MODEL_IDS = new Set([
|
|
"kimi-for-coding",
|
|
"kimi-k2.5",
|
|
"kimi-k2.6",
|
|
"kimi-k2.7-code",
|
|
"kimi-k2-thinking",
|
|
"kimi-k2-thinking-turbo",
|
|
"mimo-v2-pro",
|
|
"mimo-v2-omni",
|
|
"mimo-v2.5",
|
|
"mimo-v2.5-pro",
|
|
"mimo-v2.6-pro",
|
|
]);
|
|
|
|
function requiresReasoningContentReplay(modelId: string | null | undefined): boolean {
|
|
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
const parts = normalized.split("/").filter(Boolean);
|
|
const finalPart = parts[parts.length - 1] ?? normalized;
|
|
const candidates = [finalPart];
|
|
const colonParts = finalPart.split(":").filter(Boolean);
|
|
if (colonParts.length > 1) {
|
|
candidates.push(colonParts[0] ?? "", colonParts[colonParts.length - 1] ?? "");
|
|
}
|
|
return candidates.some((candidate) => REASONING_CONTENT_REPLAY_MODEL_IDS.has(candidate));
|
|
}
|
|
|
|
function mergeTranscriptPolicy(
|
|
policy: ProviderReplayPolicy | undefined,
|
|
basePolicy: TranscriptPolicy = DEFAULT_TRANSCRIPT_POLICY,
|
|
): TranscriptPolicy {
|
|
if (!policy) {
|
|
return basePolicy;
|
|
}
|
|
|
|
return {
|
|
...basePolicy,
|
|
...(policy.sanitizeMode != null ? { sanitizeMode: policy.sanitizeMode } : {}),
|
|
...(typeof policy.sanitizeToolCallIds === "boolean"
|
|
? { sanitizeToolCallIds: policy.sanitizeToolCallIds }
|
|
: {}),
|
|
...(policy.toolCallIdMode ? { toolCallIdMode: policy.toolCallIdMode as ToolCallIdMode } : {}),
|
|
...(policy.duplicateToolCallIdStyle
|
|
? { duplicateToolCallIdStyle: policy.duplicateToolCallIdStyle }
|
|
: {}),
|
|
...(typeof policy.preserveNativeAnthropicToolUseIds === "boolean"
|
|
? { preserveNativeAnthropicToolUseIds: policy.preserveNativeAnthropicToolUseIds }
|
|
: {}),
|
|
...(typeof policy.repairToolUseResultPairing === "boolean"
|
|
? { repairToolUseResultPairing: policy.repairToolUseResultPairing }
|
|
: {}),
|
|
...(typeof policy.preserveSignatures === "boolean"
|
|
? { preserveSignatures: policy.preserveSignatures }
|
|
: {}),
|
|
...(policy.sanitizeThoughtSignatures
|
|
? { sanitizeThoughtSignatures: policy.sanitizeThoughtSignatures }
|
|
: {}),
|
|
...(typeof policy.dropThinkingBlocks === "boolean"
|
|
? { dropThinkingBlocks: policy.dropThinkingBlocks }
|
|
: {}),
|
|
...(typeof policy.dropReasoningFromHistory === "boolean"
|
|
? { dropReasoningFromHistory: policy.dropReasoningFromHistory }
|
|
: {}),
|
|
...(typeof policy.applyAssistantFirstOrderingFix === "boolean"
|
|
? { applyGoogleTurnOrdering: policy.applyAssistantFirstOrderingFix }
|
|
: {}),
|
|
...(typeof policy.validateGeminiTurns === "boolean"
|
|
? { validateGeminiTurns: policy.validateGeminiTurns }
|
|
: {}),
|
|
...(typeof policy.validateAnthropicTurns === "boolean"
|
|
? { validateAnthropicTurns: policy.validateAnthropicTurns }
|
|
: {}),
|
|
...(typeof policy.allowSyntheticToolResults === "boolean"
|
|
? { allowSyntheticToolResults: policy.allowSyntheticToolResults }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
const transcriptPolicyCache = new WeakMap<OpenClawConfig, Map<string, TranscriptPolicy>>();
|
|
|
|
function canCacheTranscriptPolicy(params: {
|
|
config?: OpenClawConfig;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): params is { config: OpenClawConfig; env?: NodeJS.ProcessEnv } {
|
|
if (!params.config) {
|
|
return false;
|
|
}
|
|
return !params.env || params.env === process.env;
|
|
}
|
|
|
|
function resolveTranscriptPolicyCacheKey(params: {
|
|
modelApi?: string | null;
|
|
provider: string;
|
|
modelId?: string | null;
|
|
model?: ProviderRuntimeModel;
|
|
config: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
}): string {
|
|
return JSON.stringify({
|
|
provider: params.provider,
|
|
modelApi: params.modelApi ?? "",
|
|
modelId: params.modelId ?? "",
|
|
dropsThinkingForReasoningCompat: modelDisablesReasoningEffort(params.model),
|
|
preservesReasoningContentReplay: params.model?.reasoning === true,
|
|
workspaceDir: params.workspaceDir ?? "",
|
|
pluginControlPlane: resolvePluginControlPlaneFingerprint({
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Resolve and cache the effective replay policy for a provider/model/config tuple. */
|
|
export function resolveTranscriptPolicy(params: {
|
|
modelApi?: string | null;
|
|
provider?: string | null;
|
|
modelId?: string | null;
|
|
config?: OpenClawConfig;
|
|
workspaceDir?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
model?: ProviderRuntimeModel;
|
|
runtimeHandle?: ProviderRuntimePluginHandle;
|
|
}): TranscriptPolicy {
|
|
const provider = normalizeProviderId(params.provider ?? "");
|
|
const cacheConfig = canCacheTranscriptPolicy(params) ? params.config : undefined;
|
|
const cacheKey = cacheConfig
|
|
? resolveTranscriptPolicyCacheKey({ ...params, provider, config: cacheConfig })
|
|
: undefined;
|
|
if (cacheConfig && cacheKey) {
|
|
const cached = transcriptPolicyCache.get(cacheConfig)?.get(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
}
|
|
const runtimePlugin =
|
|
params.runtimeHandle?.plugin ??
|
|
(provider
|
|
? resolveProviderRuntimePlugin({
|
|
provider,
|
|
modelId: params.modelId,
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
})
|
|
: undefined);
|
|
const context = {
|
|
config: params.config,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
provider,
|
|
modelId: params.modelId ?? "",
|
|
modelApi: params.modelApi,
|
|
model: params.model,
|
|
};
|
|
|
|
// Once a provider adopts the replay-policy hook, replay policy should come
|
|
// from the plugin, not from transport-family defaults in core.
|
|
const buildReplayPolicy = runtimePlugin?.buildReplayPolicy;
|
|
const policy = buildReplayPolicy
|
|
? mergeTranscriptPolicy(buildReplayPolicy(context) ?? undefined)
|
|
: mergeTranscriptPolicy(
|
|
buildUnownedProviderTransportReplayFallback({
|
|
modelApi: params.modelApi,
|
|
modelId: params.modelId,
|
|
model: params.model,
|
|
}),
|
|
);
|
|
if (cacheConfig && cacheKey) {
|
|
let configCache = transcriptPolicyCache.get(cacheConfig);
|
|
if (!configCache) {
|
|
configCache = new Map();
|
|
transcriptPolicyCache.set(cacheConfig, configCache);
|
|
}
|
|
configCache.set(cacheKey, policy);
|
|
}
|
|
return policy;
|
|
}
|