mirror of https://github.com/openclaw/openclaw.git
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:
parent
ca76e2fedc
commit
71346940ad
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
|||
return undefined;
|
||||
}
|
||||
}),
|
||||
resolveProviderReplayPolicyWithPlugin: vi.fn(() => undefined),
|
||||
resetProviderRuntimeHookCacheForTest: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue