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.
| Method | Returns |
|---|---|
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
| Method | Returns |
|---|---|
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
OutputFormat | MIME type | Nested data |
|---|---|---|
JSON | application/json | Yes |
YAML | application/x-yaml | Yes |
TOON | text/x-toon | Yes |
TONL | text/x-tonl | Yes |
TEXT | text/plain | No |
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();
}| Builder | Default | Meaning |
|---|---|---|
llm(LLMClient) | Required | The client to call |
targetType(Class<T>) | Required | The type to parse into |
outputFormat(OutputFormat) | JSON | The format asked for and parsed |
rules(List<ValidationRule>) | None | Checks on the parsed fields |
maxRetries(int) | 3 | Extra attempts after the first |
systemPrompt(String) | None | System 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:
ValidationRule | Fails 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.
Related
- Create an agent: the other chat methods.
- Streaming: replies as they arrive.
- Exceptions:
ParseExceptionis aTnsAIException.