mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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 headbb9fef7d4c. - Required merge gates passed before the squash merge. Prepared head SHA:bb9fef7d4cReview: 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:
co-authored by
clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
osolmaz
parent
f43e83c937
commit
2000227e9e
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user