refactor: add provider replay runtime hook surfaces (#59143)

Merged via squash.

Prepared head SHA: 56b41e87a5
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Josh Lehman 2026-04-01 13:45:41 -07:00 committed by GitHub
parent ca76e2fedc
commit 71346940ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 771 additions and 102 deletions

View File

@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) thanks @JoshuaLelon
- Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.
### Fixes
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.

View File

@ -37,6 +37,9 @@ vi.mock("../plugins/provider-runtime.js", () => ({
dropThinkingBlockModelHints: ["claude"],
}
: undefined,
sanitizeProviderReplayHistoryWithPlugin: vi.fn(async ({ messages }) => messages),
resolveProviderReplayPolicyWithPlugin: vi.fn(() => undefined),
validateProviderReplayTurnsWithPlugin: vi.fn(() => undefined),
}));
let sanitizeSessionHistory: SanitizeSessionHistoryFn;

View File

@ -54,11 +54,7 @@ import { ensureOpenClawModelsJson } from "../models-config.js";
import { resolveOwnerDisplaySetting } from "../owner-display.js";
import { createBundleLspToolRuntime } from "../pi-bundle-lsp-runtime.js";
import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js";
import {
ensureSessionHeader,
validateAnthropicTurns,
validateGeminiTurns,
} from "../pi-embedded-helpers.js";
import { ensureSessionHeader } from "../pi-embedded-helpers.js";
import {
consumeCompactionSafeguardCancelReason,
setCompactionSafeguardCancelReason,
@ -106,6 +102,7 @@ import {
logToolSchemasForGoogle,
sanitizeSessionHistory,
sanitizeToolsForGoogle,
validateReplayTurns,
} from "./google.js";
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
@ -488,6 +485,12 @@ export async function compactEmbeddedPiSessionDirect(
const tools = sanitizeToolsForGoogle({
tools: toolsEnabled ? toolsRaw : [],
provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId,
modelApi: model.api,
model,
});
const bundleMcpRuntime = toolsEnabled
? await createBundleMcpToolRuntime({
@ -512,7 +515,16 @@ export async function compactEmbeddedPiSessionDirect(
...(bundleLspRuntime?.tools ?? []),
];
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
logToolSchemasForGoogle({ tools: effectiveTools, provider });
logToolSchemasForGoogle({
tools: effectiveTools,
provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId,
modelApi: model.api,
model,
});
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
@ -593,7 +605,14 @@ export async function compactEmbeddedPiSessionDirect(
channelActions,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const reasoningTagHint = isReasoningTagProvider(provider, {
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId,
modelApi: model.api,
model,
});
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
@ -658,6 +677,10 @@ export async function compactEmbeddedPiSessionDirect(
modelApi: model.api,
provider,
modelId,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model,
});
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
@ -731,16 +754,25 @@ export async function compactEmbeddedPiSessionDirect(
provider,
allowedToolNames,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const validated = await validateReplayTurns({
messages: prior,
modelApi: model.api,
modelId,
provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
// Apply validated transcript to the live session even when no history limit is configured,
// so compaction and hook metrics are based on the same message set.
session.agent.replaceMessages(validated);

View File

@ -4,6 +4,12 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { TSchema } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
import {
normalizeProviderToolSchemasWithPlugin,
sanitizeProviderReplayHistoryWithPlugin,
validateProviderReplayTurnsWithPlugin,
} from "../../plugins/provider-runtime.js";
import type { ProviderRuntimeModel } from "../../plugins/types.js";
import {
hasInterSessionUserProvenance,
normalizeInputProvenance,
@ -16,6 +22,8 @@ import {
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
validateAnthropicTurns,
validateGeminiTurns,
} from "../pi-embedded-helpers.js";
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
import {
@ -23,6 +31,7 @@ import {
stripToolResultDetails,
sanitizeToolUseResultPairing,
} from "../session-transcript-repair.js";
import type { AnyAgentTool } from "../tools/common.js";
import type { TranscriptPolicy } from "../transcript-policy.js";
import { resolveTranscriptPolicy } from "../transcript-policy.js";
import {
@ -407,12 +416,39 @@ export function sanitizeToolsForGoogle<
>(params: {
tools: AgentTool<TSchemaType, TResult>[];
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
modelId?: string;
modelApi?: string | null;
model?: ProviderRuntimeModel;
}): AgentTool<TSchemaType, TResult>[] {
const provider = params.provider.trim();
const pluginNormalized = normalizeProviderToolSchemasWithPlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
context: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
provider,
modelId: params.modelId,
modelApi: params.modelApi,
model: params.model,
tools: params.tools as unknown as AnyAgentTool[],
},
});
if (Array.isArray(pluginNormalized)) {
return pluginNormalized as AgentTool<TSchemaType, TResult>[];
}
// Cloud Code Assist uses the OpenAPI 3.03 `parameters` field for both Gemini
// AND Claude models. This field does not support JSON Schema keywords such as
// patternProperties, additionalProperties, $ref, etc. We must clean schemas
// for every provider that routes through this path.
if (params.provider !== "google-gemini-cli") {
if (provider !== "google-gemini-cli") {
return params.tools;
}
return params.tools.map((tool) => {
@ -428,8 +464,17 @@ export function sanitizeToolsForGoogle<
});
}
export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider: string }) {
if (params.provider !== "google-gemini-cli") {
export function logToolSchemasForGoogle(params: {
tools: AgentTool[];
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
modelId?: string;
modelApi?: string | null;
model?: ProviderRuntimeModel;
}) {
if (params.provider.trim() !== "google-gemini-cli") {
return;
}
const toolNames = params.tools.map((tool, index) => `${index}:${tool.name}`);
@ -581,6 +626,9 @@ export async function sanitizeSessionHistory(params: {
provider?: string;
allowedToolNames?: Iterable<string>;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
model?: ProviderRuntimeModel;
sessionManager: SessionManager;
sessionId: string;
policy?: TranscriptPolicy;
@ -592,6 +640,10 @@ export async function sanitizeSessionHistory(params: {
modelApi: params.modelApi,
provider: params.provider,
modelId: params.modelId,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
model: params.model,
});
const withInterSessionMarkers = annotateInterSessionUserMessages(params.messages);
const canonicalizedAssistantHistory = canonicalizeAssistantHistoryMessages({
@ -645,6 +697,29 @@ export async function sanitizeSessionHistory(params: {
downgradeOpenAIReasoningBlocks(sanitizedCompactionUsage),
)
: sanitizedCompactionUsage;
const provider = params.provider?.trim();
const providerSanitized =
provider && provider.length > 0
? await sanitizeProviderReplayHistoryWithPlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
context: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
provider,
modelId: params.modelId,
modelApi: params.modelApi,
model: params.model,
sessionId: params.sessionId,
messages: sanitizedOpenAI,
allowedToolNames: params.allowedToolNames,
},
})
: undefined;
const sanitizedWithProvider = providerSanitized ?? sanitizedOpenAI;
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
appendModelSnapshot(params.sessionManager, {
@ -656,13 +731,13 @@ export async function sanitizeSessionHistory(params: {
}
if (!policy.applyGoogleTurnOrdering) {
return sanitizedOpenAI;
return sanitizedWithProvider;
}
// Google models use the full wrapper with logging and session markers.
if (isGoogleModelApi(params.modelApi)) {
return applyGoogleTurnOrderingFix({
messages: sanitizedOpenAI,
messages: sanitizedWithProvider,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
sessionId: params.sessionId,
@ -673,5 +748,58 @@ export async function sanitizeSessionHistory(params: {
// conversations that start with an assistant turn (e.g. delivery-mirror
// messages after /new). Apply the same ordering fix without the
// Google-specific session markers. See #38962.
return sanitizeGoogleTurnOrdering(sanitizedOpenAI);
return sanitizeGoogleTurnOrdering(sanitizedWithProvider);
}
export async function validateReplayTurns(params: {
messages: AgentMessage[];
modelApi?: string | null;
modelId?: string;
provider?: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
model?: ProviderRuntimeModel;
sessionId?: string;
policy?: TranscriptPolicy;
}): Promise<AgentMessage[]> {
const policy =
params.policy ??
resolveTranscriptPolicy({
modelApi: params.modelApi,
provider: params.provider,
modelId: params.modelId,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
model: params.model,
});
const provider = params.provider?.trim();
if (provider) {
const providerValidated = await validateProviderReplayTurnsWithPlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
context: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
provider,
modelId: params.modelId,
modelApi: params.modelApi,
model: params.model,
sessionId: params.sessionId,
messages: params.messages,
},
});
if (providerValidated) {
return providerValidated;
}
}
const validatedGemini = policy.validateGeminiTurns
? validateGeminiTurns(params.messages)
: params.messages;
return policy.validateAnthropicTurns ? validateAnthropicTurns(validatedGemini) : validatedGemini;
}

View File

@ -68,8 +68,6 @@ import {
resolveBootstrapMaxChars,
resolveBootstrapPromptTruncationWarningMode,
resolveBootstrapTotalMaxChars,
validateAnthropicTurns,
validateGeminiTurns,
} from "../../pi-embedded-helpers.js";
import { subscribeEmbeddedPiSession } from "../../pi-embedded-subscribe.js";
import { createPreparedEmbeddedPiSettingsManager } from "../../pi-project-settings.js";
@ -107,6 +105,7 @@ import {
logToolSchemasForGoogle,
sanitizeSessionHistory,
sanitizeToolsForGoogle,
validateReplayTurns,
} from "../google.js";
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "../history.js";
import { log } from "../logger.js";
@ -419,64 +418,64 @@ export async function runEmbeddedAttempt(
? []
: (() => {
const allTools = createOpenClawCodingTools({
agentId: sessionAgentId,
trigger: params.trigger,
memoryFlushWritePath: params.memoryFlushWritePath,
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
agentDir,
workspaceDir: effectiveWorkspace,
// When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points
// at the sandbox copy. Spawned subagents should inherit the real workspace instead.
spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({
agentId: sessionAgentId,
trigger: params.trigger,
memoryFlushWritePath: params.memoryFlushWritePath,
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
sandbox,
resolvedWorkspace,
}),
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelCompat: params.model.compat,
modelApi: params.model.api,
modelContextWindowTokens: params.model.contextWindow,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
onYield: (message) => {
yieldDetected = true;
yieldMessage = message;
queueYieldInterruptForSession?.();
runAbortController.abort("sessions_yield");
abortSessionForYield?.();
},
});
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
sessionKey: sandboxSessionKey,
sessionId: params.sessionId,
runId: params.runId,
agentDir,
workspaceDir: effectiveWorkspace,
// When sandboxing uses a copied workspace (`ro` or `none`), effectiveWorkspace points
// at the sandbox copy. Spawned subagents should inherit the real workspace instead.
spawnWorkspaceDir: resolveAttemptSpawnWorkspaceDir({
sandbox,
resolvedWorkspace,
}),
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelCompat: params.model.compat,
modelApi: params.model.api,
modelContextWindowTokens: params.model.contextWindow,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
onYield: (message) => {
yieldDetected = true;
yieldMessage = message;
queueYieldInterruptForSession?.();
runAbortController.abort("sessions_yield");
abortSessionForYield?.();
},
});
if (params.toolsAllow && params.toolsAllow.length > 0) {
const allowSet = new Set(params.toolsAllow);
return allTools.filter((tool) => allowSet.has(tool.name));
@ -487,6 +486,12 @@ export async function runEmbeddedAttempt(
const tools = sanitizeToolsForGoogle({
tools: toolsEnabled ? toolsRaw : [],
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
const clientTools = toolsEnabled ? params.clientTools : undefined;
const bundleMcpSessionRuntime = toolsEnabled
@ -526,7 +531,16 @@ export async function runEmbeddedAttempt(
tools: effectiveTools,
clientTools,
});
logToolSchemasForGoogle({ tools: effectiveTools, provider: params.provider });
logToolSchemasForGoogle({
tools: effectiveTools,
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
@ -568,7 +582,14 @@ export async function runEmbeddedAttempt(
})
: undefined;
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);
const reasoningTagHint = isReasoningTagProvider(params.provider, {
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
});
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions(
@ -621,7 +642,7 @@ export async function runEmbeddedAttempt(
const promptMode = resolvePromptModeForSession(params.sessionKey);
// When toolsAllow is set, use minimal prompt and strip skills catalog
const effectivePromptMode = params.toolsAllow?.length ? "minimal" as const : promptMode;
const effectivePromptMode = params.toolsAllow?.length ? ("minimal" as const) : promptMode;
const effectiveSkillsPrompt = params.toolsAllow?.length ? undefined : skillsPrompt;
const docsPath = await resolveOpenClawDocsPath({
workspaceDir: effectiveWorkspace,
@ -724,6 +745,10 @@ export async function runEmbeddedAttempt(
modelApi: params.model?.api,
provider: params.provider,
modelId: params.modelId,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
});
await prewarmSessionFile(params.sessionFile);
@ -1098,17 +1123,26 @@ export async function runEmbeddedAttempt(
provider: params.provider,
allowedToolNames,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
cacheTrace?.recordStage("session:sanitized", { messages: prior });
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const validated = await validateReplayTurns({
messages: prior,
modelApi: params.model.api,
modelId: params.modelId,
provider: params.provider,
config: params.config,
workspaceDir: effectiveWorkspace,
env: process.env,
model: params.model,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const truncated = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),

View File

@ -24,6 +24,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({
return undefined;
}
}),
resolveProviderReplayPolicyWithPlugin: vi.fn(() => undefined),
resetProviderRuntimeHookCacheForTest: vi.fn(),
}));

View File

@ -1,3 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveProviderReplayPolicyWithPlugin } from "../plugins/provider-runtime.js";
import type { ProviderRuntimeModel } from "../plugins/types.js";
import { normalizeProviderId } from "./model-selection.js";
import { isGoogleModelApi } from "./pi-embedded-helpers/google.js";
import {
@ -61,6 +64,10 @@ export function resolveTranscriptPolicy(params: {
modelApi?: string | null;
provider?: string | null;
modelId?: string | null;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
model?: ProviderRuntimeModel;
}): TranscriptPolicy {
const provider = normalizeProviderId(params.provider ?? "");
const modelId = params.modelId ?? "";
@ -111,7 +118,7 @@ export function resolveTranscriptPolicy(params: {
? { allowBase64Only: true, includeCamelCase: true }
: undefined;
return {
const basePolicy: TranscriptPolicy = {
sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",
sanitizeToolCallIds:
(!isOpenAi && sanitizeToolCallIds) || requiresOpenAiCompatibleToolIdSanitization,
@ -126,4 +133,52 @@ export function resolveTranscriptPolicy(params: {
validateAnthropicTurns: !isOpenAi && (isAnthropic || isStrictOpenAiCompatible),
allowSyntheticToolResults: !isOpenAi && (isGoogle || isAnthropic),
};
const pluginPolicy = provider
? resolveProviderReplayPolicyWithPlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
context: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
provider,
modelId,
modelApi: params.modelApi,
model: params.model,
},
})
: undefined;
if (!pluginPolicy) {
return basePolicy;
}
return {
...basePolicy,
...(pluginPolicy.sanitizeMode != null ? { sanitizeMode: pluginPolicy.sanitizeMode } : {}),
...(typeof pluginPolicy.sanitizeToolCallIds === "boolean"
? { sanitizeToolCallIds: pluginPolicy.sanitizeToolCallIds }
: {}),
...(pluginPolicy.toolCallIdMode ? { toolCallIdMode: pluginPolicy.toolCallIdMode } : {}),
...(typeof pluginPolicy.repairToolUseResultPairing === "boolean"
? { repairToolUseResultPairing: pluginPolicy.repairToolUseResultPairing }
: {}),
...(typeof pluginPolicy.preserveSignatures === "boolean"
? { preserveSignatures: pluginPolicy.preserveSignatures }
: {}),
...(pluginPolicy.sanitizeThoughtSignatures
? { sanitizeThoughtSignatures: pluginPolicy.sanitizeThoughtSignatures }
: {}),
...(typeof pluginPolicy.dropThinkingBlocks === "boolean"
? { dropThinkingBlocks: pluginPolicy.dropThinkingBlocks }
: {}),
...(typeof pluginPolicy.applyAssistantFirstOrderingFix === "boolean"
? { applyGoogleTurnOrdering: pluginPolicy.applyAssistantFirstOrderingFix }
: {}),
...(typeof pluginPolicy.allowSyntheticToolResults === "boolean"
? { allowSyntheticToolResults: pluginPolicy.allowSyntheticToolResults }
: {}),
};
}

View File

@ -93,7 +93,14 @@ export const formatBunFetchSocketError = (message: string) => {
};
export const resolveEnforceFinalTag = (run: FollowupRun["run"], provider: string) =>
Boolean(run.enforceFinalTag || isReasoningTagProvider(provider));
Boolean(
run.enforceFinalTag ||
isReasoningTagProvider(provider, {
config: run.config,
workspaceDir: run.workspaceDir,
modelId: run.model,
}),
);
export function resolveModelFallbackOptions(run: FollowupRun["run"]) {
return {

View File

@ -580,7 +580,13 @@ export async function runPreparedReply(
ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined,
inputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance,
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
...(isReasoningTagProvider(provider, {
config: cfg,
workspaceDir,
modelId: model,
})
? { enforceFinalTag: true }
: {}),
},
};

View File

@ -54,15 +54,22 @@ export type {
ProviderFetchUsageSnapshotContext,
ProviderModernModelPolicyContext,
ProviderNormalizeResolvedModelContext,
ProviderNormalizeToolSchemasContext,
ProviderPrepareDynamicModelContext,
ProviderPrepareExtraParamsContext,
ProviderPrepareRuntimeAuthContext,
ProviderPreparedRuntimeAuth,
ProviderReasoningOutputMode,
ProviderReasoningOutputModeContext,
ProviderReplayPolicy,
ProviderReplayPolicyContext,
ProviderResolveDynamicModelContext,
ProviderResolvedUsageAuth,
ProviderSanitizeReplayHistoryContext,
ProviderResolveUsageAuthContext,
ProviderRuntimeModel,
ProviderThinkingPolicyContext,
ProviderValidateReplayTurnsContext,
ProviderWrapStreamFnContext,
SpeechProviderPlugin,
} from "./plugin-entry.js";

View File

@ -31,6 +31,7 @@ import type {
ProviderFetchUsageSnapshotContext,
ProviderModernModelPolicyContext,
ProviderNormalizeConfigContext,
ProviderNormalizeToolSchemasContext,
ProviderNormalizeTransportContext,
ProviderResolveConfigApiKeyContext,
ProviderNormalizeModelIdContext,
@ -39,11 +40,17 @@ import type {
ProviderPrepareExtraParamsContext,
ProviderPrepareRuntimeAuthContext,
ProviderPreparedRuntimeAuth,
ProviderReasoningOutputMode,
ProviderReasoningOutputModeContext,
ProviderReplayPolicy,
ProviderReplayPolicyContext,
ProviderResolvedUsageAuth,
ProviderResolveDynamicModelContext,
ProviderSanitizeReplayHistoryContext,
ProviderResolveUsageAuthContext,
ProviderRuntimeModel,
ProviderThinkingPolicyContext,
ProviderValidateReplayTurnsContext,
ProviderWrapStreamFnContext,
SpeechProviderPlugin,
PluginCommandContext,
@ -70,20 +77,27 @@ export type {
ProviderFetchUsageSnapshotContext,
ProviderModernModelPolicyContext,
ProviderNormalizeConfigContext,
ProviderNormalizeToolSchemasContext,
ProviderNormalizeTransportContext,
ProviderResolveConfigApiKeyContext,
ProviderNormalizeModelIdContext,
ProviderReplayPolicy,
ProviderReplayPolicyContext,
ProviderPreparedRuntimeAuth,
ProviderReasoningOutputMode,
ProviderReasoningOutputModeContext,
ProviderResolvedUsageAuth,
ProviderPrepareExtraParamsContext,
ProviderPrepareDynamicModelContext,
ProviderPrepareRuntimeAuthContext,
ProviderSanitizeReplayHistoryContext,
ProviderResolveUsageAuthContext,
ProviderResolveDynamicModelContext,
ProviderNormalizeResolvedModelContext,
ProviderRuntimeModel,
SpeechProviderPlugin,
ProviderThinkingPolicyContext,
ProviderValidateReplayTurnsContext,
ProviderWrapStreamFnContext,
OpenClawPluginService,
OpenClawPluginServiceContext,

View File

@ -1,3 +1,4 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
expectAugmentedCodexCatalog,
@ -5,7 +6,14 @@ import {
expectCodexMissingAuthHint,
expectedAugmentedOpenaiCodexCatalogEntries,
} from "./provider-runtime.test-support.js";
import type { ProviderPlugin, ProviderRuntimeModel } from "./types.js";
import type {
AnyAgentTool,
ProviderNormalizeToolSchemasContext,
ProviderPlugin,
ProviderRuntimeModel,
ProviderSanitizeReplayHistoryContext,
ProviderValidateReplayTurnsContext,
} from "./types.js";
type ResolvePluginProviders = typeof import("./providers.runtime.js").resolvePluginProviders;
type ResolveCatalogHookProviderPluginIds =
@ -41,11 +49,15 @@ let resolveProviderBuiltInModelSuppression: typeof import("./provider-runtime.js
let createProviderEmbeddingProvider: typeof import("./provider-runtime.js").createProviderEmbeddingProvider;
let resolveProviderDefaultThinkingLevel: typeof import("./provider-runtime.js").resolveProviderDefaultThinkingLevel;
let resolveProviderModernModelRef: typeof import("./provider-runtime.js").resolveProviderModernModelRef;
let resolveProviderReasoningOutputModeWithPlugin: typeof import("./provider-runtime.js").resolveProviderReasoningOutputModeWithPlugin;
let resolveProviderReplayPolicyWithPlugin: typeof import("./provider-runtime.js").resolveProviderReplayPolicyWithPlugin;
let resolveProviderSyntheticAuthWithPlugin: typeof import("./provider-runtime.js").resolveProviderSyntheticAuthWithPlugin;
let sanitizeProviderReplayHistoryWithPlugin: typeof import("./provider-runtime.js").sanitizeProviderReplayHistoryWithPlugin;
let resolveProviderUsageSnapshotWithPlugin: typeof import("./provider-runtime.js").resolveProviderUsageSnapshotWithPlugin;
let resolveProviderCapabilitiesWithPlugin: typeof import("./provider-runtime.js").resolveProviderCapabilitiesWithPlugin;
let resolveProviderUsageAuthWithPlugin: typeof import("./provider-runtime.js").resolveProviderUsageAuthWithPlugin;
let resolveProviderXHighThinking: typeof import("./provider-runtime.js").resolveProviderXHighThinking;
let normalizeProviderToolSchemasWithPlugin: typeof import("./provider-runtime.js").normalizeProviderToolSchemasWithPlugin;
let normalizeProviderResolvedModelWithPlugin: typeof import("./provider-runtime.js").normalizeProviderResolvedModelWithPlugin;
let prepareProviderDynamicModel: typeof import("./provider-runtime.js").prepareProviderDynamicModel;
let prepareProviderRuntimeAuth: typeof import("./provider-runtime.js").prepareProviderRuntimeAuth;
@ -53,6 +65,7 @@ let resetProviderRuntimeHookCacheForTest: typeof import("./provider-runtime.js")
let refreshProviderOAuthCredentialWithPlugin: typeof import("./provider-runtime.js").refreshProviderOAuthCredentialWithPlugin;
let resolveProviderRuntimePlugin: typeof import("./provider-runtime.js").resolveProviderRuntimePlugin;
let runProviderDynamicModel: typeof import("./provider-runtime.js").runProviderDynamicModel;
let validateProviderReplayTurnsWithPlugin: typeof import("./provider-runtime.js").validateProviderReplayTurnsWithPlugin;
let wrapProviderStreamFn: typeof import("./provider-runtime.js").wrapProviderStreamFn;
const MODEL: ProviderRuntimeModel = {
@ -69,6 +82,31 @@ const MODEL: ProviderRuntimeModel = {
};
const DEMO_PROVIDER_ID = "demo";
const EMPTY_MODEL_REGISTRY = { find: () => null } as never;
const DEMO_REPLAY_MESSAGES: AgentMessage[] = [{ role: "user", content: "hello", timestamp: 1 }];
const DEMO_SANITIZED_MESSAGE: AgentMessage = {
role: "assistant",
content: [{ type: "text", text: "sanitized" }],
api: MODEL.api,
provider: MODEL.provider,
model: MODEL.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
};
const DEMO_TOOL = {
name: "demo-tool",
label: "Demo tool",
description: "Demo tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(async () => ({ content: [], details: undefined })),
} as unknown as AnyAgentTool;
function createOpenAiCatalogProviderPlugin(
overrides: Partial<ProviderPlugin> = {},
@ -228,11 +266,15 @@ describe("provider-runtime", () => {
createProviderEmbeddingProvider,
resolveProviderDefaultThinkingLevel,
resolveProviderModernModelRef,
resolveProviderReasoningOutputModeWithPlugin,
resolveProviderReplayPolicyWithPlugin,
resolveProviderSyntheticAuthWithPlugin,
sanitizeProviderReplayHistoryWithPlugin,
resolveProviderUsageSnapshotWithPlugin,
resolveProviderCapabilitiesWithPlugin,
resolveProviderUsageAuthWithPlugin,
resolveProviderXHighThinking,
normalizeProviderToolSchemasWithPlugin,
normalizeProviderResolvedModelWithPlugin,
prepareProviderDynamicModel,
prepareProviderRuntimeAuth,
@ -240,6 +282,7 @@ describe("provider-runtime", () => {
refreshProviderOAuthCredentialWithPlugin,
resolveProviderRuntimePlugin,
runProviderDynamicModel,
validateProviderReplayTurnsWithPlugin,
wrapProviderStreamFn,
} = await import("./provider-runtime.js"));
resetProviderRuntimeHookCacheForTest();
@ -428,6 +471,28 @@ describe("provider-runtime", () => {
embedBatch: async () => [[1, 0, 0]],
client: { token: "embed-token" },
}));
const buildReplayPolicy = vi.fn(() => ({
sanitizeMode: "full" as const,
toolCallIdMode: "strict9" as const,
allowSyntheticToolResults: true,
}));
const sanitizeReplayHistory = vi.fn(
async ({
messages,
}: Pick<ProviderSanitizeReplayHistoryContext, "messages">): Promise<AgentMessage[]> => [
...messages,
DEMO_SANITIZED_MESSAGE,
],
);
const validateReplayTurns = vi.fn(
async ({
messages,
}: Pick<ProviderValidateReplayTurnsContext, "messages">): Promise<AgentMessage[]> => messages,
);
const normalizeToolSchemas = vi.fn(
({ tools }: Pick<ProviderNormalizeToolSchemasContext, "tools">): AnyAgentTool[] => tools,
);
const resolveReasoningOutputMode = vi.fn(() => "tagged" as const);
const resolveSyntheticAuth = vi.fn(() => ({
apiKey: "demo-local",
source: "models.providers.demo (synthetic local key)",
@ -478,6 +543,11 @@ describe("provider-runtime", () => {
capabilities: {
providerFamily: "openai",
},
buildReplayPolicy,
sanitizeReplayHistory,
validateReplayTurns,
normalizeToolSchemas,
resolveReasoningOutputMode,
prepareExtraParams: ({ extraParams }) => ({
...extraParams,
transport: "auto",
@ -608,6 +678,28 @@ describe("provider-runtime", () => {
providerFamily: "openai",
});
expect(
resolveProviderReplayPolicyWithPlugin({
provider: DEMO_PROVIDER_ID,
context: createDemoResolvedModelContext({
modelApi: MODEL.api,
}),
}),
).toMatchObject({
sanitizeMode: "full",
toolCallIdMode: "strict9",
allowSyntheticToolResults: true,
});
expect(
resolveProviderReasoningOutputModeWithPlugin({
provider: DEMO_PROVIDER_ID,
context: createDemoResolvedModelContext({
modelApi: MODEL.api,
}),
}),
).toBe("tagged");
expect(
prepareProviderExtraParams({
provider: DEMO_PROVIDER_ID,
@ -710,6 +802,34 @@ describe("provider-runtime", () => {
windows: [{ label: "Day", usedPercent: 25 }],
},
},
{
actual: () =>
sanitizeProviderReplayHistoryWithPlugin({
provider: DEMO_PROVIDER_ID,
context: createDemoResolvedModelContext({
modelApi: MODEL.api,
sessionId: "session-1",
messages: DEMO_REPLAY_MESSAGES,
}),
}),
expected: {
1: DEMO_SANITIZED_MESSAGE,
},
},
{
actual: () =>
validateProviderReplayTurnsWithPlugin({
provider: DEMO_PROVIDER_ID,
context: createDemoResolvedModelContext({
modelApi: MODEL.api,
sessionId: "session-1",
messages: DEMO_REPLAY_MESSAGES,
}),
}),
expected: {
0: DEMO_REPLAY_MESSAGES[0],
},
},
]);
expect(
@ -721,6 +841,16 @@ describe("provider-runtime", () => {
}),
).toBeTypeOf("function");
expect(
normalizeProviderToolSchemasWithPlugin({
provider: DEMO_PROVIDER_ID,
context: createDemoResolvedModelContext({
modelApi: MODEL.api,
tools: [DEMO_TOOL],
}),
}),
).toEqual([DEMO_TOOL]);
expect(
normalizeProviderResolvedModelWithPlugin({
provider: DEMO_PROVIDER_ID,
@ -855,7 +985,12 @@ describe("provider-runtime", () => {
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
expectCalledOnce(
buildReplayPolicy,
prepareDynamicModel,
sanitizeReplayHistory,
validateReplayTurns,
normalizeToolSchemas,
resolveReasoningOutputMode,
refreshOAuth,
resolveSyntheticAuth,
buildUnknownModelHint,

View File

@ -20,8 +20,13 @@ import type {
ProviderCreateStreamFnContext,
ProviderDefaultThinkingPolicyContext,
ProviderFetchUsageSnapshotContext,
ProviderNormalizeToolSchemasContext,
ProviderNormalizeConfigContext,
ProviderNormalizeModelIdContext,
ProviderReasoningOutputMode,
ProviderReasoningOutputModeContext,
ProviderReplayPolicy,
ProviderReplayPolicyContext,
ProviderNormalizeResolvedModelContext,
ProviderNormalizeTransportContext,
ProviderModernModelPolicyContext,
@ -29,11 +34,13 @@ import type {
ProviderPrepareDynamicModelContext,
ProviderPrepareRuntimeAuthContext,
ProviderResolveConfigApiKeyContext,
ProviderSanitizeReplayHistoryContext,
ProviderResolveUsageAuthContext,
ProviderPlugin,
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
ProviderThinkingPolicyContext,
ProviderValidateReplayTurnsContext,
ProviderWrapStreamFnContext,
} from "./types.js";
@ -435,6 +442,57 @@ export function resolveProviderCapabilitiesWithPlugin(params: {
return resolveProviderRuntimePlugin(params)?.capabilities;
}
export function resolveProviderReplayPolicyWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderReplayPolicyContext;
}): ProviderReplayPolicy | undefined {
return resolveProviderHookPlugin(params)?.buildReplayPolicy?.(params.context) ?? undefined;
}
export async function sanitizeProviderReplayHistoryWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderSanitizeReplayHistoryContext;
}) {
return await resolveProviderHookPlugin(params)?.sanitizeReplayHistory?.(params.context);
}
export async function validateProviderReplayTurnsWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderValidateReplayTurnsContext;
}) {
return await resolveProviderHookPlugin(params)?.validateReplayTurns?.(params.context);
}
export function normalizeProviderToolSchemasWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderNormalizeToolSchemasContext;
}) {
return resolveProviderHookPlugin(params)?.normalizeToolSchemas?.(params.context) ?? undefined;
}
export function resolveProviderReasoningOutputModeWithPlugin(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
context: ProviderReasoningOutputModeContext;
}): ProviderReasoningOutputMode | undefined {
const mode = resolveProviderHookPlugin(params)?.resolveReasoningOutputMode?.(params.context);
return mode === "native" || mode === "tagged" ? mode : undefined;
}
export function prepareProviderExtraParams(params: {
provider: string;
config?: OpenClawConfig;

View File

@ -535,6 +535,92 @@ export type ProviderPrepareExtraParamsContext = {
thinkingLevel?: ThinkLevel;
};
export type ProviderReplaySanitizeMode = "full" | "images-only";
export type ProviderReplayToolCallIdMode = "strict" | "strict9";
export type ProviderReasoningOutputMode = "native" | "tagged";
/**
* Provider-owned replay/compaction transcript policy.
*
* These values are consumed by shared history replay and compaction logic.
* Return only the fields the provider wants to override; core fills the rest
* with its default policy.
*/
export type ProviderReplayPolicy = {
sanitizeMode?: ProviderReplaySanitizeMode;
sanitizeToolCallIds?: boolean;
toolCallIdMode?: ProviderReplayToolCallIdMode;
preserveSignatures?: boolean;
sanitizeThoughtSignatures?: {
allowBase64Only?: boolean;
includeCamelCase?: boolean;
};
dropThinkingBlocks?: boolean;
repairToolUseResultPairing?: boolean;
applyAssistantFirstOrderingFix?: boolean;
allowSyntheticToolResults?: boolean;
};
/**
* Provider-owned replay/compaction policy input.
*
* Use this when transcript replay rules depend on provider/model transport
* behavior and should stay with the provider plugin instead of core tables.
*/
export type ProviderReplayPolicyContext = {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
provider: string;
modelId?: string;
modelApi?: string | null;
model?: ProviderRuntimeModel;
};
/**
* Provider-owned replay-history sanitization input.
*
* Runs after core applies generic transcript cleanup so plugins can make
* provider-specific replay rewrites without owning the whole compaction flow.
*/
export type ProviderSanitizeReplayHistoryContext = ProviderReplayPolicyContext & {
sessionId: string;
messages: AgentMessage[];
allowedToolNames?: Iterable<string>;
};
/**
* Provider-owned final replay-turn validation input.
*
* Use this for providers that require strict turn ordering or additional
* replay-time transcript validation beyond generic sanitation.
*/
export type ProviderValidateReplayTurnsContext = ProviderReplayPolicyContext & {
sessionId?: string;
messages: AgentMessage[];
};
/**
* Provider-owned tool-schema normalization input.
*
* Runs before tool registration for replay/compaction/inference so providers
* can rewrite schema keywords that their transport family does not support.
*/
export type ProviderNormalizeToolSchemasContext = ProviderReplayPolicyContext & {
tools: AnyAgentTool[];
};
/**
* Provider-owned reasoning output mode input.
*
* Use this when a provider requires a specific reasoning-output contract, such
* as text tags instead of native structured reasoning fields.
*/
export type ProviderReasoningOutputModeContext = ProviderReplayPolicyContext;
/**
* Provider-owned transport creation.
*
@ -945,6 +1031,51 @@ export type ProviderPlugin = {
* sanitization quirks, or requires provider-family hints.
*/
capabilities?: Partial<ProviderCapabilities>;
/**
* Provider-owned replay/compaction policy override.
*
* Use this when transcript replay or compaction should follow provider-owned
* rules that are more expressive than the static `capabilities` bag.
*/
buildReplayPolicy?: (ctx: ProviderReplayPolicyContext) => ProviderReplayPolicy | null | undefined;
/**
* Provider-owned replay-history sanitization.
*
* Runs after OpenClaw performs generic transcript cleanup. Use this for
* provider-specific replay rewrites that should stay with the provider
* plugin rather than in shared core compaction helpers.
*/
sanitizeReplayHistory?: (
ctx: ProviderSanitizeReplayHistoryContext,
) => Promise<AgentMessage[] | null | undefined> | AgentMessage[] | null | undefined;
/**
* Provider-owned final replay-turn validation.
*
* Use this when provider transports need stricter replay-time validation or
* turn reshaping after generic sanitation. Returning a non-null value
* replaces the built-in replay validators rather than composing with them.
*/
validateReplayTurns?: (
ctx: ProviderValidateReplayTurnsContext,
) => Promise<AgentMessage[] | null | undefined> | AgentMessage[] | null | undefined;
/**
* Provider-owned tool-schema normalization.
*
* Use this for transport-family schema cleanup before OpenClaw registers
* tools with the embedded runner.
*/
normalizeToolSchemas?: (
ctx: ProviderNormalizeToolSchemasContext,
) => AnyAgentTool[] | null | undefined;
/**
* Provider-owned reasoning output mode.
*
* Use this when a provider requires tagged reasoning/final output instead of
* native structured reasoning fields.
*/
resolveReasoningOutputMode?: (
ctx: ProviderReasoningOutputModeContext,
) => ProviderReasoningOutputMode | null | undefined;
/**
* Provider-owned extra-param normalization before generic stream option
* wrapping.

View File

@ -1,17 +1,45 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveProviderReasoningOutputModeWithPlugin } from "../plugins/provider-runtime.js";
import type { ProviderRuntimeModel } from "../plugins/types.js";
/**
* Utility functions for provider-specific logic and capabilities.
*/
/**
* Returns true if the provider requires reasoning to be wrapped in tags
* (e.g. <think> and <final>) in the text stream, rather than using native
* API fields for reasoning/thinking.
*/
export function isReasoningTagProvider(provider: string | undefined | null): boolean {
export function resolveReasoningOutputMode(params: {
provider: string | undefined | null;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
modelId?: string;
modelApi?: string | null;
model?: ProviderRuntimeModel;
}): "native" | "tagged" {
const provider = params.provider?.trim();
if (!provider) {
return false;
return "native";
}
const normalized = provider.trim().toLowerCase();
const pluginMode = resolveProviderReasoningOutputModeWithPlugin({
provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
context: {
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
provider,
modelId: params.modelId,
modelApi: params.modelApi,
model: params.model,
},
});
if (pluginMode) {
return pluginMode;
}
const normalized = provider.toLowerCase();
// Check for exact matches or known prefixes/substrings for reasoning providers.
// Note: Ollama is intentionally excluded - its OpenAI-compatible endpoint
@ -23,13 +51,42 @@ export function isReasoningTagProvider(provider: string | undefined | null): boo
normalized === "google-gemini-cli" ||
normalized === "google-generative-ai"
) {
return true;
return "tagged";
}
// Handle Minimax (M2.5 is chatty/reasoning-like)
if (normalized.includes("minimax")) {
return true;
return "tagged";
}
return false;
return "native";
}
/**
* Returns true if the provider requires reasoning to be wrapped in tags
* (e.g. <think> and <final>) in the text stream, rather than using native
* API fields for reasoning/thinking.
*/
export function isReasoningTagProvider(
provider: string | undefined | null,
options?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
modelId?: string;
modelApi?: string | null;
model?: ProviderRuntimeModel;
},
): boolean {
return (
resolveReasoningOutputMode({
provider,
config: options?.config,
workspaceDir: options?.workspaceDir,
env: options?.env,
modelId: options?.modelId,
modelApi: options?.modelApi,
model: options?.model,
}) === "tagged"
);
}