Step 05 - Using Remote Agents (A2A)
New Requirement: Distributing the Disposition Service
In Step 4, you implemented a complete disposition system using the Supervisor Pattern with local agents. The system works well, but the Miles of Smiles management team has a new architectural requirement:
The disposition decision-making logic needs to be maintained by a separate team and run as an independent service.
This is a common real-world scenario where:
- Different teams own different capabilities: The disposition team has specialized expertise and wants to maintain their own service
- The service needs to be reusable: Multiple client applications (not just car management) might need disposition recommendations
- Independent scaling is required: The disposition service might need different resources than the main application
You’ll learn how to convert Step 4’s local DispositionAgent into a remote service using the Agent-to-Agent (A2A) protocol.
What You’ll Learn
In this step, you will:
- Understand the Agent-to-Agent (A2A) protocol for distributed agent communication
- Convert Step 4’s local
DispositionAgentinto a remote A2A service - Build a client agent that connects to remote A2A agents using
@A2AClientAgent - Create an A2A server that exposes an AI agent as a remote service
- Learn about AgentCard, AgentExecutor, and TaskUpdater components from the A2A SDK
- Understand the difference between Tasks and Messages in A2A protocol
- Run multiple Quarkus applications that communicate via A2A
- See the architectural trade-offs: lose Supervisor Pattern sophistication, gain distribution benefits
Note
At the moment the A2A integration is quite low-level and requires some boilerplate code. The Quarkus LangChain4j team is working on higher-level abstractions to simplify A2A usage in future releases.
Understanding the A2A Protocol
The Agent-to-Agent (A2A) protocol is an open protocol for AI agents to communicate across different systems and platforms.
Why A2A?
- Separation of concerns: Different teams can develop specialized agents independently
- Scalability: Distribute agent workload across multiple systems
- Reusability: One agent can serve multiple client applications
- Technology independence: Agents can be implemented in different languages/frameworks
A2A Architecture
graph LR
subgraph "Quarkus Runtime 1: Car Management System"
W[CarProcessingWorkflow]
DA["DispositionAgent<br/>@A2AClientAgent"]
W --> DA
end
subgraph "A2A Protocol Layer"
AP[JSON-RPC over HTTP]
end
subgraph "Quarkus Runtime 2: Disposition Service"
AC[AgentCard<br/>Agent Metadata]
AE[AgentExecutor<br/>Request Handler]
AI[DispositionAgent<br/>AI Service]
T[DispositionTool]
AC -.describes.-> AI
AE --> AI
AI --> T
end
DA -->|A2A Request| AP
AP -->|A2A Response| DA
AP <-->|JSON-RPC| AE
The Flow:
- Client agent (
DispositionAgentwith@A2AClientAgent) sends a request to the remote agent - A2A Protocol Layer (JSON-RPC) transports the request over HTTP
- AgentCard describes the remote agent’s capabilities (skills, inputs, outputs)
- AgentExecutor receives the request and orchestrates the execution
- Remote AI agent (
DispositionAgentAI service) processes the request using tools - Response flows back through the same path
Additional A2A Info
For more information about the A2A protocol and the actors involved, see the A2A documentation.
Understanding Tasks vs. Messages
The A2A protocol distinguishes between two types of interactions:
| Concept | Description | Use Case |
|---|---|---|
| Task | A long-running job with a defined goal and tracked state | “Determine if this car should be scrapped” |
| Message | A single conversational exchange with no tracked state | Chat messages, quick questions |
In this step, we’ll use Tasks because car disposition analysis is a discrete job with a clear objective.
Task Lifecycle:
sequenceDiagram
participant Client as Client Agent
participant Server as A2A Server
participant Executor as AgentExecutor
participant AI as AI Agent
Client->>Server: Create Task (POST /tasks)
Server->>Executor: Initialize TaskUpdater
Executor->>AI: Execute with input
AI->>AI: Process and use tools
AI->>Executor: Return result
Executor->>Server: Update task status
Server->>Client: Task result
What Are We Going to Build?

We’ll convert Step 4’s architecture to use remote agents:
- Keep DispositionFeedbackAgent: Still analyzes if a car should be disposed (same as Step 4)
- Convert DispositionAgent to A2A Client: Changes from local agent to remote A2A client
- Create Remote A2A Server: A separate Quarkus application exposing the disposition service
- Replace Supervisor with Conditional Workflow: Trade AI-driven orchestration for simpler rule-based routing (architectural trade-off for distribution)
The Complete Architecture:
graph TD
subgraph "Main Application (localhost:8080)"
R[Rental/Cleaning/Maintenance Returns]
FW[FeedbackWorkflow<br/>Parallel]
DFA[DispositionFeedbackAgent]
AW[CarAssignmentWorkflow<br/>Conditional]
DAC["DispositionAgent<br/>@A2AClientAgent"]
R --> FW
FW --> DFA
DFA --> AW
AW --> DAC
end
subgraph "Remote Disposition Service (localhost:8888)"
AC[AgentCard]
AE[AgentExecutor]
DAI[DispositionAgent<br/>AI Service]
DT[DispositionTool]
AE --> DAI
DAI --> DT
end
DAC -->|A2A Protocol| AE
Prerequisites
Before starting:
- Completed Step 04 - This step directly builds on Step 4’s disposition functionality
- Application from Step 04 is stopped (Ctrl+C)
- Ports 8080 and 8888 are available (you’ll run two applications simultaneously)
- Understanding of Step 4’s Supervisor Pattern (we’ll be replacing it with a simpler pattern)
Understanding the Project Structure
The Step 05 code includes two separate Quarkus applications:
section-2/step-05/
├── multi-agent-system/ # Main car management application (port 8080)
│ ├── src/main/java/com/carmanagement/
│ │ ├── agentic/
│ │ │ ├── agents/
│ │ │ │ ├── DispositionAgent.java # A2A client agent
│ │ │ │ └── DispositionFeedbackAgent.java # Analyzes disposal needs
│ │ │ └── workflow/
│ │ │ ├── FeedbackWorkflow.java # Parallel analysis
│ │ │ ├── CarAssignmentWorkflow.java # Conditional routing
│ │ │ └── CarProcessingWorkflow.java # Main orchestrator
│ └── pom.xml
│
└── remote-a2a-agent/ # Remote disposition service (port 8888)
├── src/main/java/com/demo/
│ ├── DispositionAgentCard.java # Describes agent capabilities
│ ├── DispositionAgentExecutor.java # Handles A2A requests
│ ├── DispositionAgent.java # AI service
│ └── DispositionTool.java # Tool for scrap/sell/donate
└── pom.xml
Why Two Applications?
- Simulates a real-world scenario where different teams maintain different agents
- The disposition service could be reused by multiple client applications
- Demonstrates cross-application agent communication via A2A
Warning: this chapter involves many steps
In order to build out the solution, you will need to go through quite a few steps. While it is entirely possible to make the code changes manually (or via copy/paste), we recommend starting fresh from Step 05 with the changes already applied. You will then be able to walk through this chapter and focus on the examples and suggested experiments at the end of this chapter.
Navigate to the complete section-2/step-05/multi-agent-system directory:
If you want to continue building on your previous code, place yourself at the root of your project and copy the updated files:
cp ../step-05/multi-agent-system/pom.xml ./pom.xml
cp ../step-05/multi-agent-system/src/main/java/com/carmanagement/model/CarInfo.java ./src/main/java/com/carmanagement/model/CarInfo.java
cp ../step-05/multi-agent-system/src/main/java/com/carmanagement/model/CarStatus.java ./src/main/java/com/carmanagement/model/CarStatus.java
cp ../step-05/multi-agent-system/src/main/resources/META-INF/resources/css/styles.css ./src/main/resources/META-INF/resources/css/styles.css
cp ../step-05/multi-agent-system/src/main/resources/META-INF/resources/js/app.js ./src/main/resources/META-INF/resources/js/app.js
cp ../step-05/multi-agent-system/src/main/resources/META-INF/resources/index.html ./src/main/resources/META-INF/resources/index.html
cp ../step-05/multi-agent-system/src/main/resources/import.sql ./src/main/resources/import.sql
copy ..\step-05\multi-agent-system\pom.xml .\pom.xml
copy ..\step-05\multi-agent-system\src\main\java\com\carmanagement\model\CarInfo.java .\src\main\java\com\carmanagement\model\CarInfo.java
copy ..\step-05\multi-agent-system\src\main\java\com\carmanagement\model\CarStatus.java .\src\main\java\com\carmanagement\model\CarStatus.java
copy ..\step-05\multi-agent-system\src\main\resources\META-INF\resources\css\styles.css .\src\main\resources\META-INF\resources\css\styles.css
copy ..\step-05\multi-agent-system\src\main\resources\META-INF\resources\js\app.js .\src\main\resources\META-INF\resources\js\app.js
copy ..\step-05\multi-agent-system\src\main\resources\META-INF\resources\index.html .\src\main\resources\META-INF\resources\index.html
copy ..\step-05\multi-agent-system\src\main\resources\import.sql .\src\main\resources\import.sql
Part 1: Build the Client-Side Components
Step 1: Update the DispositionFeedbackAgent
This agent is similar to Step 4’s version but with a simplified system message. In Step 4, we had more sophisticated keyword detection; here we simplify it for the A2A example.
In src/main/java/com/carmanagement/agentic/agents, update DispositionFeedbackAgent.java:
package com.carmanagement.agentic.agents;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
/**
* Agent that analyzes feedback to determine if a car should be disposed of.
*/
public interface DispositionFeedbackAgent {
@SystemMessage("""
You are a car disposition analyzer for a car rental company. Your job is to determine if a car should be disposed of based on feedback.
Analyze the maintenance feedback and car information to decide if the car should be scrapped, sold, or donated.
If the car is in decent shape, respond with "DISPOSITION_NOT_REQUIRED".
Include the reason for your choice but keep your response short.
""")
@UserMessage("""
Car Information:
Make: {carMake}
Model: {carModel}
Year: {carYear}
Previous Condition: {carCondition}
Feedback:
Rental Feedback: {rentalFeedback}
Cleaning Feedback: {cleaningFeedback}
Maintenance Feedback: {maintenanceFeedback}
""")
@Agent(outputKey="dispositionRequest", description="Car disposition analyzer. Using feedback, determines if a car should be disposed of.")
String analyzeForDisposition(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String rentalFeedback,
String cleaningFeedback,
String maintenanceFeedback);
}
Key Points:
- System message: Focuses on economic viability (is the car worth repairing?)
- Specific output format: Returns
"DISPOSITION_NOT_REQUIRED"when the car is repairable - outputKey:
"dispositionRequest"(stores the analysis in AgenticScope’s state) - Three feedback sources: Analyzes rental, cleaning, and maintenance feedback
Decision Criteria:
The agent considers:
- Severity of damage (structural, engine, transmission)
- Repair costs vs. car value
- Age and condition of the vehicle
- Safety concerns
Step 2: Convert the DispositionAgent to A2A Client
This is the key change from Step 4! Instead of a local agent with full system messages and logic, we now have a simple client that delegates to a remote service.
Step 4 Version (Local):
- Had detailed @SystemMessage with disposition criteria
- Received carValue parameter from PricingAgent
- Made decisions locally using AI
Step 5 Version (A2A Client):
- No @SystemMessage - just a client interface
- Uses @A2AClientAgent to connect to remote service
- Receives dispositionRequest instead of carValue
- Delegates all decision-making to the remote service
In src/main/java/com/carmanagement/agentic/agents, update DispositionAgent.java:
package com.carmanagement.agentic.agents;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.agentic.declarative.A2AClientAgent;
/**
* Agent that determines how to dispose of a car.
*/
public interface DispositionAgent {
@Agent(description = "Car disposition specialist. Determines how to dispose of a car.",
outputKey = "dispositionAction")
@A2AClientAgent(a2aServerUrl = "http://localhost:8888")
String processDisposition(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String dispositionRequest);
}
Let’s break it down:
@A2AClientAgent Annotation
This annotation transforms the method into an A2A client:
a2aServerUrl: The URL of the remote A2A server
The Method Signature
String processDisposition(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String dispositionRequest
)
These parameters are sent to the remote agent as task inputs. The remote agent can access them by name.
How It Works
-
When this method is called, Quarkus LangChain4j:
- Creates an A2A Task with the method parameters as inputs
- Sends the task to the remote server via JSON-RPC
- Waits for the remote agent to complete the task
- Returns the result as a String
-
No manual HTTP requests needed
- Type-safe: compile-time checking of parameters
- Automatic error handling and retries
Part 2: Update the Workflows
Step 3: Update FeedbackWorkflow
The FeedbackWorkflow is the same as Step 4 - it includes disposition analysis alongside cleaning and maintenance.
Update src/main/java/com/carmanagement/agentic/workflow/FeedbackWorkflow.java:
package com.carmanagement.agentic.workflow;
import com.carmanagement.agentic.agents.CleaningFeedbackAgent;
import com.carmanagement.agentic.agents.DispositionFeedbackAgent;
import com.carmanagement.agentic.agents.MaintenanceFeedbackAgent;
import dev.langchain4j.agentic.declarative.ParallelAgent;
/**
* Workflow for processing car feedback in parallel.
*/
public interface FeedbackWorkflow {
/**
* Runs multiple feedback agents in parallel to analyze different aspects of car feedback.
*/
@ParallelAgent(outputKey = "feedbackResult",
subAgents = { CleaningFeedbackAgent.class, MaintenanceFeedbackAgent.class, DispositionFeedbackAgent.class })
String analyzeFeedback(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String rentalFeedback,
String cleaningFeedback,
String maintenanceFeedback);
}
Key changes:
Added DispositionFeedbackAgent to the parallel workflow:
@ParallelAgent(outputKey = "feedbackResult",
subAgents = { CleaningFeedbackAgent.class, MaintenanceFeedbackAgent.class, DispositionFeedbackAgent.class })
Now three agents run concurrently:
CleaningFeedbackAgent— analyzes cleaning needsMaintenanceFeedbackAgent— analyzes maintenance needsDispositionFeedbackAgent— analyzes disposal needs
This parallel execution is efficient: all three analyses happen at the same time!
Step 4: Create CarAssignmentWorkflow
Key Architectural Change from Step 4:
In Step 4, we used FleetSupervisorAgent with @SupervisorAgent - an AI-driven orchestrator that made intelligent decisions about which agents to invoke.
In Step 5, we’re replacing it with CarAssignmentWorkflow using @ConditionalAgent - a simpler, rule-based router.
Why the change? - Trade-off for distribution: The Supervisor Pattern requires all sub-agents to be local. To use remote agents via A2A, we need a simpler routing mechanism. - Simpler but less flexible: Conditional routing uses hardcoded rules instead of AI reasoning. - Still effective: For this use case, rule-based routing works well.
Create src/main/java/com/carmanagement/agentic/workflow/CarAssignmentWorkflow.java:
package com.carmanagement.agentic.workflow;
import com.carmanagement.agentic.agents.CleaningAgent;
import com.carmanagement.agentic.agents.DispositionAgent;
import com.carmanagement.agentic.agents.MaintenanceAgent;
import dev.langchain4j.agentic.declarative.ActivationCondition;
import dev.langchain4j.agentic.declarative.ConditionalAgent;
/**
* Workflow for assigning cars to appropriate teams based on feedback analysis.
*/
public interface CarAssignmentWorkflow {
/**
* Assigns the car to the appropriate team based on the feedback analysis.
*/
@ConditionalAgent(outputKey = "analysisResult",
subAgents = { DispositionAgent.class, MaintenanceAgent.class, CleaningAgent.class })
String processAction(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String cleaningRequest,
String maintenanceRequest,
String dispositionRequest);
@ActivationCondition(MaintenanceAgent.class)
static boolean assignToMaintenance(String maintenanceRequest) {
return isRequired(maintenanceRequest);
}
@ActivationCondition(CleaningAgent.class)
static boolean assignToCleaning(String cleaningRequest) {
return isRequired(cleaningRequest);
}
@ActivationCondition(DispositionAgent.class)
static boolean assignToDisposition(String dispositionRequest) {
return isRequired(dispositionRequest);
}
private static boolean isRequired(String value) {
return value != null && !value.isEmpty() && !value.toUpperCase().contains("NOT_REQUIRED");
}
}
Key changes:
Added DispositionAgent to SubAgents
@ConditionalAgent(outputKey = "analysisResult",
subAgents = { DispositionAgent.class, MaintenanceAgent.class, CleaningAgent.class })
Added Activation Condition
@ActivationCondition(DispositionAgent.class)
static boolean assignToDisposition(String dispositionRequest) {
return isRequired(dispositionRequest);
}
Updated Execution Priority
The conditional workflow now has priority ordering:
- Disposition (highest priority) — if disposal is needed
- Maintenance — if maintenance is needed and disposal isn’t
- Cleaning — if cleaning is needed and neither disposal nor maintenance is
- Skip — if nothing is needed
This ensures critical issues (disposal) are handled before routine tasks (cleaning).
Step 5: Update CarProcessingWorkflow
Change from Step 4:
Step 4 used: FeedbackWorkflow → FleetSupervisorAgent → CarConditionFeedbackAgent
Step 5 uses: FeedbackWorkflow → CarAssignmentWorkflow → CarConditionFeedbackAgent
We’ve replaced the AI-driven supervisor with rule-based conditional routing.
Update src/main/java/com/carmanagement/agentic/workflow/CarProcessingWorkflow.java:
package com.carmanagement.agentic.workflow;
import com.carmanagement.agentic.agents.CarConditionFeedbackAgent;
import com.carmanagement.model.CarConditions;
import com.carmanagement.model.CarAssignment;
import dev.langchain4j.agentic.declarative.Output;
import dev.langchain4j.agentic.declarative.SequenceAgent;
/**
* Workflow for processing car returns using a sequence of agents.
*/
public interface CarProcessingWorkflow {
/**
* Processes a car return by running feedback analysis and then appropriate actions.
*/
@SequenceAgent(outputKey = "carProcessingAgentResult",
subAgents = { FeedbackWorkflow.class, CarAssignmentWorkflow.class, CarConditionFeedbackAgent.class })
CarConditions processCarReturn(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String rentalFeedback,
String cleaningFeedback,
String maintenanceFeedback);
@Output
static CarConditions output(String carCondition, String dispositionRequest, String maintenanceRequest, String cleaningRequest) {
CarAssignment carAssignment;
// Check maintenance first (higher priority)
if (isRequired(dispositionRequest)) {
carAssignment = CarAssignment.DISPOSITION;
} else if (isRequired(maintenanceRequest)) {
carAssignment = CarAssignment.MAINTENANCE;
} else if (isRequired(cleaningRequest)) {
carAssignment = CarAssignment.CLEANING;
} else {
carAssignment = CarAssignment.NONE;
}
return new CarConditions(carCondition, carAssignment);
}
private static boolean isRequired(String value) {
return value != null && !value.isEmpty() && !value.toUpperCase().contains("NOT_REQUIRED");
}
}
Key changes:
The @Output method now checks for disposition requests first:
@Output
static CarConditions output(String carCondition, String dispositionRequest, String maintenanceRequest, String cleaningRequest) {
CarAssignment carAssignment;
// Check maintenance first (higher priority)
if (isRequired(dispositionRequest)) { // Highest priority
carAssignment = CarAssignment.DISPOSITION;
} else if (isRequired(maintenanceRequest)) {
carAssignment = CarAssignment.MAINTENANCE;
} else if (isRequired(cleaningRequest)) {
carAssignment = CarAssignment.CLEANING;
} else {
carAssignment = CarAssignment.NONE;
}
return new CarConditions(carCondition, carAssignment);
}
Disposition has the highest priority in the result.
Step 6: Update CarAssignment Enum
Update the CarAssignment enum to include disposition:
Update src/main/java/com/carmanagement/model/CarAssignment.java:
package com.carmanagement.model;
/**
* Enum representing the type of possible car assignments for car processing
*/
public enum CarAssignment {
DISPOSITION,
MAINTENANCE,
CLEANING,
NONE
}
Step 7: Update CarManagementService
Update the service to handle disposition status:
Update src/main/java/com/carmanagement/service/CarManagementService.java:
package com.carmanagement.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import com.carmanagement.agentic.workflow.CarProcessingWorkflow;
import com.carmanagement.model.CarConditions;
import com.carmanagement.model.CarInfo;
import com.carmanagement.model.CarStatus;
/**
* Service for managing car returns from various operations.
*/
@ApplicationScoped
public class CarManagementService {
@Inject
CarProcessingWorkflow carProcessingWorkflow;
/**
* Process a car return from any operation.
*
* @param carNumber The car number
* @param rentalFeedback Optional rental feedback
* @param cleaningFeedback Optional cleaning feedback
* @param maintenanceFeedback Optional maintenance feedback
* @return Result of the processing
*/
@Transactional
public String processCarReturn(Long carNumber, String rentalFeedback, String cleaningFeedback, String maintenanceFeedback) {
CarInfo carInfo = CarInfo.findById(carNumber);
if (carInfo == null) {
return "Car not found with number: " + carNumber;
}
// Process the car return using the workflow and get the AgenticScope
CarConditions carConditions = carProcessingWorkflow.processCarReturn(
carInfo.make,
carInfo.model,
carInfo.year,
carNumber,
carInfo.condition,
rentalFeedback != null ? rentalFeedback : "",
cleaningFeedback != null ? cleaningFeedback : "",
maintenanceFeedback != null ? maintenanceFeedback : "");
// Update the car's condition with the result from CarConditionFeedbackAgent
carInfo.condition = carConditions.generalCondition();
// Update the car status based on the required action
switch (carConditions.carAssignment()) {
case DISPOSITION:
carInfo.status = CarStatus.PENDING_DISPOSITION;
break;
case MAINTENANCE:
carInfo.status = CarStatus.IN_MAINTENANCE;
break;
case CLEANING:
carInfo.status = CarStatus.AT_CLEANING;
break;
case NONE:
carInfo.status = CarStatus.AVAILABLE;
break;
}
// Persist the changes to the database
carInfo.persist();
return carConditions.generalCondition();
}
}
Key changes:
Added handling for CarAssignment.DISPOSITION:
} else if (carConditions.carAssignment() == CarAssignment.DISPOSITION) {
carInfo.status = CarStatus.PENDING_DISPOSITION;
}
When disposition is required, the car is marked as disposed and removed from the active fleet.
Part 3: Build the Remote A2A Server
Now let’s build the remote disposition service that will handle A2A requests.
Navigate to the remote-a2a-agent directory:
Step 8: Create the DispositionTool
The tool that executes disposition actions (scrap, sell, donate).
In src/main/java/com/demo, create DispositionTool.java:
package com.demo;
import dev.langchain4j.agent.tool.Tool;
import jakarta.inject.Singleton;
/**
* Tool for requesting car disposition operations.
* This tool is used by the LLM to determine the appropriate disposition for a car.
*/
@Singleton
public class DispositionTool {
/**
* Enum representing the possible disposition options for a car.
*/
public enum DispositionOption {
SCRAP("Scrap the car"),
SELL("Sell the car"),
DONATE("Donate the car");
private final String description;
DispositionOption(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
/**
* Requests disposition for a car based on the provided parameters.
*
* @param carNumber The car number
* @param carMake The car make
* @param carModel The car model
* @param carYear The car year
* @param dispositionOption The disposition option (SCRAP, SELL, or DONATE)
* @param carCondition The condition of the car
* @return A summary of the disposition request
*/
@Tool(name = "DispositionTool")
public String requestDisposition(
Long carNumber,
String carMake,
String carModel,
Integer carYear,
DispositionOption dispositionOption,
String carCondition) {
// In a real implementation, this would make an API call to a disposition service
// or update a database with the disposition request
String result = "Car disposition requested for " + carMake + " " +
carModel + " (" + carYear + "), Car #" +
carNumber + ": " +
dispositionOption.getDescription() +
"\n";
System.out.println("⛍ DispositionTool result: " + result);
return result;
}
}
Key Points:
- One method:
requestDisposition - @Tool annotation: Makes each method available to the AI agent
- Detailed descriptions: Help the AI agent choose the appropriate action
Step 9: Create the DispositionAgent (AI Service)
The AI agent that actually makes disposition decisions.
In src/main/java/com/demo, create DispositionAgent.java:
package com.demo;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
import io.quarkiverse.langchain4j.ToolBox;
import jakarta.enterprise.context.ApplicationScoped;
/**
* Agent that determines how to dispose of a car.
*/
@RegisterAiService
@ApplicationScoped
public interface DispositionAgent {
@SystemMessage("""
/nothink, Reasoning: low.
You handle intake for the car disposition department.
It is your job to submit a request to the provided DispositionTool function to take action on the request (SCRAP, SELL, or DONATE).
Be specific about what disposition option is most appropriate based on the car's condition.
""")
@UserMessage("""
Car Information:
Make: {carMake}
Model: {carModel}
Year: {carYear}
Car Number: {carNumber}
Previous Car Condition:
{carCondition}
Disposition Request:
{dispositionRequest}
""")
@ToolBox(DispositionTool.class)
String processDisposition(
String carMake,
String carModel,
Integer carYear,
Long carNumber,
String carCondition,
String dispositionRequest);
}
Key Points:
@RegisterAiService: Registers this as an AI service (not an agentic agent)@ToolBox(DispositionTool.class): Has access to the DispositionTool- System message: Defines the agent as a car disposition specialist
- Decision criteria: Considers condition, age, safety, and recommendation from the feedback agent
AI Service vs. Agentic Agent
Notice this is a traditional AI service (from Section 1), not an agentic workflow. The A2A server can expose both types.
Step 10: Create the AgentCard
The AgentCard describes the agent’s capabilities, skills, and interface.
In src/main/java/com/demo, create DispositionAgentCard.java:
package com.demo;
import java.util.List;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import io.a2a.server.PublicAgentCard;
import io.a2a.spec.AgentCapabilities;
import io.a2a.spec.AgentCard;
import io.a2a.spec.AgentInterface;
import io.a2a.spec.AgentSkill;
import io.a2a.spec.TransportProtocol;
@ApplicationScoped
public class DispositionAgentCard {
@Produces
@PublicAgentCard
public AgentCard agentCard() {
return new AgentCard.Builder()
.name("Disposition Agent")
.description("Determines how a car should be disposed of based on the car condition and disposition request.")
.url("http://localhost:8888/")
.version("1.0.0")
.protocolVersion("1.0.0")
.capabilities(new AgentCapabilities.Builder()
.streaming(true)
.pushNotifications(false)
.stateTransitionHistory(false)
.build())
.defaultInputModes(List.of("text"))
.defaultOutputModes(List.of("text"))
.skills(List.of(new AgentSkill.Builder()
.id("disposition")
.name("Car disposition")
.description("Makes a request to dispose of a car (SCRAP, SELL, or DONATE)")
.tags(List.of("disposition"))
.build()))
.preferredTransport(TransportProtocol.JSONRPC.asString())
.additionalInterfaces(List.of(
new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:8888")))
.build();
}
}
Let’s break it down:
@PublicAgentCard Annotation
This makes the AgentCard available at the /card endpoint.
Clients can query this endpoint to discover the agent’s capabilities.
AgentCard Components
Basic Information:
.name("Disposition Agent")
.description("Determines how a car should be disposed of based on the car condition and disposition request.")
.url("http://localhost:8888/")
.version("1.0.0")
Capabilities:
.capabilities(new AgentCapabilities.Builder()
.streaming(true)
.pushNotifications(false)
.stateTransitionHistory(false)
.build())
Skills:
.skills(List.of(new AgentSkill.Builder()
.id("disposition")
.name("Car disposition")
.description("Makes a request to dispose of a car (SCRAP, SELL, or DONATE)")
.tags(List.of("disposition"))
.build()))
Skills describe what the agent can do. This helps clients discover appropriate agents for their needs.
Transport Protocol:
.preferredTransport(TransportProtocol.JSONRPC.asString())
.additionalInterfaces(List.of(
new AgentInterface(TransportProtocol.JSONRPC.asString(), "http://localhost:8888")))
Specifies that this agent communicates via JSON-RPC over HTTP.
Step 11: Create the AgentExecutor
The AgentExecutor handles incoming A2A requests and orchestrates the AI agent.
In src/main/java/com/demo, create DispositionAgentExecutor.java:
package com.demo;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import io.a2a.server.agentexecution.AgentExecutor;
import io.a2a.server.agentexecution.RequestContext;
import io.a2a.server.events.EventQueue;
import io.a2a.server.tasks.TaskUpdater;
import java.util.ArrayList;
import java.util.List;
import io.a2a.spec.JSONRPCError;
import io.a2a.spec.Message;
import io.a2a.spec.Part;
import io.a2a.spec.TextPart;
import io.a2a.spec.UnsupportedOperationError;
/**
* Executor for the DispositionAgent.
* Handles the integration between the A2A framework and the DispositionAgent.
*/
@ApplicationScoped
public class DispositionAgentExecutor {
@Inject
DispositionAgent dispositionAgent;
@Inject
DispositionTool dispositionTool;
@Produces
public AgentExecutor agentExecutor(DispositionAgent dispositionAgent) {
return new AgentExecutor() {
@Override
public void execute(RequestContext context, EventQueue eventQueue) throws JSONRPCError {
TaskUpdater updater = new TaskUpdater(context, eventQueue);
if (context.getTask() == null) {
updater.submit();
}
updater.startWork();
List<String> inputs = new ArrayList<>();
// Process the request message
Message message = context.getMessage();
if (message.getParts() != null) {
for (Part<?> part : message.getParts()) {
if (part instanceof TextPart textPart) {
System.out.println("\uD83D\uDCAC Text part: " + textPart.getText());
inputs.add(textPart.getText());
}
}
}
// Call the agent with all parameters as strings
String agentResponse = dispositionAgent.processDisposition(
inputs.get(0), // carMake
inputs.get(1), // carModel
Integer.parseInt(inputs.get(2)), // carYear
Long.parseLong(inputs.get(3)), // carNumber
inputs.get(4), // carCondition
inputs.get(5)); // dispositionRequest
// Return the result
TextPart responsePart = new TextPart(agentResponse, null);
List<Part<?>> parts = List.of(responsePart);
updater.addArtifact(parts, null, null, null);
updater.complete();
}
@Override
public void cancel(RequestContext context, EventQueue eventQueue) throws JSONRPCError {
throw new UnsupportedOperationError();
}
};
}
}
Let’s break it down:
CDI Bean with AgentExecutor Factory
@ApplicationScoped
public class DispositionAgentExecutor {
@Produces
public AgentExecutor agentExecutor(DispositionAgent dispositionAgent)
Produces an AgentExecutor bean that Quarkus LangChain4j will use to handle A2A task requests.
Task Processing
public void execute(RequestContext context, EventQueue eventQueue) {
TaskUpdater updater = new TaskUpdater(context, eventQueue);
// Extract input parts from the task
Map<String, MessagePart> inputParts = context.task().input();
The RequestContext contains the incoming task with all input parameters sent by the client.
Extract Parameters
String carMake = getTextPart(inputParts, "carMake");
String carModel = getTextPart(inputParts, "carModel");
Integer carYear = getIntegerPart(inputParts, "carYear");
// ... etc
Extracts each parameter by name from the task input. These names must match the client agent’s method parameters.
Call the AI Agent
String result = dispositionAgent.processDisposition(
carMake, carModel, carYear, carNumber, carCondition, dispositionRequest
);
Invokes the AI agent to process the disposition request.
Update Task Status
Sends the result back to the client via the TaskUpdater. This completes the A2A task.
Helper Methods
private String getTextPart(Map<String, MessagePart> parts, String key) {
MessagePart part = parts.get(key);
return part != null ? part.content() : "";
}
Safely extracts text values from MessagePart objects.
Try It Out
You’ll need to run two applications simultaneously.
Terminal 1: Start the Remote A2A Server
Wait for:
The disposition service is now running and ready to accept A2A requests!
Terminal 2: Start the Main Application
Open a new terminal and run:
Wait for:
Test the Complete Flow
Open your browser to http://localhost:8080.
You’ll see the same UI as Step 4 with the Returns and Dispositions section and the Dispositions tab (introduced in Step 4).

On the Maintenance Return tab, enter feedback indicating severe damage for the Ford F-150:
Click Return.
What happens?
-
Parallel Analysis (FeedbackWorkflow):
DispositionFeedbackAgent: “Disposition required — severe damage”MaintenanceFeedbackAgent: “Major repairs needed”CleaningFeedbackAgent: “Not applicable”
-
Conditional Routing (CarAssignmentWorkflow):
- Disposition condition:
true(required) - → Executes
DispositionAgent(A2A client)
- Disposition condition:
-
A2A Communication:
- Client sends task to
http://localhost:8888 AgentExecutorreceives and processes taskDispositionAgent(AI service) analyzes usingDispositionTool- Result flows back to client
- Client sends task to
-
UI Update:
- Car status →
DISPOSED - Car appears in the Dispositions tab
- Car status →
Check the Logs
Terminal 1 (Remote A2A Server):
Terminal 2 (Main Application):
[DispositionFeedbackAgent] DISPOSITION_REQUIRED - Severe structural damage, uneconomical to repair
[CarAssignmentWorkflow] Activating DispositionAgent
[DispositionAgent @A2AClientAgent] Sending task to http://localhost:8888
[DispositionAgent @A2AClientAgent] Received result: Car should be scrapped...
Notice the cross-application communication via A2A!
How It All Works Together
Let’s trace the complete flow:
sequenceDiagram
participant User
participant Service as CarManagementService
participant Workflow as CarProcessingWorkflow
participant FeedbackWF as FeedbackWorkflow
participant ActionWF as CarAssignmentWorkflow
participant Client as DispositionAgent<br/>@A2AClientAgent
participant A2A as A2A Protocol<br/>(JSON-RPC)
participant Executor as AgentExecutor
participant Remote as DispositionAgent<br/>AI Service
participant Tool as DispositionTool
User->>Service: Return car with severe damage
Service->>Workflow: processCarReturn(...)
rect rgb(255, 243, 205)
Note over Workflow,FeedbackWF: Parallel Analysis
Workflow->>FeedbackWF: Execute
par Concurrent Execution
FeedbackWF->>FeedbackWF: CleaningFeedbackAgent
and
FeedbackWF->>FeedbackWF: MaintenanceFeedbackAgent
and
FeedbackWF->>FeedbackWF: DispositionFeedbackAgent<br/>Result: "DISPOSITION_REQUIRED"
end
end
rect rgb(248, 215, 218)
Note over Workflow,ActionWF: Conditional Routing
Workflow->>ActionWF: Execute
ActionWF->>ActionWF: Check: dispositionRequest required? YES
ActionWF->>Client: Execute DispositionAgent
end
rect rgb(212, 237, 218)
Note over Client,Tool: A2A Communication
Client->>A2A: Create Task with inputs
A2A->>Executor: POST /tasks
Executor->>Remote: processDisposition(...)
Remote->>Tool: scrapCar() / sellCar() / donateCar()
Tool->>Tool: Execute disposal action
Tool->>Remote: Return result
Remote->>Executor: Return recommendation
Executor->>A2A: Update task status
A2A->>Client: Return result
end
Client->>Workflow: Return disposition result
Workflow->>Service: Return CarConditions
Service->>Service: Set status to DISPOSED
Service->>User: Update UI
Understanding the A2A Implementation
Client Side (@A2AClientAgent)
The client agent is remarkably simple:
Quarkus LangChain4j handles:
- Creating the A2A task
- Serializing method parameters as task inputs
- Sending the HTTP request via JSON-RPC
- Waiting for the response
- Deserializing the result
- Error handling and retries
Server Side (AgentCard + AgentExecutor)
The server requires more components:
| Component | Purpose |
|---|---|
| AgentCard | Describes agent capabilities, published at /card endpoint |
| AgentExecutor | Receives and processes A2A task requests |
| TaskUpdater | Updates task status and sends results back to client |
| AI Agent | The actual AI service that processes requests |
| Tools | Actions the AI agent can perform |
This separation allows: - Agents to focus on business logic - A2A infrastructure to handle protocol details - Multiple agents to be exposed from one server
Key Takeaways
- A2A enables distributed agents: Different teams can maintain specialized agents in separate systems
@A2AClientAgentis powerful: Simple annotation transforms a method into an A2A client- AgentCard describes capabilities: Clients can discover what remote agents can do
- AgentExecutor handles protocol: Separates A2A infrastructure from agent logic
- Tasks vs. Messages: A2A supports both task-based and conversational interactions
- Type-safe integration: Method parameters automatically become task inputs
- Remote agents integrate seamlessly: Works with existing workflows and agents
- Two runtimes communicate: Real-world simulation of distributed agent systems
- Architectural trade-offs: We traded Step 4’s sophisticated Supervisor Pattern for simpler Conditional routing to enable distribution
- Distribution benefits: The disposition service can now be maintained independently, scaled separately, and reused by other applications
Experiment Further
1. Add Agent Discovery
The AgentCard is published at http://localhost:8888/card. Try:
You’ll see the full agent description including skills, capabilities, and transport protocols.
2. Test Different Disposition Scenarios
Try these feedback examples:
Scenario 1: Sell the car
Scenario 2: Donate the car
Scenario 3: Scrap the car
Observe how the remote agent makes different decisions!
3. Create Your Own A2A Agent
What other specialized agents could be useful?
- Pricing Agent: Determines optimal rental pricing based on demand
- Route Planner Agent: Plans maintenance schedules for the fleet
- Insurance Agent: Assesses insurance claims for damaged cars
Try creating a simple A2A server for one of these!
4. Monitor A2A Communication
Add logging to see the JSON-RPC messages:
This shows the raw A2A protocol messages.
Troubleshooting
Connection refused to localhost:8888
Make sure the remote A2A server is running in Terminal 1. Check for:
If you see “Port already in use”, another application is using port 8888. You can change it in remote-a2a-agent/src/main/resources/application.properties:
Then update the client’s a2aServerUrl accordingly.
Task execution timeout
If the remote agent takes too long to respond, you might see a timeout error. The default timeout is sufficient for most cases, but you can increase it if needed by configuring the A2A client.
Parameter mismatch errors
If you see errors about missing parameters, verify that:
- Client agent method parameter names match what AgentExecutor extracts
- The
getTextPart()/getIntegerPart()calls use the correct keys - All required parameters are being sent by the client
Agent not activating
If the DispositionAgent never executes, check:
- The
@ActivationConditionmethod is correctly implemented - The
dispositionRequestcontains"DISPOSITION_REQUIRED" - The condition is being checked in the correct order
Both applications on same port
If you see “Port already in use” on 8080:
- Make sure you stopped the application from Step 04
- Only run the main application from
multi-agent-system, not from a previous step directory - Check for zombie Java processes:
ps aux | grep java
What’s Next?
You’ve successfully converted Step 4’s local disposition system into a distributed agent system using the A2A protocol!
You learned how to:
- Convert local agents to remote A2A services
- Connect to remote agents using
@A2AClientAgent - Build A2A servers with AgentCard and AgentExecutor
- Integrate remote agents into complex workflows
- Run multiple Quarkus applications that communicate via A2A
- Understand the architectural trade-offs between local and distributed agents
Key Progression: - Step 4: Sophisticated local orchestration with Supervisor Pattern - Step 5: Distributed architecture with A2A protocol
This completes Section 2: Agentic Systems! You’ve progressed from simple agents to complex distributed workflows with remote agent communication.
Congratulations! You now have the skills to build sophisticated multi-agent systems with Quarkus LangChain4j!