Skip to content

Commit f2c7ed1

Browse files
committed
Add typed ChatRequest/ChatResponse + tool calling + agent loop (§2.2)
New typed chat API on top of the existing handleChatCompletions JNI path — no native changes. Value types: - ChatChoice, ChatResponse — choices array, Usage, Timings, raw JSON - ToolCall, ToolDefinition — OAI-shaped tool wire types - ChatMessage (extended) — tool_call_id + tool_calls support, with toolResult() and assistantToolCalls() factory methods (backwards- compatible 2-arg constructor kept for Session and existing tests) - ToolHandler — functional interface for tool callbacks - ChatRequest — builder with messages, tools, tool_choice, maxToolRounds, and an InferenceParameters customizer InferenceParameters: new setMessagesJson(String), setToolsJson(String), setToolChoice(String) for verbatim JSON injection from ChatRequest. LlamaModel: - chat(ChatRequest) → ChatResponse Serializes the request (auto-enables use_jinja when tools present), calls chatComplete, parses the OAI JSON into ChatResponse via the extended ChatResponseParser.parseResponse. - chatWithTools(ChatRequest, Map<String, ToolHandler>) → ChatResponse Agent loop: per round, calls chat(); if the assistant returned tool_calls, invokes each handler (capturing exceptions as {"error":...} tool results so the loop continues), appends the assistant turn and tool-result turns to the request, and loops up to ChatRequest.maxToolRounds (default 8). Unknown tool names produce a {"error":"unknown tool: <name>"} result. ChatResponseParser: new parseResponse() and tool-call/choice parsers; handles both string-shaped and object-shaped tool_calls.arguments (some upstream variants emit each shape). Tests: - ChatResponseTest (7 new unit tests, model-free): plain reply, tool calls with string arguments, object-shaped arguments, malformed input, ChatRequest serialization round-trip. - LlamaModelTest: testTypedChat and testChatWithToolsLoopShortCircuits (model-gated). mvn javadoc:jar BUILD SUCCESS (0 errors, 60 warnings — same as before, none from new files). https://claude.ai/code/session_01R4ZrEy3ptJDLuUgUKuM4Gy
1 parent 70df324 commit f2c7ed1

12 files changed

Lines changed: 930 additions & 4 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
package net.ladenthin.llama;
6+
7+
/**
8+
* One choice in a chat completion response: the assistant message and the finish reason.
9+
* Mirrors the OpenAI {@code choices[i]} object.
10+
*/
11+
public final class ChatChoice {
12+
13+
private final int index;
14+
private final ChatMessage message;
15+
private final String finishReason;
16+
17+
/**
18+
* Construct a chat choice.
19+
*
20+
* @param index the index in the choices array
21+
* @param message the assistant's message for this choice
22+
* @param finishReason the finish reason (e.g. {@code "stop"}, {@code "length"}, {@code "tool_calls"})
23+
*/
24+
public ChatChoice(int index, ChatMessage message, String finishReason) {
25+
this.index = index;
26+
this.message = message;
27+
this.finishReason = finishReason;
28+
}
29+
30+
/**
31+
* Choice index.
32+
* @return the integer index in the choices array
33+
*/
34+
public int getIndex() {
35+
return index;
36+
}
37+
38+
/**
39+
* Assistant message accessor.
40+
* @return the assistant's reply (may include tool_calls)
41+
*/
42+
public ChatMessage getMessage() {
43+
return message;
44+
}
45+
46+
/**
47+
* Finish reason accessor.
48+
* @return the OAI finish reason string, or {@code ""} if absent
49+
*/
50+
public String getFinishReason() {
51+
return finishReason;
52+
}
53+
}

src/main/java/net/ladenthin/llama/ChatMessage.java

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,72 @@
44

55
package net.ladenthin.llama;
66

7+
import java.util.Collections;
8+
import java.util.List;
9+
710
/**
811
* A single message in a chat conversation: a role ({@code "user"}, {@code "assistant"},
9-
* or {@code "system"}) and its textual content. Used by {@link Session} to accumulate
10-
* conversation turns.
12+
* {@code "system"}, or {@code "tool"}) and its textual content. Used by {@link Session}
13+
* to accumulate conversation turns and by {@link ChatRequest} / {@link ChatResponse}
14+
* for the typed chat API.
15+
* <p>
16+
* Tool-call turns have role {@code "assistant"}, possibly empty content, and a non-empty
17+
* {@link #getToolCalls()} list. Tool-result turns have role {@code "tool"}, the tool's
18+
* output as content, and {@link #getToolCallId()} pointing back at the originating call.
19+
* </p>
1120
*/
1221
public final class ChatMessage {
1322

1423
private final String role;
1524
private final String content;
25+
private final String toolCallId;
26+
private final List<ToolCall> toolCalls;
1627

1728
/**
18-
* Construct a chat message.
29+
* Plain user/assistant/system message.
1930
*
20-
* @param role the message role: {@code "user"}, {@code "assistant"}, or {@code "system"}
31+
* @param role the message role
2132
* @param content the message text
2233
*/
2334
public ChatMessage(String role, String content) {
35+
this(role, content, null, Collections.<ToolCall>emptyList());
36+
}
37+
38+
/**
39+
* Full constructor including tool-related fields.
40+
*
41+
* @param role the message role
42+
* @param content the message text (may be empty for assistant tool-call turns)
43+
* @param toolCallId for tool-result turns ({@code role="tool"}), the id of the originating call; {@code null} otherwise
44+
* @param toolCalls for assistant tool-call turns, the list of calls; empty otherwise
45+
*/
46+
public ChatMessage(String role, String content, String toolCallId, List<ToolCall> toolCalls) {
2447
this.role = role;
2548
this.content = content;
49+
this.toolCallId = toolCallId;
50+
this.toolCalls = toolCalls == null ? Collections.<ToolCall>emptyList() : toolCalls;
51+
}
52+
53+
/**
54+
* Factory for a tool-result turn.
55+
*
56+
* @param toolCallId the id of the originating tool call
57+
* @param content the tool's output as a string
58+
* @return a {@link ChatMessage} with role {@code "tool"}
59+
*/
60+
public static ChatMessage toolResult(String toolCallId, String content) {
61+
return new ChatMessage("tool", content, toolCallId, Collections.<ToolCall>emptyList());
62+
}
63+
64+
/**
65+
* Factory for an assistant turn that issues tool calls.
66+
*
67+
* @param content optional reasoning text accompanying the tool calls (may be empty)
68+
* @param toolCalls the tool calls to issue
69+
* @return a {@link ChatMessage} with role {@code "assistant"}
70+
*/
71+
public static ChatMessage assistantToolCalls(String content, List<ToolCall> toolCalls) {
72+
return new ChatMessage("assistant", content == null ? "" : content, null, toolCalls);
2673
}
2774

2875
/**
@@ -41,8 +88,26 @@ public String getContent() {
4188
return content;
4289
}
4390

91+
/**
92+
* Tool-call id for tool-result turns.
93+
* @return the originating tool call id, or {@code null} for non-tool messages
94+
*/
95+
public String getToolCallId() {
96+
return toolCallId;
97+
}
98+
99+
/**
100+
* Tool calls issued by an assistant turn.
101+
* @return the calls list, never {@code null}; empty when the message is not a tool-call turn
102+
*/
103+
public List<ToolCall> getToolCalls() {
104+
return toolCalls;
105+
}
106+
44107
@Override
45108
public String toString() {
109+
if (!toolCalls.isEmpty()) return role + " (tool_calls=" + toolCalls.size() + "): " + content;
110+
if (toolCallId != null) return role + " (tool_call_id=" + toolCallId + "): " + content;
46111
return role + ": " + content;
47112
}
48113
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
2+
//
3+
// SPDX-License-Identifier: MIT
4+
5+
package net.ladenthin.llama;
6+
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.fasterxml.jackson.databind.node.ArrayNode;
9+
import com.fasterxml.jackson.databind.node.ObjectNode;
10+
11+
import java.util.ArrayList;
12+
import java.util.Collections;
13+
import java.util.List;
14+
import java.util.function.Consumer;
15+
16+
/**
17+
* Builder for a typed chat completion call.
18+
* <p>
19+
* Bundles the conversation messages, optional tool definitions, an optional
20+
* {@code tool_choice} hint, and an {@link InferenceParameters} customizer that gets
21+
* applied to the underlying request just before invocation. Built with the fluent
22+
* setters; consumed by {@link LlamaModel#chat(ChatRequest)} and
23+
* {@link LlamaModel#chatWithTools(ChatRequest, java.util.Map)}.
24+
* </p>
25+
*/
26+
public final class ChatRequest {
27+
28+
private static final ObjectMapper MAPPER = new ObjectMapper();
29+
30+
private final List<ChatMessage> messages = new ArrayList<ChatMessage>();
31+
private final List<ToolDefinition> tools = new ArrayList<ToolDefinition>();
32+
private String toolChoice;
33+
private int maxToolRounds = 8;
34+
private Consumer<InferenceParameters> paramsCustomizer;
35+
36+
/** Construct an empty request; populate via the setters. */
37+
public ChatRequest() {
38+
// empty
39+
}
40+
41+
/**
42+
* Append a message to the conversation.
43+
* @param message the message to append
44+
* @return this builder
45+
*/
46+
public ChatRequest addMessage(ChatMessage message) {
47+
messages.add(message);
48+
return this;
49+
}
50+
51+
/**
52+
* Convenience for adding a system/user/assistant turn.
53+
* @param role the role
54+
* @param content the content
55+
* @return this builder
56+
*/
57+
public ChatRequest addMessage(String role, String content) {
58+
messages.add(new ChatMessage(role, content));
59+
return this;
60+
}
61+
62+
/**
63+
* Append a tool definition.
64+
* @param tool the tool definition to expose to the model
65+
* @return this builder
66+
*/
67+
public ChatRequest addTool(ToolDefinition tool) {
68+
tools.add(tool);
69+
return this;
70+
}
71+
72+
/**
73+
* Set the {@code tool_choice} hint: typically {@code "auto"}, {@code "none"}, or
74+
* {@code "required"}. Defaults to absent (server default applies).
75+
*
76+
* @param toolChoice the hint string, or {@code null} to clear
77+
* @return this builder
78+
*/
79+
public ChatRequest setToolChoice(String toolChoice) {
80+
this.toolChoice = toolChoice;
81+
return this;
82+
}
83+
84+
/**
85+
* Set the maximum number of agent-loop rounds for
86+
* {@link LlamaModel#chatWithTools(ChatRequest, java.util.Map)}. A round is one
87+
* model call followed by zero or more tool invocations. Default {@code 8}.
88+
*
89+
* @param maxToolRounds the round cap (must be positive)
90+
* @return this builder
91+
*/
92+
public ChatRequest setMaxToolRounds(int maxToolRounds) {
93+
if (maxToolRounds <= 0) {
94+
throw new IllegalArgumentException("maxToolRounds must be > 0");
95+
}
96+
this.maxToolRounds = maxToolRounds;
97+
return this;
98+
}
99+
100+
/**
101+
* Register a callback that customizes the {@link InferenceParameters} (e.g.
102+
* {@code setNPredict}, {@code setTemperature}) right before each request is sent.
103+
*
104+
* @param customizer the customizer; {@code null} clears any prior customizer
105+
* @return this builder
106+
*/
107+
public ChatRequest setInferenceCustomizer(Consumer<InferenceParameters> customizer) {
108+
this.paramsCustomizer = customizer;
109+
return this;
110+
}
111+
112+
/**
113+
* Messages accessor.
114+
* @return an unmodifiable view of the messages added so far
115+
*/
116+
public List<ChatMessage> getMessages() {
117+
return Collections.unmodifiableList(messages);
118+
}
119+
120+
/**
121+
* Tools accessor.
122+
* @return an unmodifiable view of the tool definitions added so far
123+
*/
124+
public List<ToolDefinition> getTools() {
125+
return Collections.unmodifiableList(tools);
126+
}
127+
128+
/**
129+
* Tool choice accessor.
130+
* @return the {@code tool_choice} hint, or {@code null} when unset
131+
*/
132+
public String getToolChoice() {
133+
return toolChoice;
134+
}
135+
136+
/**
137+
* Max rounds accessor.
138+
* @return the agent-loop round cap
139+
*/
140+
public int getMaxToolRounds() {
141+
return maxToolRounds;
142+
}
143+
144+
/**
145+
* Build the OAI-style {@code messages} array as a JSON string. Each entry carries
146+
* role and content; assistant tool-call turns add a {@code tool_calls} array; tool-
147+
* result turns add a {@code tool_call_id} field.
148+
*
149+
* @return the JSON array as a string
150+
*/
151+
public String buildMessagesJson() {
152+
ArrayNode arr = MAPPER.createArrayNode();
153+
for (ChatMessage m : messages) {
154+
ObjectNode obj = MAPPER.createObjectNode();
155+
obj.put("role", m.getRole());
156+
obj.put("content", m.getContent() == null ? "" : m.getContent());
157+
if (m.getToolCallId() != null) {
158+
obj.put("tool_call_id", m.getToolCallId());
159+
}
160+
if (!m.getToolCalls().isEmpty()) {
161+
ArrayNode tc = MAPPER.createArrayNode();
162+
for (ToolCall call : m.getToolCalls()) {
163+
ObjectNode entry = MAPPER.createObjectNode();
164+
entry.put("id", call.getId());
165+
entry.put("type", "function");
166+
ObjectNode fn = MAPPER.createObjectNode();
167+
fn.put("name", call.getName());
168+
fn.put("arguments", call.getArgumentsJson() == null ? "" : call.getArgumentsJson());
169+
entry.set("function", fn);
170+
tc.add(entry);
171+
}
172+
obj.set("tool_calls", tc);
173+
}
174+
arr.add(obj);
175+
}
176+
return arr.toString();
177+
}
178+
179+
/**
180+
* Build the OAI-style {@code tools} array as a JSON string. Returns {@code null}
181+
* when no tools were added.
182+
*
183+
* @return the JSON array as a string, or {@code null} when there are no tools
184+
*/
185+
public String buildToolsJson() {
186+
if (tools.isEmpty()) return null;
187+
ArrayNode arr = MAPPER.createArrayNode();
188+
for (ToolDefinition t : tools) {
189+
ObjectNode entry = MAPPER.createObjectNode();
190+
entry.put("type", "function");
191+
ObjectNode fn = MAPPER.createObjectNode();
192+
fn.put("name", t.getName());
193+
if (t.getDescription() != null) fn.put("description", t.getDescription());
194+
try {
195+
fn.set("parameters", MAPPER.readTree(t.getParametersSchemaJson()));
196+
} catch (Exception e) {
197+
fn.put("parameters", t.getParametersSchemaJson());
198+
}
199+
entry.set("function", fn);
200+
arr.add(entry);
201+
}
202+
return arr.toString();
203+
}
204+
205+
/**
206+
* Apply the optional customizer to an {@link InferenceParameters} instance.
207+
* Package-private; called by {@link LlamaModel}.
208+
*
209+
* @param params the parameters to mutate
210+
*/
211+
void applyCustomizer(InferenceParameters params) {
212+
if (paramsCustomizer != null) {
213+
paramsCustomizer.accept(params);
214+
}
215+
}
216+
}

0 commit comments

Comments
 (0)