DEV Community

Cover image for Building AI Agents with Embabel, Spring AI and Amazon Bedrock AgentCore - Part 2 Develop Agents with Embabel shell
Vadym Kazulkin for AWS Heroes

Posted on • Originally published at vkazulkin.com

Building AI Agents with Embabel, Spring AI and Amazon Bedrock AgentCore - Part 2 Develop Agents with Embabel shell

Introduction

In part 1, we addressed some challenges when developing AI agents with Spring AI frameworks only. We start exploring how Embabel is capable of addressing and solving those challenges. Throughout this series, we'll start improving the agent developed in the Provide MCP tools for Conference application via AgentCore Gateway article using the Embabel framework. First, we'll use the Embabel shell, then the web application. You can look at my GitHub embabel-1.x-conference-app-agent-local repository for the example. I updated them to use the recently released Embabel version 1.0.0.

Implement AI agents with Embabel shell

In this article, we'll implement 2 AI agents capable of responding to the following prompts:

  1. "Please provide me with the list of conferences, including their IDs, with Java topics happening in 2027, with the call for papers open today. Also, provide me with the list of my talks with this topic in the title. Finally, for each conference and talk retrieved, apply individually for the conference."
  2. "Please create a talk with a cool title (max 60 characters long) and description (max 300 characters long) about using Spring AI on the Amazon Bedrock AgentCore service. Then provide me with the list of conferences, including their IDs, with Java topics happening in 2026 and 2027, with the call for papers open today. Finally, for each conference, apply individually for it with the talk just created."

As our sample application also supports only searching for the conference as an attendee, not as a speaker, you can similarly implement those agents.

Before we start, we need to understand the following key concepts of the Embabel framework:

  • Actions: Steps an agent takes
  • Goals: What an agent is trying to achieve
  • Conditions: Conditions to assess before executing an action or determining that a goal has been achieved. Conditions are reassessed after each action is executed.
  • Domain model: Objects underpinning the flow and informing Actions, Goals and Conditions.
  • Plan: A sequence of actions to achieve a goal. Plans are dynamically formulated by the system, not the programmer. The system replans after the completion of each action, allowing it to adapt to new information as well as observe the effects of the previous action. This is effectively an OODA loop.

Now let's first define the actions for our agent - steps an agent takes.
We can split our first agent into the following actions:

  1. Request: "Please provide me with the list of conferences, including their IDs, with Java topics happening in 2027, with the call for papers open today". LLM responds to it.
  2. Request: "Provide me with the list of my talks with this topic in the title". LLM responds to it.
  3. Request: "For each conference and talk retrieved, apply individually for the conference". This request means that the LLM uses its responses from the first 2 requests as the request and responds to it.

And we can split our second agent into the following actions:

  1. Request: "Please create a talk with a cool title (max 60 characters long) and description (max 300 characters long) about using Spring AI on the Amazon Bedrock AgentCore service". LLM responds to it.
  2. Request: "Provide me with the list of my talks with this topic in the title". LLM responds to it.
  3. Request: "For each conference and talk, apply individually for the conference". This request means that the LLM uses its responses from the first 2 requests as the request and responds to it.

In this particular case, steps 2 and 3 are mainly the same for our 2 agents. The difference lies in step 1, where we either retrieve an existing talk(s) or create a new one. Your case might be different, so the design of the agents and their actions might also be different.

First, let's declare some important dependencies in pom.xml to use the Embabel framework (I use version 1.0.0) and its shell:

 <dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-starter-bedrock</artifactId>
    <version>${embabel-agent.version}</version>
 </dependency>
 <dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-starter-shell</artifactId>
    <version>${embabel-agent.version}</version>
 </dependency>
Enter fullscreen mode Exit fullscreen mode

Also, make sure not to declare the spring-boot-starter-web dependency, as we'll implement a web application in the next article.

The EmbabelConferenceApplication is the main entry point of our application. Here we also load Amazon Bedrock models, which we declared in the additional-bedrock.yaml:

@Configuration
class AdditionalBedrockModels {
@Bean
   BedrockModelLoader bedrockModels() {
   return new BedrockModelLoader(new DefaultResourceLoader(), "classpath:models/additional-bedrock.yaml");
   }
}
Enter fullscreen mode Exit fullscreen mode

In the Domain class, we declared a domain object (Java record) for each request and response, which corresponds to the agent actions described above. For example, for the action to search for the talks, those domain objects look like this:

// Request to LLM to create the talk
public record ConferenceSearchRequest(String topic, LocalDate startDate, 
  LocalDate endDate, LocalDate callForPapersOpenOnThistDate) 
}


// Response from LLM for the talk creation request
public record Conference (Integer conferenceId, String name, Set<String> topics, String homepage, LocalDate startDate, LocalDate endDate, LocalDate callForPapersStartDate, LocalDate callForPapersEndDate,  String city, String linkToCallforPapers) {
}
Enter fullscreen mode Exit fullscreen mode

Now let's design those 2 agents. We defined the common actions for both of them in the AbstractConferenceAgent class. We defined individual agent-specific actions in the CreateTalksAndApplyForConferencesAgent and SearchForTalksAndApplyForConferencesAgent classes, respectively.

Let's explain the talk search agent action from the SearchForTalksAndApplyForConferencesAgent class in more detail. First of all, we annotate each agent with Embabel's Agent annotation, for example:

@Agent(name=SearchForTalksAndApplyForConferencesAgent.AGENT_NAME , description = "search for the talk(s), search for the conference(s) by the given criteria (all, by the topic, by the date range, and by call for papers being open on some date), and apply for them with the found talks")
public final class SearchForTalksAndApplyForConferencesAgent extends AbstractConferenceAgent {
 ....
}
Enter fullscreen mode Exit fullscreen mode

Here we give the agent a name and description.

The first question is how we can break down a big unstructured prompt into the 3 actions that we described above? Let's start with the action to search for the talk first. The answer is: we can ask LLMs to do this job. Even cheap models are already capable of doing this. Here is how this looks for the action: "Please provide me with the list of conferences, including their IDs, with Java topics happening in 2027, with the call for papers open today"

@Action
Domain.TalkSearchRequest extractTalkSearchRequest(UserInput userInput, OperationContext context) {
return  context.ai()
   .withLlm(LlmOptions.withModel("us.amazon.nova-pro-v1:0"))
     .createObject("""
Create a talk search request from this user input, extracting the optional parameters like talk title substring, conference start and end date, and whether the call for papers is still open on a certain date. 
Don't include any other information in the request.
Here is the user input:
     %s""".formatted(userInput.getContent()), Domain.TalkSearchRequest.class);
}   
Enter fullscreen mode Exit fullscreen mode

Let's explain step by step what's happening here. The extractTalkSearchRequest method is an action, which the Agent executes. That's why we annotated it with the Embabel Action annotation. This method also takes 2 parameters, both coming from the Embabel framework:

  • UserInput object, which represents the unstructured user prompt
  • OperationContext, which gives us access to the AI functionality

We first invoke the ai() method of the context object and then provide the LLM to do the job. We can either pass the concrete model AI or use other options that are available, like using the default LLM, LLM by role, and others. As a result, we get back the Embabel implementation of the PromptRunnerOperations. Or, we can declare such LLMs in the application.properties, for example :

embabel.models.default-llm=us.anthropic.claude-sonnet-4-6
embabel.models.llms.best=us.anthropic.claude-sonnet-4-6
embabel.models.llms.balanced=us.amazon.nova-pro-v1:0
Enter fullscreen mode Exit fullscreen mode

By invoking the createObject() method on the PromptRunnerOperations implementation, we pass the user prompt and instruction to the LLM on how to split this prompt into one subtask or action. It should only contain the information about the talk search criteria. Additionally, we pass the domain object, in this case, the object of type Domain.TalkSearchRequest. Embabel will make the best effort to map the LLM response to this domain object, which additionally provides us with type safety.

The next step is to implement the talk search itself. For this, we define another action in the SearchForTalksAndApplyForConferencesAgent class:

@Action
Domain.Talks talkSearch(Domain.TalkSearchRequest talkSearchRequest, Ai ai) {        
return config.speaker()     
    .promptRunner(ai)            
    .withPromptContributors(List.of(talkSearchRequest))
    .withToolGroup(this.getMcpToolGroupByName("get-talks-by-"))
    .createObject("Search for the talks with the given criteria", Domain.Talks.class);
}   
Enter fullscreen mode Exit fullscreen mode

Please note that the object of the Domain.TalkSearchRequest type, which is the output of the previous action, is now the input of this action. Also, we inject the Ai object coming from the Embabel framework this time directly. Let's first ignore the config.speaker invocation, which we'll explain later. Here we pass the prompt, which contains the properties required to search for the talk. When with createObject() method invocation, we once again pass the prompt and the domain object, which should match the outcome, in this case, the object of type Domain.Talks.

To respond to this prompt, the agent needs tools. Please review the articles Develop local MCP client for Conference application and Provide MCP tools for Conference application via AgentCore Gateway to understand the whole setup. I implemented very similar code to initialize the MCP client connected to the Bedrock AgentCore Gateway in the McpToolService class. MCP client is then exposed as a Spring bean in the EmbabelConferenceApplication class:

@Bean 
@Primary
public McpSyncClient geMcpClient() {
    return this.mcpToolService.getMcpClient();
}
Enter fullscreen mode Exit fullscreen mode

By invoking the withToolGroup(this.getMcpToolGroupByName("get-talks-by-")) method on the PromptRunner (see above), we achieved another goal that we described in part 1. We not only split the prompt into individual actions, but are now also able to provide exact MCP tool(s) required to execute it. Embabel provides the ToolGroup abstraction for this. In this case, we give the LLM only the tools whose names consist of the get-talks-by- substring. I implemented the MCP tool filtering in the AbstractConferenceAgent class:

protected ToolGroup getMcpToolGroupByName(String name) {
    var  toolGroupDescription=  ToolGroupDescription.Companion.invoke(
         "A collection of tools to interact with 
         the MCP conference search service",
         "location",""
   );

  var noOp= ToolCallContextMcpMetaConverter.Companion.noOp();   

  return new McpToolGroup(
          toolGroupDescription,
          "Vadym",
          name,
          Set.of(ToolGroupPermission.INTERNET_ACCESS),
          List.of(mcpClient),
          callback -> filterMcpTool(callback, name),
          noOp
      );
}

private boolean filterMcpTool (ToolCallback toolCallback, String name) {
    // not passing name means return true -> pass all tools
      if(name==null) return true;
      return toolCallback.getToolDefinition().name().contains(name);
 }
Enter fullscreen mode Exit fullscreen mode

The downside is that if we were to rename the MCP tools, we'd need to adjust their names here also. The alternative is to use semantic search for the tools. The alternative is to use semantic search for the tools.

Now let's come back to the config.speaker() method invocation, which we saw in the talkSearch action. Embabel gives the possibility to create different roles and attach different LLMs to them. In our case, we have 2 roles: conference attendee and conference speaker. We can define these roles either programmatically or in the application.properties:

conference.attendee.llm.role=best
conference.attendee.persona.role=Conference attendee
conference.attendee.persona.goal=Conduct a thorough conference search with the information (topics, conference dates) provided so I decide which to attend.
conference.attendee.persona.backstory=As a Conference Attendee Specialist, your mission is to find a suitable conference that matches the given request


conference.speaker.llm.role=best
conference.speaker.persona.role=Conference speaker
conference.speaker.persona.goal=Conduct the search for the talks or create the talk with the given criteria with the conference application for those talks
conference.speaker.persona.backstory=As a Conference Speaker Specialist, your mission is to search for the talk or create the talk with the given criteria with the conference application for those talks
Enter fullscreen mode Exit fullscreen mode

We also link each role to the LLM model by referencing the previously defined LLM strategy (in our case, by the name best). See the LLM definition once again:

embabel.models.default-llm=us.anthropic.claude-sonnet-4-6
embabel.models.llms.best=us.anthropic.claude-sonnet-4-6
embabel.models.llms.balanced=us.amazon.nova-pro-v1:0
Enter fullscreen mode Exit fullscreen mode

In our case, the best LLM strategy will use the us.anthropic.claude-sonnet-4-6 model.
Next, we defined ConferenceConfig to have access to the roles and actors:

@Validated
@ConfigurationProperties(prefix = "conference")
public record ConferenceConfig(Actor<RoleGoalBackstory> attendee,   
    Actor<RoleGoalBackstory> speaker) {
}
Enter fullscreen mode Exit fullscreen mode

The conference prefix, together with Actor definitions, determines the mapping to the roles. For example, Actor speaker matched the conference.speaker role definition in the application.properties.

Finally, we injected the ConferenceConfig object (as config) into the constructor of each agent.

Similarly, we split the initial prompt into other individual steps or actions for searching for conferences. See the extractConferenceSearchRequest and conferenceSearch agents in the AbstractConferenceAgent class. Also, the last step (applying for conferences with talks) looks similar. See the applyForConference action in the same class. There are 2 things to notice here:

  1. We don't ask the LLM to split the prompt. This is because the input to this action is the output of 2 actions before: talkSearch and conferenceSearch, and 2 domain objects, Domain.Conferences and Domain.Talks respectively. Both become the input to the PromptContributors.
  2. This agent is additionally annotated with the AchievesGoal annotation to outline that this action execution is the final step that the agent should take in the workflow. See the complete code:
@Action
@AchievesGoal(description = "apply for the conferences with their IDs and talk IDs and provide status of the application")
    Domain.ConferenceApplications applyForConference(Domain.Conferences conferences, Domain.Talks talks, Ai ai) {
     return config.speaker()
         .promptRunner(ai)
         .withPromptContributors(List.of(conferences, talks))
         .withToolGroup(this.getMcpToolGroupByName("apply-to-conference"))
         .createObject("Apply for the conference with the given criteria", Domain.ConferenceApplications.class);
 }
Enter fullscreen mode Exit fullscreen mode

We're completely done with the SearchForTalksAndApplyForConferencesAgent agent.

The implementation of the agent CreateTalksAndApplyForConferencesAgent looks very similar. The biggest difference is the first step: instead of searching for the existing talks, the talk is created. See the extractTalkCreationRequest and createTalks actions.

Now come the biggest questions:

  • How is Embabel capable of identifying which agent to execute?
  • How is Embabel capable of identifying the order of actions to execute for the individual agent? We only defined the last action to finally achieve the goal properly by annotation.

Let's build the application with mvn clean package and run it locally with mvn spring-boot:run. We see that Embabel starts the shell, and we can execute the prompt by running:

x 'Please provide me with the list of conferences, including their IDs, with Java topics happening in 2027, with the call for papers open today. Also, provide me with the list of my talks with this topic in the title. Finally, for each conference and talk retrieved, apply individually for the conference.'

Let's see what happens next:

Embabel identifies 2 agents, takes their description to compute the score, and chooses a concrete agent to execute based on the user input (prompt). I assume Embabel also uses an LLM for it. As a result, it computed: As a result, it computed :

  • SearchForExistingTalksAndApplyForConferencesAgent: 0,97
  • CreateNewTalksAndApplyForConferencesAgent: 0,05

So, the SearchForExistingTalksAndApplyForConferencesAgent agent is clearly a winner.

Next, we see that Embabel formulates a plan to achieve the goal:

The sequence of agent actions to execute is a correct one :

Embabel could also come up with the plan to first search for the conferences, then for the talks, and then to apply. Both plans are valid. How does Embabel create such a plan? The system, not the programmer, dynamically formulates plans. The system replans after the completion of each action, allowing it to adapt to new information as well as observe the effects of the previous action. This is effectively an OODA loop. Emabebl analyzes the input and output types of each agent that we defined. This also helps to formulate the plan.

We see how Embabel executes those individual actions in the identified order. It logs all structured outputs of each action. The final output looks like this:

It found 2 talks and 1 conference to apply those talks that matched the criteria that we defined in the prompt. Finally, Embabel provides the statistics of the LLM used, the number of input (prompt) and output (completion) tokens spent. We also see the total cost of the agent execution. Embabel maintains the LLM pricing database. I don't expect this information to be very accurate, but it provides some indication. We also see the MCP tool names that the LLM used during the agent execution, their response times, and the number of failures.

If we execute the prompt x 'Please create a talk with a cool title (max 60 characters long) and description (max 300 characters long) about using Spring AI on the Amazon Bedrock AgentCore service. Then provide me with the list of conferences, including their IDs, with Java topics happening in 2026 and 2027, with the call for papers open today. Finally, for each conference, apply individually for it with the talk just created.', we'll see that Embabel chooses to execute the CreateNewTalksAndApplyForConferencesAgent agent :

It then formulates the plan and executes the actions to achieve the goal:

Finally, it provides the same statistics, which we previously explained:

There are many more features that Embabel provides, like retries, which we'll explore in later articles.

Conclusion

In this article, we developed our first AI agents with the Embabel shell. We learned many concepts of this framework, such as agents, actions, and goals. In the next article, we'll slightly adjust our application to convert it into a web application.

If you like my content, please follow me on GitHub and give my repositories a star!

Please also check out my website for more technical content and upcoming public speaking activities.

Top comments (0)