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
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
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
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
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
The most common use case for clearBlackboard is enabling loops in state-based workflows:
@State
record ProcessingState(String data, int iteration) {
@Action(clearBlackboard = true) // ①
LoopOutcome process() {
if (iteration >= 3) {
return new DoneState(data);
}
return new ProcessingState(data + "+", iteration + 1); // ②
}
}
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
You can also use clearBlackboard to reset context in multi-step workflows:
@Agent(description = "Multi-step document processing")
public class DocumentProcessor {
@Action(clearBlackboard = true) // ①
public ProcessedDocument preprocess(RawDocument doc) {
return new ProcessedDocument(doc.getContent().trim());
}
@AchievesGoal(description = "Produce final output")
@Action
public FinalOutput transform(ProcessedDocument doc) { // ②
return new FinalOutput(doc.getContent().toUpperCase());
}
}
- After
preprocesscompletes, the blackboard is cleared and onlyProcessedDocumentremains. The originalRawDocumentis removed. - The
transformaction receives only theProcessedDocument, not any earlier inputs.
Avoid using clearBlackboard on goal-achieving actions (those with @AchievesGoal).
Clearing the blackboard removes hasRun tracking conditions, which may interfere with goal satisfaction.
Use clearBlackboard on intermediate actions instead.
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") // ①
public double computeProcessingCost(@Nullable LargeDataSet data) { // ②
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") // ③
public ProcessedData process(RawData input) {
return new 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()));
}
}
The @Cost method is called during planning, before the action executes.
It allows the planner to make informed decisions about which actions to prefer based on runtime state.
Dynamic cost is especially useful with Utility planning (PlannerType.UTILITY), where cost/value tradeoffs are a core concept.
The utility planner evaluates actions based on their net value (value minus cost), making dynamic cost computation essential for sophisticated decision-making.
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
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"} // ①
)
public void handleUrgentIssue(Issue issue, IssueAssessment assessment) {
// This action only runs when urgency exceeds 0.5
}
- The
spel:prefix indicates this is a SpEL expression evaluated against the blackboard.
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"} // ①
)
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"} // ②
)
public void reviewLargePullRequest(
GHPullRequest issue,
PullRequestAssessment assessment
) {
logger.info("Large PR detected: #{} with {} files changed",
issue.getNumber(), issue.getChangedFiles());
}
}
- 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
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" // ①
}
)
public IssueAssessment reactToNewIssue(
GHIssue ghIssue,
NewEntity<?> newEntity,
Ai ai
) {
// Fires only when newEntities contains Issues that aren't PullRequests
return ai.withLlm("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" // ②
}
)
public PullRequestAssessment reactToNewPullRequest(
GHPullRequest pr,
NewEntity<?> newEntity,
Ai ai
) {
// Fires only when newEntities contains PullRequests
return ai.withLlm("claude-sonnet-4")
.creating(PullRequestAssessment.class)
.fromTemplate("pr_triage", Map.of("pr", 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
| 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 |
Use SpEL conditions for simple property checks and type discrimination.
For complex logic or conditions that need to be reused across multiple actions, prefer @Condition methods.
For reactive scenarios where you simply want an action to fire when a specific type is added to the blackboard, consider using the trigger field instead—it’s simpler than writing a SpEL expression.
Blackboard binding names are derived from the class name in camelCase by default.
You can specify explicit binding names using outputBinding on actions or by adding objects to the blackboard with specific names.
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
@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.
Domain objects drive planning, specifying the preconditions to an action.
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
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.
Note the similarity with Spring MVC, where you can provide custom parameters by implementing a HandlerMethodArgumentResolver.
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.
Make sure to register the BlackboardArgumentResolver as last resolver, to ensure that others take precedence.
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
@EmbabelComponent
public 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, // ①
@Provided ReservationFlow flow // ②
) {
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);
}
}
}
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
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.
@State
public record ProcessingState(String data) {
@Action
public NextState process(
@Provided MyService myService, // ①
@Provided AppConfig config // ②
) {
var result = myService.process(data, config.getSetting());
return new NextState(result);
}
}
- Any Spring bean can be injected using
@Provided. - Multiple
@Providedparameters can be used in a single method.
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
Since @State classes must be static nested classes or top-level classes, @Provided is the recommended way to access the enclosing component’s services.
This keeps state classes serializable while still providing access to dependencies.
@Provided parameters are resolved before blackboard parameters.
If a type could come from either source, @Provided takes precedence.
Binding by name
The @RequireNameMatch annotation can be used to bind parameters by name.
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) // ①
public Response handleMessage(
UserMessage message,
Conversation conversation // ②
) {
return new 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) // ①
public Result handleEventA(EventA eventA, EventB eventB) {
return new Result("Triggered by A");
}
@AchievesGoal(description = "Handle event B")
@Action(trigger = EventB.class) // ②
public Result handleEventB(EventA eventA, EventB eventB) {
return new Result("Triggered by B");
}
}
handleEventAfires whenEventAis added (andEventBis available).handleEventBfires whenEventBis added (andEventAis available).
The trigger field checks that the specified type matches the lastResult() on the blackboard.
The last result is the most recent object added via any binding operation.
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 interface
public 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());
}
}
This enables routing scenarios in an elegant manner.
Multiple fields of the SomeOf instance may be non-null and this is not an error.
It may enable the most appropriate routing.
Routing can also be achieved via subtypes, as in the following example:
@Action
public Intent classifyIntent(UserInput userInput) { // ①
return switch (userInput.content()) {
case "billing" -> new BillingIntent();
case "sales" -> new SalesIntent();
case "service" -> new ServiceIntent();
default -> {
logger.warn("Unknown intent: {}", userInput);
yield null;
}
};
}
@Action
public IntentClassificationSuccess billingAction(BillingIntent intent) { // ②
return new IntentClassificationSuccess("billing");
}
@Action
public IntentClassificationSuccess salesAction(SalesIntent intent) {
return new 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
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
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
@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
@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')") // ①
class NewsDigestAgent {
@Action
fun extractTopic(userInput: UserInput, context: OperationContext): NewsTopic { ... } // ②
@AchievesGoal(description = "Produce a curated news digest",
export = Export(remote = true, name = "newsDigest",
startingInputTypes = [UserInput::class]))
@Action
fun produceDigest(topic: NewsTopic, context: OperationContext): NewsDigest { ... } // ②
}
- 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')") // ①
class MarketIntelligenceAgent {
@Action
fun gatherIntelligence(subject: AnalysisSubject, context: OperationContext): String { ... }
@SecureAgentTool("hasAuthority('market:admin')") // ②
@AchievesGoal(description = "Produce market report")
@Action
fun synthesiseReport(
subject: AnalysisSubject,
rawIntelligence: String,
context: OperationContext
): MarketIntelligenceReport { ... }
}
- All actions default to requiring
market:read. synthesiseReportrequiresmarket:admin— the method-level annotation overrides the class.
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 |
Setup
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.
@SecureAgentTool is a method-level security control, not an HTTP-level one.
For production use, combine it with a SecurityFilterChain that validates JWT Bearer tokens
so unauthenticated requests are rejected before reaching the GOAP planner.
See the Spring Security JWT Resource Server documentation for general setup,
or MCP Security for an MCP-specific example.
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
);
}
}
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:
@Action
public 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()))
));
}
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
Use RunSubagent.fromAnnotatedInstance() when you have an instance of a class annotated with @Agent:
The annotated instance can be Spring-injected into your agent class.
Since @Agent is a Spring stereotype annotation, you can inject one agent into another and run it as a subagent.
This enables clean separation of concerns while maintaining testability.
@Agent(description = "Outer agent that delegates to an injected subagent")
public class OuterAgent {
private final InnerSubAgent innerSubAgent;
public OuterAgent(InnerSubAgent innerSubAgent) { // ①
this.innerSubAgent = innerSubAgent;
}
@Action
public TaskOutput start(UserInput input) {
return RunSubagent.fromAnnotatedInstance(
innerSubAgent, // ②
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());
}
}
- 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;
}
}
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;
}
}
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;
}
}
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
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) |
Use RunSubagent when your action method only needs to delegate to a subagent.
Use ActionContext.asSubProcess() when you need additional context operations.
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
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
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), NonRetryable
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)




