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:
Baek, JH 2026-01-27 09:40:06 -08:00
parent cc55a6a81a
commit 994452f201
2 changed files with 197 additions and 0 deletions

View 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 };
}

View File

@ -22,6 +22,7 @@ import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { createCacheTrace } from "../../cache-trace.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { createOllamaToolCallFixer } from "../../ollama-tool-call-fixer.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.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 {
const prior = await sanitizeSessionHistory({
messages: activeSession.messages,