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 <[email protected]> Claude-Session: https://claude.ai/code/session_01G8ikz8xdWuTP5yun8DZ1hk
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -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 `<module>/output/` when you run the tests.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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.agents</groupId>
|
||||
<artifactId>java-ai-agents</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>embabel</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.embabel.agent</groupId>
|
||||
<artifactId>embabel-agent-api</artifactId>
|
||||
<version>${embabel.version}</version>
|
||||
</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,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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> items) {
|
||||
}
|
||||
|
||||
public record Summary(String text) {
|
||||
}
|
||||
|
||||
public record Brief(String text) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, List<String>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<String> prompts = new ArrayList<>();
|
||||
private final Iterator<String> 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()))));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>
|
||||
|
||||
<groupId>com.ankurm.agents</groupId>
|
||||
<artifactId>java-ai-agents</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>embabel</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- Verified on Maven Central 2026-10-11 -->
|
||||
<embabel.version>1.5.3</embabel.version>
|
||||
<junit.version>6.1.3</junit.version>
|
||||
<assertj.version>3.27.6</assertj.version>
|
||||
<slf4j.version>2.0.17</slf4j.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.14.0</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.4</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
Reference in New Issue
Block a user