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
- Attempts to parse JSON response
- On error, adds a user message with the error details
- Retries LLM call with error feedback
- 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- aNormandy.Telemetry.Requesttracker, passed to each retry'sconverse/3(withrequest_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 ofNormandy.LLM.ClaudioAdapterwhen JSON deserialization fails after all retries are exhausted. Set per client withoptions: %{on_parse_failure: ...}, or for all clients under the:normandyapplication key:
Accepted values:config :normandy, :on_parse_failure, :fallback # default:fallback(default) — Returns the response schema withchat_messageset to the raw LLM text (unchanged when the output is not text), emits aLogger.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:modelor: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
@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:
- Extracts the error message
- Adds a user message with the error details
- Calls the LLM again with feedback
- Attempts deserialization again
Parameters
content- Raw string content from LLMschema- Target schema struct to populateclient- LLM clientrequest- theNormandy.LLM.Requestthat producedcontent; a retry resends it with the error feedback appended to its messagesopts- 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 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 LLMschema- Target schema struct to populateopts- Options (:max_input_bytes); an unknown option raisesArgumentError
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}}