From ec1b076e9b4ea520e79ae1f58a85593a53ccefae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:15:46 +0000 Subject: [PATCH] Add embabel module: GOAP planning from types, conditions, cost-based choice and a plain Spring AI comparison, each with a captured transcript Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk --- .gitignore | 4 + README.md | 29 ++++ embabel/output/01-plan.txt | 19 +++ embabel/output/02-skips-known.txt | 9 ++ embabel/output/03-condition.txt | 11 ++ embabel/output/04-cost.txt | 10 ++ embabel/output/05-plain-spring-ai.txt | 13 ++ embabel/output/06-condition-trap.txt | 4 + embabel/output/07-dependencies.txt | 13 ++ embabel/pom.xml | 33 +++++ .../embabel/CostAwareResearchAgent.java | 38 +++++ .../com/ankurm/agents/embabel/Domain.java | 22 +++ .../agents/embabel/ForgetfulGuardedAgent.java | 39 +++++ .../agents/embabel/GuardedResearchAgent.java | 39 +++++ .../ankurm/agents/embabel/PlainResearch.java | 32 +++++ .../ankurm/agents/embabel/ResearchAgent.java | 45 ++++++ .../ankurm/agents/embabel/EmbabelTest.java | 133 ++++++++++++++++++ .../agents/embabel/ScriptedChatModel.java | 28 ++++ .../com/ankurm/agents/embabel/Transcript.java | 23 +++ pom.xml | 57 ++++++++ 20 files changed, 601 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 embabel/output/01-plan.txt create mode 100644 embabel/output/02-skips-known.txt create mode 100644 embabel/output/03-condition.txt create mode 100644 embabel/output/04-cost.txt create mode 100644 embabel/output/05-plain-spring-ai.txt create mode 100644 embabel/output/06-condition-trap.txt create mode 100644 embabel/output/07-dependencies.txt create mode 100644 embabel/pom.xml create mode 100644 embabel/src/main/java/com/ankurm/agents/embabel/CostAwareResearchAgent.java create mode 100644 embabel/src/main/java/com/ankurm/agents/embabel/Domain.java create mode 100644 embabel/src/main/java/com/ankurm/agents/embabel/ForgetfulGuardedAgent.java create mode 100644 embabel/src/main/java/com/ankurm/agents/embabel/GuardedResearchAgent.java create mode 100644 embabel/src/main/java/com/ankurm/agents/embabel/PlainResearch.java create mode 100644 embabel/src/main/java/com/ankurm/agents/embabel/ResearchAgent.java create mode 100644 embabel/src/test/java/com/ankurm/agents/embabel/EmbabelTest.java create mode 100644 embabel/src/test/java/com/ankurm/agents/embabel/ScriptedChatModel.java create mode 100644 embabel/src/test/java/com/ankurm/agents/embabel/Transcript.java create mode 100644 pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2e433c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..63b7281 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# java-ai-agents + +Companion code for the ankurm.com posts on agents on the JVM. Every number and transcript quoted in a post +comes from a file in a module's `output/` folder, and the tests that write those files also assert the same +facts, so the build fails when a claim stops being true. + +| Module | Post | What it shows | +|---|---|---| +| `embabel` | Embabel: Goal-Oriented AI Agents on the JVM | actions, goals, conditions and cost-based planning with GOAP, compared with plain Spring AI | + +## Versions (verified on Maven Central, 2026-10-11) + +| Component | Version | Note | +|---|---|---| +| Embabel `embabel-agent-api` | 1.5.3 | brings Spring AI 2.0.1 and Spring Boot 4.1.1 transitively | +| JDK | 25 LTS | | +| JUnit | 6.1.3 | | + +## Run + +```bash +export JAVA_HOME=/path/to/jdk-25 +mvn test # everything +mvn test -pl embabel -am # one module +``` + +No API key is needed. The Embabel tests use `ScriptedLlmOperations`, the scripted stand-in shipped inside +`embabel-agent-api`, and the Spring AI comparison uses a small scripted `ChatModel`. +Each module writes its transcripts to `/output/` when you run the tests. diff --git a/embabel/output/01-plan.txt b/embabel/output/01-plan.txt new file mode 100644 index 0000000..e1895ac --- /dev/null +++ b/embabel/output/01-plan.txt @@ -0,0 +1,19 @@ +ResearchAgent, input Topic("virtual threads"), planner GOAP. + +Planner: AStarGoapPlanner +Status: COMPLETED + +Actions in the order they ran: + com.ankurm.agents.embabel.ResearchAgent.findSources + com.ankurm.agents.embabel.ResearchAgent.summarise + com.ankurm.agents.embabel.ResearchAgent.writeBrief + +Objects on the blackboard at the end: + Topic[text=virtual threads] + Sources[items=[JEP 444 makes virtual threads final in Java 21., Virtual threads are cheap to block, so thread-per-request scales again.]] + Summary[text=Virtual threads make blocking cheap.] + Brief[text=Virtual threads: blocking is cheap again.] + +Prompts the LLM received (2): + Summarise these notes in one sentence: JEP 444 makes virtual threads final in Java 21. Virtual threads are cheap to block, so thread-per-request scales again. + Write a two-line brief titled 'virtual threads' from: Virtual threads make blocking cheap. diff --git a/embabel/output/02-skips-known.txt b/embabel/output/02-skips-known.txt new file mode 100644 index 0000000..059ffcb --- /dev/null +++ b/embabel/output/02-skips-known.txt @@ -0,0 +1,9 @@ +Same agent, but the caller also hands over a Sources object. + +Actions in the order they ran: + com.ankurm.agents.embabel.ResearchAgent.summarise + com.ankurm.agents.embabel.ResearchAgent.writeBrief + +Prompts the LLM received: + Summarise these notes in one sentence: A note we already had. + Write a two-line brief titled 'virtual threads' from: Virtual threads make blocking cheap. diff --git a/embabel/output/03-condition.txt b/embabel/output/03-condition.txt new file mode 100644 index 0000000..9946d41 --- /dev/null +++ b/embabel/output/03-condition.txt @@ -0,0 +1,11 @@ +GuardedResearchAgent: summarise has pre = "hasSources". + +Topic "virtual threads": status COMPLETED, actions: + com.ankurm.agents.embabel.GuardedResearchAgent.findSources + com.ankurm.agents.embabel.GuardedResearchAgent.summarise + com.ankurm.agents.embabel.GuardedResearchAgent.writeBrief + LLM prompts: 2 + +Topic "quantum gravity": status STUCK, actions: + com.ankurm.agents.embabel.GuardedResearchAgent.findSources + LLM prompts: 0 diff --git a/embabel/output/04-cost.txt b/embabel/output/04-cost.txt new file mode 100644 index 0000000..b9e894d --- /dev/null +++ b/embabel/output/04-cost.txt @@ -0,0 +1,10 @@ +CostAwareResearchAgent: summariseWithLlm cost 0.9, summariseLocally cost 0.1, both return Summary. + +Status: COMPLETED +Actions in the order they ran: + com.ankurm.agents.embabel.CostAwareResearchAgent.findSources + com.ankurm.agents.embabel.CostAwareResearchAgent.summariseLocally + com.ankurm.agents.embabel.CostAwareResearchAgent.writeBrief + +Prompts the LLM received (1): + Write a two-line brief titled 'virtual threads' from: JEP 444 makes virtual threads final in Java 21. diff --git a/embabel/output/05-plain-spring-ai.txt b/embabel/output/05-plain-spring-ai.txt new file mode 100644 index 0000000..2b5be25 --- /dev/null +++ b/embabel/output/05-plain-spring-ai.txt @@ -0,0 +1,13 @@ +Plain Spring AI (ChatClient, order written by hand): + result: Brief[text=Virtual threads: blocking is cheap again.] + prompts: + Summarise these notes in one sentence: JEP 444 makes virtual threads final in Java 21. Virtual threads are cheap to block, so thread-per-request scales again. + Write a two-line brief titled 'virtual threads' from: Virtual threads make blocking cheap. + +Embabel: + result: Brief[text=Virtual threads: blocking is cheap again.] + prompts: + Summarise these notes in one sentence: JEP 444 makes virtual threads final in Java 21. Virtual threads are cheap to block, so thread-per-request scales again. + Write a two-line brief titled 'virtual threads' from: Virtual threads make blocking cheap. + +Same prompts: true diff --git a/embabel/output/06-condition-trap.txt b/embabel/output/06-condition-trap.txt new file mode 100644 index 0000000..72d1910 --- /dev/null +++ b/embabel/output/06-condition-trap.txt @@ -0,0 +1,4 @@ +ForgetfulGuardedAgent: identical to GuardedResearchAgent except findSources has no post = "hasSources". + +Topic "virtual threads" (a topic that has sources): status STUCK, actions run: 0 + LLM prompts: 0 diff --git a/embabel/output/07-dependencies.txt b/embabel/output/07-dependencies.txt new file mode 100644 index 0000000..639ddc7 --- /dev/null +++ b/embabel/output/07-dependencies.txt @@ -0,0 +1,13 @@ +mvn dependency:tree for the embabel module, filtered to embabel, spring-ai, spring-boot and kotlin-stdlib: +com.ankurm.agents:embabel:jar:1.0.0 +com.embabel.agent:embabel-agent-api:jar:1.5.3:compile +com.embabel.agent:embabel-agent-ai:jar:1.5.3:compile +com.embabel.common:embabel-common-core:jar:2.0.4:compile +com.embabel.common:embabel-common-util:jar:2.0.4:compile +ch.qos.logback:logback-classic:jar:1.5.38:compile +com.embabel.common:embabel-common-textio:jar:2.0.4:compile +org.jetbrains.kotlin:kotlin-stdlib:jar:2.2.21:compile +org.springframework.boot:spring-boot:jar:4.1.1:compile +org.springframework.ai:spring-ai-client-chat:jar:2.0.1:compile +org.springframework.ai:spring-ai-model:jar:2.0.1:compile +org.springframework.ai:spring-ai-commons:jar:2.0.1:compile diff --git a/embabel/pom.xml b/embabel/pom.xml new file mode 100644 index 0000000..72e5102 --- /dev/null +++ b/embabel/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + + com.ankurm.agents + java-ai-agents + 1.0.0 + + + embabel + + + + com.embabel.agent + embabel-agent-api + ${embabel.version} + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + diff --git a/embabel/src/main/java/com/ankurm/agents/embabel/CostAwareResearchAgent.java b/embabel/src/main/java/com/ankurm/agents/embabel/CostAwareResearchAgent.java new file mode 100644 index 0000000..5d5a831 --- /dev/null +++ b/embabel/src/main/java/com/ankurm/agents/embabel/CostAwareResearchAgent.java @@ -0,0 +1,38 @@ +package com.ankurm.agents.embabel; + +import com.ankurm.agents.embabel.Domain.Brief; +import com.ankurm.agents.embabel.Domain.Sources; +import com.ankurm.agents.embabel.Domain.Summary; +import com.ankurm.agents.embabel.Domain.Topic; +import com.embabel.agent.api.annotation.AchievesGoal; +import com.embabel.agent.api.annotation.Action; +import com.embabel.agent.api.annotation.Agent; +import com.embabel.agent.api.common.OperationContext; + +/** Two ways to produce a Summary. Embabel is told which one is cheaper and has to choose. */ +@Agent(description = "Researches a topic, preferring the cheaper way to summarise") +public class CostAwareResearchAgent { + + private final ResearchAgent delegate = new ResearchAgent(); + + @Action + public Sources findSources(Topic topic) { + return delegate.findSources(topic); + } + + @Action(cost = 0.9, description = "Summarise with an LLM") + public Summary summariseWithLlm(Sources sources, OperationContext context) { + return delegate.summarise(sources, context); + } + + @Action(cost = 0.1, description = "Take the first source verbatim") + public Summary summariseLocally(Sources sources) { + return new Summary(sources.items().getFirst()); + } + + @AchievesGoal(description = "A short brief about the topic") + @Action + public Brief writeBrief(Topic topic, Summary summary, OperationContext context) { + return delegate.writeBrief(topic, summary, context); + } +} diff --git a/embabel/src/main/java/com/ankurm/agents/embabel/Domain.java b/embabel/src/main/java/com/ankurm/agents/embabel/Domain.java new file mode 100644 index 0000000..de992ff --- /dev/null +++ b/embabel/src/main/java/com/ankurm/agents/embabel/Domain.java @@ -0,0 +1,22 @@ +package com.ankurm.agents.embabel; + +import java.util.List; + +/** The domain objects the research agent passes between actions. Embabel plans over these types. */ +public final class Domain { + + private Domain() { + } + + public record Topic(String text) { + } + + public record Sources(List items) { + } + + public record Summary(String text) { + } + + public record Brief(String text) { + } +} diff --git a/embabel/src/main/java/com/ankurm/agents/embabel/ForgetfulGuardedAgent.java b/embabel/src/main/java/com/ankurm/agents/embabel/ForgetfulGuardedAgent.java new file mode 100644 index 0000000..b955410 --- /dev/null +++ b/embabel/src/main/java/com/ankurm/agents/embabel/ForgetfulGuardedAgent.java @@ -0,0 +1,39 @@ +package com.ankurm.agents.embabel; + +import com.ankurm.agents.embabel.Domain.Brief; +import com.ankurm.agents.embabel.Domain.Sources; +import com.ankurm.agents.embabel.Domain.Summary; +import com.ankurm.agents.embabel.Domain.Topic; +import com.embabel.agent.api.annotation.AchievesGoal; +import com.embabel.agent.api.annotation.Action; +import com.embabel.agent.api.annotation.Agent; +import com.embabel.agent.api.annotation.Condition; +import com.embabel.agent.api.common.OperationContext; + +/** The guarded agent with one line missing: findSources does not declare that it makes hasSources true. */ +@Agent(description = "The guarded agent without the post condition") +public class ForgetfulGuardedAgent { + + private final ResearchAgent delegate = new ResearchAgent(); + + @Action + public Sources findSources(Topic topic) { + return delegate.findSources(topic); + } + + @Condition(name = "hasSources") + public boolean hasSources(Sources sources) { + return !sources.items().isEmpty(); + } + + @Action(pre = "hasSources") + public Summary summarise(Sources sources, OperationContext context) { + return delegate.summarise(sources, context); + } + + @AchievesGoal(description = "A short brief about the topic") + @Action + public Brief writeBrief(Topic topic, Summary summary, OperationContext context) { + return delegate.writeBrief(topic, summary, context); + } +} diff --git a/embabel/src/main/java/com/ankurm/agents/embabel/GuardedResearchAgent.java b/embabel/src/main/java/com/ankurm/agents/embabel/GuardedResearchAgent.java new file mode 100644 index 0000000..bf7e685 --- /dev/null +++ b/embabel/src/main/java/com/ankurm/agents/embabel/GuardedResearchAgent.java @@ -0,0 +1,39 @@ +package com.ankurm.agents.embabel; + +import com.ankurm.agents.embabel.Domain.Brief; +import com.ankurm.agents.embabel.Domain.Sources; +import com.ankurm.agents.embabel.Domain.Summary; +import com.ankurm.agents.embabel.Domain.Topic; +import com.embabel.agent.api.annotation.AchievesGoal; +import com.embabel.agent.api.annotation.Action; +import com.embabel.agent.api.annotation.Agent; +import com.embabel.agent.api.annotation.Condition; +import com.embabel.agent.api.common.OperationContext; + +/** The same agent, but summarising is only allowed when there is something to summarise. */ +@Agent(description = "Researches a topic, but refuses to summarise nothing") +public class GuardedResearchAgent { + + private final ResearchAgent delegate = new ResearchAgent(); + + @Action(post = "hasSources") + public Sources findSources(Topic topic) { + return delegate.findSources(topic); + } + + @Condition(name = "hasSources") + public boolean hasSources(Sources sources) { + return !sources.items().isEmpty(); + } + + @Action(pre = "hasSources") + public Summary summarise(Sources sources, OperationContext context) { + return delegate.summarise(sources, context); + } + + @AchievesGoal(description = "A short brief about the topic") + @Action + public Brief writeBrief(Topic topic, Summary summary, OperationContext context) { + return delegate.writeBrief(topic, summary, context); + } +} diff --git a/embabel/src/main/java/com/ankurm/agents/embabel/PlainResearch.java b/embabel/src/main/java/com/ankurm/agents/embabel/PlainResearch.java new file mode 100644 index 0000000..4f355a8 --- /dev/null +++ b/embabel/src/main/java/com/ankurm/agents/embabel/PlainResearch.java @@ -0,0 +1,32 @@ +package com.ankurm.agents.embabel; + +import com.ankurm.agents.embabel.Domain.Brief; +import com.ankurm.agents.embabel.Domain.Sources; +import com.ankurm.agents.embabel.Domain.Summary; +import com.ankurm.agents.embabel.Domain.Topic; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; + +/** + * The same job written the plain Spring AI way: you choose the order, you pass the values, you write the guard. + * The prompts are identical to {@link ResearchAgent}, so the two can be compared call for call. + */ +public class PlainResearch { + + private final ChatClient chat; + private final ResearchAgent search = new ResearchAgent(); + + public PlainResearch(ChatModel model) { + this.chat = ChatClient.create(model); + } + + public Brief research(Topic topic) { + Sources sources = search.findSources(topic); + Summary summary = new Summary(chat.prompt() + .user("Summarise these notes in one sentence: " + String.join(" ", sources.items())) + .call().content()); + return new Brief(chat.prompt() + .user("Write a two-line brief titled '" + topic.text() + "' from: " + summary.text()) + .call().content()); + } +} diff --git a/embabel/src/main/java/com/ankurm/agents/embabel/ResearchAgent.java b/embabel/src/main/java/com/ankurm/agents/embabel/ResearchAgent.java new file mode 100644 index 0000000..f3277fa --- /dev/null +++ b/embabel/src/main/java/com/ankurm/agents/embabel/ResearchAgent.java @@ -0,0 +1,45 @@ +package com.ankurm.agents.embabel; + +import com.ankurm.agents.embabel.Domain.Brief; +import com.ankurm.agents.embabel.Domain.Sources; +import com.ankurm.agents.embabel.Domain.Summary; +import com.ankurm.agents.embabel.Domain.Topic; +import com.embabel.agent.api.annotation.AchievesGoal; +import com.embabel.agent.api.annotation.Action; +import com.embabel.agent.api.annotation.Agent; +import com.embabel.agent.api.common.OperationContext; + +import java.util.List; +import java.util.Map; + +/** + * A small research agent. No method calls another: each declares what it needs as parameters and what it + * produces as its return type, and Embabel works out the order. + */ +@Agent(description = "Researches a topic and writes a short brief") +public class ResearchAgent { + + /** A stand-in for a search engine, so the demo needs no network. */ + static final Map> CORPUS = Map.of( + "virtual threads", List.of("JEP 444 makes virtual threads final in Java 21.", + "Virtual threads are cheap to block, so thread-per-request scales again."), + "records", List.of("Records are final classes with value semantics, final in Java 16.")); + + @Action(description = "Look the topic up in the local corpus") + public Sources findSources(Topic topic) { + return new Sources(CORPUS.getOrDefault(topic.text(), List.of())); + } + + @Action(description = "Summarise the sources with an LLM") + public Summary summarise(Sources sources, OperationContext context) { + return context.ai().withDefaultLlm() + .createObject("Summarise these notes in one sentence: " + String.join(" ", sources.items()), Summary.class); + } + + @AchievesGoal(description = "A short brief about the topic") + @Action(description = "Write the brief from the summary") + public Brief writeBrief(Topic topic, Summary summary, OperationContext context) { + return context.ai().withDefaultLlm() + .createObject("Write a two-line brief titled '" + topic.text() + "' from: " + summary.text(), Brief.class); + } +} diff --git a/embabel/src/test/java/com/ankurm/agents/embabel/EmbabelTest.java b/embabel/src/test/java/com/ankurm/agents/embabel/EmbabelTest.java new file mode 100644 index 0000000..8534d1b --- /dev/null +++ b/embabel/src/test/java/com/ankurm/agents/embabel/EmbabelTest.java @@ -0,0 +1,133 @@ +package com.ankurm.agents.embabel; + +import com.ankurm.agents.embabel.Domain.Brief; +import com.ankurm.agents.embabel.Domain.Sources; +import com.ankurm.agents.embabel.Domain.Summary; +import com.ankurm.agents.embabel.Domain.Topic; +import com.embabel.agent.api.annotation.support.AgentMetadataReader; +import com.embabel.agent.core.Agent; +import com.embabel.agent.core.AgentPlatform; +import com.embabel.agent.core.AgentProcess; +import com.embabel.agent.core.ProcessOptions; +import com.embabel.agent.test.integration.IntegrationTestUtils; +import com.embabel.agent.test.integration.ScriptedLlmOperations; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +class EmbabelTest { + + record Run(AgentProcess process, ScriptedLlmOperations llm) { + String order() { + return process.getHistory().stream().map(a -> a.getActionName()).collect(Collectors.joining("\n ")); + } + } + + private static Run run(Object agentInstance, Map inputs) { + return run(agentInstance, inputs, new Summary("Virtual threads make blocking cheap."), + new Brief("Virtual threads: blocking is cheap again.")); + } + + /** The scripted model hands back these objects in order, one per LLM call the agent makes. */ + private static Run run(Object agentInstance, Map inputs, Object... llmReplies) { + ScriptedLlmOperations llm = new ScriptedLlmOperations(); + for (Object o : llmReplies) llm.returnObject(o); + AgentPlatform platform = IntegrationTestUtils.dummyAgentPlatform(llm); + Agent agent = (Agent) new AgentMetadataReader().createAgentMetadata(agentInstance); + platform.deploy(agent); + AgentProcess p = platform.runAgentFrom(agent, ProcessOptions.DEFAULT, inputs); + return new Run(p, llm); + } + + @Test + void plannerFindsTheOrderFromTypes() { + Run r = run(new ResearchAgent(), Map.of("it", new Topic("virtual threads"))); + StringBuilder sb = new StringBuilder("ResearchAgent, input Topic(\"virtual threads\"), planner GOAP.\n\n"); + sb.append("Planner: ").append(r.process.getPlanner().getClass().getSimpleName()).append("\n"); + sb.append("Status: ").append(r.process.getStatus()).append("\n\nActions in the order they ran:\n ").append(r.order()).append('\n'); + sb.append("\nObjects on the blackboard at the end:\n"); + r.process.getObjects().forEach(o -> sb.append(" ").append(o).append('\n')); + sb.append("\nPrompts the LLM received (").append(r.llm.getPromptsReceived().size()).append("):\n"); + r.llm.getPromptsReceived().forEach(p -> sb.append(" ").append(p).append('\n')); + Transcript.write("01-plan.txt", sb.toString()); + assertThat(r.process.getStatus().name()).isEqualTo("COMPLETED"); + assertThat(r.process.getHistory()).extracting(a -> a.getActionName()).containsExactly( + "com.ankurm.agents.embabel.ResearchAgent.findSources", + "com.ankurm.agents.embabel.ResearchAgent.summarise", + "com.ankurm.agents.embabel.ResearchAgent.writeBrief"); + assertThat(r.llm.getPromptsReceived()).hasSize(2); + } + + // ------------------------------------------------------------------ 2. the plan follows what is already known + + @Test + void plannerSkipsAnActionWhoseOutputIsAlreadyKnown() { + Run r = run(new ResearchAgent(), Map.of("it", new Topic("virtual threads"), + "sources", new Sources(java.util.List.of("A note we already had.")))); + String sb = "Same agent, but the caller also hands over a Sources object.\n\nActions in the order they ran:\n " + r.order() + + "\n\nPrompts the LLM received:\n " + String.join("\n ", r.llm.getPromptsReceived()) + "\n"; + Transcript.write("02-skips-known.txt", sb); + assertThat(r.order()).doesNotContain("findSources"); + assertThat(r.llm.getPromptsReceived().getFirst()).contains("A note we already had."); + } + + // ------------------------------------------------------------------ 3. a condition can make the goal unreachable + + @Test + void aConditionStopsThePlanBeforeAnyLlmCall() { + Run known = run(new GuardedResearchAgent(), Map.of("it", new Topic("virtual threads"))); + Run unknown = run(new GuardedResearchAgent(), Map.of("it", new Topic("quantum gravity"))); + String sb = "GuardedResearchAgent: summarise has pre = \"hasSources\".\n\n" + + "Topic \"virtual threads\": status " + known.process.getStatus() + ", actions:\n " + known.order() + + "\n LLM prompts: " + known.llm.getPromptsReceived().size() + "\n\n" + + "Topic \"quantum gravity\": status " + unknown.process.getStatus() + ", actions:\n " + unknown.order() + + "\n LLM prompts: " + unknown.llm.getPromptsReceived().size() + "\n"; + Transcript.write("03-condition.txt", sb); + assertThat(known.process.getStatus().name()).isEqualTo("COMPLETED"); + assertThat(unknown.llm.getPromptsReceived()).isEmpty(); + assertThat(unknown.process.getStatus().name()).isEqualTo("STUCK"); + assertThat(unknown.process.getHistory()).hasSize(1); + assertThat(known.process.getHistory()).hasSize(3); + } + + @Test + void aConditionNobodyEstablishesMakesTheGoalUnreachable() { + Run r = run(new ForgetfulGuardedAgent(), Map.of("it", new Topic("virtual threads"))); + String sb = "ForgetfulGuardedAgent: identical to GuardedResearchAgent except findSources has no post = \"hasSources\".\n\n" + + "Topic \"virtual threads\" (a topic that has sources): status " + r.process.getStatus() + ", actions run: " + + r.process.getHistory().size() + "\n LLM prompts: " + r.llm.getPromptsReceived().size() + "\n"; + Transcript.write("06-condition-trap.txt", sb); + assertThat(r.process.getStatus().name()).isEqualTo("STUCK"); + assertThat(r.process.getHistory()).isEmpty(); + } + + // ------------------------------------------------------------------ 4. cost steers the choice + + @Test + void theCheaperRouteIsChosen() { + Run r = run(new CostAwareResearchAgent(), Map.of("it", new Topic("virtual threads")), new Brief("Virtual threads: blocking is cheap again.")); + String sb = "CostAwareResearchAgent: summariseWithLlm cost 0.9, summariseLocally cost 0.1, both return Summary.\n\n" + + "Status: " + r.process.getStatus() + "\nActions in the order they ran:\n " + r.order() + + "\n\nPrompts the LLM received (" + r.llm.getPromptsReceived().size() + "):\n " + + String.join("\n ", r.llm.getPromptsReceived()) + "\n"; + Transcript.write("04-cost.txt", sb); + assertThat(r.order()).contains("summariseLocally").doesNotContain("summariseWithLlm"); + } + + // ------------------------------------------------------------------ 5. the same job in plain Spring AI + + @Test + void plainSpringAiSendsTheSamePrompts() { + ScriptedChatModel model = new ScriptedChatModel("Virtual threads make blocking cheap.", "Virtual threads: blocking is cheap again."); + Brief plain = new PlainResearch(model).research(new Topic("virtual threads")); + Run r = run(new ResearchAgent(), Map.of("it", new Topic("virtual threads"))); + String sb = "Plain Spring AI (ChatClient, order written by hand):\n result: " + plain + "\n prompts:\n " + + String.join("\n ", model.prompts) + "\n\nEmbabel:\n result: " + r.process.resultOfType(Brief.class) + "\n prompts:\n " + + String.join("\n ", r.llm.getPromptsReceived()) + "\n\nSame prompts: " + model.prompts.equals(r.llm.getPromptsReceived()) + "\n"; + Transcript.write("05-plain-spring-ai.txt", sb); + assertThat(model.prompts).isEqualTo(r.llm.getPromptsReceived()); + } +} diff --git a/embabel/src/test/java/com/ankurm/agents/embabel/ScriptedChatModel.java b/embabel/src/test/java/com/ankurm/agents/embabel/ScriptedChatModel.java new file mode 100644 index 0000000..73bb36d --- /dev/null +++ b/embabel/src/test/java/com/ankurm/agents/embabel/ScriptedChatModel.java @@ -0,0 +1,28 @@ +package com.ankurm.agents.embabel; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** Replies with a fixed list of strings in order and records every prompt it was given. */ +final class ScriptedChatModel implements ChatModel { + + final List prompts = new ArrayList<>(); + private final Iterator replies; + + ScriptedChatModel(String... replies) { + this.replies = List.of(replies).iterator(); + } + + @Override + public ChatResponse call(Prompt prompt) { + prompts.add(prompt.getContents()); + return new ChatResponse(List.of(new Generation(new AssistantMessage(replies.next())))); + } +} diff --git a/embabel/src/test/java/com/ankurm/agents/embabel/Transcript.java b/embabel/src/test/java/com/ankurm/agents/embabel/Transcript.java new file mode 100644 index 0000000..80e3ce3 --- /dev/null +++ b/embabel/src/test/java/com/ankurm/agents/embabel/Transcript.java @@ -0,0 +1,23 @@ +package com.ankurm.agents.embabel; + +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 new file mode 100644 index 0000000..e85e54b --- /dev/null +++ b/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + com.ankurm.agents + java-ai-agents + 1.0.0 + pom + + + embabel + + + + 25 + UTF-8 + + 1.5.3 + 6.1.3 + 3.27.6 + 2.0.17 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.0 + + true + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + + + +