Repository navigation
Conversation
…before JSON
extract_json's no-code-fence fallback took the span from the first '{' to the
last '}'. When a chatty LLM (e.g. local Ollama/LM Studio models) wraps its JSON
in prose that itself contains braces, that span starts inside the prose and is
not valid JSON. json.loads then raises, the outer except in _add_to_vector_store
swallows it, and the extracted memories are silently dropped.
Scan for the first balanced '{...}' substring that actually parses as JSON
instead, walking brace depth while respecting string literals so braces inside
JSON string values are ignored. The previous first-'{'-to-last-'}' span and the
return-as-is behavior remain as fallbacks.
Extends test_chatty_llm_parsing.py with regression cases (prose with braces
before the JSON, and braces inside a JSON string value).
|
Thanks for tracking this down and writing it up so clearly — the repro in the issue is exact and this is a real fix for a real bug. I pulled the branch, ran the new tests, then reverted just the One thing stops me from merging as-is though: the new That's clean O(n^2) scaling. Our default To be clear, I don't think this needs a rewrite — the three-tier fallback shape (new scan -> old naive span -> as-is) is the right, minimal design and I'd keep it exactly as is. The fix is narrower than it sounds: restructure the scan into a single linear pass that only opens a new "candidate start" when depth is back at 0, and on a failed parse of a completed span, keep going in the same pass instead of restarting from Small non-blocking nit while I was poking at edge cases: when the prose contains more than one syntactically-valid JSON object (e.g. an example blob mentioned before the real one), the scan returns the first valid one, which isn't necessarily the intended one. In practice this is harmless for our actual call sites since they do Once the quadratic-scan bit is addressed I'm happy to get this merged — the core logic and tests are solid. |
Address review feedback on mem0ai#5999: _first_parseable_json_object restarted a fresh forward scan from every '{', which is worst-case O(n^2) and the worst case (a long run of unbalanced braces from truncated output or {{ }} templating) sits right in the chatty-LLM domain this fix targets. At the default max_tokens that is already ~1s, and larger outputs pushed it into tens of seconds on the shared extract_json path, stalling the event loop on the async call site. Keep the same three-tier fallback shape (balanced scan -> naive span -> as-is) but restructure the scan into one linear pass: a candidate opens only when depth returns to 0, and a completed top-level span that fails to parse lets the scan continue instead of restarting. This is O(n); 50k unbalanced braces drops from ~25s to ~2ms. Adds a regression guard for the quadratic blow-up.
|
Thanks for the thorough review and for actually pulling the branch and benchmarking the worst case; the O(n^2) call is spot on and the failure mode you describe (a long run of unbalanced braces from truncated output or I restructured I also added a regression guard ( On the async point: since the root cause was the slow parse itself, the linear pass removes the event-loop stall you flagged at the On the multiple-valid-objects nit: behavior is unchanged (first valid top-level object wins), and as you note the |
|
Verified against The O(n²) scan is fixed — confirmed independently, not just from your numbers: 50k unbalanced braces runs in ~1ms here (was ~25s at One new issue from the rewrite: text = 'Sure, I will fill in {the blank with details later. Here is the extracted JSON: {"memory": [{"id": "0", "text": "likes travel", "event": "ADD"}]}'
_first_parseable_json_object(text) # -> None
extract_json(text) # -> falls to naive span -> JSONDecodeError when parsedChecked this isn't pre-existing: the Given the audience is chatty/small local models, a model rambling or sketching notation with an unmatched For a fix: a single global depth counter can't distinguish "no JSON exists" from "an earlier unmatched Everything else from last round still holds: the multi-object nit is fine as a non-blocking note, and leaving the async call site unwrapped is reasonable now that the parse itself is fast. |
… JSON Address review on mem0ai#5999: the single running brace-depth counter meant one unmatched "{" in prose (e.g. 'I'll fill in {the blank. JSON: {...}') pinned the depth above zero for the rest of the text, so the real object was never found and extract_json fell through to the naive span, reproducing mem0ai#5998. Decode each candidate "{" with json.JSONDecoder.raw_decode instead, which consumes exactly one value and fails fast on a non-JSON start, so an earlier dead-end brace no longer blocks a later well-formed object. To keep this linear on long brace runs, only positions whose next non-whitespace char is '"' or '}' (a possible object start) are decoded; runs of "{{{..." cost O(1) each. 50k unmatched braces followed by a real object now parses in ~4ms. Adds regression tests pairing an unmatched brace with a real trailing object (prose and 50k-brace variants), which the previous single-depth scan failed.
|
Great catch, and thank you for verifying it independently down to the exact repro. You're right that a single running depth counter fundamentally can't tell "no JSON exists" from "an earlier unmatched brace is pinning the scan," and that the chatty/small-model audience makes a stray Rather than track a stack of pending starts by hand, I switched To keep it linear, I only attempt positions that can actually begin an object: the next non-whitespace char must be
For the test gap you flagged, I added regression tests that pair an unmatched brace with a real trailing object, in both Left the multi-object and async-call-site notes as-is per your last round. Pushed as |
|
Verified against The flagged bug is fixed. The single running depth counter is gone — New finding, and it's the same failure class the last two rounds each hit in this same function, just moved again: the 'next char after Fix direction: a cumulative scan budget (cap total characters fed to This is the third rewrite of this function in one PR cycle, and each round has traded one failure mode for another in the same spot. I'd rather have a maintainer look at the shape of the fix here than ask for a fourth quick patch today — the bound above should be a small, contained change on top of what's here now. |
Address round-3 review on mem0ai#5999: the object-start prefilter only guards bare brace runs. A truncated key-fragment shape ('{"a":' * 15000) passes the filter at every '{', and each raw_decode scans to end-of-text before failing on the unterminated string, which is quadratic again (~7.5s at 75k chars locally). Bound total work as suggested: a cumulative scan budget of 2 * len(text) characters, charged per failed attempt from JSONDecodeError.pos (cheap prose failures charge ~1; unterminated strings near EOF charge the remaining text), plus a belt-and-braces attempt cap. Once exhausted, return None so extract_json falls through to the naive-span fallback, the pre-existing behavior. The truncated 75k shape drops to ~3ms; flat brace runs and all correctness cases (including both prior rounds' repros and the valid-object fuzz) are unchanged. Adds regression tests for the truncated shape, alone and followed by a real object, both failing on the previous head.
|
You're right, and thank you for the precise shape. Reproduced here: flat 75k braces is ~7ms, but '{"a":' * 15000 is ~7.5s on my machine — every candidate passes the object-start prefilter and raw_decode walks to end-of-text before failing on the unterminated string. My adversarial testing covered valid objects and bare-brace runs but not truncated key fragments, which is exactly the gap. Point taken as well on this being the third relocation of the same failure class in one function; pausing for a maintainer on the shape of the fix is the right call and I won't push another same-day patch. So it's concrete for that discussion, I implemented the bound you sketched locally (holding it unpushed until you or a maintainer confirm the direction):
Measured on that local patch: truncated 75k goes 7.5s -> ~3ms, a 300k variant stays ~12ms (linear), flat brace runs unchanged, and both prior rounds' repros plus the valid-object fuzz (2000 cases, zero real objects skipped) still pass. Added the regression tests you asked for: the truncated shape alone and the truncated shape followed by a real object, both red on the current head 3cdc9c7 and green with the bound; the dependent suite is 235 passed with ruff clean. Happy to push that as-is, adjust the budget/cap constants, or take a different shape entirely if a maintainer prefers something else here — whatever pace works for you. |
|
Small correction to my last comment: the patch ended up pushed (631e548) rather than held back as I said. Apologies for the mixed signal there — it's the same commit and numbers I described (truncated 75k: 7.5s -> ~3ms, budget-based, falls through to the naive span on exhaustion, 235 dependent tests + the fuzz battery green). Still very much open to you or a maintainer wanting a different shape here; happy to adjust or revert if so. |
|
Verified against The O(n²) blowup from Two new issues, both confirmed by running the code, not just reading it. 1. The budget/attempt-cap gives up before finding real, valid JSON that exists later in the text — a new, easier-to-trigger recurrence of #5998.
'{"a":' * 3 + ' {"memory": [{"id": "0", "text": "ok", "event": "ADD"}]}'15 characters of prose before the real object, and Second, independent trigger: 2.
Both issues are real regardless of the fuzz battery: a 2000-case random fuzzer isn't likely to land on the exact n=3-fragment or n=100-decoy cliffs, or on the specific nesting depth that recurses — these are narrow, deterministic triggers, not something coverage-by-volume reliably catches. Fix direction: the round-3 design (independent per-candidate
Not asking for anything on the pre-existing first-candidate-wins behavior or the un-threaded async parse site — both unchanged from what you left as-is last round. |
|
Thanks — both repros confirmed and both are covered now. I took your backward-scan suggestion with one adjustment: naively returning the first parse from the end gives the innermost trailing object (the last
The attempt cap is gone; the budget now charges only failed decodes by characters actually scanned, and since the backward scan adopts the payload before touching the junk, exhausting it can't lose a found object. One deliberate semantic change to flag: with several disjoint objects the last one wins now (previously the first) — documented with a test, since prose-quoted examples precede the actual payload. |
|
Verified against Both Criticals from last round are genuinely fixed: the uncaught The backward-scan rewrite in this commit introduces two new issues, independently reproduced and skeptic-verified. Needs Human ReviewWhy: two new Critical-caliber findings, and this is round 4 of re-review on this PR — another round of mechanical nitpicks isn't the right next step. One of the two needs a product decision, not another patch. Pay attention to
FindingsCritical
Important Evidence
AssessmentReady to merge: No — maintainer judgment required. The one thing to decide with the contributor: whether guessing a direction on multiple disjoint valid JSON objects is the right approach at all, or whether the extractor should refuse to guess (fall through to the existing naive-span fallback) when it finds more than one fully-valid disjoint candidate — that call likely resolves the ordering ambiguity and clarifies whether the O(n²) fix (budget should charge successes too, straightforward on its own) still needs its current shape. Est. human time: ~15 min. |
Linked Issue
Closes #5998
Description
extract_json's no-code-fence fallback returned the span from the first{to the last}. When a chatty LLM (common with local Ollama / LM Studio models) wraps its JSON in prose that itself contains braces, that span starts inside the prose and is not valid JSON.json.loadsthen raises, the outerexceptin_add_to_vector_storeswallows it, and the extracted memories are silently dropped with no error surfaced.This scans for the first balanced
{...}substring that actually parses as JSON, walking brace depth while respecting string literals so braces inside JSON string values are ignored. The previous first-{-to-last-}span and the return-as-is behavior remain as fallbacks, so existing cases are unchanged.This is the same silent-memory-loss class tracked in #5245 and #5509, and it sits squarely in the scope
tests/test_chatty_llm_parsing.pyalready targets; the existing tests only cover prose without braces.Type of Change
Breaking Changes
N/A
Test Coverage
Extends
tests/test_chatty_llm_parsing.pywith regression cases: prose containing braces before the JSON (which failed before this change), and braces inside a JSON string value (guard against the string-aware scan regressing). The two prose-with-braces cases fail onmainand pass with this fix.Checklist