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 |
|
| `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 |
|
| `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)
|
## 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
|
```bash
|
||||||
export JAVA_HOME=/path/to/jdk-25
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
mvn test # everything
|
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
|
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