Add agentic module: sequential, parallel, loop, supervisor and error-recovery workflows

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
2026-10-11 07:58:09 +00:00
co-authored by Claude Sonnet 5.5
parent f87573fcb8
commit da39f6437a
27 changed files with 832 additions and 1 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ the same facts, so the build fails when a claim stops being true.
|---|---|---|
| `ai-services` | LangChain4j AI Services with Spring Boot 4: Declarative LLM Interfaces | prompt templates, chat memory, tools, RAG, and the `@MemoryId` trap |
| `spring-boot-ai-service` | same post | `@AiService` on Spring Boot 4.1.1 with the 1.22.0-beta32 starter |
| `agentic` | Agentic Workflows with LangChain4j: Sequential, Parallel, Loop and Supervisor Agents | the five workflow builders, state passing by key, supervisor JSON, error recovery |
## Versions (verified on Maven Central, 2026-10-11)
@@ -24,7 +25,7 @@ the same facts, so the build fails when a claim stops being true.
```bash
export JAVA_HOME=/path/to/jdk-25
mvn test # everything
mvn test -pl spring-boot-ai-service -am # one module (-am builds the parent and ai-services too)
mvn test -pl agentic -am # one module (-am builds the parent and ai-services too)
```
No API key is needed. The tests use `ScriptedChatModel`, a deterministic stand-in for an LLM, so
+18
View File
@@ -0,0 +1,18 @@
Order the agents ran in:
findFlight read [traveler, destination] and wrote AI-101 Mumbai to Lisbon
findHotel read [traveler, destination] and wrote Hotel Alfama
findActivity read [traveler, destination] and wrote Tram 28
write read [hotels, activities, destination, flights] and wrote Fly AI-101, stay at Hotel Alfama, ride Tram 28.
Shared state at the end:
traveler = Ankur
hotels = Hotel Alfama
activities = Tram 28
destination = Lisbon
flights = AI-101 Mumbai to Lisbon
plan = Fly AI-101, stay at Hotel Alfama, ride Tram 28.
The prompt the planner was sent (last model call):
Write a one-line itinerary for Lisbon. Flight: AI-101 Mumbai to Lisbon. Hotel: Hotel Alfama. Activity: Tram 28.
Model calls: 4
+11
View File
@@ -0,0 +1,11 @@
A) Planner declares only @V destination, template mentions {{flights}} {{hotels}} {{activities}}.
omit nothing: Value for the variable 'hotels' is missing
omit flights: Value for the variable 'hotels' is missing
omit hotels: Value for the variable 'hotels' is missing
omit activities: Value for the variable 'hotels' is missing
B) Planner declares @V destination, @V flights, @V hotels, @V activities.
omit nothing: OK: Fly AI-101, stay at Hotel Alfama, ride Tram 28.
omit flights: Missing argument: flights
omit hotels: Missing argument: hotels
omit activities: Missing argument: activities
+9
View File
@@ -0,0 +1,9 @@
Three finders, each model call sleeps 300 ms.
#27 start 0 ms end 301 ms
#31 start 1 ms end 302 ms
#29 start 0 ms end 302 ms
Distinct threads: 3
Wall clock for the whole sequence: 320 ms (sequential would be at least 900 ms)
Plan: Fly AI-101, stay at Hotel Alfama, ride Tram 28.
+4
View File
@@ -0,0 +1,4 @@
Scorer replies 5, 7, 9, 10. Exit condition: score >= 8. maxIterations 5.
testExitAtLoopEnd = false (default): final plan 'draft 3', scorer ran 3 times, improver ran 2 times, final score 9
testExitAtLoopEnd = true: final plan 'draft 4', scorer ran 3 times, improver ran 3 times, final score 9
+44
View File
@@ -0,0 +1,44 @@
Supervisor model was scripted to reply with three JSON decisions.
Answer: Hotel Alfama
Worker calls: 2
List one flight option to Lisbon for Ankur.
List one hotel option in Lisbon for Ankur.
Supervisor model calls: 3
First prompt the supervisor saw:
The user request is: 'Find me a flight and a hotel for Lisbon'.
The last received response is: ''.
You must answer strictly in the following JSON format: {
"agentName": (type: string),
"arguments": (type: java.util.Map<java.lang.String, java.lang.Object>)
}
What each responseStrategy returned for the same three decisions:
(default) Hotel Alfama
SCORED Flight AI-101 and Hotel Alfama booked options found. (supervisor calls: 4)
The extra SCORED call asked the supervisor model:
You are a response evaluator that is provided with two responses to a user request.
Your role is to score the two responses based on their relevance for the user request.
For each of the two responses, response1 and response2, you will return a score, respectively score 1 and score 2,
between 0.0 and 1.0, where 0.0 means the response is completely irrelevant to the user request,
and 1.0 means the response is perfectly relevant to the user request.
Return only the score and nothing else, without any additional text or explanation.
The user request is: 'Find me a flight and a hotel for Lisbon'.
The first response is: 'Hotel Alfama'.
The second response is: 'Flight AI-101 and Hotel Alfama booked options found.'.
You must answer strictly in the following JSON format: {
"score1": (type: double),
"score2": (type: double)
}
SUMMARY Flight AI-101 and Hotel Alfama booked options found. (supervisor calls: 3)
LAST Hotel Alfama (supervisor calls: 3)
+7
View File
@@ -0,0 +1,7 @@
A) Supervisor model never says done, maxAgentsInvocations = 2.
returned: AI-101 Mumbai to Lisbon
worker calls: 2, supervisor calls: 2
B) Supervisor model replies in prose instead of JSON.
threw: JsonParseException: Unrecognized token 'Sure': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')
at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1]
+32
View File
@@ -0,0 +1,32 @@
Two agents, both with a method named find. What the supervisor model was shown:
SYSTEM: SystemMessage { text = "You are a planner expert that is provided with a set of agents.
You know nothing about any domain, don't take any assumptions about the user request,
the only thing that you can do is rely on the provided agents.
Your role is to analyze the user request and decide which one of the provided agents to call next to address it.
You return an agent invocation consisting of the name of the agent and the arguments to pass to it.
If no further agent requests are required, return an agentName of "done" and an argument named
"response", where the value of the response argument is a recap of all the performed actions,
written in the same language as the user request.
Agents are provided with their name and description together with a list of applicable arguments
in the format {'name', 'description', [argument1: type1, argument2: type2]}.
Decide which agent to invoke next, doing things in small steps and
never taking any shortcuts or relying on your own knowledge.
Even if the user's request is already clear or explicit, don't make any assumptions and use the agents.
Be sure to query ALL necessary agents.
The comma separated list of available agents is: '{'find$0', 'Finds a train', [destination: String]}, {'find$1', 'Finds a bus', [destination: String]}'.
", attributes = {} }
USER: UserMessage { name = null, contents = [TextContent { text = "The user request is: 'Get me to Lisbon'.
The last received response is: ''.
You must answer strictly in the following JSON format: {
"agentName": (type: string),
"arguments": (type: java.util.Map<java.lang.String, java.lang.Object>)
}" }], attributes = {} }
+6
View File
@@ -0,0 +1,6 @@
FlakyAgent's model throws on its first N calls.
no handler, N=1: Attempt[outcome=threw IllegalStateException: model unavailable (attempt 1), modelCalls=1]
handler retry(), N=2: Attempt[outcome=returned 'AI-101 Mumbai to Lisbon', modelCalls=3] (handler invoked 2 times)
handler result(".."), N=5: Attempt[outcome=returned 'no flight found, ask the traveler', modelCalls=1]
handler throwException(), N=5: Attempt[outcome=threw IllegalStateException: model unavailable (attempt 1), modelCalls=1]
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.langchain4j</groupId>
<artifactId>langchain4j-demo</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>agentic</artifactId>
<dependencies>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-agentic</artifactId>
<version>${langchain4j.beta.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,12 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
public interface ActivityAgent {
@UserMessage("List one activity in {{destination}} for {{traveler}}.")
@Agent(value = "Finds an activity", outputKey = "activities")
String findActivity(@V("destination") String destination, @V("traveler") String traveler);
}
@@ -0,0 +1,24 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/** Two agents whose methods share a name, to show what the supervisor sees. */
public final class Finders {
private Finders() {
}
public interface TrainFinder {
@UserMessage("Find a train to {{destination}}.")
@Agent("Finds a train")
String find(@V("destination") String destination);
}
public interface BusFinder {
@UserMessage("Find a bus to {{destination}}.")
@Agent("Finds a bus")
String find(@V("destination") String destination);
}
}
@@ -0,0 +1,13 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/** An agent whose model fails on purpose, to show the error handler. */
public interface FlakyAgent {
@UserMessage("Find a flight to {{destination}}.")
@Agent(value = "Finds a flight, unreliably", outputKey = "flights")
String findFlight(@V("destination") String destination);
}
@@ -0,0 +1,12 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
public interface FlightAgent {
@UserMessage("List one flight option to {{destination}} for {{traveler}}.")
@Agent(value = "Finds a flight", outputKey = "flights")
String findFlight(@V("destination") String destination, @V("traveler") String traveler);
}
@@ -0,0 +1,12 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
public interface HotelAgent {
@UserMessage("List one hotel option in {{destination}} for {{traveler}}.")
@Agent(value = "Finds a hotel", outputKey = "hotels")
String findHotel(@V("destination") String destination, @V("traveler") String traveler);
}
@@ -0,0 +1,13 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/** Writes back to the same key it reads, "plan", which is what makes it a refinement loop. */
public interface ImproverAgent {
@UserMessage("The itinerary scored {{score}} out of 10. Improve it. Itinerary: {{plan}}")
@Agent(value = "Improves an itinerary", outputKey = "plan")
String improve(@V("plan") String plan, @V("score") int score);
}
@@ -0,0 +1,19 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/**
* Reads what the earlier agents wrote into the shared state. The three finder outputs are declared as
* parameters, and the parameter names match the finders' output keys ("flights", "hotels", "activities").
*/
public interface PlannerAgent {
@UserMessage("Write a one-line itinerary for {{destination}}. Flight: {{flights}}. Hotel: {{hotels}}. Activity: {{activities}}.")
@Agent(value = "Writes the itinerary", outputKey = "plan")
String write(@V("destination") String destination,
@V("flights") String flights,
@V("hotels") String hotels,
@V("activities") String activities);
}
@@ -0,0 +1,12 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
import dev.langchain4j.service.V;
/** Entry point for the loop. Returning ResultWithAgenticScope exposes the shared state and the invocation log. */
public interface Refiner {
@Agent
ResultWithAgenticScope<String> refine(@V("plan") String plan);
}
@@ -0,0 +1,12 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
public interface ScorerAgent {
@UserMessage("Score this itinerary from 1 to 10. Reply with only the number. Itinerary: {{plan}}")
@Agent(value = "Scores an itinerary from 1 to 10", outputKey = "score")
int score(@V("plan") String plan);
}
@@ -0,0 +1,73 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
/**
* A deterministic stand-in for a real LLM, as in the ai-services module. It is thread-safe because
* the parallel workflow calls it from several threads at once, and it records the thread of every call.
*/
public class ScriptedChatModel implements ChatModel {
public record Call(String thread, long startNanos, long endNanos, ChatRequest request) {
}
private final Function<ChatRequest, AiMessage> script;
private final long delayMillis;
private final List<Call> calls = new CopyOnWriteArrayList<>();
public ScriptedChatModel(Function<ChatRequest, AiMessage> script) {
this(script, 0);
}
public ScriptedChatModel(Function<ChatRequest, AiMessage> script, long delayMillis) {
this.script = script;
this.delayMillis = delayMillis;
}
@Override
public ChatResponse doChat(ChatRequest request) {
long start = System.nanoTime();
if (delayMillis > 0) {
try {
Thread.sleep(delayMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
AiMessage reply = script.apply(request);
calls.add(new Call(Thread.currentThread().getName() + "#" + Thread.currentThread().threadId(), start, System.nanoTime(), request));
return ChatResponse.builder().aiMessage(reply).build();
}
public List<Call> calls() {
return calls;
}
/** The text of the last user message of a request. */
public static String userText(ChatRequest request) {
List<ChatMessage> messages = request.messages();
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof UserMessage u) {
return u.singleText();
}
}
return "";
}
public static String systemText(ChatRequest request) {
return request.messages().stream()
.filter(m -> m instanceof SystemMessage)
.map(m -> ((SystemMessage) m).text())
.findFirst().orElse("");
}
}
@@ -0,0 +1,13 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/** A planner whose template mentions three placeholders but which declares only one parameter. */
public interface TemplateOnlyPlanner {
@UserMessage("Write a one-line itinerary for {{destination}}. Flight: {{flights}}. Hotel: {{hotels}}. Activity: {{activities}}.")
@Agent(value = "Writes the itinerary", outputKey = "plan")
String write(@V("destination") String destination);
}
@@ -0,0 +1,11 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.V;
/** The entry point of every workflow in this module: one method, two inputs, one String out. */
public interface TripPlanner {
@Agent
String plan(@V("destination") String destination, @V("traveler") String traveler);
}
@@ -0,0 +1,12 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
import dev.langchain4j.service.V;
/** Same workflow entry point as TripPlanner, but the result carries the AgenticScope for inspection. */
public interface TripPlannerWithScope {
@Agent
ResultWithAgenticScope<String> plan(@V("destination") String destination, @V("traveler") String traveler);
}
@@ -0,0 +1,123 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.agentic.UntypedAgent;
import dev.langchain4j.agentic.agent.ErrorRecoveryResult;
import dev.langchain4j.agentic.scope.AgentInvocation;
import dev.langchain4j.agentic.scope.AgenticScope;
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
import dev.langchain4j.agentic.supervisor.SupervisorAgent;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
class AgenticTest {
/** Answers every finder with a fixed line and the planner with a line that echoes what it was given. */
private static ScriptedChatModel travelModel(long delayMillis) {
return new ScriptedChatModel(r -> {
String u = ScriptedChatModel.userText(r);
if (u.startsWith("List one flight")) return AiMessage.from("AI-101 Mumbai to Lisbon");
if (u.startsWith("List one hotel")) return AiMessage.from("Hotel Alfama");
if (u.startsWith("List one activity")) return AiMessage.from("Tram 28");
return AiMessage.from("Fly AI-101, stay at Hotel Alfama, ride Tram 28.");
}, delayMillis);
}
private static FlightAgent flights(ScriptedChatModel m) {
return AgenticServices.agentBuilder(FlightAgent.class).chatModel(m).build();
}
private static HotelAgent hotels(ScriptedChatModel m) {
return AgenticServices.agentBuilder(HotelAgent.class).chatModel(m).build();
}
private static ActivityAgent activities(ScriptedChatModel m) {
return AgenticServices.agentBuilder(ActivityAgent.class).chatModel(m).build();
}
private static PlannerAgent planner(ScriptedChatModel m) {
return AgenticServices.agentBuilder(PlannerAgent.class).chatModel(m).build();
}
// ------------------------------------------------------------------ 1. sequential
@Test
void sequentialRunsAgentsInOrderAndPassesStateByKey() {
ScriptedChatModel model = travelModel(0);
TripPlannerWithScope trip = AgenticServices.sequenceBuilder(TripPlannerWithScope.class)
.subAgents(flights(model), hotels(model), activities(model), planner(model))
.outputKey("plan")
.build();
ResultWithAgenticScope<String> result = trip.plan("Lisbon", "Ankur");
AgenticScope scope = result.agenticScope();
StringBuilder sb = new StringBuilder("Order the agents ran in:\n");
for (AgentInvocation inv : scope.agentInvocations()) {
sb.append(" ").append(inv.agentName()).append(" read ").append(inv.input().keySet())
.append(" and wrote ").append(inv.output()).append('\n');
}
sb.append("\nShared state at the end:\n");
scope.state().forEach((k, v) -> sb.append(" ").append(k).append(" = ").append(v).append('\n'));
sb.append("\nThe prompt the planner was sent (last model call):\n ")
.append(ScriptedChatModel.userText(model.calls().get(model.calls().size() - 1).request())).append('\n');
sb.append("\nModel calls: ").append(model.calls().size()).append('\n');
Transcript.write("01-sequential.txt", sb.toString());
assertThat(result.result()).contains("Hotel Alfama");
assertThat(scope.agentInvocations()).extracting(AgentInvocation::agentName)
.containsExactly("findFlight", "findHotel", "findActivity", "write");
}
// ------------------------------------------------------------------ 2. state is matched by parameter name
@Test
void stateIsMatchedByParameterNameNotByPlaceholder() {
ScriptedChatModel model = travelModel(0);
StringBuilder sb = new StringBuilder();
sb.append("A) Planner declares only @V destination, template mentions {{flights}} {{hotels}} {{activities}}.\n");
for (String omit : new String[]{"nothing", "flights", "hotels", "activities"}) {
sb.append(" omit ").append(omit).append(": ").append(rootMessage(() -> runPlanner(model, omit, true))).append('\n');
}
sb.append("\nB) Planner declares @V destination, @V flights, @V hotels, @V activities.\n");
for (String omit : new String[]{"nothing", "flights", "hotels", "activities"}) {
sb.append(" omit ").append(omit).append(": ").append(rootMessage(() -> runPlanner(model, omit, false))).append('\n');
}
Transcript.write("02-state-by-parameter.txt", sb.toString());
assertThat(rootMessage(() -> runPlanner(model, "nothing", false))).startsWith("OK");
assertThat(rootMessage(() -> runPlanner(model, "hotels", false))).isEqualTo("Missing argument: hotels");
assertThat(rootMessage(() -> runPlanner(model, "nothing", true))).contains("'hotels' is missing");
}
private static String runPlanner(ScriptedChatModel model, String omit, boolean templateOnly) {
List<Object> agents = new ArrayList<>();
if (!omit.equals("flights")) agents.add(flights(model));
if (!omit.equals("hotels")) agents.add(hotels(model));
if (!omit.equals("activities")) agents.add(activities(model));
agents.add(templateOnly
? AgenticServices.agentBuilder(TemplateOnlyPlanner.class).chatModel(model).build()
: planner(model));
TripPlanner trip = AgenticServices.sequenceBuilder(TripPlanner.class).subAgents(agents.toArray()).outputKey("plan").build();
return "OK: " + trip.plan("Lisbon", "Ankur");
}
private static String rootMessage(java.util.function.Supplier<String> run) {
try {
return run.get();
} catch (Exception e) {
Throwable root = e;
while (root.getCause() != null) root = root.getCause();
return root.getMessage();
}
}
}
@@ -0,0 +1,271 @@
package com.ankurm.lc4j.agentic;
import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.agentic.UntypedAgent;
import dev.langchain4j.agentic.agent.ErrorRecoveryResult;
import dev.langchain4j.agentic.scope.AgentInvocation;
import dev.langchain4j.agentic.scope.AgenticScope;
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
import dev.langchain4j.agentic.supervisor.SupervisorAgent;
import dev.langchain4j.data.message.AiMessage;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
class FlowsTest {
private static ScriptedChatModel travelModel(long delay) {
return new ScriptedChatModel(r -> {
String u = ScriptedChatModel.userText(r);
if (u.startsWith("List one flight")) return AiMessage.from("AI-101 Mumbai to Lisbon");
if (u.startsWith("List one hotel")) return AiMessage.from("Hotel Alfama");
if (u.startsWith("List one activity")) return AiMessage.from("Tram 28");
return AiMessage.from("Fly AI-101, stay at Hotel Alfama, ride Tram 28.");
}, delay);
}
private static <T> T agent(Class<T> type, ScriptedChatModel m) {
return AgenticServices.agentBuilder(type).chatModel(m).build();
}
private static String root(Throwable e) {
Throwable r = e;
while (r.getCause() != null) r = r.getCause();
return r.getClass().getSimpleName() + ": " + r.getMessage();
}
// ------------------------------------------------------------------ 3. parallel
@Test
void parallelFindersOverlapInTime() {
ScriptedChatModel slow = travelModel(300);
UntypedAgent gather = AgenticServices.parallelBuilder()
.subAgents(agent(FlightAgent.class, slow), agent(HotelAgent.class, slow), agent(ActivityAgent.class, slow))
.build();
ScriptedChatModel fast = travelModel(0);
TripPlanner trip = AgenticServices.sequenceBuilder(TripPlanner.class)
.subAgents(gather, agent(PlannerAgent.class, fast))
.outputKey("plan").build();
long t0 = System.nanoTime();
String plan = trip.plan("Lisbon", "Ankur");
long ms = (System.nanoTime() - t0) / 1_000_000;
StringBuilder sb = new StringBuilder("Three finders, each model call sleeps 300 ms.\n\n");
long base = slow.calls().stream().mapToLong(ScriptedChatModel.Call::startNanos).min().orElse(0);
for (ScriptedChatModel.Call c : slow.calls()) {
sb.append(String.format(" %-14s start %4d ms end %4d ms%n", c.thread(),
(c.startNanos() - base) / 1_000_000, (c.endNanos() - base) / 1_000_000));
}
long threads = slow.calls().stream().map(ScriptedChatModel.Call::thread).distinct().count();
sb.append("\nDistinct threads: ").append(threads).append('\n');
sb.append("Wall clock for the whole sequence: ").append(ms).append(" ms (sequential would be at least 900 ms)\n");
sb.append("Plan: ").append(plan).append('\n');
Transcript.write("03-parallel.txt", sb.toString());
assertThat(plan).contains("Hotel Alfama");
assertThat(threads).isEqualTo(3);
assertThat(ms).isLessThan(800);
}
// ------------------------------------------------------------------ 4. loop
private record LoopRun(String finalPlan, int scoreCalls, int improveCalls, Object score) {
}
private static LoopRun runLoop(boolean testExitAtLoopEnd) {
AtomicInteger scores = new AtomicInteger();
AtomicInteger improves = new AtomicInteger();
int[] sequence = {5, 7, 9, 10};
ScriptedChatModel m = new ScriptedChatModel(r -> {
String u = ScriptedChatModel.userText(r);
if (u.startsWith("Score this")) return AiMessage.from(String.valueOf(sequence[Math.min(scores.getAndIncrement(), 3)]));
return AiMessage.from("draft " + (improves.incrementAndGet() + 1));
});
Refiner loop = AgenticServices.loopBuilder(Refiner.class)
.subAgents(agent(ScorerAgent.class, m), agent(ImproverAgent.class, m))
.maxIterations(5)
.outputKey("plan")
.testExitAtLoopEnd(testExitAtLoopEnd)
.exitCondition(scope -> scope.readState("score", 0) >= 8)
.build();
ResultWithAgenticScope<String> r = loop.refine("draft 1");
return new LoopRun(r.result(), scores.get(), improves.get(), r.agenticScope().readState("score"));
}
@Test
void loopStopsWhenTheExitConditionHolds() {
LoopRun mid = runLoop(false);
LoopRun end = runLoop(true);
String sb = "Scorer replies 5, 7, 9, 10. Exit condition: score >= 8. maxIterations 5.\n\n"
+ "testExitAtLoopEnd = false (default): final plan '" + mid.finalPlan + "', scorer ran " + mid.scoreCalls
+ " times, improver ran " + mid.improveCalls + " times, final score " + mid.score + "\n"
+ "testExitAtLoopEnd = true: final plan '" + end.finalPlan + "', scorer ran " + end.scoreCalls
+ " times, improver ran " + end.improveCalls + " times, final score " + end.score + "\n";
Transcript.write("04-loop.txt", sb);
assertThat(mid.scoreCalls).isEqualTo(3);
assertThat(mid.improveCalls).isEqualTo(2);
assertThat(mid.finalPlan).isEqualTo("draft 3");
assertThat(end.scoreCalls).isEqualTo(3);
assertThat(end.improveCalls).isEqualTo(3);
assertThat(end.finalPlan).isEqualTo("draft 4");
}
// ------------------------------------------------------------------ 5. supervisor
private static ScriptedChatModel supervisorScript(List<String> replies, List<String> seen) {
AtomicInteger i = new AtomicInteger();
return new ScriptedChatModel(r -> {
String u = ScriptedChatModel.userText(r);
seen.add(u);
if (!u.startsWith("The user request is")) {
return AiMessage.from("{\"score1\": 0.2, \"score2\": 0.9}");
}
return AiMessage.from(replies.get(Math.min(i.getAndIncrement(), replies.size() - 1)));
});
}
@Test
void supervisorChoosesAgentsFromModelJson() {
ScriptedChatModel workers = travelModel(0);
List<String> seen = new ArrayList<>();
ScriptedChatModel sup = supervisorScript(List.of(
"{\"agentName\":\"findFlight\",\"arguments\":{\"destination\":\"Lisbon\",\"traveler\":\"Ankur\"}}",
"{\"agentName\":\"findHotel\",\"arguments\":{\"destination\":\"Lisbon\",\"traveler\":\"Ankur\"}}",
"{\"agentName\":\"done\",\"arguments\":{\"response\":\"Flight AI-101 and Hotel Alfama booked options found.\"}}"), seen);
SupervisorAgent s = AgenticServices.supervisorBuilder().chatModel(sup)
.subAgents(agent(FlightAgent.class, workers), agent(HotelAgent.class, workers), agent(ActivityAgent.class, workers))
.maxAgentsInvocations(5).build();
String answer = (String) s.invoke("Find me a flight and a hotel for Lisbon");
StringBuilder sb = new StringBuilder("Supervisor model was scripted to reply with three JSON decisions.\n\n");
sb.append("Answer: ").append(answer).append("\n\nWorker calls: ").append(workers.calls().size()).append('\n');
for (ScriptedChatModel.Call c : workers.calls()) sb.append(" ").append(ScriptedChatModel.userText(c.request())).append('\n');
sb.append("\nSupervisor model calls: ").append(sup.calls().size()).append('\n');
sb.append("\nFirst prompt the supervisor saw:\n").append(seen.get(0)).append('\n');
sb.append("\nWhat each responseStrategy returned for the same three decisions:\n");
sb.append(" (default) ").append(answer).append('\n');
for (var strategy : dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy.values()) {
List<String> strategySeen = new ArrayList<>();
ScriptedChatModel w = travelModel(0);
ScriptedChatModel sm = supervisorScript(List.of(
"{\"agentName\":\"findFlight\",\"arguments\":{\"destination\":\"Lisbon\",\"traveler\":\"Ankur\"}}",
"{\"agentName\":\"findHotel\",\"arguments\":{\"destination\":\"Lisbon\",\"traveler\":\"Ankur\"}}",
"{\"agentName\":\"done\",\"arguments\":{\"response\":\"Flight AI-101 and Hotel Alfama booked options found.\"}}"), strategySeen);
SupervisorAgent sv = AgenticServices.supervisorBuilder().chatModel(sm).responseStrategy(strategy)
.subAgents(agent(FlightAgent.class, w), agent(HotelAgent.class, w), agent(ActivityAgent.class, w))
.maxAgentsInvocations(5).build();
sb.append(String.format(" %-9s %s (supervisor calls: %d)%n", strategy, sv.invoke("Find me a flight and a hotel for Lisbon"), sm.calls().size()));
if (strategy == dev.langchain4j.agentic.supervisor.SupervisorResponseStrategy.SCORED) {
strategySeen.stream().filter(u -> !u.startsWith("The user request is")).findFirst()
.ifPresent(u -> sb.append("\n The extra SCORED call asked the supervisor model:\n").append(u.indent(4)).append('\n'));
}
}
Transcript.write("05-supervisor.txt", sb.toString());
assertThat(workers.calls()).hasSize(2);
assertThat(sup.calls()).hasSize(3);
}
@Test
void supervisorStopsAtMaxInvocationsAndRejectsNonJson() {
ScriptedChatModel workers = travelModel(0);
List<String> seen = new ArrayList<>();
ScriptedChatModel sup = supervisorScript(List.of(
"{\"agentName\":\"findFlight\",\"arguments\":{\"destination\":\"Lisbon\",\"traveler\":\"Ankur\"}}"), seen);
SupervisorAgent s = AgenticServices.supervisorBuilder().chatModel(sup)
.subAgents(agent(FlightAgent.class, workers)).maxAgentsInvocations(2).build();
StringBuilder sb = new StringBuilder("A) Supervisor model never says done, maxAgentsInvocations = 2.\n");
try {
String out = String.valueOf(s.invoke("Find a flight"));
sb.append(" returned: ").append(out).append('\n');
} catch (Exception e) {
sb.append(" threw: ").append(root(e)).append('\n');
}
sb.append(" worker calls: ").append(workers.calls().size()).append(", supervisor calls: ").append(sup.calls().size()).append('\n');
ScriptedChatModel chatty = new ScriptedChatModel(r -> AiMessage.from("Sure, I will book that for you."));
SupervisorAgent s2 = AgenticServices.supervisorBuilder().chatModel(chatty)
.subAgents(agent(FlightAgent.class, workers)).maxAgentsInvocations(2).build();
sb.append("\nB) Supervisor model replies in prose instead of JSON.\n");
try {
String out = String.valueOf(s2.invoke("Find a flight"));
sb.append(" returned: ").append(out).append('\n');
} catch (Exception e) {
sb.append(" threw: ").append(root(e)).append('\n');
}
Transcript.write("06-supervisor-limits.txt", sb.toString());
}
@Test
void sameMethodNameCollidesInTheSupervisorsMenu() {
ScriptedChatModel workers = new ScriptedChatModel(r -> AiMessage.from("ok"));
ScriptedChatModel sup = new ScriptedChatModel(r -> AiMessage.from("{}"));
SupervisorAgent s = AgenticServices.supervisorBuilder().chatModel(sup)
.subAgents(agent(Finders.TrainFinder.class, workers), agent(Finders.BusFinder.class, workers))
.maxAgentsInvocations(1).build();
try {
s.invoke("Get me to Lisbon");
} catch (Exception ignored) {
}
StringBuilder sb = new StringBuilder("Two agents, both with a method named find. What the supervisor model was shown:\n\n");
sup.calls().forEach(c -> c.request().messages().forEach(m -> sb.append(m.type()).append(": ").append(m).append("\n")));
Transcript.write("07-name-collision.txt", sb.toString());
assertThat(sup.calls()).isNotEmpty();
assertThat(sb.toString()).contains("{'find$0', 'Finds a train'").contains("{'find$1', 'Finds a bus'");
}
// ------------------------------------------------------------------ 6. error handling
private record Attempt(String outcome, int modelCalls) {
}
private static Attempt flaky(int failures, java.util.function.Function<dev.langchain4j.agentic.agent.ErrorContext, ErrorRecoveryResult> handler) {
AtomicInteger n = new AtomicInteger();
ScriptedChatModel m = new ScriptedChatModel(r -> {
if (n.getAndIncrement() < failures) throw new IllegalStateException("model unavailable (attempt " + n.get() + ")");
return AiMessage.from("AI-101 Mumbai to Lisbon");
});
var b = AgenticServices.sequenceBuilder(TripPlanner.class)
.subAgents(agent(FlakyAgent.class, m)).outputKey("flights");
if (handler != null) b = b.errorHandler(handler);
try {
return new Attempt("returned '" + b.build().plan("Lisbon", "Ankur") + "'", n.get());
} catch (Exception e) {
return new Attempt("threw " + root(e), n.get());
}
}
@Test
void errorHandlerCanRetryReplaceOrRethrow() {
AtomicInteger seenByHandler = new AtomicInteger();
Attempt none = flaky(1, null);
Attempt retry = flaky(2, ctx -> {
seenByHandler.incrementAndGet();
return ErrorRecoveryResult.retry();
});
Attempt replace = flaky(5, ctx -> ErrorRecoveryResult.result("no flight found, ask the traveler"));
Attempt rethrow = flaky(5, ctx -> ErrorRecoveryResult.throwException());
String sb = "FlakyAgent's model throws on its first N calls.\n\n"
+ "no handler, N=1: " + none + "\n"
+ "handler retry(), N=2: " + retry + " (handler invoked " + seenByHandler.get() + " times)\n"
+ "handler result(\"..\"), N=5: " + replace + "\n"
+ "handler throwException(), N=5: " + rethrow + "\n";
Transcript.write("08-error-handling.txt", sb);
assertThat(none.outcome).isEqualTo("threw IllegalStateException: model unavailable (attempt 1)");
assertThat(none.modelCalls).isEqualTo(1);
assertThat(retry.outcome).isEqualTo("returned 'AI-101 Mumbai to Lisbon'");
assertThat(retry.modelCalls).isEqualTo(3);
assertThat(seenByHandler.get()).isEqualTo(2);
assertThat(replace.outcome).isEqualTo("returned 'no flight found, ask the traveler'");
assertThat(rethrow.outcome).startsWith("threw IllegalStateException");
}
}
@@ -0,0 +1,23 @@
package com.ankurm.lc4j.agentic;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Writes what a test observed to output/NN-name.txt so every figure in the post comes from a file. */
final class Transcript {
private static final Path DIR = Path.of("output");
private Transcript() {
}
static void write(String name, String content) {
try {
Files.createDirectories(DIR);
Files.writeString(DIR.resolve(name), content);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
+1
View File
@@ -12,6 +12,7 @@
<modules>
<module>ai-services</module>
<module>spring-boot-ai-service</module>
<module>agentic</module>
</modules>
<properties>