diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 6930bd6bc8e..bbebf7928d5 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -783,8 +783,8 @@ describe("ollama plugin", () => { modelApi: "ollama", modelId: "qwen3.5:9b", } as never); - expect(nativePolicy?.sanitizeToolCallIds).toBe(true); - expect(nativePolicy?.toolCallIdMode).toBe("strict"); + expect(nativePolicy?.sanitizeToolCallIds).toBe(false); + expect(nativePolicy?.toolCallIdMode).toBeUndefined(); expect(nativePolicy?.applyAssistantFirstOrderingFix).toBe(true); expect(nativePolicy?.validateGeminiTurns).toBe(true); expect(nativePolicy?.validateAnthropicTurns).toBe(true); diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index a44360d888f..f32cbb056bf 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -7,6 +7,7 @@ import { type ProviderAuthMethodNonInteractiveContext, type ProviderAuthResult, type ProviderCatalogContext, + type ProviderReplayPolicy, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth"; @@ -66,6 +67,15 @@ function usesOllamaOpenAICompatTransport(model: { ); } +function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy { + return { + ...buildOpenAICompatibleReplayPolicy("openai-completions", { + sanitizeToolCallIds: false, + }), + sanitizeToolCallIds: false, + }; +} + const dynamicModelCache = new Map(); function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string { @@ -245,7 +255,7 @@ export default definePluginEntry({ ...OPENAI_COMPATIBLE_REPLAY_HOOKS, buildReplayPolicy: (ctx) => ctx.modelApi === "ollama" - ? buildOpenAICompatibleReplayPolicy("openai-completions") + ? buildNativeOllamaReplayPolicy() : buildOpenAICompatibleReplayPolicy(ctx.modelApi), contributeResolvedModelCompat: ({ model }) => usesOllamaOpenAICompatTransport(model) ? { supportsUsageInStreaming: true } : undefined, diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts index 9a7b9e9b6fd..00d894a42a1 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.test.ts @@ -1,6 +1,9 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; -import { sanitizeReplayToolCallIdsForStream } from "./attempt.tool-call-normalization.js"; +import { + sanitizeReplayToolCallIdsForStream, + shouldApplyReplayToolCallIdSanitizer, +} from "./attempt.tool-call-normalization.js"; type AssistantMessage = Extract; type ToolResultMessage = Extract; @@ -47,6 +50,29 @@ function toolResultSummary(message: AgentMessage | undefined) { } describe("sanitizeReplayToolCallIdsForStream", () => { + it("skips strict stream id sanitization when provider policy opts out", () => { + expect( + shouldApplyReplayToolCallIdSanitizer({ + sanitizeToolCallIds: false, + isOpenAIResponsesApi: false, + }), + ).toBe(false); + expect( + shouldApplyReplayToolCallIdSanitizer({ + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + isOpenAIResponsesApi: false, + }), + ).toBe(true); + expect( + shouldApplyReplayToolCallIdSanitizer({ + sanitizeToolCallIds: true, + toolCallIdMode: "strict", + isOpenAIResponsesApi: true, + }), + ).toBe(false); + }); + it("drops orphaned tool results after strict id sanitization", () => { const messages: AgentMessage[] = [ { diff --git a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts index a0541fdfa09..6977205379d 100644 --- a/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts +++ b/src/agents/pi-embedded-runner/run/attempt.tool-call-normalization.ts @@ -879,6 +879,20 @@ export function wrapStreamFnTrimToolCallNames( }; } +type ReplayToolCallIdSanitizerDecision = { + sanitizeToolCallIds: boolean; + toolCallIdMode?: ToolCallIdMode; + isOpenAIResponsesApi: boolean; +}; + +export function shouldApplyReplayToolCallIdSanitizer( + params: ReplayToolCallIdSanitizerDecision, +): params is ReplayToolCallIdSanitizerDecision & { toolCallIdMode: ToolCallIdMode } { + return ( + params.sanitizeToolCallIds && Boolean(params.toolCallIdMode) && !params.isOpenAIResponsesApi + ); +} + export function sanitizeReplayToolCallIdsForStream(params: { messages: AgentMessage[]; mode: ToolCallIdMode; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 8439eec68aa..1dec46da32f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -355,6 +355,7 @@ import { wrapStreamFnRepairMalformedToolCallArguments, } from "./attempt.tool-call-argument-repair.js"; import { + shouldApplyReplayToolCallIdSanitizer, sanitizeReplayToolCallIdsForStream, wrapStreamFnSanitizeMalformedToolCalls, wrapStreamFnTrimToolCallNames, @@ -2779,13 +2780,14 @@ export async function runEmbeddedAttempt( params.model.api === "azure-openai-responses" || params.model.api === "openai-codex-responses"; - if ( - transcriptPolicy.sanitizeToolCallIds && - transcriptPolicy.toolCallIdMode && - !isOpenAIResponsesApi - ) { + const replayToolCallIdSanitizerDecision = { + sanitizeToolCallIds: transcriptPolicy.sanitizeToolCallIds, + toolCallIdMode: transcriptPolicy.toolCallIdMode, + isOpenAIResponsesApi, + }; + if (shouldApplyReplayToolCallIdSanitizer(replayToolCallIdSanitizerDecision)) { const inner = activeSession.agent.streamFn; - const mode = transcriptPolicy.toolCallIdMode; + const mode = replayToolCallIdSanitizerDecision.toolCallIdMode; activeSession.agent.streamFn = (model, context, options) => { const ctx = context as unknown as { messages?: unknown }; const messages = ctx?.messages;