aregmi.net
Resume

Building AI agents with Spring AI - routing, orchestration, tool integration, and agent transfer patterns

agents agentic-patterns routing orchestration mcp tools spring-ai

Agentic Patterns with Spring AI

What even is an "AI Agent"?

Think of it this way: a normal LLM call is like asking someone a question and getting an answer. An agent is like hiring someone who can actually go do things β€” use tools, make decisions, loop until the job's done, and even hand off work to specialists.

Aspect Workflow Agent
Control Flow Predetermined, coded LLM-directed, dynamic
Predictability High Medium
Flexibility Low High
Complexity Simple to debug Requires careful guardrails

When should you reach for agents?

If your flow is predictable and sequential, you probably don't need an agent. A simple chain will do.


Agentic Design Patterns

Based on research from Anthropic's Building Effective Agents and Spring AI implementation patterns.

1. Chain Pattern

The simplest pattern. Tasks flow in a fixed sequence β€” each step's output feeds into the next.

[Input] β†’ [Step 1] β†’ [Step 2] β†’ [Step 3] β†’ [Output]
public String chainedExecution(String input) {
    // Step 1: Categorize
    String category = chatClient.prompt()
        .user("Categorize this: " + input)
        .call().content();
    
    // Step 2: Analyze based on category
    String analysis = chatClient.prompt()
        .system("You are a " + category + " expert")
        .user("Analyze: " + input)
        .call().content();
    
    // Step 3: Generate output
    return chatClient.prompt()
        .user("Summarize this analysis: " + analysis)
        .call().content();
}

Trade-offs:


2. Parallelization Pattern

Multiple independent tasks run concurrently, with results aggregated at the end.

           β”Œβ†’ [Task A] ─┐
[Input] ───┼→ [Task B] ─┼→ [Aggregator] β†’ [Output]
           β””β†’ [Task C] β”€β”˜

Two variants here:

  1. Sectioning β€” split task into independent subtasks
  2. Voting β€” same task, multiple perspectives, majority wins
public Map<String, String> parallelAnalysis(String input) {
    CompletableFuture<String> security = CompletableFuture.supplyAsync(() ->
        chatClient.prompt()
            .system("You are a security expert")
            .user("Analyze: " + input)
            .call().content()
    );
    
    CompletableFuture<String> performance = CompletableFuture.supplyAsync(() ->
        chatClient.prompt()
            .system("You are a performance expert")
            .user("Analyze: " + input)
            .call().content()
    );
    
    return Map.of(
        "security", security.join(),
        "performance", performance.join()
    );
}

Trade-offs:


3. Routing Pattern

An AI classifier routes input to the most appropriate specialized handler. This is the pattern I use the most.

                    β”Œβ†’ [Agent A: Incidents]
[Input] β†’ [Router] ─┼→ [Agent B: Change Requests]
                    β””β†’ [Agent C: Executive Summary]
@Component
public class AgentRouting {
    
    public String route(String input, List<String> agentNames) {
        RoutingResponse response = chatClient.prompt()
            .system("""
                You are a routing classifier. Analyze the user input and 
                select the most appropriate agent.
                Available agents: """ + String.join(", ", agentNames))
            .user(input)
            .call()
            .entity(RoutingResponse.class);
        
        return response.selection();
    }
}

record RoutingResponse(String reasoning, String selection) {}

Why routing works well:


4. Orchestrator-Workers Pattern

A central orchestrator breaks tasks into subtasks and delegates to specialized workers. Think of it as "the manager pattern."

                              β”Œβ†’ [Worker A] ─┐
[Input] β†’ [Orchestrator] ─────┼→ [Worker B] ─┼→ [Synthesizer] β†’ [Output]
              ↑               β””β†’ [Worker C] β”€β”˜
              └──── feedback loop β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
public String orchestrateTask(String complexTask) {
    // Orchestrator plans the work
    TaskPlan plan = chatClient.prompt()
        .system("Break down this task into subtasks")
        .user(complexTask)
        .call()
        .entity(TaskPlan.class);
    
    // Workers execute subtasks
    List<String> results = plan.subtasks().stream()
        .map(subtask -> executeWorker(subtask))
        .toList();
    
    // Synthesize results
    return chatClient.prompt()
        .system("Synthesize these results into a cohesive response")
        .user(String.join("\n", results))
        .call().content();
}

5. Evaluator-Optimizer Pattern

Generate β†’ Evaluate β†’ Refine β†’ Repeat. The loop runs until quality criteria are met.

[Input] β†’ [Generator] β†’ [Evaluator] ─┬→ [Output] (if pass)
              ↑                      β”‚
              └──── feedback β”€β”€β”€β”€β”€β”€β”€β”€β”˜ (if fail)
public String generateWithQualityCheck(String input, int maxIterations) {
    String output = "";
    
    for (int i = 0; i < maxIterations; i++) {
        output = chatClient.prompt()
            .system("Generate a high-quality response")
            .user(input + (i > 0 ? "\nPrevious attempt: " + output : ""))
            .call().content();
        
        QualityScore score = chatClient.prompt()
            .system("Rate this output from 1-10")
            .user(output)
            .call()
            .entity(QualityScore.class);
        
        if (score.rating() >= 8) {
            return output;
        }
    }
    return output;
}

Real Implementation: Routing + Agent Transfer

Here's the architecture I used in a real project β€” routing pattern with agent transfer capability.

                                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                β”‚        AgentOrchestrator        β”‚
                                β”‚   - Routes requests to agents   β”‚
                                β”‚   - Manages agent execution     β”‚
                                β”‚   - Handles transfers           β”‚
                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                             β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚                              β”‚                              β”‚
              β–Ό                              β–Ό                              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  IncidentSummaryAgent   β”‚    β”‚ChangeRequestSummaryAgentβ”‚    β”‚  ExecutiveSummaryAgent  β”‚
β”‚                         β”‚    β”‚                         β”‚    β”‚                         β”‚
β”‚  Tools:                 β”‚    β”‚  Tools:                 β”‚    β”‚  Tools:                 β”‚
β”‚  - get_recent_incidents β”‚    β”‚  - get_recent_CRs       β”‚    β”‚  - ALL incident tools   β”‚
β”‚  - get_by_application   β”‚    β”‚  - get_by_application   β”‚    β”‚  - ALL CR tools         β”‚
β”‚  - get_statistics       β”‚    β”‚  - get_statistics       β”‚    β”‚                         β”‚
β”‚  - get_critical         β”‚    β”‚  - get_high_risk        β”‚    β”‚  No transfer needed     β”‚
β”‚                         β”‚    β”‚  - get_failed           β”‚    β”‚                         β”‚
β”‚  Can transfer to: CR    β”‚    β”‚  Can transfer to: Inc   β”‚    β”‚                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Core Components

Component Purpose
Agent.java Record defining agent configuration
AgentRouting.java Routes user input to appropriate agent
AgentOrchestrator.java Central orchestrator managing all agents
AgentTransferTool.java Enables inter-agent transfers
AgentConfig.java Configures and registers all agents
AgentService.java Business layer for agent operations
AgentController.java REST endpoints for agent invocation

Agent Transfer Mechanism

Agents can transfer control to other specialized agents when cross-referencing is beneficial.

  1. Detection: Agent identifies need for transfer (e.g., "incident after deployment")
  2. Tool Call: Agent calls transferToAgent(agentName, reason)
  3. Context Handoff: Transfer includes conversation context
  4. Execution: Target agent continues the task
  5. Response: Final response from the completing agent
@Tool(description = "Transfer to another specialized agent")
public String transferToAgent(
    @ToolParam(description = "Target agent name") String agentName,
    @ToolParam(description = "Reason for transfer") String reason
) {
    this.transferRequested = true;
    this.transferTarget = agentName;
    return "Transfer initiated to " + agentName;
}

Example flow:

User: "Analyze the incidents from yesterday, especially any related to deployments"

1. Router β†’ IncidentSummaryAgent (primary focus is incidents)
2. Agent finds incident: "Production outage after CRM deployment"
3. Agent calls: transferToAgent("ChangeRequestSummaryAgent", "Need CR details for correlation")
4. ChangeRequestSummaryAgent analyzes related changes
5. Combined insight returned to user

Best Practices

Keep Agent Prompts Focused

❌ Don't: Create one agent with broad, vague instructions

You are an AI that helps with IT operations. Do whatever the user asks.

βœ… Do: Create specialized agents with clear scope

You are an expert IT Incident Analyst. Your task is to analyze 
ServiceNow incidents and generate focused incident summary emails.

Limit Tools Per Agent

Don't give every agent access to every tool. It increases confusion, slows response times, and burns more tokens.

Use Structured Output

❌ Parsing free-form LLM responses with regex:

String response = chatClient.call().content();
// Brittle regex parsing...

βœ… Using Spring AI's entity extraction:

RoutingResponse response = chatClient.prompt()
    .call()
    .entity(RoutingResponse.class);

Handle Transfer Loops

Prevent infinite transfer loops with:

Audit All Agent Actions

Log and persist: which agent handled the request, what tools were called, transfer history, and generated output.


API Reference

Method Endpoint Description
POST /api/v1/agents/incident-summary Generate incident-focused summary
POST /api/v1/agents/cr-summary Generate CR-focused summary
POST /api/v1/agents/executive-summary Generate combined executive summary
POST /api/v1/agents/auto Auto-route to appropriate agent
POST /api/v1/agents/{agentName}/with-transfer Execute with transfer capability
# Auto-Route
curl -X POST "http://localhost:8080/api/v1/agents/auto" \
  -H "Content-Type: text/plain" \
  -d "What high-risk changes were deployed yesterday?"

# With Transfer
curl -X POST "http://localhost:8080/api/v1/agents/IncidentSummaryAgent/with-transfer" \
  -H "Content-Type: text/plain" \
  -d "Analyze incidents, check if any are related to recent deployments"

Further Reading