diff --git a/CHANGELOG.md b/CHANGELOG.md index e99c2ee6270..3b5522f8531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index e7c869b98e9..0a24e5fe08f 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 1a24e85e0e4..0a797b49579 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index ef4a91ef49f..39ff44b3058 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -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[]; provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + modelId?: string; + modelApi?: string | null; + model?: ProviderRuntimeModel; }): AgentTool[] { + 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[]; + } + // 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; 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 { + 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; } diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 8cb2549d301..3863f1cc733 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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), diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 8dcd3acd14f..36b019127dc 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -24,6 +24,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({ return undefined; } }), + resolveProviderReplayPolicyWithPlugin: vi.fn(() => undefined), resetProviderRuntimeHookCacheForTest: vi.fn(), })); diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index 7a8d99b5dd6..a3d0c6a0458 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -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 } + : {}), + }; } diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index ccf91e1b6a4..f9579e2fcbd 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -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 { diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 876b46c6fa4..01cd48727b1 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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 } + : {}), }, }; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 3a971bab13a..af9c0c19a34 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -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"; diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index f88041ae3b3..c6bc083731f 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -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, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index d67e22de727..ba9e44a1740 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -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 = {}, @@ -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): Promise => [ + ...messages, + DEMO_SANITIZED_MESSAGE, + ], + ); + const validateReplayTurns = vi.fn( + async ({ + messages, + }: Pick): Promise => messages, + ); + const normalizeToolSchemas = vi.fn( + ({ tools }: Pick): 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, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index f89fda34e9c..73f86dc50b3 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -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; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 2f9c6ed1cb7..03250d1ddef 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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; +}; + +/** + * 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; + /** + * 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; + /** + * 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; + /** + * 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. diff --git a/src/utils/provider-utils.ts b/src/utils/provider-utils.ts index af7efeda042..ef2931fab0b 100644 --- a/src/utils/provider-utils.ts +++ b/src/utils/provider-utils.ts @@ -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. and ) 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. and ) 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" + ); }