Normandy.LLM.JsonDeserializer (normandy v2.2.0)

View Source

JSON deserialization helper with automatic error recovery.

It strips Markdown fences and surrounding prose, decodes through Normandy.JSON (with a :max_input_bytes guard), casts and validates the result against the target schema, and builds the corrective retry prompt after a parse error.

This module provides robust JSON deserialization with:

  • Automatic retry on JSON parse errors
  • Error feedback to the LLM in a user message after the conversation
  • Configurable retry attempts

Usage with Agent

# Retry is on by default (2 retries); each retry is a paid LLM call whose
# token usage is added to the reported usage.
agent = BaseAgent.init(%{
  client: client,
  model: "claude-opus-5-5",
  json_retry_max_attempts: 3         # default: 2
  # enable_json_retry: false         # no retries
})

{:ok, agent, response} = BaseAgent.run(agent, input)

Manual Usage

request = %Normandy.LLM.Request{model: model, messages: messages, max_tokens: 1024}

# With default retries (2)
{:ok, schema} = JsonDeserializer.deserialize_with_retry(raw_content, schema, client, request)

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

How It Works

  1. Attempts to parse JSON response
  2. On error, adds a user message with the 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)
  • :on_retry_usage - (usage -> any), called with each retry call's token usage as that retry finishes (a billed failure included)
  • :request_tracker - a Normandy.Telemetry.Request tracker, passed to each retry's converse/3 (with request_kind: :json_retry) so the retry's request span is numbered within the enclosing llm_call
  • :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, the parse 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. Set per client with options: %{on_parse_failure: ...}, or for all clients under the :normandy application key:
    config :normandy, :on_parse_failure, :fallback   # default
    Accepted values:
    • :fallback (default) — Returns the response schema with chat_message set to the raw LLM text (unchanged when the output is not text), emits a Logger.warning, and fires [:normandy, :json_deserializer, :fallback] telemetry with %{reason: tag, content_bytes: n}. Neither carries the model output, which can hold user data. The event has no :model or :agent_name: the parser is not given them, and Normandy keeps no process-global state to look them up. Correlate through the enclosing [:normandy, :agent, :llm_call] span, which runs in the same process.
    • :error — Returns {:error, %Normandy.LLMError{type: :invalid_output, reason: reason}} to the caller; no fallback text is produced.

Retries

On a parse failure the loop calls Normandy.Agents.Model.converse/3 again with the error feedback appended to request.messages and response_model: nil, which asks the client for the answer as text. Every attempt, initial or corrective, is parsed here (parse_and_populate/4), so this module is the single parse-and-retry authority. A client that ignores response_model: nil and returns a struct still works: its text is extracted and parsed.

Summary

Functions

Deserialize JSON content with automatic retry on errors.

Parse and validate JSON content without retry.

Functions

deserialize_with_retry(content, schema, client, request, opts \\ [])

@spec deserialize_with_retry(
  String.t(),
  struct(),
  struct(),
  Normandy.LLM.Request.t(),
  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. Adds a user message with the 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
  • request - the Normandy.LLM.Request that produced content; a retry resends it with the error feedback appended to its messages
  • opts - Options (:max_retries, :on_retry_usage, :request_tracker, :max_input_bytes)

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/5 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 (:max_input_bytes); an unknown option raises ArgumentError

Returns

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

Examples

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

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