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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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 = {} }
|
||||
@@ -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]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user