One Goal Too Many: Learning Embabel's Planner the Hard Way
Embabel, an agent framework for the JVM. I came in expecting yet another "call an LLM in a loop" library. I left with a much sharper mental model — delivered, as usual, by a bug I caused myself. This is the write-up of that first contact: what the framework actually does, the mistake I made, the cryptic error it produced, and the fix that made the whole design click.
What Embabel actually is
Embabel is a goal-driven agent framework built on Spring Boot. That Spring lineage isn't cosmetic — your agent is a Spring bean, so you get constructor injection, @Value config, component scanning, and testability for free. The application entry point is a plain @SpringBootApplication. Nothing exotic.
The interesting part is the execution model. Most "agent" code you see is imperative: you write craft(); then review(); then translate(); and you own the control flow. Embabel inverts that. You give it a bag of typed actions and a goal, and a planner works out the order for you. The technique is GOAP — Goal-Oriented Action Planning — an idea borrowed from game AI, where NPCs plan a sequence of moves to reach a desired world state. Embabel does A* search over your actions to find a path from "what I have" to "what the goal needs."
The wiring mechanism is the type system. An action's parameter types are its preconditions; its return type is its effect. An action that returns a Story and another that consumes a Story are chained automatically. You never write the sequence. That is the entire pitch, and it is genuinely different from writing the orchestration by hand.
The starter agent
The template ships a two-step agent: write a short story, then have a "reviewer" persona critique it. Stripped of the prompt boilerplate, the skeleton is just this:
@Agent(description = "Generate a story based on user input and review it")
public class WriteAndReviewAgent {
@Action // UserInput -> Story
Story craftStory(UserInput userInput, Ai ai) { ... }
@AchievesGoal(...) // reaching this = done
@Action // (UserInput, Story) -> ReviewedStory
ReviewedStory reviewStory(UserInput userInput, Story story, Ai ai) { ... }
}Watch the planner reason backwards from the goal. It needs a ReviewedStory. Only reviewStory produces one, but that needs a Story, which I don't have yet. craftStory produces a Story and only needs UserInput, which the caller supplies. Plan found: craftStory → reviewStory. I wrote no ordering code. The Ai handle, injected into each action, is the door to the LLM — a fluent API where you pick a model, set temperature, attach a persona, and either ask for free text (generateText) or a typed object (.creating(Story.class), which deserializes the model's JSON straight into a record).
The experiment that broke it
To feel the planner adapt, I added a third action: translate the reviewed story into French. My instinct — and this was the mistake — was to mark it as a second goal, reasoning that asking AgentInvocation for a ReviewedStory would give me a 2-step plan and asking for a TranslatedStory would give me a 3-step one. Same action set, two entry points. Elegant, I thought.
@AchievesGoal(description = "...reviewed by a book reviewer") // goal #1
@Action
ReviewedStory reviewStory(UserInput userInput, Story story, Ai ai) { ... }
@AchievesGoal(description = "...translated into French") // goal #2 (my addition)
@Action
TranslatedStory translateStory(ReviewedStory reviewedStory, Ai ai) { ... }It compiled. I invoked it asking for a TranslatedStory. And it fell over.
The failure
The run produced a review, then died with a Kotlin null error that told me nothing on its own:
goal ...reviewStory achieved in PT12.0S
it:...$TranslatedStory=FALSE
hasRun_...translateStory=FALSE
get(...) must not be nullRead those four lines carefully, because they are the whole diagnosis. The process achieved the reviewStory goal and stopped. translateStory never ran — its "hasRun" flag is FALSE and no TranslatedStory was ever produced. Then AgentInvocation, having been asked for a TranslatedStory, went looking for one in the result, found nothing, and threw get(...) must not be null. My mental model had just been contradicted by the log, out loud.
Why it really happened
Two things were wrong in my head, and both matter.
GOAP terminates the moment any goal is satisfied. With two @AchievesGoal actions on a single linear chain, both goals are always visible to the planner. After reviewStory ran, a ReviewedStory existed on the blackboard, so the reviewStory goal was met. A* saw a completed goal and declared victory. Walking on to translateStory would have cost another LLM call for zero additional goal-value, so the planner correctly refused. The goals were competing, and the cheaper one won.
The result type you pass to AgentInvocation selects the agent, not the goal. This was the subtler error. I assumed AgentInvocation.create(platform, TranslatedStory.class) would steer the planner toward the goal that yields that type. It doesn't. The docs are explicit: it "finds those [agents] whose goals match the requested result type, and uses the first one found." The requested type is how it picks which agent to run. Once inside the agent, the planner is free to satisfy whichever goal is cheapest — and here that was reviewStory. So get(...) must not be null is, precisely, the signature of "you asked for a type the process never actually produced."
The fix: one goal per linear chain
The correction is small and, in hindsight, obvious. A linear pipeline should have exactly one goal — the final state. To extend the pipeline, you don't add a goal; you move it. Demote reviewStory to a plain @Action and let translateStory be the sole @AchievesGoal:
// plain @Action now -- an intermediate step, not a goal
@Action
ReviewedStory reviewStory(UserInput userInput, Story story, Ai ai) { ... }
@AchievesGoal(description = "...translated into French") // the ONLY goal
@Action
TranslatedStory translateStory(ReviewedStory reviewedStory, Ai ai) { ... }Now there's only one way to satisfy the agent, and it forces the full walk: craftStory → reviewStory → translateStory. Ask for a TranslatedStory and you get all three steps, every time. No competition, no early exit.
So when are multiple goals correct?
Here's the part that turned a dumb mistake into a real lesson. Multiple goals absolutely have a place — just not stacked on one linear path. They belong in branching agents, where the input decides which of several mutually exclusive paths runs. Think triage: classify a support ticket, then route it to exactly one handler.
Embabel supports this with the @State annotation and the UTILITY planner instead of GOAP. A classifier action returns a sealed supertype; each concrete branch is a @State record with its own @AchievesGoal:
@Agent(description = "Triage a support ticket", planner = PlannerType.UTILITY)
public class SupportTriageAgent {
@State
public sealed interface Triaged permits Critical, Bug, General {}
@Action
Triaged triage(UserInput ticket, Ai ai) {
var category = ai.withDefaultLlm().generateText("""
Classify this ticket. Reply with ONLY one word:
CRITICAL, BUG, or GENERAL.
# Ticket
%s""".formatted(ticket.getContent())).trim().toUpperCase();
if (category.contains("CRITICAL")) return new Critical(ticket);
if (category.contains("BUG")) return new Bug(ticket);
return new General(ticket);
}
@State
public record Critical(UserInput ticket) implements Triaged {
@AchievesGoal(description = "Critical ticket escalated")
@Action
ResolvedTicket handleCritical() { ... }
}
// Bug and General: same shape, their own goals
}The magic word is state scoping. From the docs: "State scoping hides previous states, making only the current state's actions available." The classifier drops you into exactly one state — Critical, Bug, or General — and entering it hides the other branches' actions entirely. Only that branch's @AchievesGoal is even reachable. The goals literally cannot compete, because only one is on the table at a time. That is exactly the property my linear two-goal version lacked. One caveat the docs flag: the terminal goal action must return a non-@State type, or you loop forever re-entering states.
The two planners map cleanly onto the two shapes. GOAP (A* to a goal, needsGoals = true) is for fixed pipelines where every step always runs. UTILITY (greedy value-based routing, needsGoals = false) is for branching where the path depends on the input.
What I'm taking away
Three things stuck, and they'll shape how I reach for this framework.
Types are the plan graph. The ordering I never wrote is derived entirely from action signatures. That's powerful, but it means the failure modes are graph-shaped too — "the planner stopped early" is really "a cheaper goal was reachable than the one I wanted."
One agent, one goal — unless you're branching. If you ever feel the urge to put two @AchievesGoal on one linear chain, stop. Either move the goal to the end, or split into @State branches. Never let goals compete on the same path.
get(...) must not be null is a friend, not noise. It's the tell that AgentInvocation was asked for a type the process never produced. Whenever it shows up, the question is always: did the planner actually reach a goal that yields my requested type?
Not bad for one afternoon and one self-inflicted bug. The framework didn't fight me — it made me check the docs and the jar, and the wrong explanation I started with turned into the most useful thing I learned all day. That's usually how it goes.