diff --git a/spring-ai-modules/pom.xml b/spring-ai-modules/pom.xml index a632d87ad676..4c61c0f18bc2 100644 --- a/spring-ai-modules/pom.xml +++ b/spring-ai-modules/pom.xml @@ -20,6 +20,7 @@ spring-ai-2 spring-ai-3 spring-ai-4 + spring-ai-a2a spring-ai-agent-skills spring-ai-agentic-patterns spring-ai-anthropic-agent-skills diff --git a/spring-ai-modules/spring-ai-a2a/pom.xml b/spring-ai-modules/spring-ai-a2a/pom.xml new file mode 100644 index 000000000000..3a3110efa429 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/pom.xml @@ -0,0 +1,91 @@ + + + 4.0.0 + + + com.baeldung + spring-ai-modules + 0.0.1 + ../pom.xml + + + com.baeldung + spring-ai-a2a + 0.0.1 + spring-ai-a2a + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.ai + spring-ai-starter-model-openai + ${spring-ai.version} + + + org.springaicommunity + spring-ai-a2a-server-autoconfigure + ${spring-ai-a2a-server-config.version} + + + io.github.a2asdk + a2a-java-sdk-client + ${a2a-client.version} + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + 21 + 6.0.3 + 4.0.6 + 2.0.0 + 0.3.0 + 0.3.3.Final + + + + + skills-matcher-server + + true + + + com.baeldung.a2a.server.skillsmatcher.SkillsMatcherServer + + + + salary-evaluator-server + + com.baeldung.a2a.server.salaryevaluator.SalaryEvaluatorServer + + + + job-screening-orchestrator-client + + com.baeldung.a2a.client.orchestrator.jobscreening.JobScreeningOrchestrator + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + ${spring.boot.mainclass} + + + + + + \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/AgentRegistry.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/AgentRegistry.java new file mode 100644 index 000000000000..17a7bb3c3348 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/AgentRegistry.java @@ -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 agentCards = new HashMap<>(); + + AgentRegistry(@Value("${remote.agents.urls}") List 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")); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/ChatClientConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/ChatClientConfiguration.java new file mode 100644 index 000000000000..f45cc5d465af --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/ChatClientConfiguration.java @@ -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(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningController.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningController.java new file mode 100644 index 000000000000..34597fd6cd5a --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningController.java @@ -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 + ) {} +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningOrchestrator.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningOrchestrator.java new file mode 100644 index 000000000000..31a9c9a855c4 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningOrchestrator.java @@ -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); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/RemoteAgentClient.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/RemoteAgentClient.java new file mode 100644 index 000000000000..f7a5d139f407 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/RemoteAgentClient.java @@ -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 response = new CompletableFuture<>(); + BiConsumer 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> parts) { + return parts + .stream() + .filter(TextPart.class::isInstance) + .map(TextPart.class::cast) + .map(TextPart::getText) + .collect(Collectors.joining("\n")); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/RemoteAgentTools.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/RemoteAgentTools.java new file mode 100644 index 000000000000..3fe53ae923a1 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/client/orchestrator/jobscreening/RemoteAgentTools.java @@ -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); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/BackgroundCheckerServer.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/BackgroundCheckerServer.java new file mode 100644 index 000000000000..27302c37da0d --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/BackgroundCheckerServer.java @@ -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); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/BackgroundCheckerTools.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/BackgroundCheckerTools.java new file mode 100644 index 000000000000..06cd4c9021b2 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/BackgroundCheckerTools.java @@ -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 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 + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/ChatClientConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/ChatClientConfiguration.java new file mode 100644 index 000000000000..f4612c4c9a51 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/ChatClientConfiguration.java @@ -0,0 +1,24 @@ +package com.baeldung.a2a.server.backgroundchecker; + +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, + BackgroundCheckerTools backgroundCheckerTools + ) { + return chatClientBuilder + .defaultSystem(""" + You are a background-check assistant for recruiters. + Use the check-background tool to run a background check on a + candidate using their name and email, then summarize the result. + """) + .defaultTools(backgroundCheckerTools) + .build(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/ServerConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/ServerConfiguration.java new file mode 100644 index 000000000000..cfd8a3f22a2e --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/backgroundchecker/ServerConfiguration.java @@ -0,0 +1,55 @@ +package com.baeldung.a2a.server.backgroundchecker; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentSkill; +import org.springaicommunity.a2a.server.executor.DefaultAgentExecutor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +class ServerConfiguration { + + @Bean + AgentExecutor agentExecutor(ChatClient chatClient) { + return new DefaultAgentExecutor(chatClient, (client, requestContext) -> { + String userMessage = DefaultAgentExecutor.extractTextFromMessage(requestContext.getMessage()); + return client + .prompt() + .user(userMessage) + .call() + .content(); + }); + } + + @Bean + AgentCard agentCard( + @Value("${server.host}") String host, + @Value("${server.port}") int port + ) { + return new AgentCard.Builder() + .name("Background Check Agent") + .description("Runs a background check on a candidate") + .url(String.format("http://%s:%d/", host, port)) + .version("1.0.0") + .capabilities(new AgentCapabilities + .Builder() + .streaming(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(new AgentSkill.Builder() + .id("background_check") + .name("Background Check") + .description("Runs a background check on a candidate using their name and email") + .tags(List.of("hiring", "recruiting", "compliance")) + .build())) + .protocolVersion("1.0.1") + .build(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/ChatClientConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/ChatClientConfiguration.java new file mode 100644 index 000000000000..12952b8617f8 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/ChatClientConfiguration.java @@ -0,0 +1,24 @@ +package com.baeldung.a2a.server.salaryevaluator; + +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, + SalaryEvaluatorTools salaryEvaluatorTools + ) { + return chatClientBuilder + .defaultSystem(""" + You are a salary-evaluation assistant for recruiters. + Use the evaluate-salary tool to compare a candidate's expected + salary against the job title they've applied for. + """) + .defaultTools(salaryEvaluatorTools) + .build(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/SalaryEvaluatorServer.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/SalaryEvaluatorServer.java new file mode 100644 index 000000000000..e80269affd57 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/SalaryEvaluatorServer.java @@ -0,0 +1,14 @@ +package com.baeldung.a2a.server.salaryevaluator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.PropertySource; + +@SpringBootApplication +@PropertySource("classpath:application-salary-evaluator-server.properties") +public class SalaryEvaluatorServer { + + public static void main(String[] args) { + SpringApplication.run(SalaryEvaluatorServer.class, args); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/SalaryEvaluatorTools.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/SalaryEvaluatorTools.java new file mode 100644 index 000000000000..15d201f167cc --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/SalaryEvaluatorTools.java @@ -0,0 +1,45 @@ +package com.baeldung.a2a.server.salaryevaluator; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Service; + +@Service +class SalaryEvaluatorTools { + + @Tool( + name = "evaluate-salary", + description = "Compares a candidate's expected salary for a given job title" + ) + SalaryEvaluationResult evaluateSalary( + @ToolParam(description = "The job title being applied for") String jobTitle, + @ToolParam(description = "Candidate's expected annual salary") int expectedSalary + ) { + SalaryRange salaryRange = budgetLookup(jobTitle); + Verdict verdict = expectedSalary > salaryRange.max() + ? Verdict.ABOVE_BUDGET + : expectedSalary < salaryRange.min() + ? Verdict.BELOW_BUDGET + : Verdict.WITHIN_BUDGET; + return new SalaryEvaluationResult(verdict); + } + + private SalaryRange budgetLookup(String jobTitle) { + return new SalaryRange(80000, 120000); + } + + record SalaryRange( + int min, + int max + ) {} + + record SalaryEvaluationResult( + Verdict verdict + ) {} + + enum Verdict { + WITHIN_BUDGET, + ABOVE_BUDGET, + BELOW_BUDGET + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/ServerConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/ServerConfiguration.java new file mode 100644 index 000000000000..04b5fab9f1d8 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/salaryevaluator/ServerConfiguration.java @@ -0,0 +1,55 @@ +package com.baeldung.a2a.server.salaryevaluator; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentSkill; +import org.springaicommunity.a2a.server.executor.DefaultAgentExecutor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +class ServerConfiguration { + + @Bean + AgentExecutor agentExecutor(ChatClient chatClient) { + return new DefaultAgentExecutor(chatClient, (client, requestContext) -> { + String userMessage = DefaultAgentExecutor.extractTextFromMessage(requestContext.getMessage()); + return client + .prompt() + .user(userMessage) + .call() + .content(); + }); + } + + @Bean + AgentCard agentCard( + @Value("${server.host}") String host, + @Value("${server.port}") int port + ) { + return new AgentCard.Builder() + .name("Salary Evaluator Agent") + .description("Checks if a candidate's expected salary fits a job title's budget range") + .url(String.format("http://%s:%d/", host, port)) + .version("1.0.0") + .capabilities(new AgentCapabilities + .Builder() + .streaming(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(new AgentSkill.Builder() + .id("salary_evaluation") + .name("Salary Evaluation") + .description("Compares a candidate's expected salary against a job title's budget range") + .tags(List.of("hiring", "recruiting", "compensation")) + .build())) + .protocolVersion("1.0.1") + .build(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/ChatClientConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/ChatClientConfiguration.java new file mode 100644 index 000000000000..c24d90000ce2 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/ChatClientConfiguration.java @@ -0,0 +1,24 @@ +package com.baeldung.a2a.server.skillsmatcher; + +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, + SkillsMatcherTools skillsMatcherTools + ) { + return chatClientBuilder + .defaultSystem(""" + You are a skills-matching assistant for recruiters. + Use the match-skills tool to compare a candidate's skills + against a job's required skills, then summarize the result. + """) + .defaultTools(skillsMatcherTools) + .build(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/ServerConfiguration.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/ServerConfiguration.java new file mode 100644 index 000000000000..60f8ae3e238e --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/ServerConfiguration.java @@ -0,0 +1,55 @@ +package com.baeldung.a2a.server.skillsmatcher; + +import io.a2a.server.agentexecution.AgentExecutor; +import io.a2a.spec.AgentCapabilities; +import io.a2a.spec.AgentCard; +import io.a2a.spec.AgentSkill; +import org.springaicommunity.a2a.server.executor.DefaultAgentExecutor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +class ServerConfiguration { + + @Bean + AgentExecutor agentExecutor(ChatClient chatClient) { + return new DefaultAgentExecutor(chatClient, (client, requestContext) -> { + String userMessage = DefaultAgentExecutor.extractTextFromMessage(requestContext.getMessage()); + return client + .prompt() + .user(userMessage) + .call() + .content(); + }); + } + + @Bean + AgentCard agentCard( + @Value("${server.host}") String host, + @Value("${server.port}") int port + ) { + return new AgentCard.Builder() + .name("Skills Matcher Agent") + .description("Evaluates how well a candidate's skills match a job's required skills") + .url(String.format("http://%s:%d/", host, port)) + .version("1.0.0") + .capabilities(new AgentCapabilities + .Builder() + .streaming(false) + .build()) + .defaultInputModes(List.of("text")) + .defaultOutputModes(List.of("text")) + .skills(List.of(new AgentSkill.Builder() + .id("skills_matching") + .name("Skills Matching") + .description("Compares candidate skills to job requirements and scores the fit") + .tags(List.of("hiring", "recruiting")) + .build())) + .protocolVersion("1.0.1") + .build(); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/SkillsMatcherServer.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/SkillsMatcherServer.java new file mode 100644 index 000000000000..351c30fed8af --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/SkillsMatcherServer.java @@ -0,0 +1,14 @@ +package com.baeldung.a2a.server.skillsmatcher; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.PropertySource; + +@SpringBootApplication +@PropertySource("classpath:application-skills-matcher-server.properties") +public class SkillsMatcherServer { + + public static void main(String[] args) { + SpringApplication.run(SkillsMatcherServer.class, args); + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/SkillsMatcherTools.java b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/SkillsMatcherTools.java new file mode 100644 index 000000000000..bbb2c0bee8f0 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/java/com/baeldung/a2a/server/skillsmatcher/SkillsMatcherTools.java @@ -0,0 +1,62 @@ +package com.baeldung.a2a.server.skillsmatcher; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +class SkillsMatcherTools { + + @Tool( + name = "match-skills", + description = "Compares a candidate's skills against a job's required skills and returns a fit score" + ) + SkillsMatchResult matchSkills( + @ToolParam(description = "Candidate skills, comma-separated") String candidateSkills, + @ToolParam(description = "Required job skills, comma-separated") String requiredSkills + ) { + Set candidateSkillSet = normalize(candidateSkills); + Set requiredSkillSet = normalize(requiredSkills); + + Set matchedSkillSet = new HashSet<>(candidateSkillSet); + matchedSkillSet.retainAll(requiredSkillSet); + + Set missingSkillSet = new HashSet<>(requiredSkillSet); + missingSkillSet.removeAll(candidateSkillSet); + + int score = requiredSkillSet.isEmpty() ? 0 : (matchedSkillSet.size() * 100) / requiredSkillSet.size(); + Verdict verdict = score >= 75 + ? Verdict.STRONG_MATCH + : score >= 40 + ? Verdict.PARTIAL_MATCH + : Verdict.WEAK_MATCH; + + return new SkillsMatchResult(score, verdict, matchedSkillSet, missingSkillSet); + } + + private Set normalize(String csv) { + return Arrays.stream(csv.split(",")) + .map(String::trim) + .map(String::toLowerCase) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toSet()); + } + + record SkillsMatchResult( + int score, + Verdict verdict, + Set matchedSkills, + Set missingSkills + ) {} + + enum Verdict { + STRONG_MATCH, + PARTIAL_MATCH, + WEAK_MATCH + } +} \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/resources/application-background-checker-server.properties b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-background-checker-server.properties new file mode 100644 index 000000000000..ea7410ff61c0 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-background-checker-server.properties @@ -0,0 +1,5 @@ +server.host=localhost +server.port=8083 + +spring.ai.openai.api-key=${OPENAI_API_KEY} +spring.ai.openai.chat.model=gpt-5.5 \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/resources/application-job-screening-orchestrator.properties b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-job-screening-orchestrator.properties new file mode 100644 index 000000000000..f242593dae60 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-job-screening-orchestrator.properties @@ -0,0 +1,6 @@ +server.port=8080 + +spring.ai.openai.api-key=${OPENAI_API_KEY} +spring.ai.openai.chat.model=gpt-5.5 + +remote.agents.urls=http://localhost:8081,http://localhost:8082,http://localhost:8083 \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/resources/application-salary-evaluator-server.properties b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-salary-evaluator-server.properties new file mode 100644 index 000000000000..b0019c806654 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-salary-evaluator-server.properties @@ -0,0 +1,5 @@ +server.host=localhost +server.port=8082 + +spring.ai.openai.api-key=${OPENAI_API_KEY} +spring.ai.openai.chat.model=gpt-5.5 \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/resources/application-skills-matcher-server.properties b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-skills-matcher-server.properties new file mode 100644 index 000000000000..0bbebb2388df --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/resources/application-skills-matcher-server.properties @@ -0,0 +1,5 @@ +server.host=localhost +server.port=8081 + +spring.ai.openai.api-key=${OPENAI_API_KEY} +spring.ai.openai.chat.model=gpt-5.5 \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/main/resources/logback-spring.xml b/spring-ai-modules/spring-ai-a2a/src/main/resources/logback-spring.xml new file mode 100644 index 000000000000..449efbdaebb0 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/main/resources/logback-spring.xml @@ -0,0 +1,15 @@ + + + + [%d{yyyy-MM-dd HH:mm:ss}] [%p] [%c{1}] - %m%n + + + + + + + + + + + \ No newline at end of file diff --git a/spring-ai-modules/spring-ai-a2a/src/test/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningLiveTest.java b/spring-ai-modules/spring-ai-a2a/src/test/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningLiveTest.java new file mode 100644 index 000000000000..776339419215 --- /dev/null +++ b/spring-ai-modules/spring-ai-a2a/src/test/java/com/baeldung/a2a/client/orchestrator/jobscreening/JobScreeningLiveTest.java @@ -0,0 +1,73 @@ +package com.baeldung.a2a.client.orchestrator.jobscreening; + +import com.baeldung.a2a.server.backgroundchecker.BackgroundCheckerServer; +import com.baeldung.a2a.server.salaryevaluator.SalaryEvaluatorServer; +import com.baeldung.a2a.server.skillsmatcher.SkillsMatcherServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = JobScreeningOrchestrator.class) +@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".*") +class JobScreeningLiveTest { + + private static ConfigurableApplicationContext skillsMatcherServerContext; + private static ConfigurableApplicationContext salaryEvaluatorServerContext; + private static ConfigurableApplicationContext backgroundCheckerServerContext; + + @Autowired + private AgentRegistry agentRegistry; + + @Autowired + private JobScreeningController jobScreeningController; + + @BeforeAll + static void startRemoteAgentServers() { + backgroundCheckerServerContext = SpringApplication.run(BackgroundCheckerServer.class); + salaryEvaluatorServerContext = SpringApplication.run(SalaryEvaluatorServer.class); + skillsMatcherServerContext = SpringApplication.run(SkillsMatcherServer.class); + } + + @AfterAll + static void stopRemoteAgentServers() { + backgroundCheckerServerContext.close(); + salaryEvaluatorServerContext.close(); + skillsMatcherServerContext.close(); + } + + @Test + void whenApplicationStarts_thenAllAgentCardsFetched() { + String agentDescriptions = agentRegistry.describeAgents(); + + assertThat(agentDescriptions) + .contains("Background Check Agent") + .contains("Salary Evaluator Agent") + .contains("Skills Matcher Agent"); + } + + @Test + void whenCandidateScreened_thenScreeningVerdictReturned() { + var screeningRequest = new JobScreeningController.ScreeningRequest( + "John Doe", + "john.doe@baeldung.com", + "Backend Developer", + "Java, Spring Boot, AWS, Kafka", + "Java, Spring Boot, Azure, Kafka", + 110000 + ); + + var screeningResponse = jobScreeningController.screenCandidate(screeningRequest); + + assertThat(screeningResponse.verdict()) + .isNotBlank() + .containsAnyOf("75%", "3 out of 4 skills") + .containsIgnoringCase("AWS"); + } +} \ No newline at end of file