One mistake, three failures: named bindings in Embabel
Embabel wires actions together by type. A method's parameters are its preconditions, its return type is its effect, and a GOAP planner searches over that to build the chain. It works right up until an action needs two objects of the same type — at which point "the CustomerOrders" stops meaning anything.
The fix is a named binding. What's worth writing down is that binding is two declarations — one on the producer, one on the consumer — and nothing validates them against each other. Get them out of step three different ways and you get three completely different failures, only one of which is loud at the right time.
Everything binds to "it"
The thing I hadn't internalised: parameter names are decorative until you opt in. From utils.kt:
internal fun getBindingParameterName(
parameterName: String?,
requireNameMatch: RequireNameMatch?,
): String? {
if (requireNameMatch == null) {
return IoBinding.DEFAULT_BINDING
}
return requireNameMatch.value.ifBlank { parameterName }
}
DEFAULT_BINDING is the string "it" — "the latest value of this type". Rename the parameter and nothing changes. The same applies on the way out: an action's result lands under "it" unless you set outputBinding.
And one detail that turns out to matter enormously: resolveInputBindings accumulates into a Set<IoBinding>. Two unannotated parameters of the same type don't produce two bindings that happen to be equal. They produce one.
The version that works
@Action(outputBinding = "firstOrders")
CustomerOrdersFor loadFirst(CustomerPair pair) { ... }
@Action(outputBinding = "secondOrders")
CustomerOrdersFor loadSecond(CustomerPair pair) { ... }
@Action
@AchievesGoal(description = "Which of two customers ordered more")
public Comparison compare(
@RequireNameMatch("firstOrders") CustomerOrdersFor firstOrders,
@RequireNameMatch("secondOrders") CustomerOrdersFor secondOrders) { ... }
Rather than trust my reading of that, I printed what the planner actually sees — AgentMetadataReader constructs standalone, so a throwaway JUnit test can dump the real metadata:
var reader = new AgentMetadataReader();
var agent = (Agent) reader.createAgentMetadata(new CustomerComparisonAgent(null));
agent.getActions().forEach(a -> System.out.println(a.getName() + " pre " + a.getPreconditions()));
compare pre : {firstOrders:CustomerOrdersFor=TRUE, secondOrders:CustomerOrdersFor=TRUE, ...}
loadFirst eff : {firstOrders:CustomerOrdersFor=TRUE, ...}
loadSecond eff : {secondOrders:CustomerOrdersFor=TRUE, ...}
Connected graph, reachable goal. Good.
Failure one: mixed annotations throw
Delete one @RequireNameMatch and you get DuplicateParameterTypeException at startup, with a message naming the method and the conflicting parameter positions. Excellent error, exactly when you want it.
Which makes it tempting to assume the check has your back. It doesn't. Read the predicate:
internal fun isNotProperlyAnnotated(params: List<Parameter>): Boolean =
!params.all { it.isAnnotationPresent(RequireNameMatch::class.java) } &&
!params.none { it.isAnnotationPresent(RequireNameMatch::class.java) }
All annotated: no throw. Mixed: throw. None annotated: no throw. Only the provably-inconsistent case is rejected; plain omission sails through. The class javadoc — "multiple parameters of the same type that have not been annotated" — reads like it covers omission. It does not.
Failure two: an unreachable goal
So delete both annotations and leave the producers as they are. I predicted a wrong answer. I was wrong, and the actual behaviour is more interesting.
ERROR AStarGoapPlanner - Some of the conditions [hasRun_...compare,
it:...CustomerOrdersFor, it:...Comparison] of goal '...compare'
are not satisfied.
The consumer's two parameters collapsed to a single it:CustomerOrdersFor. The producers still write to firstOrders: and secondOrders:, because their outputBinding is untouched. Nothing in the agent produces it:CustomerOrdersFor, so no path to the goal exists and the process is stuck having executed nothing at all — every hasRun_* false with only the input true.
Loud, and it points at the right place. Half the binding was updated and half wasn't, which is precisely the failure mode of two declarations nobody cross-checks.
Failure three: the plausible lie
Now remove the outputBindings as well, so nothing is named anywhere. The naive version. This one runs:
loadFirst pre : {it:Pair=TRUE, it:OrdersFor=FALSE, ...} eff : {it:OrdersFor=TRUE, ...}
loadSecond pre : {it:Pair=TRUE, it:OrdersFor=FALSE, ...} eff : {it:OrdersFor=TRUE, ...}
compare pre : {it:OrdersFor=TRUE, ...}
Look at the loaders' preconditions. Because canRerun is false, each action gets "my own output must not already exist" as a precondition — it:OrdersFor=FALSE. So loadFirst runs, writes it:OrdersFor, and loadSecond is now permanently ineligible: its precondition is violated by the thing its sibling just did.
One loader runs. compare's single collapsed binding resolves to that one object, handed to both parameters. My seed data has Arthur Dent on 2 items and Zaphod Beeblebrox on 6; this configuration compares a customer against themselves and reports a tie. No exception, no stuck planner, no null. A grammatical English sentence that is false.
What I'm taking away
Binding is a contract with two signatures and no compiler. outputBinding on one side, @RequireNameMatch on the other, agreeing by string. Nothing checks a consumer's requested name against any producer's declared name — a typo in either is not an error, it's a different graph. Change one side, change the other in the same commit.
The severity of a mistake here is inversely proportional to how likely you are to make it. The inconsistent case throws at startup. The half-migrated case fails loudly at runtime. The one where you simply never knew about named bindings produces a confident wrong answer.
Print the metadata instead of reasoning about it. I had a plausible, wrong story about this from reading the framework source, and a fifteen-line JUnit test that dumps getPreconditions() and getEffects() settled it in one run. The planner's view of your agent is a data structure you can just look at. Do that before theorising — I now keep the probe in the repo permanently.