LLM Function Calling in Java: Give Your AI App Live Data
Imagine you ship an AI support bot for an online store. A customer asks, "Where is my order ORD-1001?" The model is fluent, confident — and completely blind. Its training data ended months ago, it has never seen your database, and it has no idea what ORD-1001 is. If you let it answer from its weights alone, it will do what models do under uncertainty: invent a plausible-sounding status. "Your order shipped Tuesday!" Maybe. Maybe not. Either way, you cannot afford the guess.
The fix is not a bigger model. It is a narrower job for the model: let it decide what to look up, while your code does the looking up. You hand the model a menu of functions — getOrderStatus(orderId), listRecentOrders(limit) — each with a description of when to use it and a schema for its arguments. The model reads the user's question, picks the right function, and fills in the arguments. Your Java code validates those arguments, runs the function against the real database, and hands the result back. The model then writes the final answer from the real data.
This is function calling (also called tool calling), and it is the mechanism underneath nearly every "AI agent" you have seen demoed. This post builds the whole mechanism by hand with java.net.http.HttpClient — no framework — because once you have seen the loop, every framework's version becomes obvious. Then we will look at the framework shorthand, and finally the part that matters most: the safety rules. A tool is code execution triggered by a probabilistic model, and this post teaches you to treat it that way.
The core idea: the model proposes, your code disposes
Strip away the marketing and function calling is deliberately un-magical:
- The model never runs your code. It cannot touch your database, your filesystem, or your network.
- What the model can do is emit a structured request: "call the function named
getOrderStatuswith the arguments{"orderId": "ORD-1001"}". - Your code receives that request, validates the arguments, executes the real function, and sends the result back to the model as an ordinary message.
- The model reads the result and produces the final answer to the user.
That separation is the whole security model, and it is worth stating plainly: the model is an untrusted planner; your code is the trusted executor. Every safety rule in this post follows from that sentence. Here is what a typical turn looks like on the wire (illustrative transcript):
User: Where is my order ORD-1001?
Model → your code: tool_call getOrderStatus({"orderId": "ORD-1001"})
Your code → model: {"orderId":"ORD-1001","found":true,"status":"SHIPPED",
"updated":"2026-10-12","note":"Left the Memphis hub"}
Model → user: Your order ORD-1001 shipped on October 12 — it left the
Memphis hub and is on its way to you.
Notice what did not happen: the model never saw your database credentials, never ran SQL, and never decided what "shipped" means. It proposed; your code disposed.
The tool-calling loop, step by step
The loop your code runs for every user message is always the same shape:
In concrete terms, each round is one HTTP call to a chat-completions endpoint. The request carries the conversation so far plus your tool definitions. The response is either plain text (done — return it to the user) or a list of tool_calls. For every tool call you execute the function, append a role: "tool" message with the result, and go around again. The loop ends when a round comes back with no tool calls — or when you hit your iteration cap, which is not optional (more on that in "When it breaks").
Describing your tools: the JSON Schema the model reads
Before the loop can run, you must tell the model what it is allowed to ask for. Each tool is described as a JSON object with a name, a human-readable description, and a JSON Schema for its arguments. This schema is the contract between the model's judgment and your code's validation — write it as carefully as you would a public API. Here is the one for our order lookup:
{
"type": "function",
"function": {
"name": "getOrderStatus",
"description": "Look up the current shipping status of a customer order. Use it when the user asks where their order is or for an order update.",
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "Order ID in the form ORD-NNNN, e.g. ORD-1001"
}
},
"required": ["orderId"],
"additionalProperties": false
}
}
}
Three details here earn their keep in production:
- The description drives the model's decisions. "Look up the current shipping status" tells the model when to call it; the argument description tells it the expected format. Vague descriptions produce vague tool choices — this is the cheapest place to improve reliability.
requiredandadditionalProperties: falseshrink the attack surface. The model is less likely to invent extra arguments, and your validator has less to reject.- The schema is a hint, not a guarantee. Models routinely send arguments that violate their own schema — wrong types, made-up enum values, missing required fields. The schema guides; your Java code enforces. Never skip the enforcement because the schema "should" have prevented it.
The hand-rolled loop: the whole mechanism in one file
Now the valuable part: the complete loop in one self-contained Java file, using only the JDK (java.net.http for HTTP, plus a small hand-written JSON parser — about 100 lines — so there are no dependencies to argue about). It talks to any OpenAI-compatible chat-completions endpoint. Compile it with javac --release 21 FunctionCallingDemo.java; it needs no libraries and makes no network calls at compile time. To actually run it, set OPENAI_API_KEY (and optionally MODEL).
Block 1 — the tools. Each tool declares its name, description, and parameter schema, and — critically — validates every argument inside execute. A ToolException means "the model sent bad arguments"; the loop converts it into an error message for the model, not a crash. Both demo tools are read-only, which is deliberate: start every tool-calling project with reads.
// ------------------------------------------------------------------ tools
/** A tool the model may ask us to run. Tools are code execution: validate everything. */
interface Tool {
String name();
String description();
/** JSON Schema (as a JSON string) describing the tool's arguments object. */
String parametersSchema();
/** Execute with already-parsed arguments. Throw ToolException for bad input. */
String execute(Map<String, Object> args) throws ToolException;
}
/** Thrown when the model's arguments fail validation. Never let bad args reach your code. */
static final class ToolException extends Exception {
private static final long serialVersionUID = 1L;
ToolException(String message) { super(message); }
}
/** Read-only lookup of a single order. Backed by an in-memory map for the demo. */
static final class GetOrderStatusTool implements Tool {
private final Map<String, String[]> orders = Map.of(
"ORD-1001", new String[]{"SHIPPED", "2026-10-12", "Left the Memphis hub"},
"ORD-1002", new String[]{"PROCESSING", "2026-10-14", "Payment confirmed, picking items"},
"ORD-1003", new String[]{"DELIVERED", "2026-10-09", "Signed for by front desk"}
);
public String name() { return "getOrderStatus"; }
public String description() {
return "Look up the current shipping status of a customer order. "
+ "Use it when the user asks where their order is or for an order update.";
}
public String parametersSchema() {
return """
{"type":"object",
"properties":{"orderId":{"type":"string",
"description":"Order ID in the form ORD-NNNN, e.g. ORD-1001"}},
"required":["orderId"],"additionalProperties":false}""";
}
public String execute(Map<String, Object> args) throws ToolException {
Object raw = args.get("orderId");
if (!(raw instanceof String id) || !id.matches("ORD-\\d{4}")) {
throw new ToolException(
"orderId must look like ORD-1001; got: " + raw);
}
String[] rec = orders.get(id);
if (rec == null) {
// Unknown but well-formed ID: report it as data, not as a crash.
return "{\"orderId\":" + q(id) + ",\"found\":false}";
}
return "{\"orderId\":" + q(id)
+ ",\"found\":true"
+ ",\"status\":" + q(rec[0])
+ ",\"updated\":" + q(rec[1])
+ ",\"note\":" + q(rec[2]) + "}";
}
}
/** Read-only listing of recent orders. Demonstrates numeric + enum-style validation. */
static final class ListRecentOrdersTool implements Tool {
private static final List<String> VALID_FILTERS = List.of("ALL", "SHIPPED", "PROCESSING", "DELIVERED");
public String name() { return "listRecentOrders"; }
public String description() {
return "List the customer's most recent orders, newest first. "
+ "Use it when the user asks for an overview of their orders.";
}
public String parametersSchema() {
return """
{"type":"object",
"properties":{
"limit":{"type":"integer","minimum":1,"maximum":10,
"description":"How many orders to return (1-10)"},
"statusFilter":{"type":"string","enum":["ALL","SHIPPED","PROCESSING","DELIVERED"],
"description":"Only return orders with this status"}},
"required":["limit"],"additionalProperties":false}""";
}
public String execute(Map<String, Object> args) throws ToolException {
Object rawLimit = args.get("limit");
if (!(rawLimit instanceof Long limit) || limit < 1 || limit > 10) {
throw new ToolException("limit must be an integer between 1 and 10; got: " + rawLimit);
}
Object rawFilter = args.getOrDefault("statusFilter", "ALL");
if (!(rawFilter instanceof String filter) || !VALID_FILTERS.contains(filter)) {
throw new ToolException("statusFilter must be one of " + VALID_FILTERS + "; got: " + rawFilter);
}
// Demo data; a real tool would query your database here.
var rows = new ArrayList<String>();
rows.add("{\"orderId\":\"ORD-1003\",\"status\":\"DELIVERED\"}");
rows.add("{\"orderId\":\"ORD-1002\",\"status\":\"PROCESSING\"}");
rows.add("{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\"}");
var kept = new ArrayList<String>();
for (String row : rows) {
if (filter.equals("ALL") || row.contains("\"" + filter + "\"")) kept.add(row);
if (kept.size() >= limit) break;
}
return "[" + String.join(",", kept) + "]";
}
}
Block 2 — JSON plumbing. A minimal JSON parser (objects, arrays, strings with escapes, numbers, booleans, null) that also remembers the raw text of each value. We need that raw text for one subtle reason: when the model emits tool calls, the API requires us to echo the assistant's message back verbatim in the next request, and re-serializing by hand risks corrupting it. Capturing the raw substring sidesteps the problem entirely.
// ------------------------------------------------------------- JSON plumbing
/** Minimal JSON value model. Enough to parse chat-completions responses; nothing more. */
static final class Json {
record Node(Object value, String raw) {
@SuppressWarnings("unchecked")
Map<String, Node> asObject() { return (Map<String, Node>) value; }
@SuppressWarnings("unchecked")
List<Node> asArray() { return (List<Node>) value; }
String asString() { return (String) value; }
}
static Node parse(String text) {
Parser p = new Parser(text);
Node n = p.value();
p.ws();
if (p.pos != p.s.length()) throw new IllegalArgumentException("Trailing data after JSON value");
return n;
}
private static final class Parser {
final String s; int pos;
Parser(String s) { this.s = s; }
void ws() {
while (pos < s.length()) {
char c = s.charAt(pos);
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') pos++;
else break;
}
}
Node value() {
ws();
int start = pos;
if (pos >= s.length()) throw new IllegalArgumentException("Unexpected end of JSON input");
char c = s.charAt(pos);
Object v = switch (c) {
case '{' -> object();
case '[' -> array();
case '"' -> string();
case 't' -> literal("true", Boolean.TRUE);
case 'f' -> literal("false", Boolean.FALSE);
case 'n' -> literal("null", null);
default -> number();
};
return new Node(v, s.substring(start, pos));
}
Map<String, Node> object() {
pos++; // consume '{'
Map<String, Node> map = new LinkedHashMap<>();
ws();
if (pos < s.length() && s.charAt(pos) == '}') { pos++; return map; }
while (true) {
ws();
if (pos >= s.length() || s.charAt(pos) != '"')
throw new IllegalArgumentException("Expected string key at index " + pos);
String key = string();
ws();
expect(':');
map.put(key, value());
ws();
if (pos >= s.length()) throw new IllegalArgumentException("Unterminated object");
char c = s.charAt(pos++);
if (c == '}') break;
if (c != ',') throw new IllegalArgumentException("Expected ',' or '}' at index " + (pos - 1));
}
return map;
}
List<Node> array() {
pos++; // consume '['
List<Node> list = new ArrayList<>();
ws();
if (pos < s.length() && s.charAt(pos) == ']') { pos++; return list; }
while (true) {
list.add(value());
ws();
if (pos >= s.length()) throw new IllegalArgumentException("Unterminated array");
char c = s.charAt(pos++);
if (c == ']') break;
if (c != ',') throw new IllegalArgumentException("Expected ',' or ']' at index " + (pos - 1));
}
return list;
}
String string() {
pos++; // consume opening quote
StringBuilder sb = new StringBuilder();
while (true) {
if (pos >= s.length()) throw new IllegalArgumentException("Unterminated string");
char c = s.charAt(pos++);
if (c == '"') break;
if (c == '\\') {
if (pos >= s.length()) throw new IllegalArgumentException("Unterminated escape");
char e = s.charAt(pos++);
switch (e) {
case '"' -> sb.append('"');
case '\\' -> sb.append('\\');
case '/' -> sb.append('/');
case 'b' -> sb.append('\b');
case 'f' -> sb.append('\f');
case 'n' -> sb.append('\n');
case 'r' -> sb.append('\r');
case 't' -> sb.append('\t');
case 'u' -> {
if (pos + 4 > s.length())
throw new IllegalArgumentException("Bad unicode escape");
sb.append((char) Integer.parseInt(s.substring(pos, pos + 4), 16));
pos += 4;
}
default -> throw new IllegalArgumentException("Bad escape \\" + e);
}
} else {
sb.append(c);
}
}
return sb.toString();
}
Object literal(String word, Object v) {
if (!s.startsWith(word, pos)) throw new IllegalArgumentException("Bad literal at index " + pos);
pos += word.length();
return v;
}
Object number() {
int start = pos;
while (pos < s.length() && "-+0123456789.eE".indexOf(s.charAt(pos)) >= 0) pos++;
String num = s.substring(start, pos);
try {
if (num.contains(".") || num.contains("e") || num.contains("E"))
return Double.parseDouble(num);
return Long.parseLong(num);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Bad number '" + num + "' at index " + start);
}
}
void expect(char c) {
if (pos >= s.length() || s.charAt(pos) != c)
throw new IllegalArgumentException("Expected '" + c + "' at index " + pos);
pos++;
}
}
}
/** Quote a string for inclusion in JSON we build by hand. */
static String q(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\"";
}
Block 3 — the loop itself. chat() runs the rounds with a hard cap of 8; executeTool() is where the safety policy lives — unknown tools rejected, bad arguments converted to model-readable errors, every invocation audit-logged. Note the two message roles the loop juggles: the assistant's tool-call message goes back verbatim, and each tool result goes back as a role: "tool" message tied to its call ID.
// ------------------------------------------------------------------ the loop
record ToolCall(String id, String name, String argumentsJson) {}
static final class ModelReply {
final String content; // may be null when the model only emits tool calls
final String assistantRaw; // raw JSON of choices[0].message, echoed back into history
final List<ToolCall> toolCalls;
ModelReply(String content, String assistantRaw, List<ToolCall> toolCalls) {
this.content = content; this.assistantRaw = assistantRaw; this.toolCalls = toolCalls;
}
}
private static final String API_URL = "https://api.openai.com/v1/chat/completions";
private static final int MAX_ROUNDS = 8; // hard cap: a confused model must not loop forever
private static final String SYSTEM_PROMPT = """
You are a support assistant for an online store. You have tools that read order data.
When the user asks about an order, call the right tool with validated arguments.
Answer in plain language using the tool results. Never invent order details you did not receive from a tool.""";
private final HttpClient http = HttpClient.newHttpClient();
private final String apiKey = apiKey();
private final String model = System.getenv().getOrDefault("MODEL", "gpt-4.1-mini");
private final Map<String, Tool> tools = new LinkedHashMap<>();
private static String apiKey() {
String key = System.getenv("OPENAI_API_KEY");
if (key == null || key.isBlank())
throw new IllegalStateException("Set the OPENAI_API_KEY environment variable first.");
return key;
}
void register(Tool tool) { tools.put(tool.name(), tool); }
/** Run one user turn through the tool-calling loop and return the final answer. */
String chat(String userMessage) throws IOException, InterruptedException {
List<String> messages = new ArrayList<>();
messages.add(msg("system", SYSTEM_PROMPT));
messages.add(msg("user", userMessage));
for (int round = 1; round <= MAX_ROUNDS; round++) {
ModelReply reply = parseReply(post(requestBody(messages)));
if (reply.toolCalls.isEmpty()) {
return reply.content == null ? "" : reply.content; // final answer
}
messages.add(reply.assistantRaw); // echo the tool_call message back, verbatim
for (ToolCall tc : reply.toolCalls) {
messages.add(toolMessage(tc.id(), executeTool(tc)));
}
}
throw new IllegalStateException(
"Model did not produce a final answer within " + MAX_ROUNDS + " tool rounds");
}
/** Execute one tool call: unknown tools and bad arguments become data, not crashes. */
private String executeTool(ToolCall tc) {
String outcome;
String result;
Tool tool = tools.get(tc.name);
if (tool == null) {
outcome = "rejected:unknown_tool";
result = "{\"error\":\"unknown_tool\",\"message\":" + q("No tool named '" + tc.name + "' is registered.") + "}";
} else {
try {
Map<String, Object> args = javaValues(Json.parse(tc.argumentsJson).asObject());
result = tool.execute(args);
outcome = "ok";
} catch (ToolException te) {
// Argument hallucination goes here: tell the model exactly what was wrong.
outcome = "rejected:invalid_arguments";
result = "{\"error\":\"invalid_arguments\",\"message\":" + q(te.getMessage()) + "}";
} catch (Exception e) {
outcome = "error:exception";
result = "{\"error\":\"tool_failed\",\"message\":" + q("Tool '" + tc.name + "' failed: " + e) + "}";
}
}
String logLine = "tool=" + tc.name + " outcome=" + outcome + " args=" + tc.argumentsJson;
AUDIT.info(logLine); // audit every invocation: who asked, what ran, what happened
return result;
}
// ------------------------------------------------------- HTTP + JSON wiring
private String post(String body) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(API_URL))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() / 100 != 2) {
throw new IOException("Model API returned HTTP " + resp.statusCode()
+ ": " + resp.body().substring(0, Math.min(500, resp.body().length())));
}
return resp.body();
}
private String requestBody(List<String> messageJsons) {
StringBuilder toolsJson = new StringBuilder("[");
boolean first = true;
for (Tool t : tools.values()) {
if (!first) toolsJson.append(',');
first = false;
toolsJson.append("{\"type\":\"function\",\"function\":{")
.append("\"name\":").append(q(t.name())).append(',')
.append("\"description\":").append(q(t.description())).append(',')
.append("\"parameters\":").append(t.parametersSchema())
.append("}}");
}
toolsJson.append(']');
return "{\"model\":" + q(model)
+ ",\"messages\":[" + String.join(",", messageJsons) + "]"
+ ",\"tools\":" + toolsJson
+ ",\"tool_choice\":\"auto\"}";
}
private static String msg(String role, String content) {
return "{\"role\":" + q(role) + ",\"content\":" + (content == null ? "null" : q(content)) + "}";
}
private static String toolMessage(String toolCallId, String content) {
return "{\"role\":\"tool\",\"tool_call_id\":" + q(toolCallId)
+ ",\"content\":" + q(content) + "}";
}
private ModelReply parseReply(String json) {
Json.Node message = Json.parse(json).asObject()
.get("choices").asArray().get(0).asObject()
.get("message");
Map<String, Json.Node> m = message.asObject();
String content = m.get("content").value() instanceof String s ? s : null;
List<ToolCall> calls = new ArrayList<>();
Json.Node tcNode = m.get("tool_calls");
if (tcNode != null && tcNode.value() instanceof List<?> raw) {
for (Object o : raw) {
Map<String, Json.Node> item = ((Json.Node) o).asObject();
Map<String, Json.Node> fn = item.get("function").asObject();
calls.add(new ToolCall(
item.get("id").asString(),
fn.get("name").asString(),
fn.get("arguments").asString()));
}
}
return new ModelReply(content, message.raw(), calls);
}
/** Convert parsed JSON nodes into plain Java values (String, Long, Double, Boolean, null, Map, List). */
private static Map<String, Object> javaValues(Map<String, Json.Node> obj) {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : obj.entrySet()) out.put(e.getKey(), javaValue(e.getValue()));
return out;
}
@SuppressWarnings("unchecked")
private static Object javaValue(Json.Node n) {
return switch (n.value()) {
case Map<?, ?> map -> {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : ((Map<String, Json.Node>) map).entrySet())
out.put(e.getKey(), javaValue(e.getValue()));
yield out;
}
case List<?> list -> {
List<Object> out = new ArrayList<>();
for (Object o : list) out.add(javaValue((Json.Node) o));
yield out;
}
case null -> null;
default -> n.value();
};
}
public static void main(String[] args) throws Exception {
FunctionCallingDemo demo = new FunctionCallingDemo();
demo.register(new GetOrderStatusTool());
demo.register(new ListRecentOrdersTool());
System.out.println(demo.chat("Where is my order ORD-1001?"));
}
Run it with java -cp classes FunctionCallingDemo (after compiling) and a typical turn plays out like the transcript above: one tool round, then a grounded answer. If the model ever calls tools twice — say listRecentOrders followed by getOrderStatus for each order — the loop simply keeps going until a round returns plain text. That multi-round behavior is exactly what makes the cap in MAX_ROUNDS load-bearing rather than decorative.
The framework shorthand
Once you understand the loop, the frameworks are just conveniences over it: they build the JSON schemas from your method signatures, run the rounds, and call your methods via reflection. You still own validation, and you still own the safety policy — the framework does not validate for you.
LangChain4j (verified against its current docs) collapses the wiring to an annotation. dev.langchain4j.agent.tool.Tool marks the method; AiServices.builder(...).tools(...) registers it, and the AI service runs the loop for you:
import dev.langchain4j.agent.tool.Tool;
import dev.langchain4j.service.AiServices;
class OrderTools {
private final OrderService orders;
OrderTools(OrderService orders) { this.orders = orders; }
@Tool("Look up the current shipping status of a customer order. "
+ "Use it when the user asks where their order is.")
String getOrderStatus(String orderId) {
// Same validation as the hand-rolled version — the framework
// does NOT do this for you.
if (orderId == null || !orderId.matches("ORD-\\d{4}"))
return "{\"error\":\"invalid_arguments\","
+ "\"message\":\"orderId must look like ORD-1001\"}";
return orders.statusAsJson(orderId); // your read-only lookup
}
}
interface SupportAssistant {
String chat(String message);
}
SupportAssistant assistant = AiServices.builder(SupportAssistant.class)
.chatModel(model)
.tools(new OrderTools(orderService))
.build();
String answer = assistant.chat("Where is my order ORD-1001?");
Spring AI offers the same shape with org.springframework.ai.tool.annotation.Tool and @ToolParam for argument descriptions, registered via ToolCallbacks.from(...) and attached to a ChatClient prompt:
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
class OrderTools {
@Tool(description = "Look up the current shipping status of a customer order.")
String getOrderStatus(
@ToolParam(description = "Order ID like ORD-1001") String orderId) {
// ...validate exactly as above, then look up...
return "{\"orderId\":\"" + orderId + "\",\"status\":\"SHIPPED\"}";
}
}
// Sketch: ToolCallbacks.from(new OrderTools()) wrapped and attached to the
// ChatClient prompt. Check the docs for your Spring AI version before wiring
// this up — the tool API moved from FunctionCallback to ToolCallback in the
// 1.x line, and method names have shifted between releases.
The honest summary: use a framework for the plumbing, but keep the hand-rolled mental model. When a tool call misbehaves in production at 2 a.m., you will debug it as "which round, which arguments, what did we feed back" — exactly the loop above.
Safety: tools are code execution
This is the most important section of the post. Everything before it was mechanics; this is what keeps the mechanics from hurting you. A tool is a function your code runs because a probabilistic model asked it to. Treat the model's request the way you would treat any untrusted input — because that is what it is.
1. Validate every argument, every time. Formats (ORD-\d{4}, not "a string"), ranges (limit between 1 and 10, not "an integer"), allowlists for enums. The demo rejects a SQL-injection-shaped order ID the same way it rejects a typo: with a ToolException that becomes a polite error message for the model. Validation belongs inside the tool, not in the prompt — prompts suggest, code enforces.
2. Least privilege, starting with read-only. Give the model the smallest set of tools that solves the job, and make the first version of each tool read-only. A tool should run with the minimum credentials it needs: the order-lookup tool gets a database user that can SELECT from the orders table and nothing else. If a tool does not need network access, do not give its credentials network-shaped powers.
3. Destructive tools need human approval — no exceptions by default. The moment a tool can change the world — refund an order, cancel a shipment, delete a record — the loop must stop and ask a human before executing. The practical pattern is a two-step tool: the model calls proposeRefund(...), your code creates a pending action and returns "awaiting approval"; a human approves in your admin UI; only then does executeRefund(approvalId) run, and it re-validates everything. Never hand the model a bare deleteOrder tool and hope the prompt keeps it honest. Prompts do not keep anyone honest.
4. Log every invocation. The demo's AUDIT logger records the tool name, the exact arguments, and the outcome of every call. In production this log is append-only and tamper-evident (write it somewhere the model and the tool code cannot rewrite). When something goes wrong — and with probabilistic planners, something eventually goes wrong — "what did the model ask for, exactly?" is the first question you will need answered. Without the log, you are guessing.
5. Keep secrets out of the model's reach. The model sees tool descriptions — never put API keys, connection strings, or internal URLs in them. Credentials live server-side, injected by your code at execution time, never passed through the conversation. If a tool needs to act as the current user, resolve the user server-side from your session (Spring AI's ToolContext pattern exists for exactly this) rather than letting the model supply a user ID it could forge.
6. Treat tool results as data, not instructions. Tool output goes back into the model's context, which means a compromised data source can smuggle instructions into the conversation ("ignore previous instructions and..."). Your system prompt should tell the model that tool results are data to summarize, not orders to obey — and your tools should return structured data (JSON) rather than free-form prose where possible, because structured output is harder to weaponize.
When it breaks: four traps
Trap 1 — the infinite tool loop. The model calls getOrderStatus, gets a result it does not like, and calls it again with slightly different arguments. And again. Each round costs latency and money, and without a cap it never stops. Fix: the hard MAX_ROUNDS cap (the demo throws instead of looping forever), plus a tripwire for the subtler version — if the model issues the same tool call with the same arguments twice in a row, stop and return what you have.
Trap 2 — argument hallucination. The model calls getOrderStatus with {"orderId": "1234"} or {"orderId": "the blue one"}. If your tool concatenates that into SQL or a URL, you have a vulnerability; if it throws an unhandled exception, you have an outage. Fix: validate inside the tool and feed the rejection back as data ({"error":"invalid_arguments",...}), so the model gets a chance to correct itself on the next round. Rejections are part of the protocol, not failures of it.
Trap 3 — the delete tool. Someone adds cancelOrder(orderId) "for convenience," the model misreads a frustrated customer ("cancel my... account"), and a real order is cancelled with no human in the loop. Fix: destructive tools go behind the approval pattern from the safety section, or they do not exist. A useful rule of thumb: if you would not let a junior support agent do it unsupervised on their first day, do not let the model do it unsupervised either.
Trap 4 — no audit log. Everything works for months. Then a customer claims the bot promised a refund it never issued, or finance asks why a tool ran 40,000 times last Tuesday. Without per-invocation logs — tool name, arguments, outcome, timestamp — you cannot reconstruct what happened, which means you cannot fix it or prove it. Fix: the AUDIT logger in the demo is the minimum; in production, ship those logs somewhere append-only.
Field check
Before you wire tools into anything real, run through this list:
- Can you draw the loop from memory — model proposes, your code validates and executes, result feeds back, cap on rounds?
- Does every tool validate every argument against a format, range, or allowlist — not just a type?
- Is every tool read-only unless you have a written reason (and a human-approval step) for it not to be?
- Is every invocation logged with its arguments and outcome, somewhere append-only?
- Could you explain to the tool's data owner exactly what the model can and cannot make it do?
If any answer is no, you do not have a tool-calling feature yet — you have a demo with production ambitions. Close the gap first.
The principle to carry forward: the model proposes; your code disposes — validate everything it asks to run. Function calling works because the responsibilities are split along a trust boundary: the model is brilliant at deciding what to look up and terrible at being careful, while your code is careful by construction. Keep the model on its side of the boundary — proposing — and keep execution, validation, and approval firmly on yours. Do that, and your support bot answers "Where is my order?" with the truth instead of a confident guess.
This post is part of the Java Learning Roadmap 2026 track "AI for Java Developers."
Comments
Post a Comment