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
@@ -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);
}
}
}