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))
    .outcome

An `@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()                        // judgeDraft

A 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.accepted

Two 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