diff --git a/README.md b/README.md index fd9e640..813ea83 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/agentic/output/01-sequential.txt b/agentic/output/01-sequential.txt new file mode 100644 index 0000000..d4930d2 --- /dev/null +++ b/agentic/output/01-sequential.txt @@ -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 diff --git a/agentic/output/02-state-by-parameter.txt b/agentic/output/02-state-by-parameter.txt new file mode 100644 index 0000000..8c4bbaa --- /dev/null +++ b/agentic/output/02-state-by-parameter.txt @@ -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 diff --git a/agentic/output/03-parallel.txt b/agentic/output/03-parallel.txt new file mode 100644 index 0000000..26b0345 --- /dev/null +++ b/agentic/output/03-parallel.txt @@ -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. diff --git a/agentic/output/04-loop.txt b/agentic/output/04-loop.txt new file mode 100644 index 0000000..07ab4eb --- /dev/null +++ b/agentic/output/04-loop.txt @@ -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 diff --git a/agentic/output/05-supervisor.txt b/agentic/output/05-supervisor.txt new file mode 100644 index 0000000..1f7e70a --- /dev/null +++ b/agentic/output/05-supervisor.txt @@ -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) +} + +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) diff --git a/agentic/output/06-supervisor-limits.txt b/agentic/output/06-supervisor-limits.txt new file mode 100644 index 0000000..4ec3c6f --- /dev/null +++ b/agentic/output/06-supervisor-limits.txt @@ -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] diff --git a/agentic/output/07-name-collision.txt b/agentic/output/07-name-collision.txt new file mode 100644 index 0000000..b941ad0 --- /dev/null +++ b/agentic/output/07-name-collision.txt @@ -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) +}" }], attributes = {} } diff --git a/agentic/output/08-error-handling.txt b/agentic/output/08-error-handling.txt new file mode 100644 index 0000000..5467bf2 --- /dev/null +++ b/agentic/output/08-error-handling.txt @@ -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] diff --git a/agentic/pom.xml b/agentic/pom.xml new file mode 100644 index 0000000..11b9ad2 --- /dev/null +++ b/agentic/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + com.ankurm.langchain4j + langchain4j-demo + 1.0.0 + + + agentic + + + + dev.langchain4j + langchain4j + + + dev.langchain4j + langchain4j-agentic + ${langchain4j.beta.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/ActivityAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/ActivityAgent.java new file mode 100644 index 0000000..9ed7aad --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/ActivityAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/Finders.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/Finders.java new file mode 100644 index 0000000..20afc0f --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/Finders.java @@ -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); + } +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/FlakyAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/FlakyAgent.java new file mode 100644 index 0000000..3335b01 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/FlakyAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/FlightAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/FlightAgent.java new file mode 100644 index 0000000..b5f9799 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/FlightAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/HotelAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/HotelAgent.java new file mode 100644 index 0000000..26571e9 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/HotelAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/ImproverAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/ImproverAgent.java new file mode 100644 index 0000000..fb47bbe --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/ImproverAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/PlannerAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/PlannerAgent.java new file mode 100644 index 0000000..157bbf2 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/PlannerAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/Refiner.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/Refiner.java new file mode 100644 index 0000000..606c0fc --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/Refiner.java @@ -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 refine(@V("plan") String plan); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/ScorerAgent.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/ScorerAgent.java new file mode 100644 index 0000000..8164349 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/ScorerAgent.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/ScriptedChatModel.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/ScriptedChatModel.java new file mode 100644 index 0000000..944e463 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/ScriptedChatModel.java @@ -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 script; + private final long delayMillis; + private final List calls = new CopyOnWriteArrayList<>(); + + public ScriptedChatModel(Function script) { + this(script, 0); + } + + public ScriptedChatModel(Function 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 calls() { + return calls; + } + + /** The text of the last user message of a request. */ + public static String userText(ChatRequest request) { + List 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(""); + } +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/TemplateOnlyPlanner.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/TemplateOnlyPlanner.java new file mode 100644 index 0000000..5952868 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/TemplateOnlyPlanner.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/TripPlanner.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/TripPlanner.java new file mode 100644 index 0000000..f0edba5 --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/TripPlanner.java @@ -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); +} diff --git a/agentic/src/main/java/com/ankurm/lc4j/agentic/TripPlannerWithScope.java b/agentic/src/main/java/com/ankurm/lc4j/agentic/TripPlannerWithScope.java new file mode 100644 index 0000000..4829dac --- /dev/null +++ b/agentic/src/main/java/com/ankurm/lc4j/agentic/TripPlannerWithScope.java @@ -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 plan(@V("destination") String destination, @V("traveler") String traveler); +} diff --git a/agentic/src/test/java/com/ankurm/lc4j/agentic/AgenticTest.java b/agentic/src/test/java/com/ankurm/lc4j/agentic/AgenticTest.java new file mode 100644 index 0000000..31defb3 --- /dev/null +++ b/agentic/src/test/java/com/ankurm/lc4j/agentic/AgenticTest.java @@ -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 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 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 run) { + try { + return run.get(); + } catch (Exception e) { + Throwable root = e; + while (root.getCause() != null) root = root.getCause(); + return root.getMessage(); + } + } +} diff --git a/agentic/src/test/java/com/ankurm/lc4j/agentic/FlowsTest.java b/agentic/src/test/java/com/ankurm/lc4j/agentic/FlowsTest.java new file mode 100644 index 0000000..40b44f5 --- /dev/null +++ b/agentic/src/test/java/com/ankurm/lc4j/agentic/FlowsTest.java @@ -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 agent(Class 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 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 replies, List 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 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 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 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 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"); + } +} diff --git a/agentic/src/test/java/com/ankurm/lc4j/agentic/Transcript.java b/agentic/src/test/java/com/ankurm/lc4j/agentic/Transcript.java new file mode 100644 index 0000000..ee16b28 --- /dev/null +++ b/agentic/src/test/java/com/ankurm/lc4j/agentic/Transcript.java @@ -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); + } + } +} diff --git a/pom.xml b/pom.xml index 41641fb..b07d161 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ ai-services spring-boot-ai-service + agentic