feat(ollama): add text-to-tool-call fallback for broken models
Ollama models like qwen2.5-coder emit tool call JSON as plain text instead of structured tool_calls. This streamFn wrapper detects <tool_call> tags, fenced JSON, and bare JSON matching known tool names and re-emits them as proper toolcall_* events.
This commit is contained in:
parent
cc55a6a81a
commit
994452f201
192
src/agents/ollama-tool-call-fixer.ts
Normal file
192
src/agents/ollama-tool-call-fixer.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||||
|
import type {
|
||||||
|
Api,
|
||||||
|
AssistantMessage,
|
||||||
|
AssistantMessageEvent,
|
||||||
|
AssistantMessageEventStream,
|
||||||
|
Model,
|
||||||
|
TextContent,
|
||||||
|
ToolCall,
|
||||||
|
} from "@mariozechner/pi-ai";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("agent/ollama-tool-call-fixer");
|
||||||
|
|
||||||
|
const TOOL_CALL_TAG_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
||||||
|
const JSON_FENCED_RE = /```json\s*([\s\S]*?)\s*```/g;
|
||||||
|
|
||||||
|
function extractToolCallsFromText(
|
||||||
|
text: string,
|
||||||
|
toolNames: Set<string>,
|
||||||
|
): Array<{ name: string; arguments: Record<string, unknown> }> | null {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
|
||||||
|
for (const m of text.matchAll(TOOL_CALL_TAG_RE)) {
|
||||||
|
candidates.push(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
for (const m of text.matchAll(JSON_FENCED_RE)) {
|
||||||
|
candidates.push(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||||
|
candidates.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
const results: Array<{ name: string; arguments: Record<string, unknown> }> = [];
|
||||||
|
for (const raw of candidates) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw.trim());
|
||||||
|
const items = Array.isArray(parsed) ? (parsed as unknown[]) : [parsed];
|
||||||
|
for (const item of items) {
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
"name" in item &&
|
||||||
|
typeof (item as { name: unknown }).name === "string" &&
|
||||||
|
toolNames.has((item as { name: string }).name)
|
||||||
|
) {
|
||||||
|
const obj = item as { name: string; arguments?: unknown };
|
||||||
|
results.push({
|
||||||
|
name: obj.name,
|
||||||
|
arguments:
|
||||||
|
obj.arguments && typeof obj.arguments === "object"
|
||||||
|
? (obj.arguments as Record<string, unknown>)
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.length > 0 ? results : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToolCallId(): string {
|
||||||
|
return `ollama_tc_${crypto.randomBytes(8).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOllamaProvider(model: Model<Api> | undefined | null): boolean {
|
||||||
|
const m = model as { provider?: string } | undefined | null;
|
||||||
|
return m?.provider === "ollama";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OllamaToolCallFixer = {
|
||||||
|
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a streamFn wrapper that detects tool-call JSON emitted as plain
|
||||||
|
* text by Ollama models and re-emits them as proper toolcall_* events.
|
||||||
|
*
|
||||||
|
* Consumes the inner stream fully, inspects the final result, and if the
|
||||||
|
* text content matches a known tool schema, replays with fixed events.
|
||||||
|
*/
|
||||||
|
export function createOllamaToolCallFixer(): OllamaToolCallFixer {
|
||||||
|
const wrapStreamFn = (inner: StreamFn): StreamFn => {
|
||||||
|
const wrapped: StreamFn = (model, context, options) => {
|
||||||
|
if (!isOllamaProvider(model as Model<Api>)) {
|
||||||
|
return inner(model, context, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolNames = new Set((context.tools ?? []).map((t) => t.name));
|
||||||
|
if (toolNames.size === 0) {
|
||||||
|
return inner(model, context, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawStream = inner(model, context, options);
|
||||||
|
|
||||||
|
// Dynamic import from the internal path where the class is a real value export.
|
||||||
|
return (async () => {
|
||||||
|
const { AssistantMessageEventStream: StreamClass } =
|
||||||
|
await import("@mariozechner/pi-ai/dist/utils/event-stream.js");
|
||||||
|
const innerStream: AssistantMessageEventStream =
|
||||||
|
rawStream instanceof Promise ? await rawStream : rawStream;
|
||||||
|
|
||||||
|
const buffered: AssistantMessageEvent[] = [];
|
||||||
|
for await (const event of innerStream) {
|
||||||
|
buffered.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await innerStream.result();
|
||||||
|
|
||||||
|
const textBlocks = result.content.filter((b): b is TextContent => b.type === "text");
|
||||||
|
const fullText = textBlocks.map((b) => b.text).join("");
|
||||||
|
const extracted = extractToolCallsFromText(fullText, toolNames);
|
||||||
|
|
||||||
|
const outerStream = new StreamClass() as AssistantMessageEventStream;
|
||||||
|
|
||||||
|
if (!extracted || extracted.length === 0) {
|
||||||
|
for (const event of buffered) {
|
||||||
|
outerStream.push(event);
|
||||||
|
}
|
||||||
|
return outerStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("detected tool calls in text content, converting", {
|
||||||
|
provider: (model as { provider?: string }).provider,
|
||||||
|
model: (model as { id?: string }).id,
|
||||||
|
toolCalls: extracted.map((tc) => tc.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixedToolCalls: ToolCall[] = extracted.map((tc) => ({
|
||||||
|
type: "toolCall" as const,
|
||||||
|
id: makeToolCallId(),
|
||||||
|
name: tc.name,
|
||||||
|
arguments: tc.arguments,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fixedResult: AssistantMessage = {
|
||||||
|
...result,
|
||||||
|
content: fixedToolCalls,
|
||||||
|
stopReason: "toolUse",
|
||||||
|
};
|
||||||
|
|
||||||
|
outerStream.push({ type: "start", partial: fixedResult });
|
||||||
|
|
||||||
|
for (let i = 0; i < fixedToolCalls.length; i++) {
|
||||||
|
const tc = fixedToolCalls[i];
|
||||||
|
const partial: AssistantMessage = {
|
||||||
|
...fixedResult,
|
||||||
|
content: fixedToolCalls.slice(0, i + 1),
|
||||||
|
};
|
||||||
|
outerStream.push({ type: "toolcall_start", contentIndex: i, partial });
|
||||||
|
outerStream.push({
|
||||||
|
type: "toolcall_delta",
|
||||||
|
contentIndex: i,
|
||||||
|
delta: JSON.stringify(tc.arguments),
|
||||||
|
partial,
|
||||||
|
});
|
||||||
|
outerStream.push({
|
||||||
|
type: "toolcall_end",
|
||||||
|
contentIndex: i,
|
||||||
|
toolCall: tc,
|
||||||
|
partial,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
outerStream.push({
|
||||||
|
type: "done",
|
||||||
|
reason: "toolUse",
|
||||||
|
message: fixedResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
return outerStream;
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
return wrapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { wrapStreamFn };
|
||||||
|
}
|
||||||
@ -22,6 +22,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
|||||||
import { resolveUserPath } from "../../../utils.js";
|
import { resolveUserPath } from "../../../utils.js";
|
||||||
import { createCacheTrace } from "../../cache-trace.js";
|
import { createCacheTrace } from "../../cache-trace.js";
|
||||||
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
|
||||||
|
import { createOllamaToolCallFixer } from "../../ollama-tool-call-fixer.js";
|
||||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||||
@ -514,6 +515,10 @@ export async function runEmbeddedAttempt(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fix Ollama models that emit tool calls as plain text instead of tool_calls.
|
||||||
|
const ollamaToolCallFixer = createOllamaToolCallFixer();
|
||||||
|
activeSession.agent.streamFn = ollamaToolCallFixer.wrapStreamFn(activeSession.agent.streamFn);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const prior = await sanitizeSessionHistory({
|
const prior = await sanitizeSessionHistory({
|
||||||
messages: activeSession.messages,
|
messages: activeSession.messages,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user