feat(ollama): add loop prevention to tool-call fixer

This commit is contained in:
Baek, JH 2026-01-27 09:53:51 -08:00
parent 994452f201
commit 4740bfe4bb

View File

@ -82,6 +82,9 @@ function isOllamaProvider(model: Model<Api> | undefined | null): boolean {
return m?.provider === "ollama"; return m?.provider === "ollama";
} }
/** Max consecutive turns where the same tool set is called before we stop converting. */
const MAX_REPEAT_TURNS = 3;
export type OllamaToolCallFixer = { export type OllamaToolCallFixer = {
wrapStreamFn: (streamFn: StreamFn) => StreamFn; wrapStreamFn: (streamFn: StreamFn) => StreamFn;
}; };
@ -90,11 +93,16 @@ export type OllamaToolCallFixer = {
* Creates a streamFn wrapper that detects tool-call JSON emitted as plain * Creates a streamFn wrapper that detects tool-call JSON emitted as plain
* text by Ollama models and re-emits them as proper toolcall_* events. * text by Ollama models and re-emits them as proper toolcall_* events.
* *
* Consumes the inner stream fully, inspects the final result, and if the * Includes loop prevention: if the model calls the same tool(s) for
* text content matches a known tool schema, replays with fixed events. * {@link MAX_REPEAT_TURNS} consecutive turns, conversion is skipped so
* the raw text passes through instead of feeding an infinite tool loop.
*/ */
export function createOllamaToolCallFixer(): OllamaToolCallFixer { export function createOllamaToolCallFixer(): OllamaToolCallFixer {
const wrapStreamFn = (inner: StreamFn): StreamFn => { const wrapStreamFn = (inner: StreamFn): StreamFn => {
// Cross-turn state for loop detection.
let lastToolKey = "";
let repeatCount = 0;
const wrapped: StreamFn = (model, context, options) => { const wrapped: StreamFn = (model, context, options) => {
if (!isOllamaProvider(model as Model<Api>)) { if (!isOllamaProvider(model as Model<Api>)) {
return inner(model, context, options); return inner(model, context, options);
@ -128,6 +136,35 @@ export function createOllamaToolCallFixer(): OllamaToolCallFixer {
const outerStream = new StreamClass() as AssistantMessageEventStream; const outerStream = new StreamClass() as AssistantMessageEventStream;
if (!extracted || extracted.length === 0) { if (!extracted || extracted.length === 0) {
// Model produced plain text — reset loop counter.
lastToolKey = "";
repeatCount = 0;
for (const event of buffered) {
outerStream.push(event);
}
return outerStream;
}
// Loop detection: build a stable key from sorted tool names.
const toolKey = extracted
.map((tc) => tc.name)
.sort()
.join(",");
if (toolKey === lastToolKey) {
repeatCount++;
} else {
lastToolKey = toolKey;
repeatCount = 1;
}
if (repeatCount > MAX_REPEAT_TURNS) {
log.warn("loop detected, skipping tool-call conversion", {
toolKey,
repeatCount,
threshold: MAX_REPEAT_TURNS,
});
// Pass through original text so the user sees raw model output.
for (const event of buffered) { for (const event of buffered) {
outerStream.push(event); outerStream.push(event);
} }
@ -138,6 +175,7 @@ export function createOllamaToolCallFixer(): OllamaToolCallFixer {
provider: (model as { provider?: string }).provider, provider: (model as { provider?: string }).provider,
model: (model as { id?: string }).id, model: (model as { id?: string }).id,
toolCalls: extracted.map((tc) => tc.name), toolCalls: extracted.map((tc) => tc.name),
repeatCount,
}); });
const fixedToolCalls: ToolCall[] = extracted.map((tc) => ({ const fixedToolCalls: ToolCall[] = extracted.map((tc) => ({