From 4ce26cc592ef6c63c9417e6e7a55fb0d38c54baa Mon Sep 17 00:00:00 2001 From: AutoDev Bot Date: Thu, 10 Sep 2026 21:48:42 +0800 Subject: [PATCH] fix(llm): surface truncation at maxTokens instead of misleading schema error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an LLM output is truncated at maxTokens, two cooperating bugs caused a misleading 'title must be a non-empty string' validation error that pointed blame at the model output rather than the real cause. Bug 1 — extractFirstJsonBlock (json-mode.ts): the old loop continued scanning for a nested balanced sub-block when the root opener returned null from walkToClose. This allowed a fully-balanced inner value (e.g. a domain_tags array) to pass JSON.parse successfully, after which the caller's validate() received that nested value and threw a field-level schema error. Fix: stop at the first opener. If walkToClose returns null (root block is truncated), return null immediately rather than hunting for a balanced child. Bug 2 — completeJson (client.ts): finishReason=length was set on LlmCompletion by providers when the token budget was exhausted, but completeJson passed the truncated text straight to parseLlmJson without inspecting it. Fix: check finishReason === 'length' before attempting to parse. Throw LLM_OUTPUT_MALFORMED immediately with finishReason and rawLen in details so the log clearly names the cause. Also adds rawLen to all existing malformed warn rows so token-budget diagnosis is easier without a separate log query. Closes #2356 --- apps/memos-local-plugin/core/llm/client.ts | 36 ++++++++++++ apps/memos-local-plugin/core/llm/json-mode.ts | 10 +++- .../tests/unit/llm/client.test.ts | 57 +++++++++++++++++++ .../tests/unit/llm/json-mode.test.ts | 22 +++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/memos-local-plugin/core/llm/client.ts b/apps/memos-local-plugin/core/llm/client.ts index ee456ac12..89f56bc85 100644 --- a/apps/memos-local-plugin/core/llm/client.ts +++ b/apps/memos-local-plugin/core/llm/client.ts @@ -512,6 +512,41 @@ export function createLlmClientWithProvider( attempt++; const { completion } = await callWithFallback(msgs, call, opts, op); lastRaw = completion.text; + + // Detect output truncation before attempting JSON parsing. When the + // provider signals `finishReason === "length"` the answer is incomplete + // by definition; parsing will fail (or worse: succeed on a nested sub- + // block), causing the caller's schema validator to report a field-level + // error that points the debugger at the model instead of the real cause. + // Surface a dedicated error immediately so the log clearly shows the + // truncation, including rawLen for budget diagnosis. + if (completion.finishReason === "length") { + const truncErr = new MemosError( + ERROR_CODES.LLM_OUTPUT_MALFORMED, + "LLM output truncated at maxTokens (finishReason=length); increase maxTokens or reduce prompt size", + { + provider: provider.name, + op, + finishReason: "length", + rawLen: completion.text.length, + rawPreview: completion.text.slice(0, 512), + }, + ); + lastErr = truncErr; + jsonLog.warn("malformed", { + op, + attempt, + finishReason: "length", + rawLen: completion.text.length, + err: summarizeErr(truncErr), + }); + if (attempt <= maxMalformedRetries) { + retries++; + continue; + } + break; + } + try { const parsed = opts.parse ? opts.parse(completion.text) @@ -537,6 +572,7 @@ export function createLlmClientWithProvider( jsonLog.warn("malformed", { op, attempt, + rawLen: completion.text.length, err: summarizeErr(err), }); if (attempt <= maxMalformedRetries) { diff --git a/apps/memos-local-plugin/core/llm/json-mode.ts b/apps/memos-local-plugin/core/llm/json-mode.ts index 1eccde152..604fdc123 100644 --- a/apps/memos-local-plugin/core/llm/json-mode.ts +++ b/apps/memos-local-plugin/core/llm/json-mode.ts @@ -77,14 +77,20 @@ function stripFences(s: string): string { * Find the first balanced `{…}` or `[…]` block. Returns null when nothing * obvious is found. Naive — but good enough for LLMs that say "Here you go: * {…}" or "I'll return [ …, … ] now." + * + * Stops at the first opener and returns whatever `walkToClose` finds — + * including null when that block is unbalanced (truncated). We deliberately + * do NOT continue scanning for a nested balanced sub-block: silently returning + * a nested value (e.g. a `domain_tags` array from inside a truncated root + * object) would cause the caller's schema validator to report a misleading + * field-level error instead of the real cause (output truncated at maxTokens). */ function extractFirstJsonBlock(s: string): string | null { const openers = ["{", "["]; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (!openers.includes(ch!)) continue; - const match = walkToClose(s, i); - if (match) return match; + return walkToClose(s, i); } return null; } diff --git a/apps/memos-local-plugin/tests/unit/llm/client.test.ts b/apps/memos-local-plugin/tests/unit/llm/client.test.ts index cf891e376..cb2a7813f 100644 --- a/apps/memos-local-plugin/tests/unit/llm/client.test.ts +++ b/apps/memos-local-plugin/tests/unit/llm/client.test.ts @@ -546,6 +546,16 @@ describe("llm/client", () => { expect(s.circuitOpenedReason).toBeNull(); }); + it("LlmClientStats exposes circuit fields when closed", async () => { + const fake = new FakeProvider("openai_compatible", () => ({ text: "ok", durationMs: 1 })); + const client = createLlmClientWithProvider(cfg(), fake); + await client.complete("x"); + const s = client.stats(); + expect(s.circuitOpen).toBe(false); + expect(s.circuitOpenUntil).toBeNull(); + expect(s.circuitOpenedReason).toBeNull(); + }); + it("re-opens the breaker if the half-open probe fails terminally again", async () => { const sink = statusSink(); let now = 1_000_000; @@ -571,4 +581,51 @@ describe("llm/client", () => { expect(provider.calls).toBe(2); }); }); + + it("completeJson throws LLM_OUTPUT_MALFORMED with finishReason=length detail when provider returns finishReason=length", async () => { + const fake = new FakeProvider("openai_compatible", () => ({ + text: '{"title":"x","domain_tags":["a","b"]', + finishReason: "length", + durationMs: 1, + })); + const client = createLlmClientWithProvider(cfg(), fake); + try { + await client.completeJson("ask"); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(MemosError); + expect((err as MemosError).code).toBe(ERROR_CODES.LLM_OUTPUT_MALFORMED); + expect((err as MemosError).details?.finishReason).toBe("length"); + expect((err as MemosError).details?.rawLen).toBeGreaterThan(0); + } + }); + + it("completeJson includes rawLen in malformed error when truncated", async () => { + const rawText = '{"title":"x","domain_tags":["a","b"]'; + const fake = new FakeProvider("openai_compatible", () => ({ + text: rawText, + finishReason: "length", + durationMs: 1, + })); + const client = createLlmClientWithProvider(cfg(), fake); + try { + await client.completeJson("ask"); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(MemosError); + expect((err as MemosError).details?.rawLen).toBe(rawText.length); + } + }); + + it("completeJson succeeds normally when finishReason=stop and JSON is valid", async () => { + const fake = new FakeProvider("openai_compatible", () => ({ + text: '{"title":"Good","items":[1,2,3]}', + finishReason: "stop", + durationMs: 1, + })); + const client = createLlmClientWithProvider(cfg(), fake); + const r = await client.completeJson<{ title: string; items: number[] }>("ask"); + expect(r.value.title).toBe("Good"); + expect(r.value.items).toEqual([1, 2, 3]); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/llm/json-mode.test.ts b/apps/memos-local-plugin/tests/unit/llm/json-mode.test.ts index 5b002b151..aed37184d 100644 --- a/apps/memos-local-plugin/tests/unit/llm/json-mode.test.ts +++ b/apps/memos-local-plugin/tests/unit/llm/json-mode.test.ts @@ -77,4 +77,26 @@ describe("llm/json-mode", () => { expect(h).toMatch(/Expected shape/); expect(h).toMatch(/"a"/); }); + + it("extractFirstJsonBlock stops at the first opener and returns null for truncated root objects", () => { + const raw = '{"title":"Alpine dependency resolution","domain_tags":["network","http"],"environment":[{"label":"Foo","description":"Bar truncated here'; + try { + parseLlmJson(raw); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(MemosError); + expect((err as MemosError).code).toBe("llm_output_malformed"); + } + }); + + it("does not silently return a nested balanced block when outer object is truncated", () => { + const raw = '{"title":"x","items":[1,2,3],"extra":"trunc'; + try { + parseLlmJson(raw); + throw new Error("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(MemosError); + expect((err as MemosError).code).toBe("llm_output_malformed"); + } + }); });