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:
Claude
2026-10-11 08:15:46 +00:00
commit ec1b076e9b
20 changed files with 601 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
target/
*.class
.idea/
*.iml
+29
View File
@@ -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.
+19
View File
@@ -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.
+9
View File
@@ -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.
+11
View File
@@ -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
+10
View File
@@ -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.
+13
View File
@@ -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
+4
View File
@@ -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
+13
View File
@@ -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
+33
View File
@@ -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);
}
}
}
+57
View File
@@ -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>