fix(ollama): preserve tool call ids [AI-assisted] (#84855)

Summary:
- The PR preserves native Ollama tool-call IDs through ingest and replay, opts native Ollama out of strict replay ID sanitization, and adds focused regression tests plus a changelog entry.
- Reproducibility: yes. Current main drops native Ollama tool-call IDs on ingest and replay and applies strict ...  PR discussion includes a maintainer-side before/after probe that reproduced the source-level failure path.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(ollama): keep native tool ids through replay

Validation:
- ClawSweeper review passed for head bb9fef7d4c.
- Required merge gates passed before the squash merge.

Prepared head SHA: bb9fef7d4c
Review: https://github.com/openclaw/openclaw/pull/84855#issuecomment-4505423891

Co-authored-by: IWhatsskill <whatsskilll@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: osolmaz
Co-authored-by: osolmaz <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
WhatsSkiLL
2026-05-21 09:51:00 +00:00
committed by GitHub
co-authored by clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> osolmaz
parent f43e83c937
commit 2000227e9e
8 changed files with 150 additions and 26 deletions
+1
View File
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- fix(config): validate browser sandbox bind sources [AI]. (#84799) Thanks @pgondhi987.
- doctor: constrain legacy plugin cleanup paths [AI]. (#84801) Thanks @pgondhi987.
- Update/doctor: prune stale local bundled plugin install records that point at old compiled bundled output so current bundled plugin schemas win after upgrade. (#84863) Thanks @fuller-stack-dev.
- Providers/Ollama: preserve native Ollama tool-call IDs across assistant replay so Gemini over Ollama Cloud can keep its hidden function-call thought-signature handle.
- PDF tool: time out idle remote PDF body reads after 120 seconds so stalled remote documents return an error instead of wedging the session. Fixes #68649. (#84768) Thanks @luoyanglang.
- Diagnostics/OpenTelemetry plugin: suppress handled OTLP exporter promise rejections so collector shutdowns no longer crash the Gateway. (#81085) Thanks @luoyanglang.
- Media/audio: skip empty structured sherpa-onnx transcripts instead of treating the raw JSON payload as spoken text. (#84667) Thanks @TurboTheTurtle.
+2 -2
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);
+11 -1
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,
+77 -15
View File
@@ -693,7 +693,27 @@ describe("convertToOllamaMessages", () => {
expect(result[0].role).toBe("assistant");
expect(result[0].content).toBe("Let me check.");
expect(result[0].tool_calls).toEqual([
{ function: { name: "bash", arguments: { command: "ls" } } },
{ id: "call_1", function: { name: "bash", arguments: { command: "ls" } } },
]);
});
it("preserves assistant tool-call ids before Ollama replay", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "toolCall",
id: "fc_ollama_123",
name: "bash",
arguments: { command: "pwd" },
},
],
},
];
const result = convertToOllamaMessages(messages);
expect(result[0].tool_calls).toEqual([
{ id: "fc_ollama_123", function: { name: "bash", arguments: { command: "pwd" } } },
]);
});
@@ -709,8 +729,8 @@ describe("convertToOllamaMessages", () => {
];
const result = convertToOllamaMessages(messages);
expect(result[0].tool_calls).toEqual([
{ function: { name: "exec", arguments: { command: "pwd" } } },
{ function: { name: "read", arguments: { path: "README.md" } } },
{ id: "call_1", function: { name: "exec", arguments: { command: "pwd" } } },
{ id: "call_2", function: { name: "read", arguments: { path: "README.md" } } },
]);
});
@@ -729,9 +749,9 @@ describe("convertToOllamaMessages", () => {
availableToolNames: new Set(["tool_a", "tools_invoke_test", "function-run"]),
});
expect(result[0].tool_calls).toEqual([
{ function: { name: "tool_a", arguments: { value: 1 } } },
{ function: { name: "tools_invoke_test", arguments: { value: 2 } } },
{ function: { name: "function-run", arguments: { value: 3 } } },
{ id: "call_1", function: { name: "tool_a", arguments: { value: 1 } } },
{ id: "call_2", function: { name: "tools_invoke_test", arguments: { value: 2 } } },
{ id: "call_3", function: { name: "function-run", arguments: { value: 3 } } },
]);
});
@@ -750,9 +770,9 @@ describe("convertToOllamaMessages", () => {
availableToolNames: new Set(["exec", "read"]),
});
expect(result[0].tool_calls).toEqual([
{ function: { name: "exec", arguments: { command: "pwd" } } },
{ function: { name: "read", arguments: { path: "." } } },
{ function: { name: "tool_missing", arguments: {} } },
{ id: "call_1", function: { name: "exec", arguments: { command: "pwd" } } },
{ id: "call_2", function: { name: "read", arguments: { path: "." } } },
{ id: "call_3", function: { name: "tool_missing", arguments: {} } },
]);
});
@@ -770,10 +790,10 @@ describe("convertToOllamaMessages", () => {
];
const result = convertToOllamaMessages(messages);
expect(result[0].tool_calls).toEqual([
{ function: { name: "functionshell", arguments: {} } },
{ function: { name: "tooling", arguments: {} } },
{ function: { name: "tools", arguments: {} } },
{ function: { name: "tool_a", arguments: {} } },
{ id: "call_1", function: { name: "functionshell", arguments: {} } },
{ id: "call_2", function: { name: "tooling", arguments: {} } },
{ id: "call_3", function: { name: "tools", arguments: {} } },
{ id: "call_4", function: { name: "tool_a", arguments: {} } },
]);
});
@@ -795,7 +815,7 @@ describe("convertToOllamaMessages", () => {
];
const result = convertToOllamaMessages(messages);
expect(result[0].tool_calls).toEqual([
{ function: { name: "Read", arguments: { file_path: "/tmp/test.txt" } } },
{ id: "call_2", function: { name: "Read", arguments: { file_path: "/tmp/test.txt" } } },
]);
});
@@ -810,7 +830,7 @@ describe("convertToOllamaMessages", () => {
];
const result = convertToOllamaMessages(messages);
expect(result[0].tool_calls).toEqual([
{ function: { name: "exec", arguments: { command: "echo hello" } } },
{ id: "toolu_1", function: { name: "exec", arguments: { command: "echo hello" } } },
]);
});
@@ -831,6 +851,7 @@ describe("convertToOllamaMessages", () => {
const result = convertToOllamaMessages(messages);
expect(result[0].tool_calls).toEqual([
{
id: "call_3",
function: {
name: "read",
arguments: {
@@ -981,6 +1002,47 @@ describe("buildAssistantMessage", () => {
expect(toolCall.id).toMatch(/^ollama_call_[0-9a-f-]{36}$/);
});
it("preserves Ollama response tool-call ids", () => {
const response = {
model: "gemini-3-flash-preview:cloud",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content: "",
tool_calls: [
{ id: "fc_ollama_real_1", function: { name: "bash", arguments: { command: "pwd" } } },
],
},
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expectToolCallContent(result.content[0], { name: "bash", arguments: { command: "pwd" } });
expect((result.content[0] as { id?: string }).id).toBe("fc_ollama_real_1");
});
it("preserves parallel Ollama response tool-call ids independently", () => {
const response = {
model: "gemini-3-flash-preview:cloud",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content: "",
tool_calls: [
{ id: "fc_ollama_real_1", function: { name: "read", arguments: { path: "a.txt" } } },
{ id: "fc_ollama_real_2", function: { name: "exec", arguments: { command: "date" } } },
],
},
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expect(result.content.map((part) => (part as { id?: string }).id)).toEqual([
"fc_ollama_real_1",
"fc_ollama_real_2",
]);
expectToolCallContent(result.content[0], { name: "read", arguments: { path: "a.txt" } });
expectToolCallContent(result.content[1], { name: "exec", arguments: { command: "date" } });
});
it("normalizes provider-prefixed tool-call names in Ollama responses", () => {
const response = {
model: "qwen3:32b",
+10 -1
View File
@@ -534,6 +534,7 @@ interface OllamaTool {
}
interface OllamaToolCall {
id?: string;
function: {
name: string;
arguments: Record<string, unknown> | string;
@@ -789,6 +790,10 @@ type OllamaToolCallNameOptions = {
availableToolNames?: ReadonlySet<string>;
};
function readOllamaToolCallId(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function extractToolCalls(
content: unknown,
options: OllamaToolCallNameOptions = {},
@@ -800,14 +805,18 @@ function extractToolCalls(
const result: OllamaToolCall[] = [];
for (const part of parts) {
if (part.type === "toolCall") {
const id = readOllamaToolCallId(part.id);
result.push({
...(id ? { id } : {}),
function: {
name: normalizeOllamaToolCallName(part.name, options),
arguments: ensureArgsObject(part.arguments),
},
});
} else if (part.type === "tool_use") {
const id = readOllamaToolCallId(part.id);
result.push({
...(id ? { id } : {}),
function: {
name: normalizeOllamaToolCallName(part.name, options),
arguments: ensureArgsObject(part.input),
@@ -952,7 +961,7 @@ export function buildAssistantMessage(
for (const toolCall of toolCalls) {
content.push({
type: "toolCall",
id: `ollama_call_${randomUUID()}`,
id: readOllamaToolCallId(toolCall.id) ?? `ollama_call_${randomUUID()}`,
name: normalizeOllamaToolCallName(toolCall.function.name, options),
arguments: normalizeOllamaToolCallArguments(toolCall.function.arguments),
});
@@ -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[] = [
{
@@ -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;
+8 -6
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;