Skip to content

Commit e8fa5bc

Browse files
author
이채민
committed
feat: a2a-client 내 AI 의존성 추가 및 로직 구현
1 parent c5c03a0 commit e8fa5bc

11 files changed

Lines changed: 413 additions & 87 deletions

File tree

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Spring Boot samples using the Agent2Agent (A2A) Protocol
77
|------|------|------|
88
| **a2a-server/a2a-order-server** | 8082 | 주문 취소 에이전트 (ORD-* 처리) |
99
| **a2a-server/a2a-delivery-server** | 8083 | 배송 조회 에이전트 (TRACK-* 처리) |
10-
| **a2a-client** | 8081 | 두 에이전트를 호출하는 클라이언트 |
10+
| **a2a-client** | 8081 | 진입점 + LLM 의도 분석 → 해당 에이전트 호출 |
1111

1212
## 에이전트 간 통신 (A2A Java SDK)
1313

@@ -22,5 +22,27 @@ Spring Boot samples using the Agent2Agent (A2A) Protocol
2222
2. Delivery Agent 실행: `./gradlew :a2a-server:a2a-delivery-server:bootRun` (별도 터미널)
2323
3. Client 실행: `./gradlew :a2a-client:bootRun` (별도 터미널)
2424

25+
### 직접 호출 (기존)
2526
- 배송 조회: `http://localhost:8081/api/delivery?trackingNumber=TRACK-1001`
2627
- 주문 취소: `http://localhost:8081/api/order/cancel?orderNumber=ORD-1001`
28+
29+
### 자유 문의 (LLM 라우팅)
30+
Client가 **Spring AI(OpenAI 호환)** 로 사용자 문의 의도를 분석한 뒤, 해당 A2A 에이전트를 호출합니다.
31+
32+
**필수 환경 변수 (Client 실행 전):**
33+
- `OPENAI_API_KEY`: OpenAI API 키 (또는 OpenAI 호환 서비스 키)
34+
- Ollama 등 로컬 서버 사용 시: `OPENAI_BASE_URL=http://localhost:11434/v1` 등으로 설정
35+
36+
**요청 예:**
37+
```bash
38+
curl -X POST http://localhost:8081/api/chat \
39+
-H "Content-Type: application/json" \
40+
-d '{"message": "ORD-1001 주문 취소해줘"}'
41+
```
42+
```bash
43+
curl -X POST http://localhost:8081/api/chat \
44+
-H "Content-Type: application/json" \
45+
-d '{"message": "TRACK-1001 배송 어디쯤이야?"}'
46+
```
47+
48+
**흐름:** 사용자 문의 → LLM 의도/엔티티 분석 → Order/Delivery 에이전트 A2A 호출 → 결과 반환

a2a-client/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
dependencies {
22
implementation 'org.springframework.boot:spring-boot-starter-web'
3+
implementation 'org.springframework.ai:spring-ai-openai:1.1.2'
34
implementation 'io.github.a2asdk:a2a-java-sdk-client:1.0.0.Alpha3'
4-
}
5+
}
Lines changed: 8 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,26 @@
11
package com.github.cokelee777.a2aclient;
22

3-
import java.util.List;
4-
import java.util.concurrent.CompletableFuture;
5-
import java.util.concurrent.TimeUnit;
6-
import java.util.function.BiConsumer;
7-
import java.util.function.Consumer;
8-
9-
import io.a2a.client.Client;
10-
import io.a2a.client.ClientEvent;
11-
import io.a2a.client.MessageEvent;
12-
import io.a2a.client.TaskEvent;
13-
import io.a2a.client.TaskUpdateEvent;
14-
import io.a2a.client.config.ClientConfig;
15-
import io.a2a.client.http.A2ACardResolver;
16-
import io.a2a.client.http.A2AHttpClient;
17-
import io.a2a.client.http.A2AHttpClientFactory;
18-
import io.a2a.client.transport.jsonrpc.JSONRPCTransport;
19-
import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig;
20-
import io.a2a.spec.*;
3+
import com.github.cokelee777.a2aclient.orchestrator.AgentInvokerService;
214
import org.springframework.web.bind.annotation.GetMapping;
225
import org.springframework.web.bind.annotation.RequestParam;
236
import org.springframework.web.bind.annotation.RestController;
247

258
@RestController
269
public class A2aClientController {
2710

28-
private static final String ORDER_AGENT_URL = "http://localhost:8082";
29-
private static final String DELIVERY_AGENT_URL = "http://localhost:8083";
30-
private static final String DELIVERY_AGENT_CARD_PATH = "/.well-known/delivery-agent-card.json";
31-
private static final String ORDER_AGENT_CARD_PATH = "/.well-known/order-agent-card.json";
11+
private final AgentInvokerService agentInvokerService;
12+
13+
public A2aClientController(AgentInvokerService agentInvokerService) {
14+
this.agentInvokerService = agentInvokerService;
15+
}
3216

3317
@GetMapping("/api/delivery")
3418
public String trackDelivery(@RequestParam String trackingNumber) {
35-
return sendRequest(DELIVERY_AGENT_URL, DELIVERY_AGENT_CARD_PATH, trackingNumber + " 배송 조회해줘");
19+
return agentInvokerService.callDeliveryAgent(trackingNumber + " 배송 조회해줘");
3620
}
3721

3822
@GetMapping("/api/order/cancel")
3923
public String cancelOrder(@RequestParam String orderNumber) {
40-
return sendRequest(ORDER_AGENT_URL, ORDER_AGENT_CARD_PATH, orderNumber + " 주문 취소해줘");
41-
}
42-
43-
private String sendRequest(String serverUrl, String agentCardPath, String text) {
44-
try {
45-
A2AHttpClient httpClient = A2AHttpClientFactory.create();
46-
AgentCard agentCard = new A2ACardResolver(httpClient, serverUrl, null, agentCardPath)
47-
.getAgentCard();
48-
49-
CompletableFuture<String> resultFuture = new CompletableFuture<>();
50-
51-
List<BiConsumer<ClientEvent, AgentCard>> consumers = List.of(
52-
(event, card) -> {
53-
if (event instanceof TaskEvent taskEvent) {
54-
Task task = taskEvent.getTask();
55-
StringBuilder sb = new StringBuilder();
56-
if (task.artifacts() != null) {
57-
task.artifacts().forEach(artifact ->
58-
artifact.parts().forEach(part -> {
59-
if (part instanceof TextPart textPart) {
60-
sb.append(textPart.text());
61-
}
62-
})
63-
);
64-
}
65-
resultFuture.complete(sb.toString());
66-
} else if (event instanceof MessageEvent messageEvent) {
67-
resultFuture.complete("Message: " + messageEvent.getMessage());
68-
} else if (event instanceof TaskUpdateEvent taskUpdateEvent) {
69-
resultFuture.complete("TaskUpdate: " + taskUpdateEvent.getTask());
70-
}
71-
}
72-
);
73-
74-
Consumer<Throwable> errorHandler = resultFuture::completeExceptionally;
75-
76-
ClientConfig clientConfig = new ClientConfig.Builder()
77-
.setAcceptedOutputModes(List.of("text"))
78-
.build();
79-
80-
try (Client client = Client
81-
.builder(agentCard)
82-
.clientConfig(clientConfig)
83-
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig())
84-
.addConsumers(consumers)
85-
.streamingErrorHandler(errorHandler)
86-
.build()) {
87-
Message userMessage = Message.builder()
88-
.role(Message.Role.ROLE_USER)
89-
.parts(List.of(new TextPart(text)))
90-
.build();
91-
92-
client.sendMessage(userMessage);
93-
} catch (A2AClientException e) {
94-
throw e;
95-
}
96-
97-
return resultFuture.get(10, TimeUnit.SECONDS);
98-
} catch (Exception e) {
99-
return "Error: " + e.getMessage();
100-
}
24+
return agentInvokerService.callOrderAgent(orderNumber + " 주문 취소해줘");
10125
}
10226
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.github.cokelee777.a2aclient;
2+
3+
import com.github.cokelee777.a2aclient.orchestrator.ChatOrchestratorService;
4+
import org.springframework.http.MediaType;
5+
import org.springframework.http.ResponseEntity;
6+
import org.springframework.web.bind.annotation.PostMapping;
7+
import org.springframework.web.bind.annotation.RequestBody;
8+
import org.springframework.web.bind.annotation.RestController;
9+
10+
import java.util.Map;
11+
12+
@RestController
13+
public class ChatController {
14+
15+
private final ChatOrchestratorService chatOrchestratorService;
16+
17+
public ChatController(ChatOrchestratorService chatOrchestratorService) {
18+
this.chatOrchestratorService = chatOrchestratorService;
19+
}
20+
21+
/**
22+
* 자유 문의를 받아 LLM으로 의도를 분석한 뒤, 해당 A2A 에이전트를 호출해 결과를 반환합니다.
23+
*
24+
* 요청 예: POST /api/chat Body: {"message": "ORD-1001 주문 취소해줘"}
25+
* 응답: {"response": "에이전트가 반환한 텍스트"}
26+
*/
27+
@PostMapping(value = "/api/chat", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
28+
public ResponseEntity<Map<String, String>> chat(@RequestBody(required = false) Map<String, String> body) {
29+
try {
30+
String message = body != null ? body.get("message") : null;
31+
String response = chatOrchestratorService.handleUserQuery(message);
32+
return ResponseEntity.ok(Map.of("response", response != null ? response : ""));
33+
} catch (Exception e) {
34+
String errorMessage = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
35+
return ResponseEntity.status(500)
36+
.body(Map.of("response", "오류가 발생했습니다: " + errorMessage));
37+
}
38+
}
39+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.github.cokelee777.a2aclient.config;
2+
3+
import org.springframework.ai.chat.model.ChatModel;
4+
import org.springframework.ai.openai.OpenAiChatModel;
5+
import org.springframework.ai.openai.OpenAiChatOptions;
6+
import org.springframework.ai.openai.api.OpenAiApi;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.context.annotation.Bean;
9+
import org.springframework.context.annotation.Configuration;
10+
11+
/**
12+
* Spring Boot 4와 Spring AI 1.0 autoconfigure가 호환되지 않아
13+
* (RestClientAutoConfiguration 미존재) ChatModel 빈을 수동 등록합니다.
14+
*/
15+
@Configuration
16+
public class OpenAiChatModelConfig {
17+
18+
@Bean
19+
public ChatModel chatModel(
20+
@Value("${spring.ai.openai.api-key:}") String apiKey,
21+
@Value("${spring.ai.openai.base-url:https://api.openai.com}") String baseUrl,
22+
@Value("${spring.ai.openai.chat.options.model:gpt-4o-mini}") String model,
23+
@Value("${spring.ai.openai.chat.options.temperature:0.2}") double temperature) {
24+
OpenAiApi openAiApi = OpenAiApi.builder()
25+
.apiKey(apiKey)
26+
.baseUrl(baseUrl)
27+
.build();
28+
OpenAiChatOptions options = OpenAiChatOptions.builder()
29+
.model(model)
30+
.temperature(temperature)
31+
.build();
32+
return OpenAiChatModel.builder()
33+
.openAiApi(openAiApi)
34+
.defaultOptions(options)
35+
.build();
36+
}
37+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.github.cokelee777.a2aclient.orchestrator;
2+
3+
import java.util.List;
4+
import java.util.concurrent.CompletableFuture;
5+
import java.util.concurrent.TimeUnit;
6+
import java.util.function.BiConsumer;
7+
import java.util.function.Consumer;
8+
9+
import io.a2a.client.Client;
10+
import io.a2a.client.ClientEvent;
11+
import io.a2a.client.TaskEvent;
12+
import io.a2a.client.config.ClientConfig;
13+
import io.a2a.client.http.A2ACardResolver;
14+
import io.a2a.client.http.A2AHttpClient;
15+
import io.a2a.client.http.A2AHttpClientFactory;
16+
import io.a2a.client.transport.jsonrpc.JSONRPCTransport;
17+
import io.a2a.client.transport.jsonrpc.JSONRPCTransportConfig;
18+
import io.a2a.spec.AgentCard;
19+
import io.a2a.spec.Message;
20+
import io.a2a.spec.Task;
21+
import io.a2a.spec.TextPart;
22+
import org.springframework.beans.factory.annotation.Value;
23+
import org.springframework.stereotype.Service;
24+
25+
@Service
26+
public class AgentInvokerService {
27+
28+
private static final String DELIVERY_AGENT_CARD_PATH = "/.well-known/delivery-agent-card.json";
29+
private static final String ORDER_AGENT_CARD_PATH = "/.well-known/order-agent-card.json";
30+
31+
private final String orderAgentUrl;
32+
private final String deliveryAgentUrl;
33+
34+
public AgentInvokerService(
35+
@Value("${a2a.order-agent-url:http://localhost:8082}") String orderAgentUrl,
36+
@Value("${a2a.delivery-agent-url:http://localhost:8083}") String deliveryAgentUrl) {
37+
this.orderAgentUrl = orderAgentUrl;
38+
this.deliveryAgentUrl = deliveryAgentUrl;
39+
}
40+
41+
public String callOrderAgent(String messageToSend) {
42+
return sendRequest(orderAgentUrl, ORDER_AGENT_CARD_PATH, messageToSend);
43+
}
44+
45+
public String callDeliveryAgent(String messageToSend) {
46+
return sendRequest(deliveryAgentUrl, DELIVERY_AGENT_CARD_PATH, messageToSend);
47+
}
48+
49+
private String sendRequest(String serverUrl, String agentCardPath, String text) {
50+
try {
51+
A2AHttpClient httpClient = A2AHttpClientFactory.create();
52+
AgentCard agentCard = new A2ACardResolver(httpClient, serverUrl, null, agentCardPath)
53+
.getAgentCard();
54+
55+
CompletableFuture<String> resultFuture = new CompletableFuture<>();
56+
57+
List<BiConsumer<ClientEvent, AgentCard>> consumers = List.of(
58+
(event, card) -> {
59+
if (event instanceof TaskEvent taskEvent) {
60+
Task task = taskEvent.getTask();
61+
StringBuilder sb = new StringBuilder();
62+
if (task.artifacts() != null) {
63+
task.artifacts().forEach(artifact ->
64+
artifact.parts().forEach(part -> {
65+
if (part instanceof TextPart textPart) {
66+
sb.append(textPart.text());
67+
}
68+
})
69+
);
70+
}
71+
resultFuture.complete(sb.toString());
72+
}
73+
}
74+
);
75+
76+
Consumer<Throwable> errorHandler = resultFuture::completeExceptionally;
77+
78+
ClientConfig clientConfig = new ClientConfig.Builder()
79+
.setAcceptedOutputModes(List.of("text"))
80+
.build();
81+
82+
try (Client client = Client
83+
.builder(agentCard)
84+
.clientConfig(clientConfig)
85+
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig())
86+
.addConsumers(consumers)
87+
.streamingErrorHandler(errorHandler)
88+
.build()) {
89+
Message userMessage = Message.builder()
90+
.role(Message.Role.ROLE_USER)
91+
.parts(List.of(new TextPart(text)))
92+
.build();
93+
client.sendMessage(userMessage);
94+
}
95+
96+
return resultFuture.get(15, TimeUnit.SECONDS);
97+
} catch (Exception e) {
98+
return "에이전트 호출 중 오류: " + e.getMessage();
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)