From 1b13f53047ee78f2318e60b7f02b214934661cae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 11:20:08 +0100 Subject: [PATCH] fix(ollama): reject garbled Kimi symbol output --- CHANGELOG.md | 1 + docs/providers/ollama.md | 12 ++++ extensions/ollama/src/stream-runtime.test.ts | 63 ++++++++++++++++++++ extensions/ollama/src/stream.ts | 54 +++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3752d85d72f..4243813efaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk. - CLI/model probes: keep `infer model run --gateway` raw by skipping prior session transcript, bootstrap context, context-engine assembly, tools, and bundled MCP servers, so local backends can be tested without full agent-context overhead. Fixes #73308. Thanks @ScientificProgrammer. - CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Addresses #63700. Thanks @cedricjanssens. +- Providers/Ollama: reject long non-linguistic Kimi/GLM symbol runs as provider failures instead of storing them as successful visible assistant replies, so fallback or error handling can recover from garbled cloud output. Fixes #64262; refs #67019. Thanks @Kloz813 and @xiaomenger123. - CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge. - Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah. - Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj. diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index c6a243b4347..f2784313faf 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -1062,6 +1062,18 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s + + Hosted Kimi/GLM responses that are long, non-linguistic symbol runs are treated as failed provider output instead of a successful assistant answer. That lets normal retry, fallback, or error handling take over without persisting the corrupted text into the session. + + If it happens repeatedly, capture the raw model name, the current session file, and whether the run used `Cloud + Local` or `Cloud only`, then try a fresh session and a fallback model: + + ```bash + openclaw infer model run --model ollama/kimi-k2.5:cloud --prompt "Reply with exactly: ok" --json + openclaw models set ollama/gemma4 + ``` + + + Large local models can need a long first load before streaming begins. Keep the timeout scoped to the Ollama provider, and optionally ask Ollama to keep the model loaded between turns: diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index bf4bbbe0e8d..840e14f68ca 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1297,6 +1297,69 @@ describe("createOllamaStreamFn streaming events", () => { ); }); + it("emits an error instead of accepting garbled Kimi visible text", async () => { + const garbled = + '$$"##"%#"##"####""$""""##""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' + + '#"$"$"""$""""#$"""$"""%"%###"""#%""""&"#"""$"""#"#""""%#""""&"#"""$"""$"""#%"""'; + await withMockNdjsonFetch( + [ + JSON.stringify({ + model: "kimi-k2.5:cloud", + created_at: "t", + message: { role: "assistant", content: garbled }, + done: false, + }), + '{"model":"kimi-k2.5:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":20,"eval_count":40}', + ], + async () => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + model: { id: "kimi-k2.5:cloud", provider: "ollama" }, + }); + const events = await collectStreamEvents(stream); + + const types = events.map((e) => e.type); + expect(types).toEqual(["start", "text_start", "text_delta", "error"]); + const errorEvent = events.at(-1); + expect(errorEvent).toMatchObject({ + type: "error", + error: expect.objectContaining({ + errorMessage: expect.stringContaining("garbled visible text"), + }), + }); + }, + ); + }); + + it("does not reject punctuation-heavy text from unrelated Ollama models", async () => { + const punctuationHeavy = + '$$"##"%#"##"####""$""""##""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' + + '#"$"$"""$""""#$"""$"""%"%###"""#%""""&"#"""$"""#"#""""%#""""&"#"""$"""$"""#%"""'; + await withMockNdjsonFetch( + [ + JSON.stringify({ + model: "qwen3:32b", + created_at: "t", + message: { role: "assistant", content: punctuationHeavy }, + done: false, + }), + '{"model":"qwen3:32b","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":20,"eval_count":40}', + ], + async () => { + const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" }); + const events = await collectStreamEvents(stream); + + expect(events.map((e) => e.type)).toEqual([ + "start", + "text_start", + "text_delta", + "text_end", + "done", + ]); + }, + ); + }); + it("emits a single text_delta for single-chunk responses", async () => { await withMockNdjsonFetch( [ diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 15e0b591f48..98b6f6d4a4f 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -41,6 +41,54 @@ const log = createSubsystemLogger("ollama-stream"); export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; +const GARBLED_VISIBLE_TEXT_MODEL_RE = /\b(?:glm|kimi)\b/i; +const GARBLED_VISIBLE_TEXT_MIN_CHARS = 80; +const GARBLED_VISIBLE_TEXT_SYMBOL_RE = /[$#%&="'_~`^|\\/*+\-[\]{}()<>:;,.!?]/gu; +const LETTER_OR_DIGIT_RE = /[\p{L}\p{N}]/gu; + +function countMatches(text: string, re: RegExp): number { + re.lastIndex = 0; + return Array.from(text.matchAll(re)).length; +} + +function maxCharacterFrequency(text: string): number { + const counts = new Map(); + let max = 0; + for (const char of text) { + const count = (counts.get(char) ?? 0) + 1; + counts.set(char, count); + max = Math.max(max, count); + } + return max; +} + +function isKnownOllamaGarbledVisibleTextModel(modelId: string): boolean { + return GARBLED_VISIBLE_TEXT_MODEL_RE.test(modelId); +} + +function isLikelyGarbledVisibleText(params: { text: string; modelId: string }): boolean { + if (!isKnownOllamaGarbledVisibleTextModel(params.modelId)) { + return false; + } + const compact = params.text.replace(/\s+/g, ""); + if (compact.length < GARBLED_VISIBLE_TEXT_MIN_CHARS) { + return false; + } + + const letterOrDigitCount = countMatches(compact, LETTER_OR_DIGIT_RE); + const symbolCount = countMatches(compact, GARBLED_VISIBLE_TEXT_SYMBOL_RE); + const maxFrequency = maxCharacterFrequency(compact); + const letterOrDigitRatio = letterOrDigitCount / compact.length; + const symbolRatio = symbolCount / compact.length; + const dominantCharacterRatio = maxFrequency / compact.length; + + return ( + letterOrDigitRatio < 0.08 && + symbolRatio > 0.6 && + (dominantCharacterRatio > 0.22 || /[$#%&="'_~`^|\\/*+\-[\]{}()<>:;,.!?]{12,}/u.test(compact)) + ); +} + export function resolveOllamaBaseUrlForRun(params: { modelBaseUrl?: string; providerBaseUrl?: string; @@ -1129,6 +1177,12 @@ export function createOllamaStreamFn( throw new Error("Ollama API stream ended without a final response"); } + if (isLikelyGarbledVisibleText({ text: accumulatedContent, modelId: model.id })) { + throw new Error( + `Ollama returned non-linguistic garbled visible text for ${model.id}; retry or switch models`, + ); + } + finalResponse.message.content = accumulatedContent; if (accumulatedThinking) { finalResponse.message.thinking = accumulatedThinking;