fix(ollama): keep native tool ids through replay

This commit is contained in:
IWhatsskill
2026-05-21 09:16:53 +02:00
committed by clawsweeper
parent f4102813a3
commit bb9fef7d4c
5 changed files with 62 additions and 10 deletions

View File

@@ -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);

View File

@@ -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<string, ProviderRuntimeModel[]>();
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,

View File

@@ -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<AgentMessage, { role: "assistant" }>;
type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
@@ -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[] = [
{

View File

@@ -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;

View File

@@ -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;