Embabel
Part of Java is Coming for Python’s Lunch - The Rise of JVM AI Agents. Slides 36–52.
Embabel slide 36 ↗
declare the steps, let the planner find the path
Embabel plans like a game AI: actions, conditions, goals slide 37 ↗
@Action methods types GOAP planner plan @AchievesGoal replan
A* walks the cheapest chain of actions to the goal and replans when the world changes slide 38 ↗
= 1 }"> = 1 ? 'url(#goap-head-on)' : 'url(#goap-head)'" />
loadDossier · 1
= 1, 'is-off': $clicks >= 2 }">
draftWithTools · 8
= 2 }"> = 2 ? 'url(#goap-head-on)' : 'url(#goap-head)'" />
draftReview · 3
= 3 }"> = 3 ? 'url(#goap-head-on)' : 'url(#goap-head)'" />
judgeDraft · 3
= 4, 'is-off': $clicks >= 5 }">
acceptDraft · 0
{{ $clicks >= 5 ? 'pre DRAFT_ACCEPTED = false' : 'pre DRAFT_ACCEPTED' }}
= 5 }"> = 5 ? 'url(#goap-head-on)' : 'url(#goap-head)'" />
reviseDraft · 3
pre DRAFT_REJECTED
TripReviewRequest
= 1 }">
TripDossier
= 2 }">
ReviewOutcome
= 3 }">
Judgement
= 4 }">
EmbabelReview
= 4 }" x="1265" y="480" text-anchor="middle">{{ $clicks >= 5 ? 'replanned · cost 10' : 'plan · cost 7' }}
world state = pre / post
action · cost
The caller names an input and a goal type, never the steps slide 39 ↗
type safety
data class TripReviewRequest(val tripId: Long)
data class TripDossier(val trip: TripSnapshot, val costs: CostBreakdown)
data class EmbabelReview(val outcome: ReviewOutcome, val revised: Boolean)AgentInvocation.create<EmbabelReview>(agentPlatform)
.invoke(TripReviewRequest(tripId))
.outcomeAn `@Action` can be plain code that never calls a model slide 40 ↗
@Action
fun loadDossier(request: TripReviewRequest): TripDossier =
TripDossier(facts.trip(request.tripId), facts.costs(request.tripId))A model-backed action asks for an object, not a string slide 41 ↗
type safety
@Action
fun draftReview(dossier: TripDossier, context: OperationContext): ReviewOutcome =
context.ai()
.withDefaultLlm()
.withSystemPrompt(Prompts.REVIEWER)
.createObject("Review this trip: $dossier")Tools are granted per model call, not per agent slide 42 ↗
integration
@Component
class EmbabelTravelTools(private val facts: TravelFacts) {
@LlmTool(description = "List all company travel policies")
fun listPolicies(): List<PolicyText> = facts.policies()
}context.ai().withDefaultLlm().withToolObject(tools) // draftReview
context.ai().withDefaultLlm() // judgeDraftA domain object can bring its own tools slide 43 ↗
context
data class TripDossier(val trip: TripSnapshot, val costs: CostBreakdown) {
@LlmTool(description = "Hotels above a nightly limit in EUR")
fun hotelsAbove(limit: Double): List<HotelLine> =
trip.hotels.filter { it.nightlyRate > limit }
}context.ai().withDefaultLlm().withToolObject(dossier)A side effect waits for a type only a human can produce slide 44 ↗
reliability
@Action
fun askTraveler(proposal: RebookingProposal): ApprovedRebooking =
WaitFor.confirmation(ApprovedRebooking(proposal), "Rebook ${proposal.hotel}?")
@Action
fun rebook(approved: ApprovedRebooking): HotelBooking =
bookings.rebook(approved.proposal)The memory is the typed blackboard, not a growing chat transcript slide 45 ↗
context
context.ai().withDefaultLlm().withToolObject(tools)
.withToolLoopTransformers(
SlidingWindowTransformer(20),
ToolResultTruncatingTransformer(4_000),
)Spring AI traces the model calls, Embabel adds the plan around them slide 46 ↗
observability
agent TripReviewAgent action loadDossier action draftReview gen_ai.client.operation tool listPolicies gen_ai.client.operation action judgeDraft gen_ai.client.operation action acceptDraft
The judge declares both outcomes, conditions decide which one is true slide 47 ↗
@Action(post = [DRAFT_ACCEPTED, DRAFT_REJECTED])
fun judgeDraft(dossier: TripDossier, draft: ReviewOutcome, ...): Judgement
@Condition(name = DRAFT_ACCEPTED)
fun draftAccepted(judgement: Judgement): Boolean = judgement.accepted
@Condition(name = DRAFT_REJECTED)
fun draftRejected(judgement: Judgement): Boolean = !judgement.acceptedTwo goals finish the review, the true condition picks one slide 48 ↗
reliability
@AchievesGoal(description = "The trip review was confirmed by the judge")
@Action(pre = [DRAFT_ACCEPTED])
fun acceptDraft(draft: ReviewOutcome, judgement: Judgement): EmbabelReview =
EmbabelReview(draft, revised = false)
@AchievesGoal(description = "The trip review was corrected after the judge's feedback")
@Action(pre = [DRAFT_REJECTED])
fun reviseDraft(dossier: TripDossier, draft: ReviewOutcome, judgement: Judgement, ...) =
EmbabelReview(/* one more model call with the feedback */, revised = true)Nobody wrote this order down, the planner derived it from the types slide 49 ↗
TripReviewRequest loadDossier TripDossier draftReview ReviewOutcome
judgeDraft
.outcome
EmbabelReview
accept | revise
Judgement
Shared actions live in a component, not inside one agent slide 50 ↗
type safety
@EmbabelComponent
class TripActions(val facts: TravelFacts, val rates: ExchangeRates) {
@Action fun loadDossier(request: TripReviewRequest): TripDossier = ...
@Action(readOnly = true)
fun inEuro(dossier: TripDossier): EuroCosts = rates.convert(dossier.costs)
}A whole agent becomes one step of the next agent slide 51 ↗
@Agent(description = "Settles expense claims after a trip")
class ExpenseClaimAgent(private val tripReview: TripReviewAgent) {
@Action
fun reviewTrip(claim: ExpenseClaim): EmbabelReview =
RunSubagent.fromAnnotatedInstance(tripReview, EmbabelReview::class.java)
@AchievesGoal(description = "The expense claim was settled")
@Action
fun settle(claim: ExpenseClaim, review: EmbabelReview): Settlement = TODO()
}Every new agent needs fewer new actions slide 52 ↗
trip review loadDossier draftReview judgeDraft accept | revise
expense claim
loadDossier
trip review
matchReceipts
settle
visa check
loadDossier
trip review
checkVisa