Skip to content
tnsaiDocs
GitHub

Structured output

Turn a model reply into a Java object. Agent.chatWithFormat does it in one call. The parsers underneath also work on replies you get yourself.

Ask the agent for an object

chatWithFormat(message, type, format, maxRetries) appends format instructions to the message, parses the reply into type, and on a parse failure sends the model a correction prompt, up to maxRetries times. When the last attempt fails, it throws ParseException.

Without the format argument, the format comes from @OutputFormatSpec on the target type, then on the agent class, and is otherwise JSON. chatWithStructure(message, parser, maxRetries) does the same with any OutputParser you pass.

package com.example.tnsai.docs;

import com.tnsai.agents.Agent;
import com.tnsai.llm.LLMClient;
import com.tnsai.output.OutputFormat;
import com.tnsai.output.OutputSerializerRegistry;
import com.tnsai.parser.JsonOutputParser;
import com.tnsai.parser.ParseResult;
import com.tnsai.parser.RetryableParser;

import java.util.List;

public final class StructuredOutputExample {
    private StructuredOutputExample() {}

    public record Analysis(String summary, List<String> issues, double score) {}

    // One call: the agent adds format instructions, parses, and asks again on a parse failure.
    static Analysis analyze(Agent agent, String code) {
        return agent.chatWithFormat("Review this code:\n" + code, Analysis.class, OutputFormat.JSON, 2);
    }

    // Your own LLM call: parse the reply and send correction prompts yourself.
    static ParseResult<Analysis> analyzeWith(LLMClient llm, String code) {
        JsonOutputParser<Analysis> json = JsonOutputParser.forType(Analysis.class);
        RetryableParser<Analysis> parser = RetryableParser.wrap(json).maxRetries(2).build();

        String prompt = "Review this code:\n" + code + "\n\n" + json.getFormatInstruction();
        String reply = llm.chat(prompt).getContent();
        return parser.parseWithRetry(reply, correction -> llm.chat(correction).getContent());
    }

    // Write a parsed result in another format.
    static String toYaml(Analysis analysis) {
        return OutputSerializerRegistry.getInstance().serialize(analysis, OutputFormat.YAML);
    }
}

Parsers

OutputParser<T> turns text into T.

MethodReturns
parse(text)A ParseResult<T>: the value or an error, never an exception
parseOrThrow(text)T, or throws ParseException
parseOptional(text)Optional<T>
getFormatInstruction()Prompt text that tells the model the expected format
getSchemaDescription()A schema for the target type
getErrorCorrectionPrompt(failedOutput, error)A prompt asking the model to fix its reply

JsonOutputParser.forType(type) finds the JSON in a reply, inside a ```json fence or as a bare {...} or [...], and reads it into a record or POJO with Jackson. By default it ignores unknown properties. JsonOutputParser.builder(type) sets your own objectMapper, strictMode(true) to reject unknown properties, or a validator.

RetryableParser.wrap(parser).maxRetries(n).build() adds a retry loop. parseWithRetry(reply, llmFunction) sends each correction prompt through llmFunction and parses again. getAttempts() lists every attempt with its output and error.

ParseResult

MethodReturns
isSuccess(), isFailure()Whether the parse worked
get()The value, or throws IllegalStateException on a failure
getOrElse(fallback), toOptional()The value without throwing
getError(), getValidationErrors()What went wrong
getRawOutput()The text that was parsed
map(fn), flatMap(fn)A transformed result; a failure passes through
ifSuccess(fn), ifFailure(fn), ifSuccessOrElse(ok, err)Run code for one case

Output formats

OutputFormatMIME typeNested data
JSONapplication/jsonYes
YAMLapplication/x-yamlYes
TOONtext/x-toonYes
TONLtext/x-tonlYes
TEXTtext/plainNo

TOON and TONL are compact text formats meant to use fewer tokens than JSON. OutputSerializerRegistry.getInstance() holds one serializer per format: serialize(data, format), deserialize(text, format, type) and getFormatInstructions(format, type). register(format, serializer) replaces one, and reset() restores the built-ins.

Validate fields and retry: StructuredOutputExecutor

StructuredOutputExecutor in tnsai-intelligence (package com.tnsai.output.validation) calls an LLMClient directly, parses the reply and checks field rules:

import com.tnsai.output.OutputFormat;
import com.tnsai.output.validation.StructuredOutputExecutor;
import com.tnsai.output.validation.ValidationRule;

StructuredOutputExecutor<OrderSummary> executor = StructuredOutputExecutor.<OrderSummary>builder()
    .llm(client)
    .targetType(OrderSummary.class)
    .outputFormat(OutputFormat.JSON)
    .rules(List.of(
        ValidationRule.notNull("orderId"),
        ValidationRule.range("total", 0, 100_000),
        ValidationRule.pattern("email", "[\\w.-]+@[\\w.-]+")))
    .maxRetries(3)
    .systemPrompt("You are an order processing assistant.")
    .build();

StructuredOutputExecutor.StructuredOutputResult<OrderSummary> result =
    executor.generate("Summarize this order: ...");

if (result.success()) {
    OrderSummary order = result.value();
} else {
    List<String> errors = result.errors();
}
BuilderDefaultMeaning
llm(LLMClient)RequiredThe client to call
targetType(Class<T>)RequiredThe type to parse into
outputFormat(OutputFormat)JSONThe format asked for and parsed
rules(List<ValidationRule>)NoneChecks on the parsed fields
maxRetries(int)3Extra attempts after the first
systemPrompt(String)NoneSystem prompt for every call

generate adds the format instructions to the prompt. When the reply does not parse or a rule fails, the next attempt sends a correction prompt with the errors, the original request and the format instructions. When the call itself throws, the same prompt is sent again. The result has success(), value() (null on failure), errors() from every attempt, and attempts(). It does not throw on failure.

Rules see the parsed object as a map of its top-level fields:

ValidationRuleFails when
notNull(field)The field is missing or null
range(field, min, max)The value is outside [min, max], or not a number
pattern(field, regex)The whole string does not match, or the value is not a string
custom(description, predicate)The predicate on the field map is false; the error is description

range and pattern pass a missing or null field; add notNull for that. ValidationRule has one method, validate(Map<String, Object>), so a lambda also works.