Skip to content

fix(learn): harden analyzer JSON parsing against malformed LLM output#2471

Open
abhay-codes07 wants to merge 3 commits into
headroomlabs-ai:mainfrom
abhay-codes07:fix/learn-strip-fenced-json-nonobject
Open

fix(learn): harden analyzer JSON parsing against malformed LLM output#2471
abhay-codes07 wants to merge 3 commits into
headroomlabs-ai:mainfrom
abhay-codes07:fix/learn-strip-fenced-json-nonobject

Conversation

@abhay-codes07

@abhay-codes07 abhay-codes07 commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Description

Two spots in the learn analyzer crash on JSON a model can realistically emit. Both parse fully model-controlled output.

1. _strip_fenced_json returns a non-object instead of raising. It is documented to return a dict and to raise json.JSONDecodeError when no candidate is an object, but its final fallback was result: dict = json.loads(text); return result. json.loads raises 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, raising AttributeError instead of the clean JSONDecodeError they already handle.

2. _parse_llm_response crashes on null rule lists/fields. It 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, so a present-but-null "context_file_rules": null made for rule in None raise TypeError, and a null section/content made None.strip() raise AttributeError.

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 the section/content fields with or so a null is treated like an absent value. Valid rules parse unchanged.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Code refactoring (no functional changes)

Changes Made

  • headroom/learn/analyzer.py: raise JSONDecodeError when _strip_fenced_json's fallback parse yields a non-dict; or-coerce null rule lists and null section/content in _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

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ python -m pytest tests/test_learn/test_analyzer.py::TestStripFencedJson tests/test_learn/test_analyzer.py::TestLLMResponseParser -q
# all pass; each new test fails with the corresponding fix reverted
# (JSONDecodeError contract / 'NoneType' object is not iterable / has no attribute 'strip')

$ uvx ruff@0.15.17 check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/learn/analyzer.py
Success: no issues found in 1 source file

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 clean main and is not touched by this change.

Real Behavior Proof

  • Environment: Windows 11, Python 3.12, project venv (uv sync --extra proxy), uvx ruff@0.15.17 / uvx mypy@1.20.2, pytest in the venv.
  • Exact command / steps: called the real _strip_fenced_json with object-less JSON ([1,2,3], "str", 42, ...) and valid/extractable objects, and the real _parse_llm_response with {"context_file_rules": null}, a rule with section: null, and a valid rule; then reverted each fix and re-ran.
  • Observed result: with the fixes the object-less inputs raise 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.
  • Not tested: a live headroom learn run where the backing model returns these shapes.

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have updated the CHANGELOG.md if applicable

…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.
Copilot AI review requested due to automatic review settings July 21, 2026 18:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 21, 2026
`_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.
@abhay-codes07 abhay-codes07 changed the title fix(learn): reject non-object JSON in _strip_fenced_json instead of returning it fix(learn): harden analyzer JSON parsing against malformed LLM output Jul 21, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-commenter

codecov-commenter commented Jul 21, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/learn/analyzer.py 90.90% 0 Missing and 1 partial ⚠️

📢 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.
@abhay-codes07

Copy link
Copy Markdown
Contributor Author

Good catch, you're right that the or "" guard only covered null and would still have fallen over on a truthy non-string. Pushed a follow-up (d497d76).

Both section and content now go through a small _as_str helper that returns the value only when it's actually a string, otherwise "", so a number, list, or object gets coerced to empty and the caller's existing emptiness check drops the rule. That closes the {"section": 123, "content": "x"} case you flagged along with the symmetric content one.

Added regression coverage for the truthy non-string shapes too: int section, int content, list section, and dict content, across both context_file_rules and memory_file_rules. Full TestLLMResponseParser is green locally.

@github-actions github-actions Bot removed the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 22, 2026

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 passed
  • git 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants