Skip to content

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.

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.

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

The clearBlackboard attribute is useful in two scenarios:

  1. Multi-step workflows where you want to reset the processing context
  2. 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.

The most common use case for clearBlackboard is enabling loops in state-based workflows:

@State
record 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)
}
}
  1. clearBlackboard = true enables returning to the same state type
  2. Without clearing, returning ProcessingState would be blocked since the type already exists

See Using States for more details on looping state patterns.

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());
}
}
  1. After preprocess completes, the blackboard is cleared and only ProcessedDocument remains. The original RawDocument is removed.
  2. The transform action receives only the ProcessedDocument, not any earlier inputs.

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());
}
}
  1. The @Cost annotation marks a method for dynamic cost computation. The name parameter identifies this cost method.
  2. Domain object parameters in @Cost methods must be nullable. If the object isn’t on the blackboard, null is passed.
  3. The costMethod field references the @Cost method by name.

Key differences from @Condition methods:

  • All domain object parameters in @Cost methods must be nullable (use @Nullable in Java or ? in Kotlin)
  • When a domain object is not available on the blackboard, null is passed instead of causing the method to fail
  • The method must return a double between 0.0 and 1.0
  • The Blackboard can 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 @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.

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
}
  1. The spel: prefix indicates this is a SpEL expression evaluated against the blackboard.

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());
}
}
  1. Simple property comparison: action fires only when urgency property exceeds 0.0.
  2. Type check with property access: action fires only for pull requests with more than 10 changed files. The T() operator references a Java type for instanceof checks.

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));
}
  1. The ?[] operator filters the collection. #this refers to each element. This expression checks that at least one element is an Issue but not a PullRequest.
  2. Simpler filter checking for PullRequest instances.

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

@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 the org.springframework.lang.Nullable or another Nullable annotation.
  • Infrastructure parameters, such as the OperationContext, ProcessContext, and Ai may 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 OperationContext unless you are creating a subprocess.

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, and
  • resolveArgument, which resolves the argument into an object used to invoke the action method.

All default parameters are provided by ActionMethodArgumentResolver implementations.

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 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 @State classes (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, (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);
}
}
}
  1. ReservationDetails is a domain object resolved from the blackboard.
  2. ReservationFlow is injected via @Provided from the Spring context - this gives access to the services in the enclosing component.

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, (1)
@Provided AppConfig config (2)
) {
var result = myService.process(data, config.getSetting());
return new NextState(result);
}
}
  1. Any Spring bean can be injected using @Provided.
  2. Multiple @Provided parameters can be used in a single method.

Use @Provided when you need access to:

  • The enclosing @EmbabelComponent or @Agent class from a @State action
  • 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

The @RequireNameMatch annotation can be used to bind parameters by name.

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());
}
}
  1. The trigger field means this action only fires when UserMessage is the last result added to the blackboard.
  2. Conversation must 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");
}
}
  1. handleEventA fires when EventA is added (and EventB is available).
  2. handleEventB fires when EventB is added (and EventA is available).

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.

Routing can also be achieved via subtypes, as in the following example:

@Action
public 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;
}
};
}
@Action
public IntentClassificationSuccess billingAction(BillingIntent intent) { (2)
return new IntentClassificationSuccess("billing");
}
@Action
public IntentClassificationSuccess salesAction(SalesIntent intent) {
return new IntentClassificationSuccess("sales");
}
// ...
  1. Classification action returns supertype Intent. Real classification would likely use an LLM.
  2. billingAction and other action methods takes a subtype of Intent, so will only be invoked if the classification action returned that subtype.

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 can be added to an @Action method to indicate that the completion of the action achieves a specific goal.

@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.

@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)
}
  1. One annotation on the class protects every @Action in the agent.
  2. Both extractTopic and produceDigest require news:read. Without class-level protection, intermediate actions like extractTopic would 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 { ... }
}
  1. All actions default to requiring market:read.
  2. synthesiseReport requires market:admin — the method-level annotation overrides the class.

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.

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
);
}
}

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()))
));
}

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.

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());
}
}
  1. Spring injects the InnerSubAgent bean via constructor injection.
  2. 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;
}
}

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;
}
}

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 ActionContext to 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) |

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:

  1. Extend ActionException - Convenient base classes with built-in retry classification
  2. Implement marker interfaces - Maximum flexibility for existing exception hierarchies

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), NonRetryable

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)