Annotation model
Embabel provides a Spring-style annotation model to define agents, actions, goals, and conditions. This is the recommended model to use in Java, and remains compelling in Kotlin.
The @Agent annotation
Section titled “The @Agent annotation”This annotation is used on a class to define an agent. It is a Spring stereotype annotation, so it triggers Spring component scanning. Your agent class will automatically be registered as a Spring bean. It will also be registered with the agent framework, so it can be used in agent processes.
You must provide the description parameter, which is a human-readable description of the agent.
This is particularly important as it may be used by the LLM in agent selection.
The @EmbabelComponent annotation
Section titled “The @EmbabelComponent annotation”This annotation is used on a class to indicate that this class exposes actions, goals and conditions that may be used by agents, but is not an agent in itself. It is a Spring stereotype annotation, so it triggers Spring component scanning. Your Embabel component class will automatically be registered as a Spring bean. It will also be registered with the agent framework, so its actions, goals and conditions can be used in agent processes.
Embabel Components are most useful in combination with the Utility AI planner that selects the most valuable next action among all available actions.
The @Action annotation
Section titled “The @Action annotation”The @Action annotation is used to mark methods that perform actions within an agent.
Action metadata can be specified on the annotation, including:
description: A human-readable description of the action.pre: A list of preconditions additional to the input types that must be satisfied before the action can be executed.post: A list of postconditions additional to the output type(s) that may be satisfied after the action is executed.canRerun: A boolean indicating whether the action can be rerun if it has already been executed. Defaults to false.readOnly: A boolean indicating whether the action has no external side effects. Read-only actions only analyze data and produce derived objects without modifying external systems (APIs, databases, files, etc.). This is useful for learning/catchup modes where you want to ingest and understand data without triggering mutations. Defaults to false.clearBlackboard: A boolean indicating whether to clear the blackboard after this action completes. When true, all objects on the blackboard are removed except the action’s output. This is useful for resetting context in multi-step workflows. It can also make persistence of flows more efficient by dispensing with objects that are no longer needed. Defaults to false.cost:Relative cost of the action from 0-1. Defaults to 0.0.value: Relative value of performing the action from 0-1. Defaults to 0.0.
Clearing the Blackboard
Section titled “Clearing the Blackboard”The clearBlackboard attribute is useful in two scenarios:
- Multi-step workflows where you want to reset the processing context
- Looping states where an action returns to a previously-visited state type
When an action with clearBlackboard = true completes, all objects on the blackboard are removed except the action’s output.
This prevents accumulated intermediate data from affecting subsequent processing and enables loops.
Looping States
Section titled “Looping States”The most common use case for clearBlackboard is enabling loops in state-based workflows:
@Staterecord ProcessingState(String data, int iteration) { @Action(clearBlackboard = true) (1) LoopOutcome process() { if (iteration >= 3) { return new DoneState(data); } return new ProcessingState(data + "+", iteration + 1); (2) }}@Statedata class ProcessingState(val data: String, val iteration: Int) { @Action(clearBlackboard = true) (1) fun process(): LoopOutcome { return if (iteration >= 3) { DoneState(data) } else { ProcessingState("$data+", iteration + 1) (2) } }}clearBlackboard = trueenables returning to the same state type- Without clearing, returning
ProcessingStatewould be blocked since the type already exists
See Using States for more details on looping state patterns.
Resetting Context
Section titled “Resetting Context”You can also use clearBlackboard to reset context in multi-step workflows:
@Agent(description = "Multi-step document processing")public class DocumentProcessor {
@Action(clearBlackboard = true) (1) public ProcessedDocument preprocess(RawDocument doc) { return new ProcessedDocument(doc.getContent().trim()); }
@AchievesGoal(description = "Produce final output") @Action public FinalOutput transform(ProcessedDocument doc) { (2) return new FinalOutput(doc.getContent().toUpperCase()); }}@Agent(description = "Multi-step document processing")class DocumentProcessor {
@Action(clearBlackboard = true) (1) fun preprocess(doc: RawDocument): ProcessedDocument { return ProcessedDocument(doc.content.trim()) }
@AchievesGoal(description = "Produce final output") @Action fun transform(doc: ProcessedDocument): FinalOutput { (2) return FinalOutput(doc.content.uppercase()) }}- After
preprocesscompletes, the blackboard is cleared and onlyProcessedDocumentremains. The originalRawDocumentis removed. - The
transformaction receives only theProcessedDocument, not any earlier inputs.
Dynamic Cost Computation with @Cost
Section titled “Dynamic Cost Computation with @Cost”While the cost and value fields on @Action allow specifying static values, you can compute these dynamically at planning time using the @Cost annotation.
This is useful when the cost of an action depends on the current state of the blackboard.
The @Cost annotation marks a method that returns a cost value (a double between 0.0 and 1.0).
You then reference this method from the @Action annotation using costMethod or valueMethod.
@Agent(description = "Processor with dynamic cost")public class DataProcessor {
@Cost(name = "processingCost") (1) public double computeProcessingCost(@Nullable LargeDataSet data) { (2) if (data != null && data.size() > 1000) { return 0.9; // High cost for large datasets } return 0.1; // Low cost for small or missing datasets }
@Action(costMethod = "processingCost") (3) public ProcessedData process(RawData input) { return new ProcessedData(input.transform()); }}@Agent(description = "Processor with dynamic cost")class DataProcessor {
@Cost(name = "processingCost") (1) fun computeProcessingCost(data: LargeDataSet?): Double { (2) return if (data != null && data.size() > 1000) { 0.9 // High cost for large datasets } else { 0.1 // Low cost for small or missing datasets } }
@Action(costMethod = "processingCost") (3) fun process(input: RawData): ProcessedData { return ProcessedData(input.transform()) }}- The
@Costannotation marks a method for dynamic cost computation. Thenameparameter identifies this cost method. - Domain object parameters in
@Costmethods must be nullable. If the object isn’t on the blackboard,nullis passed. - The
costMethodfield references the@Costmethod by name.
Key differences from @Condition methods:
- All domain object parameters in
@Costmethods must be nullable (use@Nullablein Java or?in Kotlin) - When a domain object is not available on the blackboard,
nullis passed instead of causing the method to fail - The method must return a
doublebetween 0.0 and 1.0 - The
Blackboardcan be passed as a parameter for direct access to all available objects
You can also compute dynamic value using valueMethod:
@Agent(description = "Agent with dynamic value computation")public class PrioritizedAgent {
@Cost(name = "urgencyValue") public double computeUrgency(@Nullable Task task) { if (task == null) { return 0.5; } if (task.getPriority() == Priority.HIGH) { return 1.0; } if (task.getPriority() == Priority.MEDIUM) { return 0.6; } return 0.2; }
@AchievesGoal(description = "Process high-priority tasks") @Action(valueMethod = "urgencyValue") public Result processTask(Task task) { return new Result(String.format("Processed: %s", task.getName())); }}@Agent(description = "Agent with dynamic value computation")class PrioritizedAgent {
@Cost(name = "urgencyValue") fun computeUrgency(task: Task?): Double { return when { task == null -> 0.5 task.priority == Priority.HIGH -> 1.0 task.priority == Priority.MEDIUM -> 0.6 else -> 0.2 } }
@AchievesGoal(description = "Process high-priority tasks") @Action(valueMethod = "urgencyValue") fun processTask(task: Task): Result { return Result("Processed: ${task.name}") }}The @Condition annotation
Section titled “The @Condition annotation”The @Condition annotation is used to mark methods that evaluate conditions.
They can take an OperationContext parameter to access the blackboard and other infrastructure.
If they take domain object parameters, the condition will automatically be false until suitable instances are available.
Condition methods should not have side effects—for example, on the blackboard. This is important because they may be called multiple times.
Dynamic Conditions with SpEL
Section titled “Dynamic Conditions with SpEL”In addition to using @Condition methods, you can specify dynamic preconditions directly on @Action annotations using Spring Expression Language (SpEL).
These expressions are evaluated against the blackboard, allowing you to create conditions based on runtime state without writing separate condition methods.
The expression language is pluggable, but currently SpEL is the only supported implementation. See the Spring Expression Language (SpEL) documentation for full syntax details.
SpEL conditions are specified in the pre array with a spel: prefix:
@Action( pre = {"spel:assessment.urgency > 0.5"} (1))public void handleUrgentIssue(Issue issue, IssueAssessment assessment) { // This action only runs when urgency exceeds 0.5}@Action( pre = ["spel:assessment.urgency > 0.5"] (1))fun handleUrgentIssue(issue: Issue, assessment: IssueAssessment) { // This action only runs when urgency exceeds 0.5}- The
spel:prefix indicates this is a SpEL expression evaluated against the blackboard.
Expression Syntax
Section titled “Expression Syntax”SpEL expressions reference blackboard objects by their binding names (typically the camelCase form of the class name). The expression must evaluate to a boolean.
@Agent(description = "Issue triage agent")public class IssueTriageAgent {
@Action( pre = {"spel:issueAssessment.urgency > 0.0"} (1) ) public void escalateUrgentIssue( GHIssue issue, IssueAssessment issueAssessment ) { logger.info("Escalating urgent issue #{}", issue.getNumber()); }
@Action( pre = {"spel:ghIssue instanceof T(org.kohsuke.github.GHPullRequest) && ghIssue.changedFiles > 10"} (2) ) public void reviewLargePullRequest( GHPullRequest issue, PullRequestAssessment assessment ) { logger.info("Large PR detected: #{} with {} files changed", issue.getNumber(), issue.getChangedFiles()); }}@Agent(description = "Issue triage agent")class IssueTriageAgent {
@Action( pre = ["spel:issueAssessment.urgency > 0.0"] (1) ) fun escalateUrgentIssue( issue: GHIssue, issueAssessment: IssueAssessment ) { logger.info("Escalating urgent issue #{}", issue.number) }
@Action( pre = ["spel:ghIssue instanceof T(org.kohsuke.github.GHPullRequest) && ghIssue.changedFiles > 10"] (2) ) fun reviewLargePullRequest( issue: GHPullRequest, assessment: PullRequestAssessment ) { logger.info("Large PR detected: #{} with {} files changed", issue.number, issue.changedFiles) }}- Simple property comparison: action fires only when
urgencyproperty exceeds 0.0. - Type check with property access: action fires only for pull requests with more than 10 changed files. The
T()operator references a Java type forinstanceofchecks.
Collection Filtering
Section titled “Collection Filtering”SpEL’s collection selection syntax (?[]) is useful for checking conditions on collections stored in the blackboard:
@Action( pre = { "spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.Issue) " + "&& !(#this instanceof T(com.example.domain.PullRequest))].size() > 0" (1) })public IssueAssessment reactToNewIssue( GHIssue ghIssue, NewEntity<?> newEntity, Ai ai) { // Fires only when newEntities contains Issues that aren't PullRequests return ai.withDefaultLlm() // Example uses claude-sonnet-4 .creating(IssueAssessment.class) .fromTemplate("issue_triage", Map.of("issue", ghIssue));}
@Action( pre = { "spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.PullRequest)].size() > 0" (2) })public PullRequestAssessment reactToNewPullRequest( GHPullRequest pr, NewEntity<?> newEntity, Ai ai) { // Fires only when newEntities contains PullRequests return ai.withDefaultLlm() // Example uses claude-sonnet-4 .creating(PullRequestAssessment.class) .fromTemplate("pr_triage", Map.of("pr", pr));}@Action( pre = [ "spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.Issue) " + "&& !(#this instanceof T(com.example.domain.PullRequest))].size() > 0" (1) ])fun reactToNewIssue( ghIssue: GHIssue, newEntity: NewEntity<*>, ai: Ai): IssueAssessment { // Fires only when newEntities contains Issues that aren't PullRequests return ai.withDefaultLlm() // Example uses claude-sonnet-4 .creating(IssueAssessment::class.java) .fromTemplate("issue_triage", mapOf("issue" to ghIssue))}
@Action( pre = [ "spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.PullRequest)].size() > 0" (2) ])fun reactToNewPullRequest( pr: GHPullRequest, newEntity: NewEntity<*>, ai: Ai): PullRequestAssessment { // Fires only when newEntities contains PullRequests return ai.withDefaultLlm() // Example uses claude-sonnet-4 .creating(PullRequestAssessment::class.java) .fromTemplate("pr_triage", mapOf("pr" to pr))}- The
?[]operator filters the collection.#thisrefers to each element. This expression checks that at least one element is anIssuebut not aPullRequest. - Simpler filter checking for
PullRequestinstances.
Common SpEL Patterns
Section titled “Common SpEL Patterns”| Pattern | Description |
| --- | --- |
| spel:obj.property > value | Simple property comparison |
| spel:obj instanceof T(com.example.Type) | Type checking using fully qualified class name |
| spel:collection.size() > 0 | Check collection is not empty |
| spel:collection.?[condition].size() > 0 | Check that filtered collection has elements |
| spel:obj.property != null | Null checking |
| spel:condition1 && condition2 | Combining conditions with AND |
| spel:condition1 \|\| condition2 | Combining conditions with OR |
Both Action and Condition methods may be inherited from superclasses. That is, annotated methods on superclasses will be treated as actions on a subclass instance.
Give your Action and Condition methods unique names, so the planner can distinguish between them.
Parameters
Section titled “Parameters”@Action methods must have at least one parameter.
@Condition methods must have zero or more parameters, but otherwise follow the same rules as @Action methods regarding parameters.
Ordering of parameters is not important.
Parameters fall in two categories:
- Domain objects.
These are the normal inputs for action methods.
They are backed by the blackboard and will be used as inputs to the action method.
A nullable domain object parameter will be populated if it is non-null on the blackboard.
This enables nice-to-have parameters that are not required for the action to run.
In Kotlin, use a nullable parameter with
?: in Java, mark the parameter with theorg.springframework.lang.Nullableor anotherNullableannotation. - Infrastructure parameters, such as the
OperationContext,ProcessContext, andAimay be used in action or condition methods.
The ActionContext or ExecutingOperationContext subtype can be used in action methods.
It adds asSubProcess methods that can be used to run other agents in subprocesses.
This is an important element of composition.
Use the least specific type possible for parameters. Use
OperationContextunless you are creating a subprocess.
Custom Parameters
Section titled “Custom Parameters”Besides two default parameter categories described above, you can provide your own parameters by implementing the ActionMethodArgumentResolver interface.
The two main methods of this interface are:
supportsParameter, which indicates what kind of parameters are supported, andresolveArgument, which resolves the argument into an object used to invoke the action method.
All default parameters are provided by
ActionMethodArgumentResolverimplementations.
To register your custom argument resolver, provide it to the DefaultActionMethodManager component in your Spring configuration.
Typically, you will register (some of) the defaults as well your custom resolver, in order to support the default parameters.
The @Provided Annotation
Section titled “The @Provided Annotation”The @Provided annotation marks an action method parameter as being provided by the platform context (such as Spring’s ApplicationContext) rather than resolved from the blackboard.
This is particularly useful for:
- Accessing the enclosing component from within
@Stateclasses (which must be static or top-level) - Injecting services that aren’t domain objects but are needed for processing
- Accessing configuration or other platform-managed beans
@EmbabelComponentpublic class ReservationFlow {
private final BookingService bookingService; private final NotificationService notificationService;
public ReservationFlow(BookingService bookingService, NotificationService notificationService) { this.bookingService = bookingService; this.notificationService = notificationService; }
@Action public CollectDetails start(UserRequest request) { return new CollectDetails(request.customerId()); }
@State public record CollectDetails(String customerId) {
@Action public ConfirmReservation confirm( ReservationDetails details, (1) @Provided ReservationFlow flow (2) ) { var booking = flow.bookingService.reserve(details); flow.notificationService.sendConfirmation(booking); return new ConfirmReservation(booking); } }
@State public record ConfirmReservation(Booking booking) { @AchievesGoal(description = "Reservation completed") @Action public BookingResult complete() { return new BookingResult(booking); } }}@EmbabelComponentclass ReservationFlow( private val bookingService: BookingService, private val notificationService: NotificationService) {
@Action fun start(request: UserRequest): CollectDetails { return CollectDetails(request.customerId) }
@State data class CollectDetails(val customerId: String) {
@Action fun confirm( details: ReservationDetails, (1) @Provided flow: ReservationFlow (2) ): ConfirmReservation { val booking = flow.bookingService.reserve(details) flow.notificationService.sendConfirmation(booking) return ConfirmReservation(booking) } }
@State data class ConfirmReservation(val booking: Booking) { @AchievesGoal(description = "Reservation completed") @Action fun complete(): BookingResult { return BookingResult(booking) } }}ReservationDetailsis a domain object resolved from the blackboard.ReservationFlowis injected via@Providedfrom the Spring context - this gives access to the services in the enclosing component.
How It Works
Section titled “How It Works”When Spring is available, the SpringContextProvider resolves @Provided parameters by looking up beans from the ApplicationContext.
The parameter type must match a bean in the context.
@Statepublic record ProcessingState(String data) {
@Action public NextState process( @Provided MyService myService, (1) @Provided AppConfig config (2) ) { var result = myService.process(data, config.getSetting()); return new NextState(result); }}@Statedata class ProcessingState(val data: String) {
@Action fun process( @Provided myService: MyService, (1) @Provided config: AppConfig, (2) ): NextState { val result = myService.process(data, config.setting) return NextState(result) }}- Any Spring bean can be injected using
@Provided. - Multiple
@Providedparameters can be used in a single method.
When to Use @Provided
Section titled “When to Use @Provided”Use @Provided when you need access to:
- The enclosing
@EmbabelComponentor@Agentclass from a@Stateaction - Services that are infrastructure concerns, not domain objects
- Configuration or environment values
Do not use @Provided for:
- Domain objects that should drive planning (use regular parameters instead)
- Objects that need to be tracked on the blackboard
Binding by name
Section titled “Binding by name”The @RequireNameMatch annotation can be used to bind parameters by name.
Reactive triggers with trigger
Section titled “Reactive triggers with trigger”The trigger field on the @Action annotation enables reactive behavior where an action only fires when a specific type is the most recently added value to the blackboard.
This is useful in event-driven scenarios where you want to react to a particular event even when multiple parameters of various types are available.
For example, in a chat system you might want an action to fire only when a new user message arrives, not when other context is updated:
@Agent(description = "Chat message handler")public class ChatAgent {
@AchievesGoal(description = "Respond to user message") @Action(trigger = UserMessage.class) (1) public Response handleMessage( UserMessage message, Conversation conversation (2) ) { return new Response("Received: " + message.content()); }}@Agent(description = "Chat message handler")class ChatAgent {
@AchievesGoal(description = "Respond to user message") @Action(trigger = UserMessage::class) (1) fun handleMessage( message: UserMessage, conversation: Conversation (2) ): Response { return Response("Received: ${message.content}") }}- The
triggerfield means this action only fires whenUserMessageis the last result added to the blackboard. Conversationmust also be available, but doesn’t need to be the triggering event.
Without trigger, an action fires as soon as all its parameters are available on the blackboard.
With trigger, the specified type must additionally be the most recent value added.
This is particularly useful when:
- You have multiple actions that could handle different event types
- You want to distinguish between “data available” and “event just occurred”
- You’re building event-driven or reactive workflows
@Agent(description = "Multi-event processor")public class EventProcessor {
@Action(trigger = EventA.class) (1) public Result handleEventA(EventA eventA, EventB eventB) { return new Result("Triggered by A"); }
@AchievesGoal(description = "Handle event B") @Action(trigger = EventB.class) (2) public Result handleEventB(EventA eventA, EventB eventB) { return new Result("Triggered by B"); }}@Agent(description = "Multi-event processor")class EventProcessor {
@Action(trigger = EventA::class) (1) fun handleEventA(eventA: EventA, eventB: EventB): Result { return Result("Triggered by A") }
@AchievesGoal(description = "Handle event B") @Action(trigger = EventB::class) (2) fun handleEventB(eventA: EventA, eventB: EventB): Result { return Result("Triggered by B") }}handleEventAfires whenEventAis added (andEventBis available).handleEventBfires whenEventBis added (andEventAis available).
Handling of return types
Section titled “Handling of return types”Action methods normally return a single domain object.
Nullable return types are allowed. Returning null will trigger replanning. There may or not be an alternative path from that point, but it won’t be what the planner was previously trying to achieve.
There is a special case where the return type can essentially be a union type, where the action method can return one ore more of several types.
This is achieved by a return type implementing the SomeOf tag interface.
Implementations of this interface can have multiple nullable fields.
Any non-null values will be bound to the blackboard, and the postconditions of the action will include all possible fields of the return type.
For example:
// Must implement the SomeOf interfacepublic record FrogOrDog( @Nullable Frog frog, @Nullable Dog dog) implements SomeOf {}
@Agent(description = "Illustrates use of the SomeOf interface")public class ReturnsFrogOrDog {
@Action public FrogOrDog frogOrDog() { return new FrogOrDog(new Frog("Kermit"), null); }
// This works because the frog field of the return type was set @AchievesGoal(description = "Create a prince from a frog") @Action public PersonWithReverseTool toPerson(Frog frog) { return new PersonWithReverseTool(frog.name()); }}// Must implement the SomeOf interfacedata class FrogOrDog( val frog: Frog? = null, val dog: Dog? = null,) : SomeOf
@Agent(description = "Illustrates use of the SomeOf interface")class ReturnsFrogOrDog {
@Action fun frogOrDog(): FrogOrDog { return FrogOrDog(frog = Frog("Kermit")) }
// This works because the frog field of the return type was set @AchievesGoal(description = "Create a prince from a frog") @Action fun toPerson(frog: Frog): PersonWithReverseTool { return PersonWithReverseTool(frog.name) }}This enables routing scenarios in an elegant manner.
Routing can also be achieved via subtypes, as in the following example:
@Actionpublic Intent classifyIntent(UserInput userInput) { (1) return switch (userInput.content()) { case "billing" -> new BillingIntent(); case "sales" -> new SalesIntent(); case "service" -> new ServiceIntent(); default -> { logger.warn("Unknown intent: {}", userInput); yield null; } };}
@Actionpublic IntentClassificationSuccess billingAction(BillingIntent intent) { (2) return new IntentClassificationSuccess("billing");}
@Actionpublic IntentClassificationSuccess salesAction(SalesIntent intent) { return new IntentClassificationSuccess("sales");}
// ...@Actionfun classifyIntent(userInput: UserInput): Intent? = (1) when (userInput.content) { "billing" -> BillingIntent() "sales" -> SalesIntent() "service" -> ServiceIntent() else -> { loggerFor<IntentReceptionAgent>().warn("Unknown intent: $userInput") null } }
@Actionfun billingAction(intent: BillingIntent): IntentClassificationSuccess { (2) return IntentClassificationSuccess("billing")}
@Actionfun salesAction(intent: SalesIntent): IntentClassificationSuccess { return IntentClassificationSuccess("sales")}
// ...- Classification action returns supertype
Intent. Real classification would likely use an LLM. billingActionand other action methods takes a subtype ofIntent, so will only be invoked if the classification action returned that subtype.
Action method implementation
Section titled “Action method implementation”Embabel makes it easy to seamlessly integrate LLM invocation and application code, using common types.
An @Action method is a normal method, and can use any libraries or frameworks you like.
The only special thing about it is its ability to use the OperationContext parameter to access the blackboard and invoke LLMs.
The @AchievesGoal annotation
Section titled “The @AchievesGoal annotation”The @AchievesGoal annotation can be added to an @Action method to indicate that the completion of the action achieves a specific goal.
The @SecureAgentTool annotation
Section titled “The @SecureAgentTool annotation”@SecureAgentTool declares the security contract for an Embabel @Action method or @Agent
class exposed as a remote MCP tool.
It accepts a Spring Security SpEL expression evaluated against the current Authentication
at the point of tool invocation, before Embabel’s GOAP planner executes the action body.
Placement
Section titled “Placement”@SecureAgentTool can be placed on the @Agent class to protect every @Action uniformly,
or on individual methods for finer-grained control.
Method-level annotation takes precedence over class-level when both are present.
Class-level — one annotation secures all actions in the agent, including intermediate steps that run before the goal-achieving action:
@Agent(description = "Research a topic and return a news digest")@SecureAgentTool("hasAuthority('news:read')") (1)class NewsDigestAgent {
@Action fun extractTopic(userInput: UserInput, context: OperationContext): NewsTopic { ... } (2)
@AchievesGoal(description = "Produce a curated news digest", export = Export(remote = true, name = "newsDigest", startingInputTypes = [UserInput::class])) @Action fun produceDigest(topic: NewsTopic, context: OperationContext): NewsDigest { ... } (2)}@Agent(description = "Research a topic and return a news digest")@SecureAgentTool("hasAuthority('news:read')") (1)public class NewsDigestAgent {
@Action public NewsTopic extractTopic(UserInput userInput, OperationContext context) { ... } (2)
@AchievesGoal(description = "Produce a curated news digest", export = @Export(remote = true, name = "newsDigest", startingInputTypes = {UserInput.class})) @Action public NewsDigest produceDigest(NewsTopic topic, OperationContext context) { ... } (2)}- One annotation on the class protects every
@Actionin the agent. - Both
extractTopicandproduceDigestrequirenews:read. Without class-level protection, intermediate actions likeextractTopicwould run freely before the security check on the goal-achieving action fires.
Method-level override — a method-level annotation takes precedence over the class-level expression, allowing one action to require elevated authority:
@Agent(description = "Market intelligence agent")@SecureAgentTool("hasAuthority('market:read')") (1)class MarketIntelligenceAgent {
@Action fun gatherIntelligence(subject: AnalysisSubject, context: OperationContext): String { ... }
@SecureAgentTool("hasAuthority('market:admin')") (2) @AchievesGoal(description = "Produce market report") @Action fun synthesiseReport( subject: AnalysisSubject, rawIntelligence: String, context: OperationContext ): MarketIntelligenceReport { ... }}@Agent(description = "Market intelligence agent")@SecureAgentTool("hasAuthority('market:read')") (1)public class MarketIntelligenceAgent {
@Action public String gatherIntelligence(AnalysisSubject subject, OperationContext context) { ... }
@SecureAgentTool("hasAuthority('market:admin')") (2) @AchievesGoal(description = "Produce market report") @Action public MarketIntelligenceReport synthesiseReport( AnalysisSubject subject, String rawIntelligence, OperationContext context) { ... }}- All actions default to requiring
market:read. synthesiseReportrequiresmarket:admin— the method-level annotation overrides the class.
Supported expressions
Section titled “Supported expressions”Any Spring Security SpEL expression is valid:
| Expression | Meaning |
| --- | --- |
| hasAuthority('finance:read') | Principal must carry this exact authority |
| hasAnyAuthority('finance:read', 'finance:admin') | Principal must carry at least one of the listed authorities |
| hasRole('ADMIN') | Principal must carry ROLE_ADMIN (the ROLE_ prefix is added automatically) |
| isAuthenticated() | Any authenticated principal, regardless of authorities |
| hasAuthority('payments:write') and #request.amount < 10000 | Combines an authority check with a method parameter expression |
Add the MCP security starter to your pom.xml:
<dependency> <groupId>com.embabel.agent</groupId> <artifactId>embabel-agent-starter-mcpserver-security</artifactId> <version>${embabel-agent.version}</version></dependency>The starter auto-configures SecureAgentToolAspect and the required Spring Security
MethodSecurityExpressionHandler.
No additional @EnableMethodSecurity annotation is required.
Implementing the StuckHandler interface
Section titled “Implementing the StuckHandler interface”If an annotated agent class implements the StuckHandler interface, it can handle situations where an action is stuck itself.
For example, it can add data to the blackboard.
Example:
@Agent(description = "self unsticking agent")public class SelfUnstickingAgent implements StuckHandler {
private boolean called = false;
// The agent will get stuck as there's no dog to convert to a frog @Action @AchievesGoal(description = "the big goal in the sky") public Frog toFrog(Dog dog) { return new Frog(dog.name()); }
// This method will be called when the agent is stuck @Override public StuckHandlerResult handleStuck(AgentProcess agentProcess) { called = true; agentProcess.addObject(new Dog("Duke")); return new StuckHandlerResult( "Unsticking myself", this, StuckHandlingResultCode.REPLAN, agentProcess ); }}@Agent( description = "self unsticking agent",)class SelfUnstickingAgent : StuckHandler {
// The agent will get stuck as there's no dog to convert to a frog @Action @AchievesGoal(description = "the big goal in the sky") fun toFrog(dog: Dog): Frog { return Frog(dog.name) }
// This method will be called when the agent is stuck override fun handleStuck(agentProcess: AgentProcess): StuckHandlerResult { called = true agentProcess.addObject(Dog("Duke")) return StuckHandlerResult( message = "Unsticking myself", handler = this, code = StuckHandlingResultCode.REPLAN, agentProcess = agentProcess, ) }}Advanced Usage: Nested processes
Section titled “Advanced Usage: Nested processes”An @Action method can invoke another agent process.
This is often done to use a stereotyped process that is composed using the DSL.
Use the ActionContext.asSubProcess method to create a sub-process from the action context.
For example:
@Actionpublic ScoredResult<Report, SimpleFeedback> report( ReportRequest reportRequest, ActionContext context) { return context.asSubProcess( // Will create an agent sub process with strong typing EvaluatorOptimizer.generateUntilAcceptable( 5, ctx -> ctx.promptRunner() .withToolGroup(CoreToolGroups.WEB) .create(String.format(""" Given the topic, generate a detailed report in %d words.
# Topic %s
# Feedback %s """, reportRequest.words(), reportRequest.topic(), ctx.getInput() != null ? ctx.getInput() : "No feedback provided")), ctx -> ctx.promptRunner() .withToolGroup(CoreToolGroups.WEB) .create(String.format(""" Given the topic and word count, evaluate the report and provide feedback Feedback must be a score between 0 and 1, where 1 is perfect.
# Report %s
# Report request: %s Word count: %d """, ctx.getInput().report(), reportRequest.topic(), reportRequest.words())) ));}@Actionfun report( reportRequest: ReportRequest, context: ActionContext,): ScoredResult<Report, SimpleFeedback> = context.asSubProcess( // Will create an agent sub process with strong typing EvaluatorOptimizer.generateUntilAcceptable( maxIterations = 5, generator = { it.promptRunner().withToolGroup(CoreToolGroups.WEB).create( """ Given the topic, generate a detailed report in ${reportRequest.words} words.
# Topic ${reportRequest.topic}
# Feedback ${it.input ?: "No feedback provided"} """.trimIndent() ) }, evaluator = { it.promptRunner().withToolGroup(CoreToolGroups.WEB).create( """ Given the topic and word count, evaluate the report and provide feedback Feedback must be a score between 0 and 1, where 1 is perfect.
# Report ${it.input.report}
# Report request:
${reportRequest.topic} Word count: ${reportRequest.words} """.trimIndent() ) }, ))Running Subagents with RunSubagent
Section titled “Running Subagents with RunSubagent”The RunSubagent utility provides a convenient way to run a nested agent from within an @Action method without needing direct access to ActionContext.
This is particularly useful when you want to delegate work to another @Agent-annotated class or an Agent instance.
Running an @Agent-annotated Instance
Section titled “Running an @Agent-annotated Instance”Use RunSubagent.fromAnnotatedInstance() when you have an instance of a class annotated with @Agent:
@Agent(description = "Outer agent that delegates to an injected subagent")public class OuterAgent {
private final InnerSubAgent innerSubAgent;
public OuterAgent(InnerSubAgent innerSubAgent) { (1) this.innerSubAgent = innerSubAgent; }
@Action public TaskOutput start(UserInput input) { return RunSubagent.fromAnnotatedInstance( innerSubAgent, (2) TaskOutput.class ); }
@Action @AchievesGoal(description = "Processing complete") public TaskOutput done(TaskOutput output) { return output; }}
@Agent(description = "Inner subagent that processes input")public class InnerSubAgent {
@Action public Intermediate stepOne(UserInput input) { return new Intermediate(input.getContent()); }
@Action @AchievesGoal(description = "Subagent complete") public TaskOutput stepTwo(Intermediate data) { return new TaskOutput(data.value().toUpperCase()); }}@Agent(description = "Outer agent that delegates to an injected subagent")class OuterAgent( private val innerSubAgent: InnerSubAgent (1)) {
@Action fun start(input: UserInput): TaskOutput { return RunSubagent.fromAnnotatedInstance( innerSubAgent, (2) TaskOutput::class.java ) }
@Action @AchievesGoal(description = "Processing complete") fun done(output: TaskOutput): TaskOutput = output}
@Agent(description = "Inner subagent that processes input")class InnerSubAgent {
@Action fun stepOne(input: UserInput): Intermediate { return Intermediate(input.content) }
@Action @AchievesGoal(description = "Subagent complete") fun stepTwo(data: Intermediate): TaskOutput { return TaskOutput(data.value.uppercase()) }}- Spring injects the
InnerSubAgentbean via constructor injection. - The injected instance is passed to
RunSubagent.fromAnnotatedInstance().
In Kotlin, you can use the reified version for a more concise syntax:
@Agent(description = "Outer agent via explicit type parameter")public class OuterAgentExplicit {
@Action public TaskOutput start(UserInput input) { return RunSubagent.fromAnnotatedInstance( new InnerSubAgent(), TaskOutput.class ); }
@Action @AchievesGoal(description = "Processing complete") public TaskOutput done(TaskOutput output) { return output; }}@Agent(description = "Outer agent via reified subagent")class OuterAgentReified {
@Action fun start(input: UserInput): TaskOutput = RunSubagent.fromAnnotatedInstance<TaskOutput>(InnerSubAgent())
@Action @AchievesGoal(description = "Processing complete") fun done(output: TaskOutput): TaskOutput = output}Running an Agent Instance
Section titled “Running an Agent Instance”Use RunSubagent.instance() when you already have an Agent object (for example, one created programmatically or via AgentMetadataReader):
@Agent(description = "Outer agent with Agent instance")public class OuterAgentWithAgentInstance {
@Action public TaskOutput start(UserInput input) { Agent agent = (Agent) new AgentMetadataReader() .createAgentMetadata(new InnerSubAgent()); return RunSubagent.instance(agent, TaskOutput.class); }
@Action @AchievesGoal(description = "Processing complete") public TaskOutput done(TaskOutput output) { return output; }}@Agent(description = "Outer agent with Agent instance")class OuterAgentWithAgentInstance {
@Action fun start(input: UserInput): TaskOutput { val agent = AgentMetadataReader() .createAgentMetadata(InnerSubAgent()) as Agent return RunSubagent.instance(agent, TaskOutput::class.java) }
@Action @AchievesGoal(description = "Processing complete") fun done(output: TaskOutput): TaskOutput = output}In Kotlin with reified types:
@Agent(description = "Outer agent via explicit agent instance")public class OuterAgentExplicitInstance {
@Action public TaskOutput start(UserInput input) { Agent agent = (Agent) new AgentMetadataReader() .createAgentMetadata(new InnerSubAgent()); return RunSubagent.instance(agent, TaskOutput.class); }
@Action @AchievesGoal(description = "Processing complete") public TaskOutput done(TaskOutput output) { return output; }}@Agent(description = "Outer agent via reified agent instance")class OuterAgentReifiedInstance {
@Action fun start(input: UserInput): TaskOutput { val agent = AgentMetadataReader().createAgentMetadata(InnerSubAgent()) as Agent return RunSubagent.instance<TaskOutput>(agent) }
@Action @AchievesGoal(description = "Processing complete") fun done(output: TaskOutput): TaskOutput = output}How It Works
Section titled “How It Works”RunSubagent methods throw a SubagentExecutionRequest exception that is caught by the framework.
The framework then executes the subagent as a subprocess within the current agent process, sharing the same blackboard context.
The result of the subagent’s goal-achieving action is returned to the calling action.
This approach has several advantages:
- Cleaner syntax: No need to pass
ActionContextto the action method - Type safety: The return type is enforced at compile time
- Composition: Easily compose complex workflows from simpler agents
- Reusability: The same subagent can be used in multiple contexts
Comparison with ActionContext.asSubProcess
Section titled “Comparison with ActionContext.asSubProcess”Both RunSubagent and ActionContext.asSubProcess achieve the same result, but differ in style:
| Approach | When to use | Example |
| --- | --- | --- |
| RunSubagent.fromAnnotatedInstance() | When you have an @Agent-annotated instance and don’t need ActionContext | RunSubagent.fromAnnotatedInstance(new SubAgent(), Result.class) |
| RunSubagent.instance() | When you have an Agent object | RunSubagent.instance(agent, Result.class) |
| ActionContext.asSubProcess() | When you need access to ActionContext for other operations | context.asSubProcess(Result.class, agent) |
Action Exception Handling
Section titled “Action Exception Handling”Exception handling within Action is governed by Retry Policy.
All exceptions below, except TransientAiException are considered as non-retryable.
More specifically, policy categorises non-retryable exception in the order:
- ReplanRequestedException
- TerminateActionException
- TerminateAgentException
- ToolControlFlowSignal
- NonTransientAiException
- IllegalArgumentException
- IllegalStateException
- UnsupportedOperationException
- ClassCastException
If exception does not belong to any of the exceptions from the list above - it gets mapped to retryable exception.
Framework allows creating custom Retryable / NonRetryable exception in order for developers to exercise complete control over Action Retry.
Embabel provides with two approaches for defining custom retryable and non-retryable exceptions:
- Extend ActionException - Convenient base classes with built-in retry classification
- Implement marker interfaces - Maximum flexibility for existing exception hierarchies
Approach 1: Extending ActionException
Section titled “Approach 1: Extending ActionException”The recommended approach is to extend ActionException.Transient for retryable failures or ActionException.Permanent for non-retryable failures:
import com.embabel.agent.core.ActionException
// Transient failure - will be retried class ApiTimeoutException(message: String, cause: Throwable? = null) : ActionException.Transient(message, cause)
// Permanent failure - will not be retried class ValidationException(message: String, cause: Throwable? = null) : ActionException.Permanent(message, cause)Approach 2: Implementing Marker Interfaces
Section titled “Approach 2: Implementing Marker Interfaces”For existing exception hierarchies or when you need more control, implement the `Retryable` or `NonRetryable` marker interfaces directly: import com.embabel.agent.core.Retryable import com.embabel.agent.core.NonRetryable
// Transient failure - will be retried class NetworkException(message: String, cause: Throwable? = null) : RuntimeException(message, cause), Retryable
// Permanent failure - will not be retried class InvalidOrderException(message: String) : RuntimeException(message), NonRetryableCommon Use Cases
Section titled “Common Use Cases”Transient Failures (use ActionException.Transient or Retryable):
- Network timeouts
- Rate limiting (429 errors)
- Temporary resource unavailability
- Connection failures
- Database deadlocks
Permanent Failures (use ActionException.Permanent or NonRetryable):
- Validation errors
- Business rule violations
- Invalid parameters
- Resource not found (404 errors)
- Authentication failures (401 errors)
- Authorization failures (403 errors)