n4nAI

Building a self-healing JSON parser for agent pipelines

Build a self-healing JSON parser LLM wrapper that fixes malformed model output locally and escalates to a model call only when needed in agents.

n4n Team3 min read713 words

Audio narration

Coming soon — every post will get a voice note here.

Agent pipelines that depend on tool calls live or die by strict JSON contracts. When a model emits a truncated object, a stray comma, or a code fence, the whole run stalls. A self-healing JSON parser LLM layer intercepts those failures, applies cheap local fixes first, and only spends a model call when the syntax is beyond regex repair. This tutorial builds that layer from scratch and shows where it pays off in a real ReAct loop.

Prerequisites

  • Python 3.10+ with pip install openai
  • An OpenAI-compatible API endpoint and key. If you want the repair call to survive provider outages, point the client at a gateway that offers automatic fallback; an OpenAI-compatible endpoint such as n4n.ai forwards requests to 240+ models and reroutes on rate limits without code changes.
  • Familiarity with json.JSONDecodeError and basic string manipulation.

You should be able to run every snippet below in a single module.

Step 1: The naive parse and why it breaks

A tool-calling agent typically does this:

import json

def parse_tool_output(raw: str) -> dict:
    return json.loads(raw)

That throws on anything but pristine JSON. Common failure modes from real model outputs:

  • Markdown fences: ```json
  • Trailing commas: {"a": 1,}
  • Truncated streams: {"name": "Alice", "roles": ["admin"
  • Prose wrappers: Here is the result: {"ok": true}

Each of these aborts the agent step. We need a repair stage before the hard parse.

Step 2: Local heuristic repair

Local repair is free and fast. We strip fences, extract the first balanced brace block, and kill trailing commas. This covers 80% of cases I see in production logs.

import re

def local_repair(raw: str) -> str:
    # Strip ```json ... ``` or ``` ... ```
    cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw.strip(), flags=re.MULTILINE)
    start = cleaned.find("{")
    if start == -1:
        raise ValueError("No JSON object delimiter found")
    depth = 0
    end = -1
    for i, ch in enumerate(cleaned[start:], start):
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                end = i
                break
    if end == -1:
        # Truncated: close what's open
        candidate = cleaned[start:] + "}" * depth
    else:
        candidate = cleaned[start:end + 1]
    # Remove trailing commas before } or ]
    candidate = re.sub(r",\s*([}\]])", r"\1", candidate)
    return candidate

Checkpoint output with a typical bad string:

bad = '```json\n{"name": "Alice", "roles": ["admin", "user",],}\n```'
print(local_repair(bad))
# {"name": "Alice", "roles": ["admin", "user"]}

The fences are gone, the trailing comma inside the array and the one before the closing brace are removed. json.loads now succeeds.

Handling truncated streams

truncated = '{"query": "sales", "limit": 10'
print(local_repair(truncated))
# {"query": "sales", "limit": 10}

The function appends one } because depth was 1. It will not invent values; if the truncation cut a string mid-word, you still get a decode error and escalate.

Extracting from prose

prose = 'Sure! Here is the payload: {"ok": true, "id": 42} Hope that helps'
print(local_repair(prose))
# {"ok": true, "id": 42}

The first { anchors extraction; the depth counter stops at the matching }.

Step 3: Escalating to an LLM repair

When local repair still raises JSONDecodeError, we call a model. The prompt is tight: output only valid JSON. We run the response through local_repair again as a safety net.

from openai import OpenAI

client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY")

def llm_repair(raw: str, client: OpenAI) -> dict:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0,
        messages=[
            {"role": "system", "content": "You repair malformed JSON. Respond with only valid JSON, no commentary."},
            {"role": "user", "content": f"Repair this into valid JSON:\n{raw}"}
        ],
    )
    text = resp.choices[0].message.content or ""
    return json.loads(local_repair(text))

Expected behavior on a deeply broken input:

broken = "{'name': 'Bob', 'age': 'thirty'}"  # single quotes, string age
repaired = llm_repair(broken, client)
print(repaired)
# {'name': 'Bob', 'age': 'thirty'}  (now double-quoted, valid)

The LLM converts Python-style dict syntax to JSON. Local repair would have failed on single quotes.

Step 4: Composing the self-healing parser

Wrap both strategies in a class with a single parse entry point. Track which path succeeded for observability.

class SelfHealingJSONParser:
    def __init__(self, client: OpenAI | None = None):
        self.client = client
        self.last_repair = "none"

    def parse(self, raw: str) -> dict:
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            pass

        try:
            result = json.loads(local_repair(raw))
            self.last_repair = "local"
            return result
        except json.JSONDecodeError:
            pass

        if self.client is None:
            raise ValueError("Local repair failed and no LLM client configured")

        self.last_repair = "llm"
        return llm_repair(raw, self.client)

Usage:

parser = SelfHealingJSONParser(client)
data = parser.parse(bad)
print(data, parser.last_repair)
# {'name': 'Alice', 'roles': ['admin', 'user']} local

Step 5: Wiring into an agent pipeline

In a ReAct loop, tool output is often a raw model string. Swap the naive json.loads for the parser:

def handle_tool_response(tool_raw: str, parser: SelfHealingJSONParser):
    try:
        args = parser.parse(tool_raw)
    except Exception as e:
        # Surface to agent as a tool error, do not crash the run
        return {"error": f"parse_failed: {e}"}
    if "error" in args:
        return args
    # Continue with tool execution using validated args
    return execute_tool(args)

This keeps a single malformed response from killing the episode. The last_repair flag feeds your metrics: if llm repairs spike, your primary model’s JSON mode needs tuning.

Step 6: Guardrails and observability

LLM repair costs tokens. Cap it per parse and log counts:

import collections

repair_counts = collections.Counter()

def instrumented_parse(parser: SelfHealingJSONParser, raw: str) -> dict:
    result = parser.parse(raw)
    repair_counts[parser.last_repair] += 1
    return result

# Later: print(repair_counts) -> Counter({'local': 142, 'llm': 3, 'none': 89})

If you use a gateway with per-token usage metering, the repair calls show up as ordinary completions—no extra instrumentation needed. Set max_tokens low (e.g., 256) on the repair call; a well-formed JSON object for tool args rarely exceeds that.

Edge cases you must handle

  • Strings containing braces: {"code": "def f(): return {"} fools the naive depth scanner. Local repair will misbalance. In practice, escalate to LLM when the extracted block fails and contains colon-quote patterns outside brackets.
  • Numeric truncation: {"temp": 22. is unrecoverable locally. LLM repair can infer 22.0 or 22, but you should validate against a schema afterward.
  • Schema validation: Use pydantic after parse. Self-healing gets you valid JSON, not semantically correct JSON.

Step 7: Adding a schema check

from pydantic import BaseModel, ValidationError

class ToolArgs(BaseModel):
    name: str
    roles: list[str]

def parse_and_validate(raw: str, parser: SelfHealingJSONParser) -> ToolArgs:
    data = parser.parse(raw)
    try:
        return ToolArgs(**data)
    except ValidationError as e:
        raise ValueError(f"Schema mismatch: {e}")

Now the pipeline rejects {"name": "Alice"} (missing roles) even if the JSON is syntactically fine. That separation—syntax healing vs. schema enforcement—keeps the self-healing JSON parser LLM logic focused and your agent logic strict.

Closing notes for production

Run local repair synchronously in the hot path; it adds microseconds. Make the LLM repair asynchronous or behind a circuit breaker so a slow provider does not block the agent. The self-healing JSON parser LLM pattern is not a substitute for asking the model for JSON mode, but it is the difference between a retry storm and a quiet recovery when the context window clips the last brace.

Tagsjson-parserstructured-outputagent-pipelineerror-handling

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured outputs & json mode for agents posts →