Normandy.LLM.JsonDeserializer (normandy v1.3.0)

View Source

JSON deserialization helper with automatic error recovery.

This module is a thin facade over five focused units under Normandy.LLM.Json.*:

UnitResponsibility
Normandy.LLM.Json.ScannerTruncated top-level-string recovery: scans raw bytes to find the safe truncation point and produces a closed-string fragment with balancing closers
Normandy.LLM.Json.ContentCleanerMarkdown fence-strip, whitespace trim, and balanced-brace prose extraction via extract_balanced/1 — isolates the first well-formed JSON object embedded in prose
Normandy.LLM.Json.DecoderAdapter decode with optional truncated-string recovery (delegates to Scanner) and a :max_input_bytes size guard that short-circuits before any parsing attempt
Normandy.LLM.Json.SchemaBinderNormalises decoded maps, casts and validates against the target schema, and unwraps tool-use "arguments" envelopes
Normandy.LLM.Json.RetryFeedbackBuilds an adapter-encoded corrective retry prompt from the parse error and augments the message history before the next LLM call

This module provides robust JSON deserialization with:

  • Automatic retry on JSON parse errors
  • Error feedback to LLM via system prompt augmentation
  • Configurable retry attempts
  • Support for nested/double-encoded JSON

Usage with Agent

# Enable in agent config
agent = BaseAgent.init(%{
  client: client,
  model: "claude-3-5-sonnet-20241022",
  temperature: 0.7,
  enable_json_retry: true,           # Enable automatic retry
  json_retry_max_attempts: 3         # Optional: default is 2
})

# The agent will automatically retry on JSON parse errors
{agent, response} = BaseAgent.run(agent, input)

Manual Usage

# With default retries (2)
{:ok, schema} = JsonDeserializer.deserialize_with_retry(
  raw_content,
  schema,
  client,
  model,
  temperature,
  max_tokens,
  messages
)

# With custom retries
{:ok, schema} = JsonDeserializer.deserialize_with_retry(
  raw_content,
  schema,
  client,
  model,
  temperature,
  max_tokens,
  messages,
  max_retries: 3
)

How It Works

  1. Attempts to parse JSON response
  2. On error, augments system prompt with error details
  3. Retries LLM call with error feedback
  4. Repeats until success or max_retries reached

Configuration

Call-site options

  • :max_retries - Maximum retry attempts (default: 2)
  • :tools - Tool schemas to include in retry
  • :adapter - JSON adapter module (default: from :normandy app config)
  • :recover_truncated_strings - Opt-in recovery from unclosed top-level string truncation (default: false). See parse_and_validate/3.
  • :max_input_bytes - Maximum byte size of the raw content accepted before any parsing is attempted (default: 10_000_000). When the content exceeds this limit, Decoder.decode/3 returns {:error, {:input_too_large, actual_size, limit}} immediately, skipping all JSON parsing and schema binding.

Application config

  • :on_parse_failure - Controls the behaviour of Normandy.LLM.ClaudioAdapter when JSON deserialization fails after all retries are exhausted. Configured under the :normandy application key:
    config :normandy, :on_parse_failure, :fallback   # default
    Accepted values:
    • :fallback (default) — Returns the raw LLM text as-is, emits a Logger.warning describing the failure, and fires [:normandy, :json_deserializer, :fallback] telemetry.
    • :error — Returns {:error, reason} directly to the caller; no fallback text is produced.

Retry raw-completion contract

On a parse-failure retry, the loop calls Normandy.Agents.Model.converse/7 with raw: true in its options to request raw model text rather than a deserialized struct. Clients that do not recognise the raw option may ignore it and return their normal shape; the return value is always normalised via Normandy.Agents.ConverseResult.normalize/1 before the loop inspects it. A ClaudioAdapter that honours raw: true returns the model's text directly, bypassing its own JSON deserialization path, so the content flows back into deserialize_loop/11 without double-decoding. This makes JsonDeserializer the single parse-and-retry authority: every JSON attempt — initial or corrective — passes through parse_and_populate/4.

Summary

Functions

Parse and validate JSON content without retry.

Functions

deserialize_with_retry(content, schema, client, model, temperature, max_tokens, messages, opts \\ [])

@spec deserialize_with_retry(
  String.t(),
  struct(),
  struct(),
  String.t(),
  float() | nil,
  integer() | nil,
  list(),
  keyword()
) :: {:ok, struct()} | {:error, term()}

Deserialize JSON content with automatic retry on errors.

If initial deserialization fails, this function:

  1. Extracts the error message
  2. Augments the system prompt with error details
  3. Calls the LLM again with feedback
  4. Attempts deserialization again

Parameters

  • content - Raw string content from LLM
  • schema - Target schema struct to populate
  • client - LLM client
  • model - Model name
  • temperature - Temperature setting
  • max_tokens - Max tokens
  • messages - Original message history
  • opts - Options (:max_retries, :tools, :adapter, :recover_truncated_strings — see parse_and_validate/3 for details)

Returns

  • {:ok, populated_schema} - Success
  • {:error, reason} - Failed after all retries

parse_and_validate(content, schema, opts \\ [])

@spec parse_and_validate(String.t(), struct(), keyword()) ::
  {:ok, struct()} | {:error, term()}

Parse and validate JSON content without retry.

This is a simpler version of deserialize_with_retry/8 that performs one-shot parsing and validation without LLM retry. Useful for parsing final LLM responses where retry is not needed.

Parameters

  • content - Raw string content from LLM
  • schema - Target schema struct to populate
  • opts - Options:
    • :adapter - JSON adapter module (default: from :normandy app config)
    • :recover_truncated_strings - When true, on adapter decode failure attempt one recovery pass for the failure mode "unclosed top-level string at depth 1" (e.g. Nemotron-VL page_text payloads that exhaust max_tokens mid-string). The canonical case is a \n-escape runaway tail, but any unclosed depth-1 string also recovers: scan truncates at the last non-\n-escape position (or the byte after the opening quote if none), appends " and the balancing object or array closers, re-decodes, and emits [:normandy, :json_deserializer, :recovery] telemetry on success. Truncations inside nested objects or arrays are not recovered. On recovery failure the original adapter error is returned unchanged. Default: false.

Returns

  • {:ok, populated_schema} - Success
  • {:error, reason} - Parsing or validation failed

Examples

iex> schema = %BaseAgentOutputSchema{}
iex> JsonDeserializer.parse_and_validate(~s({"chat_message": "Hello"}), schema)
{:ok, %BaseAgentOutputSchema{chat_message: "Hello"}}

iex> JsonDeserializer.parse_and_validate("invalid json", schema)
{:error, {:json_parse_error, reason, content}}