Typed Tool Inputs in Java Agent Systems: Records as Contracts
There is a design tension at the boundary between an LLM and a tool. The LLM generates a structured JSON call. Your code receives it. Between those two points, something has to parse the JSON, validate the fields, and turn it into something the tool can actually work with.
Most agent frameworks handle this with a single opaque string. The LLM gets a parameter named input. The tool receives a raw string. The tool author writes their own JSON parsing. Required-field validation is up to them. If the LLM passes malformed JSON or omits a required field, the tool finds out at runtime.
The question I kept coming back to was: why doesn’t the framework own this? Java has records. Records have components with names and types. Those components can carry annotations. Everything needed to generate a typed JSON Schema, deserialize the call, and validate required fields is already there, waiting to be read.
The Problem With Single-String Tool Inputs
Section titled “The Problem With Single-String Tool Inputs”The original model is simple and universal. A tool implements one method: execute(String input). The LLM is told to format the input as JSON if the tool needs structured data. The tool parses it.
This works, but it puts a burden in the wrong place. Consider a web search tool:
public class WebSearchTool extends AbstractAgentTool { @Override public String name() { return "web_search"; }
@Override public String description() { return "Performs a web search. Input: JSON with 'query' field."; }
@Override protected ToolResult doExecute(String input) { JsonNode node = mapper.readTree(input); String query = node.get("query").asText(); // NPE if missing // ... }}A few problems here. The description must instruct the LLM about the expected JSON format, mixing tool contract with tool documentation. The tool code handles parsing. There is no schema — the LLM has no structured signal about what fields are expected, what their types are, or which are required. A missing query field produces a null pointer exception rather than a clean validation failure.
The tool input boundary is where type safety matters most. It is the handoff between model output and application code. A malformed call here does not just fail — it can silently corrupt state or produce errors that are hard to trace back to the model call.
Records as Input Contracts
Section titled “Records as Input Contracts”The alternative is to declare the input contract as a Java record. The framework can derive everything it needs from the record’s components and their annotations: the JSON Schema for the LLM, the deserialization logic, the required-field validation.
@ToolInput(description = "Parameters for a web search")public record WebSearchInput( @ToolParam(description = "Search query string, e.g. 'Java 21 virtual threads'") String query) {}The tool then extends AbstractTypedAgentTool<WebSearchInput> instead of AbstractAgentTool:
public final class WebSearchTool extends AbstractTypedAgentTool<WebSearchInput> {
@Override public Class<WebSearchInput> inputType() { return WebSearchInput.class; }
@Override public ToolResult execute(WebSearchInput input) { String query = input.query().trim(); if (query.isBlank()) { return ToolResult.failure("Search query must not be blank"); } // ... perform search }}The @ToolParam annotation marks fields as required by default. Optional fields use @ToolParam(required = false). There is no JSON parsing in the tool body. The framework handles it.
What the Framework Does With the Record
Section titled “What the Framework Does With the Record”At startup, ToolSchemaGenerator introspects the record class and produces a JsonObjectSchema for LangChain4j. The mapping covers the types you would expect:
| Java type | JSON Schema type |
|---|---|
String | string |
int, long, Integer | integer |
double, float, BigDecimal | number |
boolean | boolean |
enum | string with enum constraint |
List<T> | array |
Map<String, V> | object |
When the LLM calls the tool, LangChain4jToolAdapter passes the full JSON arguments to AbstractTypedAgentTool, which uses ToolInputDeserializer to deserialize them into a record instance. Required fields that are missing or null produce a clean ToolResult.failure before the tool body executes.
The LLM receives a proper multi-parameter schema instead of a single input string. It can see the field names, their types, and their descriptions directly in the tool specification.
The Adapter Path
Section titled “The Adapter Path”LangChain4jToolAdapter dispatches based on whether a tool implements TypedAgentTool:
if (tool instanceof TypedAgentTool<?> typed) { parameters = ToolSchemaGenerator.generateSchema(typed.inputType());} else { // original single-parameter schema, fully backward compatible parameters = JsonObjectSchema.builder() .addStringProperty("input", "The input string for the tool") .required(List.of("input")) .build();}The same instanceof check applies at execution time — typed tools receive the full JSON arguments object; legacy tools receive the extracted "input" field value. No changes required to existing tool implementations.
Before and After: The Schema Difference
Section titled “Before and After: The Schema Difference”For the legacy WebSearchTool, the LLM received this schema:
{ "properties": { "input": { "type": "string", "description": "The input string for the tool" } }, "required": ["input"]}With TypedAgentTool, it receives:
{ "properties": { "query": { "type": "string", "description": "Search query string, e.g. 'Java 21 virtual threads'" } }, "required": ["query"]}The field is named correctly. The description is the one written for that field. For tools with multiple inputs, each parameter is a separate, named, typed entry in the schema rather than buried inside an opaque string that the tool must document and parse.
Migrating a Multi-Field Tool
Section titled “Migrating a Multi-Field Tool”The pattern is the same regardless of how many fields the record carries. A hypothetical HTTP request tool:
@ToolInput(description = "Parameters for an HTTP request")public record HttpRequestInput( @ToolParam(description = "The URL to request") String url, @ToolParam(description = "HTTP method: GET, POST, PUT, DELETE") String method, @ToolParam(description = "Request body (optional)", required = false) String body) {}
public final class HttpRequestTool extends AbstractTypedAgentTool<HttpRequestInput> {
@Override public Class<HttpRequestInput> inputType() { return HttpRequestInput.class; }
@Override public ToolResult execute(HttpRequestInput input) { // url and method are guaranteed non-null; body may be null // no parsing, no null checks on deserialization }}The body field is optional — it will be absent from the required array in the schema, and will be null if the LLM omits it. url and method are required — a call missing either produces a failure before execute is reached.
Tradeoffs
Section titled “Tradeoffs”The typed system is opt-in for a reason. There are cases where it does not fit well.
Records only. The framework introspects Java records specifically. If an existing tool has complex input logic tied to its own parsing, migrating to a record may not be a clean fit.
Reflection at startup. Schema generation uses reflection on the record’s components. For a large number of tools, this adds initialization cost. In practice it is small, but it is not zero.
Enum constraints. Enum fields generate a string schema with an enum list derived from Enum.name(). This aligns with Jackson’s default deserialization behavior. If you have custom serialization names, the generated enum list may not match the LLM’s expectations without additional configuration.
Not a substitute for description quality. The schema tells the LLM the structure. The field descriptions still determine whether the LLM understands how to fill that structure correctly. A typed schema with poor descriptions is only marginally better than a well-documented string input.
Five of the built-in tools were migrated to the typed system (FileReadTool, FileWriteTool, JsonParserTool, WebSearchTool, WebScraperTool). Four were left as legacy tools (CalculatorTool, DateTimeTool, HttpAgentTool, ProcessAgentTool) — either because their inputs are naturally single-valued, or because the migration cost was not justified by the gain.
Practical Takeaway
Section titled “Practical Takeaway”The tool input boundary is a contract. Java records are a natural way to express that contract, and the language gives you enough reflective machinery to derive everything you need from it without additional code generation or build-time processing.
The main gain is not ergonomics, though that improves. The main gain is that the LLM receives an accurate schema for the tool it is calling, which reduces hallucinated input formats and makes validation failures deterministic rather than buried in manual parsing code.
AgentEnsemble is a JVM-native framework for building multi-agent systems in Java. The typed tool input system is part of the core library.
- Guide: /guides/tools/
- Source: https://github.com/AgentEnsemble/agentensemble
- MIT licensed
If you have been working with agent tool systems and have found a different approach to the input boundary problem — typed DSLs, code generation, something else — I would be interested in the tradeoffs you have seen.