Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spring-ai-modules/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<module>spring-ai-2</module>
<module>spring-ai-3</module>
<module>spring-ai-4</module>
<module>spring-ai-a2a</module>
<module>spring-ai-agent-skills</module>
<module>spring-ai-agentic-patterns</module>
<module>spring-ai-anthropic-agent-skills</module>
Expand Down
91 changes: 91 additions & 0 deletions spring-ai-modules/spring-ai-a2a/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.baeldung</groupId>
<artifactId>spring-ai-modules</artifactId>
<version>0.0.1</version>
<relativePath>../pom.xml</relativePath>
</parent>

<groupId>com.baeldung</groupId>
<artifactId>spring-ai-a2a</artifactId>
<version>0.0.1</version>
<name>spring-ai-a2a</name>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>spring-ai-a2a-server-autoconfigure</artifactId>
<version>${spring-ai-a2a-server-config.version}</version>
</dependency>
<dependency>
<groupId>io.github.a2asdk</groupId>
<artifactId>a2a-java-sdk-client</artifactId>
<version>${a2a-client.version}</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<properties>
<java.version>21</java.version>
<junit-jupiter.version>6.0.3</junit-jupiter.version>
<spring-boot.version>4.0.6</spring-boot.version>
<spring-ai.version>2.0.0</spring-ai.version>
<spring-ai-a2a-server-config.version>0.3.0</spring-ai-a2a-server-config.version>
<a2a-client.version>0.3.3.Final</a2a-client.version>
</properties>

<profiles>
<profile>
<id>skills-matcher-server</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<spring.boot.mainclass>com.baeldung.a2a.server.skillsmatcher.SkillsMatcherServer</spring.boot.mainclass>
</properties>
</profile>
<profile>
<id>salary-evaluator-server</id>
<properties>
<spring.boot.mainclass>com.baeldung.a2a.server.salaryevaluator.SalaryEvaluatorServer</spring.boot.mainclass>
</properties>
</profile>
<profile>
<id>job-screening-orchestrator-client</id>
<properties>
<spring.boot.mainclass>com.baeldung.a2a.client.orchestrator.jobscreening.JobScreeningOrchestrator</spring.boot.mainclass>
</properties>
</profile>
</profiles>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>${spring.boot.mainclass}</mainClass>
</configuration>
</plugin>
</plugins>
</build>

</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.baeldung.a2a.client.orchestrator.jobscreening;

import io.a2a.A2A;
import io.a2a.spec.AgentCard;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
class AgentRegistry {

private final Map<String, AgentCard> agentCards = new HashMap<>();

AgentRegistry(@Value("${remote.agents.urls}") List<String> agentUrls) throws URISyntaxException {
for (String url : agentUrls) {
String path = new URI(url).getPath();
AgentCard card = A2A.getAgentCard(url, path + ".well-known/agent-card.json", null);
agentCards.put(card.name(), card);
}
}

AgentCard get(String agentName) {
return agentCards.get(agentName);
}

String describeAgents() {
return agentCards
.values()
.stream()
.map(card -> "- " + card.name() + ": " + card.description())
.collect(Collectors.joining("\n"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.baeldung.a2a.client.orchestrator.jobscreening;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class ChatClientConfiguration {

@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder,
AgentRegistry agentRegistry,
RemoteAgentTools remoteAgentTools
) {
return chatClientBuilder
.defaultSystem("""
You are a job-screening orchestrator for recruiters.
You do not evaluate candidates yourself. Instead, you delegate
to the following remote agents:

%s

Once all agents have responded, combine their responses into a short screening summary.
""".formatted(agentRegistry.describeAgents()))
.defaultTools(remoteAgentTools)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.baeldung.a2a.client.orchestrator.jobscreening;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class JobScreeningController {

private final ChatClient chatClient;

JobScreeningController(ChatClient chatClient) {
this.chatClient = chatClient;
}

@PostMapping("/screenings")
ScreeningResponse screenCandidate(@RequestBody ScreeningRequest screeningRequest) {
String verdict = chatClient
.prompt()
.user(screeningRequest.toString())
.call()
.content();
return new ScreeningResponse(verdict);
}

record ScreeningRequest(
String name,
String email,
String jobTitle,
String requiredSkills,
String candidateSkills,
int expectedSalary
) {}

record ScreeningResponse(
String verdict
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.baeldung.a2a.client.orchestrator.jobscreening;

import org.springaicommunity.a2a.server.autoconfigure.A2AServerAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.PropertySource;

@SpringBootApplication(exclude = A2AServerAutoConfiguration.class)
@PropertySource("classpath:application-job-screening-orchestrator.properties")
class JobScreeningOrchestrator {

public static void main(String[] args) {
SpringApplication.run(JobScreeningOrchestrator.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.baeldung.a2a.client.orchestrator.jobscreening;

import io.a2a.A2A;
import io.a2a.client.Client;
import io.a2a.client.ClientEvent;
import io.a2a.client.TaskEvent;
import io.a2a.client.config.ClientConfig;
import io.a2a.client.transport.jsonrpc.JSONRPCTransport;
import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig;
import io.a2a.spec.AgentCard;
import io.a2a.spec.Artifact;
import io.a2a.spec.Message;
import io.a2a.spec.Part;
import io.a2a.spec.TextPart;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;

@Component
class RemoteAgentClient {

private final AgentRegistry agentRegistry;

RemoteAgentClient(AgentRegistry agentRegistry) {
this.agentRegistry = agentRegistry;
}

String sendMessage(String agentName, String task)
throws ExecutionException, InterruptedException, TimeoutException {
AgentCard agentCard = agentRegistry.get(agentName);

CompletableFuture<String> response = new CompletableFuture<>();
BiConsumer<ClientEvent, AgentCard> responseConsumer = (event, card) -> {
TaskEvent taskEvent = (TaskEvent) event;
response.complete(taskEvent.getTask()
.getArtifacts()
.stream()
.map(Artifact::parts)
.map(this::extractText)
.collect(Collectors.joining("\n")));
};

Client client = Client.builder(agentCard)
.clientConfig(new ClientConfig.Builder()
.setAcceptedOutputModes(List.of("text"))
.build())
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig())
.addConsumers(List.of(responseConsumer))
.streamingErrorHandler(response::completeExceptionally)
.build();

Message message = A2A.toUserMessage(task);
client.sendMessage(message);
return response.get(60, TimeUnit.SECONDS);
}

private String extractText(List<Part<?>> parts) {
return parts
.stream()
.filter(TextPart.class::isInstance)
.map(TextPart.class::cast)
.map(TextPart::getText)
.collect(Collectors.joining("\n"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.baeldung.a2a.client.orchestrator.jobscreening;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;

import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;

@Service
class RemoteAgentTools {

private final RemoteAgentClient remoteAgentClient;

RemoteAgentTools(RemoteAgentClient remoteAgentClient) {
this.remoteAgentClient = remoteAgentClient;
}

@Tool(
name = "send-message-to-agent",
description = "Sends a task to a remote agent and returns its response."
)
String sendMessageToAgent(
@ToolParam(description = "Name of the remote agent") String agentName,
@ToolParam(description = "The task to perform") String task
) throws ExecutionException, InterruptedException, TimeoutException {
return remoteAgentClient.sendMessage(agentName, task);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.baeldung.a2a.server.backgroundchecker;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.PropertySource;

@SpringBootApplication
@PropertySource("classpath:application-background-checker-server.properties")
public class BackgroundCheckerServer {

public static void main(String[] args) {
SpringApplication.run(BackgroundCheckerServer.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.baeldung.a2a.server.backgroundchecker;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Service;

import java.util.Set;

@Service
class BackgroundCheckerTools {

private static final Set<String> TRUSTED_EMAIL_DOMAINS = Set.of(
"gmail.com",
"baeldung.com"
);

@Tool(
name = "check-background",
description = "Runs a background check for a candidate"
)
BackgroundCheckResult checkBackground(
@ToolParam(description = "The candidate's full name") String candidateName,
@ToolParam(description = "The candidate's email address") String email
) {
String domain = extractDomain(email);
Verdict verdict = TRUSTED_EMAIL_DOMAINS.contains(domain)
? Verdict.CLEAR
: Verdict.NEEDS_REVIEW;
return new BackgroundCheckResult(verdict);
}

private String extractDomain(String email) {
int atIndex = email.lastIndexOf('@');
return atIndex < 0
? ""
: email.substring(atIndex + 1).trim().toLowerCase();
}

record BackgroundCheckResult(
Verdict verdict
) {}

enum Verdict {
CLEAR,
NEEDS_REVIEW
}
}
Loading