fix(learn): harden analyzer JSON parsing against malformed LLM output#2471
fix(learn): harden analyzer JSON parsing against malformed LLM output#2471abhay-codes07 wants to merge 3 commits into
Conversation
…eturning it `_strip_fenced_json` is documented to return a dict and to raise `json.JSONDecodeError` when no candidate parses as a JSON object. Its final fallback was `result: dict = json.loads(text); return result`, which does raise for invalid JSON but silently returns valid JSON that is not an object (a bare array, string, or number the model emitted). The `-> dict`-typed value is then a list/str, and all three callers (`_call_cli_llm`, `_call_llm`, and the CLI-model path) do `.get(...)` on it, crashing with `AttributeError` instead of the clean JSONDecodeError the callers already handle. Reject a non-dict result explicitly so the documented contract holds. The lenient first-brace..last-brace slice still recovers a single object a model wrapped in an array; only genuinely object-less JSON now raises.
PR governanceThis PR follows the template and is marked ready for human review. |
`_parse_llm_response` read `raw.get("context_file_rules", [])` /
`raw.get("memory_file_rules", [])` and `rule.get("section", "").strip()` /
`rule.get("content", "").strip()`. `.get(key, default)` only falls back when
the key is absent, and the model controls this JSON, so a present-but-null
rules list (`"context_file_rules": null`) made `for rule in None` raise
TypeError, and a null `section`/`content` made `None.strip()` raise
AttributeError. Coerce each with `or` so a null is treated like an absent value;
valid rules parse unchanged.
JerrettDavis
left a comment
There was a problem hiding this comment.
This is close, but I think the new _parse_llm_response hardening should go one step further before approval.
The PR correctly handles present-but-null rule lists and null section/content, but those fields are still model-controlled. With the new code, a truthy non-string value still crashes:
_parse_llm_response({"context_file_rules": [{"section": 123, "content": "x"}]})
# AttributeError: 'int' object has no attribute 'strip'Same issue for content if the model returns a number/list/object. Since this PR is specifically about malformed LLM JSON not crashing the analyzer, please normalize those fields with an isinstance(value, str) guard (or equivalent helper) before calling .strip(), and add regression coverage for at least one truthy non-string section and content value. The _strip_fenced_json object-contract change looks good.
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
A truthy non-string section/content (number, list, object) still reached .strip() and raised AttributeError. Route both model-controlled fields through a new _as_str helper so any non-string is treated as empty and the malformed rule is dropped, matching the null handling already in place.
|
Good catch, you're right that the Both Added regression coverage for the truthy non-string shapes too: int |
JerrettDavis
left a comment
There was a problem hiding this comment.
The follow-up commit addresses my blocker: section and content now route through _as_str, so truthy non-string model output is dropped instead of reaching .strip() and crashing. The non-object JSON contract also still raises JSONDecodeError, with coverage for both paths.
Local validation on Windows:
uv run --frozen --extra dev python -m pytest tests/test_learn/test_analyzer.py -q -k "strip_fenced_json or parse_llm_response or non_object_json_raises or non_string"-> 2 passedgit diff --check upstream/main...HEAD-> passed
I also ran the full tests/test_learn/test_analyzer.py; it has one unrelated Windows path-format failure in TestDigestBuilder.test_includes_project_info (/tmp/test-project expected, \\tmp\\test-project observed), while the remaining 85 tests passed.
Description
Two spots in the
learnanalyzer crash on JSON a model can realistically emit. Both parse fully model-controlled output.1.
_strip_fenced_jsonreturns a non-object instead of raising. It is documented to return a dict and to raisejson.JSONDecodeErrorwhen no candidate is an object, but its final fallback wasresult: dict = json.loads(text); return result.json.loadsraises for invalid JSON, but for valid JSON that is not an object (a bare array, string, or number) it returns a non-dict. The-> dict-annotated value is then a list/str, and all three callers do.get(...)on it, raisingAttributeErrorinstead of the cleanJSONDecodeErrorthey already handle.2.
_parse_llm_responsecrashes on null rule lists/fields. It readraw.get("context_file_rules", [])/raw.get("memory_file_rules", [])andrule.get("section", "").strip()/rule.get("content", "").strip()..get(key, default)only falls back when the key is absent, so a present-but-null"context_file_rules": nullmadefor rule in NoneraiseTypeError, and a nullsection/contentmadeNone.strip()raiseAttributeError.Fix
_strip_fenced_json: reject a non-dict fallback result explicitly (raise json.JSONDecodeError(...)), restoring the documented contract. The lenient first-brace..last-brace slice still recovers a single object a model wrapped in an array; only genuinely object-less JSON raises._parse_llm_response: coerce the rule lists and thesection/contentfields withorso a null is treated like an absent value. Valid rules parse unchanged.Type of Change
Changes Made
headroom/learn/analyzer.py: raiseJSONDecodeErrorwhen_strip_fenced_json's fallback parse yields a non-dict;or-coerce null rule lists and nullsection/contentin_parse_llm_response.tests/test_learn/test_analyzer.py: regressions for non-object JSON raising (and array-wrapped-object still extracted), and for null rule lists / null fields being tolerated with valid rules still parsed.Testing
pytest)ruff check .)mypy headroom)Test Output
Note: one unrelated digest-builder test (
TestDigestBuilder::test_includes_project_info) fails in my local Windows venv because it hardcodes a/tmp/...path; it fails identically on a cleanmainand is not touched by this change.Real Behavior Proof
uv sync --extra proxy),uvx ruff@0.15.17/uvx mypy@1.20.2, pytest in the venv._strip_fenced_jsonwith object-less JSON ([1,2,3],"str",42, ...) and valid/extractable objects, and the real_parse_llm_responsewith{"context_file_rules": null}, a rule withsection: null, and a valid rule; then reverted each fix and re-ran.JSONDecodeError, the null shapes yield an empty recommendation list, and valid inputs parse as before; with each fix reverted the respective input raises (AttributeError: 'list' object has no attribute 'get',TypeError: 'NoneType' object is not iterable,AttributeError: 'NoneType' object has no attribute 'strip'). Ran against the actual module.headroom learnrun where the backing model returns these shapes.Review Readiness
Checklist