Using States
GOAP planning has many benefits, but can make looping hard to express. For this reason, Embabel supports the notion of states within a GOAP plan.
How States Work with GOAP
Within each state, GOAP planning works normally. Actions have preconditions based on the types they require, and effects based on the types they produce. The planner finds the optimal sequence of actions to reach the goal.
When an action returns a @State-annotated class, the framework:
- Hides previous state objects - Any existing state objects are hidden from the blackboard
- Binds the new state object - The returned state is added to the blackboard
- Re-plans from the new state - The planner considers only actions from the new state
- Continues execution - Until a goal is reached or no plan can be found
Context is preserved across state transitions - non-state objects (such as user messages, customer data, and conversation history) remain available. Only state objects are hidden, ensuring that only the current state’s actions are considered by the planner.
State transitions hide previous state objects but do not clear the blackboard.
Non-state objects remain available in the new state.
To clear the entire blackboard (e.g., for looping), use clearBlackboard = true on the action.
When to Use States
States are ideal for:
- Linear stages where each stage naturally flows to the next
- Branching workflows where a decision point leads to different processing paths
- Looping patterns where processing may need to repeat (e.g., revise-and-review cycles)
- Human-in-the-loop workflows where user feedback determines the next state
- Complex workflows that are easier to reason about as discrete phases
States allow loopback to a whole state, which may contain one or more actions. This is more flexible than traditional GOAP, where looping requires careful management of preconditions.
Staying in the Current State
An action can return this to stay in the current state.
This is useful for actions that respond to inputs without changing state, such as chat handlers:
@State
record ChitchatState(String context) {
@Action(canRerun = true) // ①
ChitchatState respond(UserMessage message, Ai ai) {
var response = ai.generateText("Respond to: " + message.content());
// ... send response
return this; // ②
}
}
canRerun = trueis required - by default, actions only run once per process- Returning
thiskeeps the same state instance active
When an action returns this:
- The state remains active with no transition
- The blackboard is preserved (no clearing)
- The action can run again on subsequent planning cycles (if
canRerun = true)
Without canRerun = true, the action’s hasRun flag would prevent it from executing again, even though it returned this.
Looping States
For looping patterns where an action may return to a previously-visited state type, use clearBlackboard = true on the looping action:
@State
record ProcessingState(String data, int iteration) implements LoopOutcome {
@Action(clearBlackboard = true) // ①
LoopOutcome process() {
if (iteration >= 3) {
return new DoneState(data); // ②
}
return new ProcessingState(data + "+", iteration + 1); // ③
}
}
clearBlackboard = trueallows the action to loop back to the same state type- Terminal condition exits the loop
- Returns a new instance of the same state type for another iteration
Without clearBlackboard = true, the planner would see the output type already exists on the blackboard and skip the action.
Clearing the blackboard resets the context, allowing natural loops.
Only use clearBlackboard = true on actions that participate in loops.
For linear state transitions, the default behavior (preserving the blackboard) is usually preferred.
The @State Annotation
Classes returned from actions that should trigger state transitions must be annotated with @State:
@State
record ProcessingState(String data) {
@Action
NextState process() {
return new NextState(data.toUpperCase());
}
}
Inheritance
The @State annotation is inherited through the class hierarchy.
If a superclass or interface is annotated with @State, all subclasses and implementing classes are automatically considered state types.
This means you don’t need to annotate every class in a hierarchy - just annotate the base type.
@State
interface Stage {} // ①
record AssessStory(String content) implements Stage { ... } // ②
record ReviseStory(String content) implements Stage { ... }
record Done(String content) implements Stage { ... }
- Only the parent interface needs
@State - Implementing records/data classes are automatically treated as state types
This works with:
- Interfaces: Classes implementing a
@Stateinterface are state types - Abstract classes: Classes extending a
@Stateabstract class are state types - Concrete classes: Classes extending a
@Stateclass are state types - Deep hierarchies: The annotation is inherited through multiple levels
Behavior
When an action returns a @State-annotated class (or a class that inherits @State):
- Any previous state objects are hidden from the blackboard (not removed, but no longer visible)
- The returned object is bound to the blackboard (as
it) - Planning considers only actions defined within the current state class
- Any
@AchievesGoalmethods in the state become potential goals
Context (non-state objects) is preserved across state transitions. This means user messages, customer data, conversation history, etc. remain available in the new state. Only state objects are hidden, providing state scoping - ensuring only the current state’s actions are considered.
For looping states that return to a previously-visited state type, use @Action(clearBlackboard = true) on the looping action.
This clears the blackboard (including hasRun conditions) and allows the loop to continue. See [](/Looping States) for details.
Parent State Interface Pattern
For dynamic choice between states, define a parent interface (or sealed interface/class) that child states implement. Thanks to inheritance, you only need to annotate the parent interface - all implementing classes are automatically state types:
@State
interface Stage {} // ①
record AssessStory(String content) implements Stage { // ②
@Action
Stage assess() {
if (isAcceptable()) {
return new Done(content);
} else {
return new ReviseStory(content);
}
}
}
record ReviseStory(String content) implements Stage {
@Action
AssessStory revise() {
return new AssessStory(improvedContent());
}
}
record Done(String content) implements Stage {
@AchievesGoal(description = "Processing complete")
@Action
Output complete() {
return new Output(content);
}
}
@Stateon the parent interface- No
@Stateneeded on implementing records/data classes - they inherit it fromStage
This pattern enables:
- Polymorphic return types: Actions can return any implementation of the parent interface
- Dynamic routing: The runtime value determines which state is entered
- Looping: States can return other states that eventually loop back
The framework automatically discovers all implementations of the parent interface and registers their actions as potential next steps.
Example: WriteAndReviewAgent
The following example demonstrates a complete write-and-review workflow with:
- State-based flow control with looping
- Human-in-the-loop feedback using
WaitFor - LLM-powered content generation and assessment
- Configurable properties passed through states
abstract class Personas { // ①
static final RoleGoalBackstory WRITER = RoleGoalBackstory
.withRole("Creative Storyteller")
.andGoal("Write engaging and imaginative stories")
.andBackstory("Has a PhD in French literature; used to work in a circus");
static final Persona REVIEWER = new Persona(
"Media Book Review",
"New York Times Book Reviewer",
"Professional and insightful",
"Help guide readers toward good stories"
);
}
@Agent(description = "Generate a story based on user input and review it")
public class WriteAndReviewAgent {
public record Story(String text) {}
public record ReviewedStory(
Story story,
String review,
Persona reviewer
) implements HasContent, Timestamped {
// ... content formatting methods
}
@State
interface Stage {} // ②
record Properties( // ③
int storyWordCount,
int reviewWordCount
) {}
private final Properties properties;
WriteAndReviewAgent(
@Value("$\{storyWordCount:100}") int storyWordCount,
@Value("$\{reviewWordCount:100}") int reviewWordCount
) {
this.properties = new Properties(storyWordCount, reviewWordCount);
}
@Action
AssessStory craftStory(UserInput userInput, Ai ai) { // ④
var draft = ai
.withLlm(LlmOptions.withAutoLlm().withTemperature(.7))
.withPromptContributor(Personas.WRITER)
.createObject(String.format("""
Craft a short story in %d words or less.
The story should be engaging and imaginative.
Use the user's input as inspiration if possible.
# User input
%s
""",
properties.storyWordCount,
userInput.getContent()
).trim(), Story.class);
return new AssessStory(userInput, draft, properties); // ⑤
}
record HumanFeedback(String comments) {} // ⑥
private record AssessmentOfHumanFeedback(boolean acceptable) {}
@State
record AssessStory(UserInput userInput, Story story, Properties properties) implements Stage {
@Action
HumanFeedback getFeedback() { // ⑦
return WaitFor.formSubmission("""
Please provide feedback on the story
%s
""".formatted(story.text),
HumanFeedback.class);
}
@Action(clearBlackboard = true) // ⑧
Stage assess(HumanFeedback feedback, Ai ai) {
var assessment = ai.withDefaultLlm().createObject("""
Based on the following human feedback, determine if the story is acceptable.
Return true if the story is acceptable, false otherwise.
# Story
%s
# Human feedback
%s
""".formatted(story.text(), feedback.comments),
AssessmentOfHumanFeedback.class);
if (assessment.acceptable) {
return new Done(userInput, story, properties); // ⑨
} else {
return new ReviseStory(userInput, story, feedback, properties); // ⑩
}
}
}
@State
record ReviseStory(UserInput userInput, Story story, HumanFeedback humanFeedback,
Properties properties) implements Stage {
@Action(clearBlackboard = true) // ⑪
AssessStory reviseStory(Ai ai) {
var draft = ai
.withLlm(LlmOptions.withAutoLlm().withTemperature(.7))
.withPromptContributor(Personas.WRITER)
.createObject(String.format("""
Revise a short story in %d words or less.
Use the user's input as inspiration if possible.
# User input
%s
# Previous story
%s
# Revision instructions
%s
""",
properties.storyWordCount,
userInput.getContent(),
story.text(),
humanFeedback.comments
).trim(), Story.class);
return new AssessStory(userInput, draft, properties); // ⑫
}
}
@State
record Done(UserInput userInput, Story story, Properties properties) implements Stage {
@AchievesGoal( // ⑬
description = "The story has been crafted and reviewed by a book reviewer",
export = @Export(remote = true, name = "writeAndReviewStory"))
@Action
ReviewedStory reviewStory(Ai ai) {
var review = ai
.withAutoLlm()
.withPromptContributor(Personas.REVIEWER)
.generateText(String.format("""
You will be given a short story to review.
Review it in %d words or less.
Consider whether the story is engaging, imaginative, and well-written.
# Story
%s
# User input that inspired the story
%s
""",
properties.reviewWordCount,
story.text(),
userInput.getContent()
).trim());
return new ReviewedStory(story, review, Personas.REVIEWER);
}
}
}
- Personas: Reusable prompt contributors that give the LLM context about its role
- Parent state interface: Allows actions to return any implementing state dynamically
- Properties record: Configuration bundled together for easy passing through states
- Entry action: Uses LLM to generate initial story draft
- State transition: Returns
AssessStorywith all necessary data - HITL data type: Simple record/data class to capture human feedback
- WaitFor integration: Pauses execution and waits for user to submit feedback form
- Looping action:
clearBlackboard = trueenables returning to a previously-visited state type - Terminal branch: If acceptable, transitions to
Donestate - Loop branch: If not acceptable, transitions to
ReviseStorywith the feedback - Looping action:
clearBlackboard = trueenables looping back toAssessStory - Loop back: Returns new
AssessStoryfor another round of feedback - Goal achievement: Final action that produces the reviewed story and exports it
Execution Flow
The execution flow for this agent:
craftStoryexecutes with LLM, returnsAssessStory-> entersAssessStorystategetFeedbackcallsWaitFor.formSubmission()-> agent pauses, waits for user input- User submits feedback ->
HumanFeedbackadded to blackboard assessexecutes with LLM to interpret feedback:- If acceptable: returns
Done-> blackboard cleared, entersDonestate - If not acceptable: returns
ReviseStory-> blackboard cleared, entersReviseStorystate
- If acceptable: returns
- If in
ReviseStory:reviseStoryexecutes with LLM, returnsAssessStory-> blackboard cleared, loop back to step 2 - When in
Done:reviewStoryexecutes with LLM, returnsReviewedStory-> goal achieved
The planner handles all transitions automatically, including loops.
The looping actions (assess and reviseStory) use clearBlackboard = true to enable returning to previously-visited state types.
Human-in-the-Loop with WaitFor
The WaitFor.formSubmission() method is key for human-in-the-loop workflows:
@Action
HumanFeedback getFeedback() {
return WaitFor.formSubmission("""
Please provide feedback on the story
%s
""".formatted(story.text),
HumanFeedback.class);
}
When this action executes:
- The agent process enters a
WAITINGstate - A form is generated based on the
HumanFeedbackrecord structure - The user sees the prompt and fills out the form
- Upon submission, the
HumanFeedbackinstance is created and added to the blackboard - The agent resumes execution with the feedback available
This integrates naturally with the state pattern: the feedback stays within the current state until the next state transition.
Passing Data Through States
When using clearBlackboard = true for looping states, all necessary context must be passed through state records since the blackboard is cleared:
@State
record AssessStory(
UserInput userInput, // Original user request
Story story, // Current story draft
Properties properties // Configuration
) implements Stage { ... }
@State
record ReviseStory(
UserInput userInput,
Story story,
HumanFeedback humanFeedback, // Additional context for revision
Properties properties
) implements Stage { ... }
Use a Properties record/data class to bundle configuration values that need to pass through multiple states, rather than repeating individual fields.
For non-looping state transitions (where clearBlackboard is not used), the blackboard is preserved, and data can be accessed from the blackboard directly.
This is useful when states need access to shared context like user identity or conversation history.
State Class Requirements
State classes must be either static nested classes (Java) or top-level classes (Kotlin).
Non-static inner classes are not allowed because they hold a reference to their enclosing instance, causing serialization and persistence issues.
The framework will throw an IllegalStateException if it detects a non-static inner class annotated with @State.
// GOOD: Static nested class (Java record is implicitly static)
@State
record AssessStory(UserInput userInput, Story story) implements Stage { ... }
// GOOD: Top-level class
@State
record ProcessingState(String data) { ... }
// BAD: Non-static inner class - will throw IllegalStateException
@State
class AssessStory implements Stage { ... } // Inner class in non-static context
In Java, records declared inside a class are implicitly static, making them ideal for state classes. In Kotlin, data classes declared inside a class are inner by default; use top-level declarations instead.
Top-level state classes are the recommended pattern for Kotlin.
They can access the enclosing component via the @Provided annotation.
See The @Provided Annotation for full documentation.
Key Points
- Annotate state classes with
@State(or inherit from a@State-annotated type) @Stateis inherited through class hierarchies - annotate only the base type- Use static nested classes (Java records) or top-level classes to avoid persistence issues
- Use a parent interface for polymorphic state returns
- State actions are automatically discovered and registered
- State scoping: When entering a new state, previous states are hidden - only current state’s actions are available
- Context is preserved: Non-state objects (user data, conversation, etc.) remain available across transitions
- Blackboard preserved: State transitions hide previous states but preserve all other blackboard contents
- Staying in state: Return
thiswithcanRerun = trueto stay in the current state without transitioning - For looping states, use
@Action(clearBlackboard = true)to enable returning to previously-visited state types - When using
clearBlackboard = true, pass all necessary data through state record fields - Goals are defined with
@AchievesGoalon terminal state actions - Use
WaitForfor human-in-the-loop interactions within states - Within a state, normal GOAP planning applies to sequence actions




