The condition nothing could satisfy: custom @Condition gates in Embabel
Where types stop being enough
Part one of this series ended on a line I've been leaning on ever since: an action's parameter types are its preconditions, and its return type is its effect. That gets you surprisingly far. Embabel derives a whole world model from method signatures, and for a linear pipeline you never write a condition at all.
Then I hit the case signatures don't cover.
The agent takes free text — "give insights about customer Arthur Dent and his orders" — asks a cheap model to pull the customer name out of it, and uses that name to query a Postgres table through a Spring Data repository. Types answer does a name exist. They cannot answer is the name any good. If the model shrugs and hands back an empty string, the query still runs, matches nothing, and the agent cheerfully reports zero orders for a customer nobody named.
I wanted the planner to refuse to schedule that query, rather than bury an if inside it. That is what conditions are for.
The annotation, briefly
@Condition(name = "customerNameProvided", cost = 0.0)
public boolean customerNameProvided(CustomerName customerName) {
return !customerName.fullName().isBlank();
}
@Action(description = "Builds insights for a customer", pre = "customerNameProvided")
@AchievesGoal(description = "Insights about the orders placed by a given customer")
public CustomerInsights buildCustomerInsights(CustomerName customerName) { ... }
name defaults to the method name; it exists so you can share a constant instead of a magic string. cost is a 0–1 hint that lets the platform run cheap checks before expensive ones. Parameters are bound from the blackboard by type and name, and you can also take an OperationContext to reach everything else.
One detail worth knowing up front: the planner's internal condition type is ConditionDetermination, a three-valued enum of TRUE, FALSE and UNKNOWN — which is why plan logs read TRUE rather than true. Your method, though, is invoked reflectively and its result is cast straight to Boolean. You cannot express "I don't know yet" from an annotated condition. UNKNOWN is the planner's own bookkeeping.
The agent that refused to start
I added the condition, wired pre, ran it, and got this:
INFO Guide - [zealous_swartz] stuck at:
customerNameProvided: FALSE
hasRun_...OrderAgent.buildCustomerInsights: FALSE
hasRun_...OrderAgent.extractCustomerName: FALSE
it:...OrderAgent$CustomerInsights: FALSE
it:...OrderAgent$CustomerName: FALSE
it:com.embabel.agent.domain.io.UserInput: TRUE
WARN SimpleAgentProcess - Process zealous_swartz is stuck with no StuckHandler.
I'm sorry. I don't know how to proceed.
The line that matters is hasRun_...extractCustomerName: FALSE. The extraction step never ran. Zero LLM calls, zero cost, nothing attempted. This was not a failure during execution — it was a refusal to plan at all.
Effects are declared, not inferred
The answer is in AbstractAction. An action's effects come from exactly three sources:
override val effects: EffectSpec
get() {
val conditions = post.associateWith { ConditionDetermination(true) }.toMutableMap()
outputs.forEach { ... conditions["${output.name}:${pot.name}"] = TRUE }
conditions += Rerun.hasRunCondition(this) to TRUE
return conditions
}
Whatever you listed in post, the output type conditions, and hasRun. That is the complete list. Nothing anywhere reads the body of your @Condition method to work out what might flip it.
So the backward search went: the goal needs a CustomerInsights on the blackboard; only buildCustomerInsights produces one; that action requires customerNameProvided to be TRUE; no action in the agent lists customerNameProvided among its effects; therefore the goal is unreachable. Stuck, before doing anything.
The fix is one attribute on the other action:
@Action(description = "Extracts the customer name from what the user asked",
post = "customerNameProvided")
CustomerName extractCustomerName(UserInput userInput, Ai ai) { ... }
In hindsight this is just GOAP being GOAP. A planner searches a graph of declared effects; a predicate that no action claims to establish is a wall, not a gate. But it reads as a bug, because the condition looks like it obviously depends on the extraction step — it takes a CustomerName as its parameter. That apparent dependency is invisible to the planner.
Two kinds of truth
What clicked for me is that pre/post and the @Condition method are deliberately different things.
pre and post are the planner's symbolic model — promises you make at build time so a plan can be formed. The @Condition method is runtime truth, evaluated for real once the action has run. GOAP plans on the promise, then execution checks the fact.
Which means the honest failure is still available to me. If the model does return a blank name, the condition comes back FALSE after extraction, the plan is invalidated, and the process gets stuck — but this time after trying, with an LLM call in the trace and a state map that says exactly which predicate failed. That is the behaviour I wanted all along.
The warning that wasn't
One more log line, because it cost me a few minutes:
WARN AgentMetadataReader - Condition method ...customerNameProvided has
unsupported argument it. Unknown type class ...OrderAgent$CustomerName
This looks like the smoking gun. It isn't. When a condition parameter isn't on the blackboard yet, invokeConditionMethod branches on whether the type is in agent.jvmTypes, logging at debug if it is (the source comments it as "not an error condition") and at warn if it isn't. Both branches then return false. Identical behaviour, different log level.
It takes the scary branch because TransformationAction reports its input and output as DynamicType rather than JvmType, so an annotation-derived domain class never lands in jvmTypes. Every condition taking a domain object will warn on every planning cycle before that object exists. Noise, not signal.
The shortcut I found afterwards
Reading the reference guide later, I found that the method is optional. Embabel accepts a Spring Expression Language predicate directly in pre, behind a spel: prefix, evaluated against the blackboard:
@Action(description = "Takes a name of a customer to get insights of his orders",
pre = {"spel:!customerName.fullName().isBlank()"})
@AchievesGoal(description = "Insights about the orders placed by a given customer")
public CustomerInsights buildCustomerInsights(CustomerName customerName) { ... }
Same gate as the @Condition method above, minus the method and minus the shared constant I recommended to protect it. Blackboard objects land in the expression model under their lowercased simple class name, so CustomerName becomes customerName — and SpEL resolves a record component either way, as fullName() or as fullName. I checked both against a package-private nested record before writing this down.
Two caveats before reaching for it.
First, it does not get you out of the post problem. A spel: string goes into the same pre list as a named condition, and the planner's knownConditions() is simply knownPreconditions() + knownEffects() — nothing special-cases the prefix. Reachability is a property of the effect model, not of how you spelled the predicate, so extractCustomerName still has to declare it in post.
Second, and this is the part I did not expect: a SpEL precondition can express the third value. SpelLogicalExpression.evaluate maps a boolean result to TRUE or FALSE, but returns UNKNOWN when the expression yields null or throws — a null property access is even logged at debug rather than warn, because it is treated as normal. A @Condition method, cast straight to Boolean, has no such option. So the terser form is also the only one of the two that can tell the planner "I don't know yet" instead of "no".
Three things I'd tell past me
- A condition no action names in
postcan only ever block. It is useful for guarding something that starts true and gets consumed, and useless as a gate you expect to open. preandpostare untypedString[]. Misspell one and you get this same silent stuck-at — no compile error, no warning, no hint. Since the failure mode is silence, this is the one place worth a sharedstatic final String.- Exceptions inside a condition become
false. They're caught and logged atwarn, so a NullPointerException is indistinguishable from a legitimately unsatisfied precondition. When a plan mysteriously won't form, check the warn log before you doubt your model.
Conditions are the part of Embabel where you stop describing data flow and start describing policy. Worth the detour — just remember that the planner only knows what you tell it twice: once where the gate is, and once where it opens.