Skip to content

Commit 97d0163

Browse files
authored
Add multi-turn tool-call evaluation (#146)
Tool calls now live on the conversation model, so the existing agent evaluators run across a whole conversation, not just a single agent turn. An assistant `Message` carries the tool calls it made (a typed `List<ToolCall>`, the same model the agent evaluators already use). `ConversationTrajectory` exposes them flat (`toolCalls()`) and per turn (`toolCallsByTurn()`), and bridges to the evaluators with `toAgentOutputs()` and `toTestCase(...)`. The deterministic tool evaluators and the judge ones run unchanged over the conversation. ## What's included - `Message` gains a typed `toolCalls` component and `assistant(content, toolCalls)`. The 3-arg constructor stays, so existing call sites compile and link unchanged. - `ConversationTrajectory`: `toolCalls()`, `toolCallsByTurn()`, `toAgentTrace()`, `toAgentOutputs()`, and `toTestCase()`/`(tools)` (deterministic, last user message as input) versus `toTestCase(tools, tasks)` (judge path, full transcript as input so the dialog is not re-wrapped). - `TrajectoryEvaluator.includeToolCalls(boolean)`, default off, so existing judge prompts are byte-identical. - `dev.dokimos.core.agents.ToolCalls.coerce(...)`, shared by the bridge and `AgentEvalCasts`. - Kotlin DSL overloads, a runnable `MultiTurnToolCallExample`, docs, and a live `LangChain4jMultiTurnToolIT`. Reuses the existing `ToolCall`, so a tool call on a turn keeps its typed `resultAs`/`argumentsAs`/`resultJson`. Tool-free conversations serialize byte-identically; `toText()`/`toJson()` only change when a turn has calls.
1 parent 124bf03 commit 97d0163

23 files changed

Lines changed: 2307 additions & 32 deletions

File tree

docs/docs/evaluation/agent-evaluation.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,10 @@ val withTools = trace.toTestCase("Find flights from NYC to Paris", tools)
419419
</TabItem>
420420
</Tabs>
421421

422+
:::tip Multi-turn agents
423+
These evaluators score one set of tool calls. When tools are called across a back-and-forth conversation, attach the calls to each assistant turn and score the conversation per turn, with the same evaluators and no LLM. A `ConversationTrajectory` exposes `toolCallsByTurn()` for per-turn scoring and `toTestCase(tools)` / `toTestCase(tools, tasks)` for the whole-conversation deterministic and judge paths. See [Tool Calls on Turns](./multi-turn-conversations.md#tool-calls-on-turns).
424+
:::
425+
422426
## Extracting Traces from Your Framework
423427

424428
The examples above assume you already have an `AgentTrace`. In practice your agent runs on a framework, and Dokimos ships extractors that turn a framework's own run result into an `AgentTrace`, so you don't hand-write the mapping. Each extractor captures the tool calls (name, parsed arguments, and result) and the final response.

docs/docs/evaluation/multi-turn-conversations.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,190 @@ trajectory.toText() // Plain text transcript
174174
</TabItem>
175175
</Tabs>
176176

177+
### Tool Calls on Turns
178+
179+
A real agent calls tools mid-conversation: it looks up the weather, searches flights, then books a hotel. An assistant turn can carry the tool calls it made, so you can score *what the agent did each turn*, not just what it said.
180+
181+
Attach a typed `List<ToolCall>` to an assistant turn. A turn that called no tools needs no change.
182+
183+
<Tabs groupId="lang" defaultValue="java">
184+
<TabItem value="java" label="Java">
185+
186+
```java
187+
ConversationTrajectory trajectory = ConversationTrajectory.builder()
188+
.userMessage("What's the weather in Paris?")
189+
.assistantMessage("It's 18C and sunny.", List.of(
190+
ToolCall.builder().name("get_weather").argument("city", "Paris").result("18C, sunny").build()
191+
))
192+
.userMessage("Book me a hotel there.")
193+
.assistantMessage("Booked the Hotel Le Marais.", List.of(
194+
ToolCall.of("book_hotel", Map.of("city", "Paris"))
195+
))
196+
.userMessage("Thanks!")
197+
.assistantMessage("You're all set!") // tool-free turn, unchanged
198+
.build();
199+
```
200+
201+
</TabItem>
202+
<TabItem value="kotlin" label="Kotlin">
203+
204+
```kotlin
205+
val trajectory = trajectory {
206+
user("What's the weather in Paris?")
207+
assistant("It's 18C and sunny.", listOf(
208+
ToolCall.builder().name("get_weather").argument("city", "Paris").result("18C, sunny").build()
209+
))
210+
user("Book me a hotel there.")
211+
assistant("Booked the Hotel Le Marais.", listOf(
212+
ToolCall.of("book_hotel", mapOf("city" to "Paris"))
213+
))
214+
user("Thanks!")
215+
assistant("You're all set!") // tool-free turn, unchanged
216+
}
217+
```
218+
219+
</TabItem>
220+
</Tabs>
221+
222+
`Message` carries the tool calls as a typed `List<ToolCall>`; an assistant message built without them returns an empty list. When your app produces a turn, attach the calls with `Message.assistant(content, toolCalls)`.
223+
224+
#### Per-Turn Evaluation (Primary Path)
225+
226+
This is the recommended way to grade tool use across a conversation. `toolCallsByTurn()` returns one tool-call list per assistant turn, in order. Pair each turn with the calls you expected and run the [deterministic agent evaluators](./agent-evaluation.md), with no LLM and no API key.
227+
228+
<Tabs groupId="lang" defaultValue="java">
229+
<TabItem value="java" label="Java">
230+
231+
```java
232+
List<List<ToolCall>> actualByTurn = trajectory.toolCallsByTurn();
233+
List<List<ToolCall>> expectedByTurn = List.of(
234+
List.of(ToolCall.of("get_weather", Map.of())),
235+
List.of(ToolCall.of("book_hotel", Map.of())),
236+
List.of() // final turn calls no tools
237+
);
238+
239+
var validity = ToolCallValidityEvaluator.builder().build();
240+
var correctness = ToolCorrectnessEvaluator.builder().build();
241+
242+
for (int turn = 0; turn < actualByTurn.size(); turn++) {
243+
EvalTestCase turnCase = EvalTestCase.builder()
244+
.actualOutput("toolCalls", actualByTurn.get(turn))
245+
.expectedOutput("toolCalls", expectedByTurn.get(turn))
246+
.metadata("tools", tools)
247+
.build();
248+
249+
EvalResult v = validity.evaluate(turnCase);
250+
EvalResult c = correctness.evaluate(turnCase);
251+
}
252+
```
253+
254+
</TabItem>
255+
<TabItem value="kotlin" label="Kotlin">
256+
257+
```kotlin
258+
val actualByTurn = trajectory.toolCallsByTurn()
259+
val expectedByTurn = listOf(
260+
listOf(ToolCall.of("get_weather", mapOf())),
261+
listOf(ToolCall.of("book_hotel", mapOf())),
262+
listOf<ToolCall>() // final turn calls no tools
263+
)
264+
265+
val validity = ToolCallValidityEvaluator.builder().build()
266+
val correctness = ToolCorrectnessEvaluator.builder().build()
267+
268+
actualByTurn.forEachIndexed { turn, calls ->
269+
val turnCase = EvalTestCase.builder()
270+
.actualOutput("toolCalls", calls)
271+
.expectedOutput("toolCalls", expectedByTurn[turn])
272+
.metadata("tools", tools)
273+
.build()
274+
275+
val v = validity.evaluate(turnCase)
276+
val c = correctness.evaluate(turnCase)
277+
}
278+
```
279+
280+
</TabItem>
281+
</Tabs>
282+
283+
:::note
284+
`toolCallsByTurn()` groups by **assistant message**, which can differ from `turnCount()` (user/assistant pairs) when a conversation has consecutive or leading assistant messages. Each inner list lines up with `assistantMessages()`.
285+
:::
286+
287+
See [`MultiTurnToolCallExample.java`](https://github.com/dokimos-dev/dokimos/blob/master/dokimos-examples/src/main/java/dev/dokimos/examples/conversation/MultiTurnToolCallExample.java) for a complete runnable version.
288+
289+
#### Whole-Conversation Shortcuts
290+
291+
When you want to assert over the whole conversation rather than per turn, build a test case straight from the trajectory.
292+
293+
- `toolCalls()`: every turn's calls flattened into one list, in order.
294+
- `toTestCase()` and `toTestCase(tools)`: a **deterministic** test case. The flattened `toolCalls` go in the actual outputs, the input is the **last user message**, and `tools` (when given) go in metadata. As-is, it feeds the rule-based evaluators that read only actual outputs (validity, error, efficiency). `ToolCorrectnessEvaluator` and `ToolTrajectoryEvaluator` additionally need an expected list, which this path does not set; wire one in yourself (for example, `EvalTestCase.builder().expectedOutput("toolCalls", expected)`) or they throw an `EvaluationException`.
295+
- `toTestCase(tools, tasks)`: the **judge** test case for `TaskCompletionEvaluator` and `ToolArgumentHallucinationEvaluator`. Its input is the rendered transcript of the whole conversation, but tool calls are rendered **name-only** (`[tool: name]`, not `[tool: name(args)]`) so the argument values a hallucination judge assesses never appear in the grounding it reads; the arguments stay available through the actual outputs. No separate output is set, so the transcript is not double-wrapped.
296+
- `toAgentTrace()` / `toAgentOutputs()`: collapse the conversation into a single `AgentTrace` (or its output map) for the standard agent data flow.
297+
298+
<Tabs groupId="lang" defaultValue="java">
299+
<TabItem value="java" label="Java">
300+
301+
```java
302+
// Deterministic: input is the last user message, calls are flattened across turns
303+
EvalTestCase deterministic = trajectory.toTestCase(tools);
304+
EvalResult validity = ToolCallValidityEvaluator.builder().build().evaluate(deterministic);
305+
306+
// Judge: input is the transcript (tool calls name-only), tasks listed in metadata
307+
EvalTestCase judgeCase = trajectory.toTestCase(tools, List.of("Check weather", "Book a hotel"));
308+
EvalResult completion = TaskCompletionEvaluator.builder().judge(judgeLM).build().evaluate(judgeCase);
309+
```
310+
311+
</TabItem>
312+
<TabItem value="kotlin" label="Kotlin">
313+
314+
```kotlin
315+
// Deterministic: input is the last user message, calls are flattened across turns
316+
val deterministic = trajectory.toTestCase(tools)
317+
val validity = ToolCallValidityEvaluator.builder().build().evaluate(deterministic)
318+
319+
// Judge: input is the transcript (tool calls name-only), tasks listed in metadata
320+
val judgeCase = trajectory.toTestCase(tools, listOf("Check weather", "Book a hotel"))
321+
val completion = TaskCompletionEvaluator.builder().judge(judgeLM).build().evaluate(judgeCase)
322+
```
323+
324+
</TabItem>
325+
</Tabs>
326+
327+
#### Tool Calls in the Transcript
328+
329+
`toText()` and `toJson()` render each turn's tool calls. `toText()` adds one compact `[tool: name(args)]` line per call under the message; `toJson()` adds a `toolCalls` array to a turn that has any. A tool-free conversation renders exactly as before, byte-identical, so adding tool calls to one turn never reshapes the rest.
330+
331+
To let the trajectory judge reason over tool usage, turn it on with `includeToolCalls(true)`. It is off by default, so existing judge suites see an unchanged prompt.
332+
333+
<Tabs groupId="lang" defaultValue="java">
334+
<TabItem value="java" label="Java">
335+
336+
```java
337+
TrajectoryEvaluator evaluator = TrajectoryEvaluator.builder()
338+
.name("Support Quality")
339+
.judge(judgeLM)
340+
.criteria(List.of(TrajectoryEvaluationCriteria.goalCompletion()))
341+
.includeToolCalls(true) // render each turn's tool calls in the judge prompt
342+
.build();
343+
```
344+
345+
</TabItem>
346+
<TabItem value="kotlin" label="Kotlin">
347+
348+
```kotlin
349+
// includeToolCalls is on the Java builder; call it directly from Kotlin
350+
val evaluator = TrajectoryEvaluator.builder()
351+
.name("Support Quality")
352+
.judge(judgeLM)
353+
.criteria(listOf(TrajectoryEvaluationCriteria.goalCompletion()))
354+
.includeToolCalls(true) // render each turn's tool calls in the judge prompt
355+
.build()
356+
```
357+
358+
</TabItem>
359+
</Tabs>
360+
177361
### Simulated Users
178362

179363
A simulated user types the user side of the chat. The `SimulatedUser` interface takes the conversation so far and returns the next user message.

docs/src/components/ReleaseTimeline/index.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,60 @@ type Group = { label: "Added" | "Changed" | "Fixed"; items: { title: string; bod
66
type Release = { version: string; date: string; lead?: string; groups: Group[] };
77

88
const RELEASES: Release[] = [
9+
{
10+
version: "0.23.0",
11+
date: "June 2026",
12+
lead: "Tool calls on conversation turns: an assistant message now carries the tool calls it made, so a multi-turn conversation feeds the agent tool evaluators per turn, with no LLM. The trajectory judge can also reason over tool usage.",
13+
groups: [
14+
{
15+
label: "Added",
16+
items: [
17+
{
18+
title: "Tool calls on assistant turns",
19+
body: (
20+
<>
21+
<code>Message</code> now carries a typed <code>List&lt;ToolCall&gt;</code>, set via{" "}
22+
<code>Message.assistant(content, toolCalls)</code> or the builder's{" "}
23+
<code>assistantMessage(content, toolCalls)</code> (and the Kotlin DSL's{" "}
24+
<code>assistant(content, toolCalls)</code>). A tool-free turn is unchanged, and the
25+
three-argument <code>Message</code> constructor still resolves, so existing code
26+
compiles and runs as before.
27+
</>
28+
),
29+
},
30+
{
31+
title: "Per-turn tool evaluation",
32+
body: (
33+
<>
34+
<code>ConversationTrajectory.toolCallsByTurn()</code> returns one tool-call list per
35+
assistant turn (in order) to score each turn with the deterministic agent
36+
evaluators, and <code>toolCalls()</code> flattens them into one list.{" "}
37+
<code>toTestCase()</code>/<code>toTestCase(tools)</code> build a deterministic case
38+
(input is the last user message), while <code>toTestCase(tools, tasks)</code> builds
39+
the judge case for <code>TaskCompletionEvaluator</code> and{" "}
40+
<code>ToolArgumentHallucinationEvaluator</code> over the whole conversation, with tool
41+
calls rendered name-only so their arguments stay out of the hallucination grounding.
42+
Also{" "}
43+
<code>toAgentTrace()</code>/<code>toAgentOutputs()</code> for the standard agent
44+
output map.
45+
</>
46+
),
47+
},
48+
{
49+
title: "Tool calls in the trajectory transcript",
50+
body: (
51+
<>
52+
<code>toText()</code> and <code>toJson()</code> render each turn's tool calls, and{" "}
53+
<code>TrajectoryEvaluator.includeToolCalls(true)</code> adds them to the judge
54+
prompt. Both are off by default for tool-free conversations, whose rendered output
55+
stays byte-identical to prior versions.
56+
</>
57+
),
58+
},
59+
],
60+
},
61+
],
62+
},
963
{
1064
version: "0.22.0",
1165
date: "June 2026",
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package dev.dokimos.core.agents;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.Map;
6+
7+
/**
8+
* Coercion helpers for turning a raw value into a typed {@code List<ToolCall>}.
9+
*
10+
* <p>A tool-call list reaches an evaluator either already typed (programmatic use) or as a list of
11+
* maps (deserialized from a JSON or CSV dataset). {@link #coerce(Object)} normalizes both into a
12+
* typed list, validating every element so a malformed dataset row fails with a clear, indexed error.
13+
*/
14+
public final class ToolCalls {
15+
16+
private ToolCalls() {}
17+
18+
/**
19+
* Coerces a value into a typed {@code List<ToolCall>}.
20+
*
21+
* <p>Accepts an already-typed {@code List<ToolCall>}, or a {@code List<Map>} mapped element by
22+
* element via {@link ToolCall#fromMap(Map)}. Every element is validated; a list mixing tool calls
23+
* and maps (or containing any other element type) is rejected. {@code null} or an empty list
24+
* yields an empty list.
25+
*
26+
* @param raw the value to coerce
27+
* @return an unmodifiable list of tool calls, never {@code null}
28+
* @throws IllegalArgumentException if the value is not a list, mixes element types, or contains a
29+
* malformed tool-call map (the message names the offending element index)
30+
*/
31+
public static List<ToolCall> coerce(Object raw) {
32+
if (raw == null) {
33+
return List.of();
34+
}
35+
if (!(raw instanceof List<?> list)) {
36+
throw new IllegalArgumentException("Expected a List of ToolCall objects or maps, got " + typeOf(raw));
37+
}
38+
if (list.isEmpty()) {
39+
return List.of();
40+
}
41+
Object first = list.get(0);
42+
List<ToolCall> out = new ArrayList<>(list.size());
43+
if (first instanceof ToolCall) {
44+
for (int i = 0; i < list.size(); i++) {
45+
if (!(list.get(i) instanceof ToolCall call)) {
46+
throw new IllegalArgumentException(
47+
"Tool-call list element %d is not a ToolCall (got %s)".formatted(i, typeOf(list.get(i))));
48+
}
49+
out.add(call);
50+
}
51+
return List.copyOf(out);
52+
}
53+
if (first instanceof Map) {
54+
for (int i = 0; i < list.size(); i++) {
55+
if (!(list.get(i) instanceof Map<?, ?> map)) {
56+
throw new IllegalArgumentException(
57+
"Tool-call list element %d is not a Map (got %s)".formatted(i, typeOf(list.get(i))));
58+
}
59+
try {
60+
@SuppressWarnings("unchecked")
61+
Map<String, Object> typed = (Map<String, Object>) map;
62+
out.add(ToolCall.fromMap(typed));
63+
} catch (RuntimeException e) {
64+
throw new IllegalArgumentException(
65+
"Invalid tool call at index %d: %s".formatted(i, e.getMessage()), e);
66+
}
67+
}
68+
return List.copyOf(out);
69+
}
70+
throw new IllegalArgumentException(
71+
"Expected a List of ToolCall objects or maps; element 0 is " + typeOf(first));
72+
}
73+
74+
private static String typeOf(Object value) {
75+
return value == null ? "null" : value.getClass().getName();
76+
}
77+
}

0 commit comments

Comments
 (0)