headroomlabs-ai/headroom · error · RuntimeError

`{' '.join(cmd)}` returned unparseable output. First {_MAX_S

Error message

`{' '.join(cmd)}` returned unparseable output. First {_MAX_SNIPPET_LEN} chars:
{snippet}

What it means

Raised when the claude-cli result event was received and its text extracted, but _strip_fenced_json + json.loads could not parse it into the expected JSON structure. The analyzer asks the model to answer with JSON (optionally fenced); any model chatter, apology text, or truncated JSON triggers this. The original JSONDecodeError is chained and the first _MAX_SNIPPET_LEN chars of the raw output are shown.

Source

Thrown at headroom/learn/analyzer.py:778

        stderr_blob = "".join(stderr_lines)[:_MAX_SNIPPET_LEN]
        raise RuntimeError(f"`{' '.join(cmd)}` failed (exit {proc.returncode}):\n{stderr_blob}")

    stderr_blob = "".join(stderr_lines)
    if stderr_blob.strip():
        logger.debug("CLI stderr (exit 0): %s", stderr_blob[:_MAX_SNIPPET_LEN])

    if final_result is None:
        stdout_snippet = "".join(stdout_lines)[:_MAX_SNIPPET_LEN]
        raise RuntimeError(
            f"`{' '.join(cmd)}` did not emit a final `result` event. "
            f"First {_MAX_SNIPPET_LEN} chars of stdout:\n{stdout_snippet}"
        )

    try:
        return _strip_fenced_json(final_result)
    except json.JSONDecodeError as exc:
        snippet = final_result[:_MAX_SNIPPET_LEN]
        raise RuntimeError(
            f"`{' '.join(cmd)}` returned unparseable output. "
            f"First {_MAX_SNIPPET_LEN} chars:\n{snippet}"
        ) from exc


def _parse_stream_event(line: str) -> dict | None:
    """Parse one line of claude-cli stream-json output, returning None on junk."""
    line = line.strip()
    if not line:
        return None
    try:
        parsed = json.loads(line)
    except json.JSONDecodeError:
        return None
    return parsed if isinstance(parsed, dict) else None


def _call_llm(digest: str, model: str) -> dict:

View on GitHub (pinned to 322425c43b)

Solutions

  1. Retry — single-shot non-JSON replies are often transient model behavior.
  2. Use a stronger or more instruction-following backend: headroom learn --model <litellm-model-name> (e.g. claude-3-5-sonnet, gpt-4o).
  3. Reduce input size: analyze fewer conversations per run so the model has output budget to complete valid JSON.
  4. Update headroom — the prompt/parse contract (fence stripping) is tightened over time.
  5. If persistent, copy the snippet from the error and check whether the model is returning an apology/refusal.

Example fix

# before
headroom learn  # 'returned unparseable output. First 500 chars: Sure! Here is...'

# after
headroom learn --model gpt-4o  # model that follows the JSON contract
Defensive patterns

Strategy: retry

Try / catch

for model in ("claude", "gpt-4o", "gemini/gemini-1.5-pro"):
    try:
        return run_learn(model=model)
    except RuntimeError as e:
        if "returned unparseable output" not in str(e):
            raise
# all backends produced non-JSON output
raise RuntimeError("no backend produced parseable JSON")

Prevention

When it happens

Trigger: The model replies with prose instead of JSON, wraps JSON with commentary, emits fenced markdown the stripper doesn't recognize, or truncates the JSON mid-object because the response hit a token limit. Occurs in both the non-streaming path (analyzer.py:627) and streaming path (analyzer.py:778).

Common situations: Smaller/weaker models via --model that ignore JSON output instructions; very large digests pushing the answer past max output tokens so JSON is cut off; prompt-format changes between headroom and CLI versions; non-English models adding preamble text.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/658769a82d7275fb. Report an issue: GitHub.