Build your own agent harness, part 3: the model never runs anything
So far, our harness is a list we resend, and we know what it costs. Nice. But let's face it, our model is still a chatterbox. It produces text, from a history we send, and that's all. Ask it what time it is: it has no idea. Ask it to read a file: it can't. Nothing outside that text exists for it.
And yet, the agents I use every day read my files, run my tests, look things up. So how does a model that only produces text do anything?
Spoiler: it doesn't. It never runs anything. It can only ask. Today we add a first tool, and we watch that exchange happen.
A tool is only a description
Let's start with the most boring tool in the world: telling the time. Here is how the model sees it, in Agent.java:
// A tool is only a description sent to the model: a name, what it does, and its parameters.
final ToolSpecification GET_TIME = ToolSpecification.builder()
.name("get_time")
.description("Returns the current local date and time")
.build();
A name and a sentence saying what it does. A tool can also declare the JSON schema of its parameters, but telling the time needs none. No code. No link to any Java method. That description is all the model will ever know about our tool. The model will decide whether to use it based on these few words, so they matter.
Sending it with every request
Until now we called model.chat(history). To attach the tool, we build a ChatRequest instead:
AiMessage send(ChatModel model, List<ChatMessage> history) {
var request = ChatRequest.builder().messages(history).toolSpecifications(GET_TIME).build();
var response = model.chat(request);
history.add(response.aiMessage());
// ... token counting, unchanged from article 02 ...
return response.aiMessage();
}
(I abridged it: the counting lines are the ones from the previous article, now wrapped in if (usage != null). Usage data is optional in the API, and step 02 crashed when a provider left it out. A harness treats optional fields as optional.)
The tool list travels with every request, just like the history. And since we've learned to count: yes, those descriptions are input tokens too, paid at every call πΈ
The counting moved into this little send() method for a reason. As we're about to see, one user question can now lead to two calls to the model.
The model asks, the harness acts
When the model decides it needs our tool, its answer contains no text. Instead, it contains a tool execution request: the tool name, the arguments, and an id. At that point, nothing has happened yet. The model has asked, and is waiting.
So the harness has to look at the answer:
// The model cannot run anything: it can only ASK us to run a tool.
if (answer.hasToolExecutionRequests()) {
for (var call : answer.toolExecutionRequests()) {
display.toolCall(call);
String result = execute(call);
display.toolResult(result);
history.add(ToolExecutionResultMessage.from(call, result));
}
// Second round trip, so the model can use the result. Step 4 turns this "if" into a "while".
answer = send(model, history);
}
A few things worth a sentence:
toolExecutionRequests()is a list: a model may ask for several tools at once.- The result goes back into the history as a
ToolExecutionResultMessage. A tool result is just another message in our list. Yes, the list again π ToolExecutionResultMessage.from(call, result)carries the id of the request. That's how the model matches each result to what it asked.- Then we call the model a second time, with the whole history, request and result included. Now it can answer.
The harness runs the code
And who actually runs something? Us:
// The harness, not the model, runs the code.
String execute(ToolExecutionRequest call) {
return switch (call.name()) {
case "get_time" -> LocalDateTime.now().withNano(0).toString();
default -> "Error: unknown tool " + call.name();
};
}
Plain Java. The harness receives a name, picks the code to run, and returns a string. Note the default case: an unknown tool name gets an error string, not an exception. The model reads it like any other result.
display.toolCall and display.toolResult only render: β get_time, then β followed by the result.
Run it
export LLM_API_KEY=...
jbang 03-first-tool/Agent.java --inspect
Try it
Ask:
What time is it?
You see β get_time, then β with the date and time, then the answer. And look at the grey line: 2 calls for one question. The first one where the model asks, the second one where it answers with the result.
Now say:
Hello
No tool call this time. The model only asks when it needs to. We send the tool with every request, but the model decides when to use it.
What the JSON shows
inspect.log tells the whole story, in three steps. Here is my run for "What time is it?" (abridged, I only kept what matters).
First request: the usual history, plus a tools array.
{ "model" : "gpt-6-luna",
"input" : [
{ "type" : "message", "role" : "system", "content" : [ { "type" : "input_text", "text" : "You are a concise assistant for software developers.\nAnswer in at most three sentences unless asked otherwise.\n" } ] },
{ "type" : "message", "role" : "user", "content" : [ { "type" : "input_text", "text" : "What time is it?" } ] } ],
"stream" : false,
"store" : false,
"tools" : [ {
"type" : "function",
"name" : "get_time",
"description" : "Returns the current local date and time",
"parameters" : { "type" : "object", "properties" : { }, "required" : [ ] },
"strict" : false
} ]
}
There's our tool: its name, its description, and an empty parameter schema. Our code declares no parameters at all: LangChain4j sends an empty object schema, as the API expects.
First response: no text at all. Instead, the output holds a request.
"output": [
{ "type": "function_call", "status": "completed", "arguments": "{}", "call_id": "call_ZLgfOGNH5dm3thSBPN6b8juI", "name": "get_time" }
]
The model asks for get_time, with no arguments, and gives the request an id: call_id. Nothing has run yet.
Second request: the same history, with two more items at the end.
{ "type" : "function_call", "call_id" : "call_ZLgfOGNH5dm3thSBPN6b8juI", "name" : "get_time", "arguments" : "{}" },
{ "type" : "function_call_output", "call_id" : "call_ZLgfOGNH5dm3thSBPN6b8juI", "output" : "2026-10-26T11:15:30" }
The model's request, then our answer, linked by the same call_id. Our harness ran LocalDateTime.now(), and the result is just a string in the history. The tools array is sent again too, of course. And the model can finally answer: "It's 11:15 AM on October 26, 2026."
LangChain4j hides these names behind ToolExecutionRequest and ToolExecutionResultMessage. The wire format is provider-specific, the concept is not: the model asks, the harness answers.
One round trip, and not one more
Let's push it a bit:
What time is it, and what time will it be in 3 hours? Check with the tool each time.
If the model asks for the tool a second time after the first result, the displayed answer isβ¦ null. Our harness only allows one round trip: one if, one extra call, and that's it. The model wanted to continue, and we just stopped listening π
I'm leaving it broken on purpose. Read the code comment again: "Step 4 turns this if into a while".
Limits
- One round trip only, as we've just seen.
- Tool descriptions are sent with every request: more tools, more input tokens, at every call.
- Our tool is harmless: telling the time never broke anything. But as soon as a tool can change things, who decides to run it becomes the real question. We'll get there.
As usual, the code, a README to run it and things to try are in the step-03 folder.
Next time
What if the model needs a second tool call, or a third? Replace one if by a while⦠and the chat becomes an agent.
Tell me what you think π