feat: implement agent core components including planner, executor, orchestrator, and memory management while refactoring tool result presentation
This commit is contained in:
@@ -32,8 +32,8 @@ This repository is a local development workspace for a loyalty AI assistant. It
|
||||
- `config/AgentConfig.java`: OAuth2-enabled `RestClient` for the loyalty core API.
|
||||
- `controller/ConversationController.java`: simple in-memory conversation/message REST endpoints.
|
||||
- AI configuration:
|
||||
- Uses Ollama at `http://localhost:11434`.
|
||||
- Default model in `application.yml`: `qwen3.5:2b`.
|
||||
- Uses Ollama at `http://192.168.99.10:11434`.
|
||||
- Default model in `application.yml`: `qwen3.5:4b`.
|
||||
- MCP client connects to `${MCP_SERVER_URL:http://localhost:9331}`.
|
||||
- Agent event types sent to the UI: `TOKEN`, `TOOL_STATUS`, `DONE`, `ERROR`.
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Netty DNS Resolver for MacOS (Apple Silicon) -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-resolver-dns-native-macos</artifactId>
|
||||
<classifier>osx-aarch_64</classifier>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- OAuth2 Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Configuration
|
||||
public class AgentConfig {
|
||||
|
||||
private static final String AGENT_SYSTEM_PROMPT = """
|
||||
You are an expert Loyalty System AI Assistant.
|
||||
Your primary job is to orchestrate operations for our Loyalty core system.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Always answer the user in the language they used (e.g., Vietnamese).
|
||||
2. When a tool result contains an `agent_instruction`, you MUST strictly follow its formatting rules when presenting the relevant data to the user. Do not output raw JSON data unless explicitly asked.
|
||||
""";
|
||||
|
||||
@Value("${loyalty.core.base-url}")
|
||||
private String coreBaseUrl;
|
||||
|
||||
@Bean
|
||||
public RestClient coreRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
return RestClient.builder()
|
||||
.baseUrl(coreBaseUrl)
|
||||
.requestInterceptor((request, body, execution) -> {
|
||||
org.springframework.security.core.Authentication principal =
|
||||
new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||
"key", "loyalty-agent-service",
|
||||
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||
.withClientRegistrationId("keycloak")
|
||||
.principal(principal)
|
||||
.build();
|
||||
try {
|
||||
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
public ChatClient loyaltyChatClient(ChatModel chatModel) {
|
||||
return ChatClient.builder(chatModel)
|
||||
.defaultSystem(AGENT_SYSTEM_PROMPT)
|
||||
.build();
|
||||
}
|
||||
|
||||
// @Bean
|
||||
// public RestClient coreRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
// return RestClient.builder()
|
||||
// .baseUrl(coreBaseUrl)
|
||||
// .requestInterceptor((request, body, execution) -> {
|
||||
// org.springframework.security.core.Authentication principal =
|
||||
// new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||
// "key", "loyalty-agent-service",
|
||||
// org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
// OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||
// .withClientRegistrationId("keycloak")
|
||||
// .principal(principal)
|
||||
// .build();
|
||||
// try {
|
||||
// OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||
// if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||
// request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||
// }
|
||||
// } catch (Exception e) {
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
// return execution.execute(request, body);
|
||||
// })
|
||||
// .build();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Created by SonPhung on Tuesday, 21-Jul-2026
|
||||
*/
|
||||
@Configuration
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
@@ -15,13 +14,31 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/v1/agent/**", "/api/v1/conversations/**", "/ws/**", "/error").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oauth2Client(Customizer.withDefaults());
|
||||
);
|
||||
// .oauth2Login(Customizer.withDefaults())
|
||||
// .oauth2Client(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
// @Bean
|
||||
// public OAuth2AuthorizedClientManager authorizedClientManager(
|
||||
// ClientRegistrationRepository clientRegistrationRepository,
|
||||
// OAuth2AuthorizedClientService authorizedClientService) {
|
||||
//
|
||||
// OAuth2AuthorizedClientProvider authorizedClientProvider =
|
||||
// OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
// .clientCredentials()
|
||||
// .build();
|
||||
//
|
||||
// AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
|
||||
// new AuthorizedClientServiceOAuth2AuthorizedClientManager(
|
||||
// clientRegistrationRepository, authorizedClientService);
|
||||
// authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
|
||||
//
|
||||
// return authorizedClientManager;
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,71 +1,64 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
|
||||
import java.security.Principal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@CrossOrigin(origins = "*")
|
||||
@RequiredArgsConstructor
|
||||
public class AgentController {
|
||||
|
||||
private final LoyaltyAgentService agentService;
|
||||
|
||||
private final org.springframework.web.client.RestClient coreRestClient;
|
||||
|
||||
public AgentController(LoyaltyAgentService agentService, org.springframework.web.client.RestClient coreRestClient) {
|
||||
this.agentService = agentService;
|
||||
this.coreRestClient = coreRestClient;
|
||||
}
|
||||
|
||||
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {}
|
||||
|
||||
@MessageMapping("/chat")
|
||||
public void chat(@Payload ChatRequest request, Principal principal) {
|
||||
agentService.chat(request, principal.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/agent/campaigns")
|
||||
public Object getCampaigns(
|
||||
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
|
||||
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||
|
||||
String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
|
||||
if (status != null && !status.isEmpty()) url += "&status=" + status;
|
||||
if (name != null && !name.isEmpty()) url += "&name=" + name;
|
||||
|
||||
String response = coreRestClient.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return standardizeResponse(response);
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
|
||||
public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
|
||||
String response = coreRestClient.get()
|
||||
.uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return standardizeResponse(response);
|
||||
}
|
||||
|
||||
Object standardizeResponse(String jsonStr) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
|
||||
com.fasterxml.jackson.databind.JsonNode arrayNode = root;
|
||||
if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
|
||||
else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
|
||||
|
||||
return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
|
||||
} catch (Exception e) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
}
|
||||
// @GetMapping("/api/v1/agent/campaigns")
|
||||
// public Object getCampaigns(
|
||||
// @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
|
||||
// @org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
|
||||
// @org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||
// @org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||
//
|
||||
// String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
|
||||
// if (status != null && !status.isEmpty()) url += "&status=" + status;
|
||||
// if (name != null && !name.isEmpty()) url += "&name=" + name;
|
||||
//
|
||||
// String response = coreRestClient.get()
|
||||
// .uri(url)
|
||||
// .retrieve()
|
||||
// .body(String.class);
|
||||
// return standardizeResponse(response);
|
||||
// }
|
||||
//
|
||||
// @GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
|
||||
// public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
|
||||
// String response = coreRestClient.get()
|
||||
// .uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
|
||||
// .retrieve()
|
||||
// .body(String.class);
|
||||
// return standardizeResponse(response);
|
||||
// }
|
||||
//
|
||||
// Object standardizeResponse(String jsonStr) {
|
||||
// try {
|
||||
// com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
// com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
|
||||
// com.fasterxml.jackson.databind.JsonNode arrayNode = root;
|
||||
// if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
|
||||
// else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
|
||||
//
|
||||
// return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
|
||||
// } catch (Exception e) {
|
||||
// return java.util.List.of();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.sonpx.loyalty.agent.core.executor;
|
||||
|
||||
import dev.sonpx.loyalty.agent.core.planner.Plan;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
|
||||
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentExecutor {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final ToolInterceptor toolInterceptor;
|
||||
private final AgentEventPublisher eventPublisher;
|
||||
private final AgentMemoryManager memoryManager;
|
||||
|
||||
public void execute(Plan plan, ChatRequest request, String sessionId) {
|
||||
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(sessionId, request.messageId());
|
||||
|
||||
StringBuilder executionContext = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < plan.tasks().size(); i++) {
|
||||
Plan.Task task = plan.tasks().get(i);
|
||||
boolean isLastTask = (i == plan.tasks().size() - 1);
|
||||
|
||||
String executorPrompt = "You are an executor for an AI Agent.\n" +
|
||||
"Your current task is: " + task.description() + "\n" +
|
||||
"Context from previous tasks:\n" + executionContext + "\n" +
|
||||
"Use the provided tools if necessary to complete the task.\n";
|
||||
|
||||
if (isLastTask) {
|
||||
executorPrompt += "This is the final task. Formulate a final response to the user based on the results.\n" +
|
||||
"CRITICAL RULE: When a tool result contains an 'agent_instruction', you MUST strictly follow its formatting rules when presenting the relevant data to the user.";
|
||||
|
||||
log.info(executorPrompt);
|
||||
|
||||
chatClient.prompt()
|
||||
.advisors(memoryManager.getAdvisor())
|
||||
.advisors(a -> a.param("chat_memory_conversation_id", sessionId).param("chat_memory_response_size", 100))
|
||||
.system(executorPrompt)
|
||||
.user(request.prompt() != null ? request.prompt() : "Execute task.")
|
||||
.tools(wrappedTools)
|
||||
.stream()
|
||||
.content()
|
||||
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
|
||||
.subscribe(
|
||||
content -> {
|
||||
if (!content.isEmpty()) {
|
||||
eventPublisher.token(sessionId, request.messageId(), content);
|
||||
}
|
||||
},
|
||||
error -> eventPublisher.error(sessionId, request.messageId(), error.getMessage())
|
||||
);
|
||||
} else {
|
||||
executorPrompt += "Do NOT address the user directly. Summarize the result of this task for the next steps.";
|
||||
|
||||
log.info(executorPrompt);
|
||||
|
||||
String stepResult = chatClient.prompt()
|
||||
.system(executorPrompt)
|
||||
.user("Execute task.")
|
||||
.tools(wrappedTools)
|
||||
.call()
|
||||
.content();
|
||||
executionContext.append("Task: ").append(task.description()).append("\nResult: ").append(stepResult).append("\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.sonpx.loyalty.agent.core.executor;
|
||||
|
||||
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
|
||||
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolInterceptor {
|
||||
|
||||
private final List<ToolCallbackProvider> toolProviders;
|
||||
private final AgentEventPublisher eventPublisher;
|
||||
private final ToolResultPresentationProcessor toolResultProcessor;
|
||||
|
||||
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
|
||||
List<ToolCallback> wrappedTools = new ArrayList<>();
|
||||
|
||||
for (ToolCallbackProvider provider : toolProviders) {
|
||||
for (ToolCallback tool : provider.getToolCallbacks()) {
|
||||
wrappedTools.add(wrap(tool, sessionId, messageId));
|
||||
}
|
||||
}
|
||||
return wrappedTools;
|
||||
}
|
||||
|
||||
private ToolCallback wrap(ToolCallback tool, String sessionId, String messageId) {
|
||||
return new ToolCallback() {
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return tool.getToolDefinition();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
eventPublisher.toolRunning(sessionId, messageId, getToolDefinition().name());
|
||||
try {
|
||||
String result = tool.call(toolInput);
|
||||
if (result != null && (
|
||||
(result.contains("java.lang.") && result.contains("Exception") && result.contains("org.springframework.")) ||
|
||||
result.contains("HTTP Status 500 – Internal Server Error")
|
||||
)) {
|
||||
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
|
||||
}
|
||||
return toolResultProcessor.process(result);
|
||||
} catch (Exception e) {
|
||||
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
|
||||
} finally {
|
||||
eventPublisher.toolDone(sessionId, messageId, getToolDefinition().name());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.sonpx.loyalty.agent.core.memory;
|
||||
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
|
||||
public interface AgentMemoryManager {
|
||||
MessageChatMemoryAdvisor getAdvisor();
|
||||
ChatMemory getChatMemory();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.sonpx.loyalty.agent.core.memory;
|
||||
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AgentMemoryManagerImpl implements AgentMemoryManager {
|
||||
|
||||
private final ChatMemory chatMemory;
|
||||
|
||||
public AgentMemoryManagerImpl(ChatMemory chatMemory) {
|
||||
this.chatMemory = chatMemory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageChatMemoryAdvisor getAdvisor() {
|
||||
return MessageChatMemoryAdvisor.builder(chatMemory).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMemory getChatMemory() {
|
||||
return chatMemory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.sonpx.loyalty.agent.core.memory;
|
||||
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
public class InMemoryChatMemory implements ChatMemory {
|
||||
|
||||
private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>();
|
||||
private static final int MAX_TOKENS = 4000;
|
||||
private static final int CHARS_PER_TOKEN = 4;
|
||||
|
||||
@Override
|
||||
public void add(String conversationId, List<Message> messages) {
|
||||
conversationHistory.compute(conversationId, (id, history) -> {
|
||||
if (history == null) {
|
||||
history = new ArrayList<>();
|
||||
}
|
||||
history.addAll(messages);
|
||||
|
||||
// Truncation logic: keep messages such that estimated tokens < MAX_TOKENS
|
||||
while (history.size() > 1 && estimateTokens(history) > MAX_TOKENS) {
|
||||
// Find first non-system message to remove
|
||||
boolean removed = false;
|
||||
for (int i = 0; i < history.size(); i++) {
|
||||
if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) {
|
||||
history.remove(i);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If only system messages are left (or we couldn't remove), break to avoid infinite loop
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return history;
|
||||
});
|
||||
}
|
||||
|
||||
private int estimateTokens(List<Message> messages) {
|
||||
int chars = messages.stream()
|
||||
.mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
|
||||
.sum();
|
||||
return chars / CHARS_PER_TOKEN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Message> get(String conversationId) {
|
||||
return conversationHistory.getOrDefault(conversationId, new ArrayList<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear(String conversationId) {
|
||||
conversationHistory.remove(conversationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.sonpx.loyalty.agent.core.orchestrator;
|
||||
|
||||
import dev.sonpx.loyalty.agent.core.executor.AgentExecutor;
|
||||
import dev.sonpx.loyalty.agent.core.planner.AgentPlanner;
|
||||
import dev.sonpx.loyalty.agent.core.planner.Plan;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentOrchestrator {
|
||||
|
||||
private final AgentPlanner planner;
|
||||
private final AgentExecutor executor;
|
||||
|
||||
public void process(ChatRequest request, String sessionId) {
|
||||
try {
|
||||
log.info("Generating plan for session: {}", sessionId);
|
||||
Plan plan;
|
||||
try {
|
||||
plan = planner.generatePlan(request, sessionId);
|
||||
log.info("Generated plan for session: {}, plan: {}", sessionId, plan);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to generate plan from LLM, falling back to default plan. Error: {}", e.getMessage());
|
||||
plan = null;
|
||||
}
|
||||
|
||||
if (plan == null || plan.tasks() == null || plan.tasks().isEmpty()) {
|
||||
log.warn("Generated plan is empty or failed, falling back to a single task");
|
||||
plan = new Plan(List.of(new Plan.Task("1", "Answer user request")));
|
||||
}
|
||||
|
||||
log.info("Generated plan with {} tasks", plan.tasks().size());
|
||||
executor.execute(plan, request, sessionId);
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing request: {}", e.getMessage(), e);
|
||||
// The previous orchestrator didn't handle general process exception through eventPublisher
|
||||
// directly here because it was in the stream onError, but let's just log it or rethrow.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.sonpx.loyalty.agent.core.orchestrator;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Component
|
||||
public class PromptBuilder {
|
||||
|
||||
public String build(ChatRequest request) {
|
||||
List<String> context = new ArrayList<>();
|
||||
|
||||
if (StringUtils.hasText(request.conversationId())) {
|
||||
context.add("System Context: The current conversationId (session ID) is "
|
||||
+ request.conversationId()
|
||||
+ ". You MUST provide this conversationId as a parameter to any tool that requires it.");
|
||||
}
|
||||
if (StringUtils.hasText(request.activeCampaignId())) {
|
||||
context.add("Context: Active Campaign ID is " + request.activeCampaignId() + ".");
|
||||
}
|
||||
|
||||
String prompt = request.prompt() == null ? "" : request.prompt();
|
||||
if (context.isEmpty()) {
|
||||
return prompt;
|
||||
}
|
||||
return String.join("\n", context) + "\n\n" + prompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.sonpx.loyalty.agent.core.planner;
|
||||
|
||||
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class AgentPlanner {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final AgentMemoryManager memoryManager;
|
||||
private final PromptBuilder promptBuilder;
|
||||
|
||||
public AgentPlanner(ChatClient chatClient, AgentMemoryManager memoryManager, PromptBuilder promptBuilder) {
|
||||
this.chatClient = chatClient;
|
||||
this.memoryManager = memoryManager;
|
||||
this.promptBuilder = promptBuilder;
|
||||
}
|
||||
|
||||
public Plan generatePlan(ChatRequest request, String sessionId) {
|
||||
var converter = new BeanOutputConverter<>(Plan.class);
|
||||
|
||||
List<Message> history = memoryManager.getChatMemory().get(sessionId);
|
||||
String historyText = history.stream()
|
||||
.map(m -> m.getMessageType().name() + ": " + m.getText())
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
String systemPrompt = "You are a task planner for an AI Agent.\n" +
|
||||
"Break down the user's request into a sequence of executable atomic tasks.\n" +
|
||||
"If it's a simple conversation or question, return a single task representing the answer generation.\n" +
|
||||
"Do NOT try to answer the question yourself, just output the Plan.\n" +
|
||||
"Conversation History:\n" + historyText + "\n\n" +
|
||||
converter.getFormat();
|
||||
|
||||
String userText = promptBuilder.build(request);
|
||||
|
||||
return chatClient.prompt()
|
||||
.system(systemPrompt)
|
||||
.user(userText)
|
||||
.call()
|
||||
.entity(converter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.sonpx.loyalty.agent.core.planner;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record Plan(List<Task> tasks) {
|
||||
public record Task(String id, String description) {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package dev.sonpx.loyalty.agent.domain;
|
||||
|
||||
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.AgentEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentEventPublisher {
|
||||
|
||||
private static final String CHAT_EVENTS_DESTINATION = "/queue/chat-events";
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public void token(String sessionId, String messageId, String content) {
|
||||
send(sessionId, AgentEvent.token(messageId, content));
|
||||
}
|
||||
|
||||
public void toolRunning(String sessionId, String messageId, String toolName) {
|
||||
send(sessionId, AgentEvent.toolStatus(messageId, toolName, "running"));
|
||||
}
|
||||
|
||||
public void toolDone(String sessionId, String messageId, String toolName) {
|
||||
send(sessionId, AgentEvent.toolStatus(messageId, toolName, "done"));
|
||||
}
|
||||
|
||||
public void done(String sessionId, String messageId) {
|
||||
send(sessionId, AgentEvent.done(messageId));
|
||||
}
|
||||
|
||||
public void error(String sessionId, String messageId, String errorMessage) {
|
||||
send(sessionId, AgentEvent.error(messageId, errorMessage));
|
||||
}
|
||||
|
||||
private void send(String sessionId, AgentEvent event) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, CHAT_EVENTS_DESTINATION, event);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,17 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import dev.sonpx.loyalty.agent.controller.AgentController;
|
||||
import dev.sonpx.loyalty.agent.domain.AgentEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import dev.sonpx.loyalty.agent.core.orchestrator.AgentOrchestrator;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoyaltyAgentService {
|
||||
|
||||
private final ChatModel chatModel;
|
||||
private final List<ToolCallbackProvider> toolProviders;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final AgentOrchestrator agentOrchestrator;
|
||||
|
||||
public void chat(AgentController.ChatRequest request, String sessionId) {
|
||||
String prompt = request.prompt();
|
||||
if (request.activeCampaignId() != null && !request.activeCampaignId().isEmpty()) {
|
||||
prompt = "Context: Active Campaign ID is " + request.activeCampaignId() + ".\n\n" + prompt;
|
||||
}
|
||||
if (request.conversationId() != null && !request.conversationId().isEmpty()) {
|
||||
prompt = "System Context: The current conversationId (session ID) is " + request.conversationId() + ". You MUST provide this conversationId as a parameter to any tool that requires it.\n\n" + prompt;
|
||||
}
|
||||
|
||||
List<ToolCallback> wrappedTools = new ArrayList<>();
|
||||
for (ToolCallbackProvider provider : toolProviders) {
|
||||
for (ToolCallback tool : provider.getToolCallbacks()) {
|
||||
wrappedTools.add(new ToolCallback() {
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return tool.getToolDefinition();
|
||||
}
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "running"));
|
||||
try {
|
||||
return tool.call(toolInput);
|
||||
} finally {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "done"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("""
|
||||
You are an expert Loyalty System AI Assistant.
|
||||
Your primary job is to orchestrate operations for our Loyalty core system.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Always answer the user in the language they used (e.g., Vietnamese).
|
||||
2. If a tool response contains a `presentation.markdown` field, you MUST display its content exactly as provided to the user. Do not summarize or alter its formatting. Use the `data` field only for your internal reasoning.
|
||||
""")
|
||||
.defaultTools(wrappedTools)
|
||||
.build();
|
||||
|
||||
chatClient.prompt()
|
||||
.user(prompt)
|
||||
.stream()
|
||||
.content()
|
||||
.doOnComplete(() -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.done(request.messageId())))
|
||||
.subscribe(
|
||||
content -> {
|
||||
if (content != null && !content.isEmpty()) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.token(request.messageId(), content));
|
||||
}
|
||||
},
|
||||
error -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.error(request.messageId(), error.getMessage()))
|
||||
);
|
||||
public void chat(ChatRequest request, String sessionId) {
|
||||
agentOrchestrator.process(request, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolResultPresentationProcessor {
|
||||
|
||||
private static final String INTERNAL_INSTRUCTION_FIELD = "_agent_instruction";
|
||||
private static final String AGENT_INSTRUCTION_FIELD = "agent_instruction";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public String process(String result) {
|
||||
if (!StringUtils.hasText(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
return root.isArray() ? processMcpContentArray(root, result) : processJsonObject(root, result);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.debug("Tool result is not JSON.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private String processMcpContentArray(JsonNode root, String originalResult)
|
||||
throws JsonProcessingException {
|
||||
boolean modified = false;
|
||||
|
||||
for (JsonNode contentItem : root) {
|
||||
if (!(contentItem instanceof ObjectNode objectContentItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode textNode = objectContentItem.get("text");
|
||||
if (textNode == null || !textNode.isTextual()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<JsonNode> parsedText = readJson(textNode.asText());
|
||||
if (parsedText.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode parsedNode = parsedText.get();
|
||||
if (processInstruction(parsedNode)) {
|
||||
modified = true;
|
||||
objectContentItem.put("text", objectMapper.writeValueAsString(parsedNode));
|
||||
}
|
||||
}
|
||||
|
||||
return modified ? objectMapper.writeValueAsString(root) : originalResult;
|
||||
}
|
||||
|
||||
private String processJsonObject(JsonNode root, String originalResult) throws JsonProcessingException {
|
||||
if (processInstruction(root)) {
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
return originalResult;
|
||||
}
|
||||
|
||||
private Optional<JsonNode> readJson(String content) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readTree(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean processInstruction(JsonNode node) {
|
||||
if (!(node instanceof ObjectNode objectNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonNode instructionNode = objectNode.get(INTERNAL_INSTRUCTION_FIELD);
|
||||
if (instructionNode == null || !instructionNode.isTextual()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String instruction = instructionNode.asText();
|
||||
if (!StringUtils.hasText(instruction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
objectNode.remove(INTERNAL_INSTRUCTION_FIELD);
|
||||
objectNode.put(AGENT_INSTRUCTION_FIELD, instruction);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ spring:
|
||||
name: loyalty-agent-service
|
||||
ai:
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
base-url: http://192.168.99.10:11434
|
||||
chat:
|
||||
options:
|
||||
model: qwen3.5:2b
|
||||
model: qwen3.5:4b
|
||||
temperature: 0.3
|
||||
mcp:
|
||||
client:
|
||||
@@ -23,25 +23,26 @@ spring:
|
||||
connections:
|
||||
loyalty:
|
||||
url: ${MCP_SERVER_URL:http://localhost:9331}
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
keycloak:
|
||||
client-id: ols-cli
|
||||
client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||
authorization-grant-type: client_credentials
|
||||
provider:
|
||||
keycloak:
|
||||
token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||
# security:
|
||||
# oauth2:
|
||||
# client:
|
||||
# registration:
|
||||
# keycloak:
|
||||
# client-id: ols-cli
|
||||
# client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||
# authorization-grant-type: client_credentials
|
||||
# provider:
|
||||
# keycloak:
|
||||
# token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||
|
||||
loyalty:
|
||||
core:
|
||||
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||
#loyalty:
|
||||
# core:
|
||||
# base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||
|
||||
logging:
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} %5p --- %-40.40logger{39} : %m%n"
|
||||
level:
|
||||
root: warn
|
||||
org.springframework.ai: INFO
|
||||
org.springframework.web: INFO
|
||||
dev.sonpx.loyalty: DEBUG
|
||||
root: warn
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentControllerTest {
|
||||
|
||||
private final AgentController controller = new AgentController(null, null);
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_Array() {
|
||||
String json = "[{\"id\":\"1\"}]";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_ContentWrapped() {
|
||||
String json = "{\"content\":[{\"id\":\"1\"}], \"page\":0}";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_DataWrapped() {
|
||||
String json = "{\"data\":[{\"id\":\"1\"}]}";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
void contextLoads() {
|
||||
// The original tests were for standardizeResponse which is now commented out in AgentController.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolResultPresentationProcessorTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final ToolResultPresentationProcessor processor = new ToolResultPresentationProcessor(objectMapper);
|
||||
|
||||
@Test
|
||||
void leavesPlainTextResultsUnchanged() {
|
||||
String result = processor.process("done");
|
||||
assertEquals("done", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsAgentInstructionFromDirectJsonResult() throws Exception {
|
||||
String toolResult = """
|
||||
{
|
||||
"data": [{"id": "1"}],
|
||||
"_agent_instruction": "Format this list of campaigns as a markdown table."
|
||||
}
|
||||
""";
|
||||
|
||||
String result = processor.process(toolResult);
|
||||
JsonNode sanitized = objectMapper.readTree(result);
|
||||
|
||||
assertFalse(sanitized.has("_agent_instruction"));
|
||||
assertTrue(sanitized.has("agent_instruction"));
|
||||
assertEquals("Format this list of campaigns as a markdown table.", sanitized.get("agent_instruction").asText());
|
||||
assertTrue(sanitized.has("data"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsAgentInstructionFromMcpTextResult() throws Exception {
|
||||
String innerResult = objectMapper.writeValueAsString(objectMapper.readTree("""
|
||||
{
|
||||
"data": [{"id": "1"}],
|
||||
"_agent_instruction": "Format this list of campaigns as a markdown table."
|
||||
}
|
||||
"""));
|
||||
String toolResult = objectMapper.writeValueAsString(new Object[] {
|
||||
java.util.Map.of("type", "text", "text", innerResult)
|
||||
});
|
||||
|
||||
String result = processor.process(toolResult);
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
JsonNode sanitizedInner = objectMapper.readTree(root.get(0).get("text").asText());
|
||||
|
||||
assertFalse(sanitizedInner.has("_agent_instruction"));
|
||||
assertTrue(sanitizedInner.has("agent_instruction"));
|
||||
assertEquals("Format this list of campaigns as a markdown table.", sanitizedInner.get("agent_instruction").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonTextualAgentInstruction() throws Exception {
|
||||
String toolResult = """
|
||||
{
|
||||
"data": [{"id": "1"}],
|
||||
"_agent_instruction": {"nested": "value"}
|
||||
}
|
||||
""";
|
||||
|
||||
String result = processor.process(toolResult);
|
||||
JsonNode parsed = objectMapper.readTree(result);
|
||||
|
||||
assertTrue(parsed.has("_agent_instruction"));
|
||||
assertFalse(parsed.has("agent_instruction"));
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,27 @@
|
||||
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- AOP for Global Tool Exception Handling -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
<version>3.4.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- WebClient (needed for generated API client if library is webclient) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Netty DNS Resolver for MacOS (Apple Silicon) -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-resolver-dns-native-macos</artifactId>
|
||||
<classifier>osx-aarch_64</classifier>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- OAuth2 Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
@@ -108,6 +123,7 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>dev.sonpx.loyalty.mcp.McpServerApplication</mainClass>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.sonpx.loyalty.mcp.aspect;
|
||||
|
||||
import dev.sonpx.loyalty.mcp.model.Result;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Global exception handler for @McpTool methods using AOP.
|
||||
* Prevents internal stack traces from leaking to the MCP Client.
|
||||
*/
|
||||
@Slf4j
|
||||
@Aspect
|
||||
@Component
|
||||
public class GlobalToolExceptionHandlerAspect {
|
||||
|
||||
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
|
||||
public Object handleException(ProceedingJoinPoint pjp) throws Throwable {
|
||||
try {
|
||||
return pjp.proceed();
|
||||
} catch (Exception e) {
|
||||
log.error("Global AOP exception caught during execution of MCP tool {}: {}", pjp.getSignature().getName(), e.getMessage());
|
||||
// Return a safe Result to prevent stack traces from leaking to the agent
|
||||
return Result.of(null, "Lỗi hệ thống khi gọi tool tại MCP server. Không thể xử lý yêu cầu. Vui lòng báo cáo lỗi này.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,22 @@ package dev.sonpx.loyalty.mcp.config;
|
||||
import static org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver.clientRegistrationId;
|
||||
import static org.springframework.security.oauth2.client.web.client.RequestAttributePrincipalResolver.principal;
|
||||
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import dev.sonpx.loyalty.mcp.reward.client.CampaignClient;
|
||||
import dev.sonpx.loyalty.mcp.reward.client.CampaignRuleClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.client.BufferingClientHttpRequestFactory;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.security.oauth2.client.web.client.OAuth2ClientHttpRequestInterceptor;
|
||||
import org.springframework.security.oauth2.client.web.client.RequestAttributeClientRegistrationIdResolver;
|
||||
import org.springframework.security.oauth2.client.web.client.RequestAttributePrincipalResolver;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.support.RestClientAdapter;
|
||||
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
|
||||
@@ -20,6 +26,7 @@ import org.springframework.web.service.invoker.HttpServiceProxyFactory;
|
||||
/**
|
||||
* Created by SonPhung on Sunday, 19-Jul-2026
|
||||
**/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class RestClientConfig {
|
||||
|
||||
@@ -35,15 +42,29 @@ public class RestClientConfig {
|
||||
|
||||
return RestClient.builder()
|
||||
.baseUrl(coreBaseUrl + "/svc/reward")
|
||||
.requestFactory(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
|
||||
.requestInterceptor((request, body, execution) -> {
|
||||
clientRegistrationId("keycloak").accept(request.getAttributes());
|
||||
principal("loyalty-service").accept(request.getAttributes());
|
||||
return oauth2Interceptor.intercept(request, body, execution);
|
||||
|
||||
log.info("MCP-Agent Call API [Request] -> {} {}", request.getMethod(), request.getURI());
|
||||
if (body.length > 0) {
|
||||
log.info("MCP-Agent Call API [Request Body] -> {}", new String(body, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
ClientHttpResponse response = oauth2Interceptor.intercept(request, body, execution);
|
||||
|
||||
log.info("MCP-Agent Call API [Response Status] <- {}", response.getStatusCode());
|
||||
if (response.getBody() != null) {
|
||||
log.info("MCP-Agent Call API [Response Body] <- {}", StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.defaultStatusHandler(
|
||||
HttpStatusCode::isError,
|
||||
(request, response) -> {
|
||||
throw new RuntimeException("API Error: HTTP Status " + response.getStatusCode());
|
||||
throw new RuntimeException("API Error: HTTP Status " + response.getStatusCode() + " Body: " + new String(response.getBody().readAllBytes()));
|
||||
}
|
||||
)
|
||||
.build();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.sonpx.loyalty.mcp.exception;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* Global exception handler for the MCP Server.
|
||||
* Prevents stack traces from leaking to the MCP Client (Agent).
|
||||
*/
|
||||
@Slf4j
|
||||
@RestControllerAdvice
|
||||
public class GlobalMcpExceptionHandler {
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<String> handleException(Exception ex) {
|
||||
log.error("Global exception caught in MCP server: {}", ex.getMessage());
|
||||
// We return a safe error message instead of the HTML stack trace.
|
||||
// Returning a generic 500 error prevents the LLM from trying to explain server internals.
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("{\"error\": \"Lỗi hệ thống tại MCP Server. Không thể xử lý yêu cầu.\"}");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package dev.sonpx.loyalty.mcp.model;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -14,16 +13,16 @@ public class Result<T> {
|
||||
|
||||
private final T data;
|
||||
|
||||
private Map<String, String> presentation;
|
||||
@JsonProperty("_agent_instruction")
|
||||
private String agentInstruction;
|
||||
|
||||
public static <T> Result<T> of(T data, String content) {
|
||||
public static <T> Result<T> of(T data, String instruction) {
|
||||
Result<T> result = of(data);
|
||||
result.markdown(content);
|
||||
result.instruction(instruction);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void markdown(String content) {
|
||||
this.presentation = new HashMap<>();
|
||||
this.presentation.put("markdown", content);
|
||||
public void instruction(String instruction) {
|
||||
this.agentInstruction = instruction;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,21 +23,21 @@ public interface CampaignClient {
|
||||
@GetExchange("/api/campaign/csr/list")
|
||||
List<Campaign> getAll(CampaignCriteria criteria, Pageable pageable);
|
||||
|
||||
@GetExchange("/csr/count")
|
||||
@GetExchange("/api/campaign/csr/count")
|
||||
Long count(CampaignCriteria criteria);
|
||||
|
||||
@GetExchange("/csr/id/{id}")
|
||||
@GetExchange("/api/campaign/csr/id/{id}")
|
||||
Campaign findActiveById(@PathVariable String id);
|
||||
|
||||
@GetExchange("/csr/id")
|
||||
List<Campaign> findActiveByIds(@RequestParam Map<String, String> params);
|
||||
@GetExchange("/api/campaign/list-by-ids")
|
||||
List<Campaign> findActiveByIds(@RequestParam("idList") List<String> idList);
|
||||
|
||||
@GetExchange("/csr/id/generate")
|
||||
@GetExchange("/api/campaign/csr/id/generate")
|
||||
String generateId();
|
||||
|
||||
@GetExchange("/csr/id/check/{id}")
|
||||
@GetExchange("/api/campaign/csr/id/check/{id}")
|
||||
String checkId(@PathVariable String id);
|
||||
|
||||
@PostExchange("/csr/create")
|
||||
@PostExchange("/api/campaign/csr/create")
|
||||
OperationResult<Campaign> create(@Valid @RequestBody Campaign dto);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ public interface CampaignRuleClient {
|
||||
@GetExchange("/api/campaign-rule/csr/id/{id}")
|
||||
CampaignRule findActiveById(@PathVariable String id);
|
||||
|
||||
@GetExchange("/api/campaign-rule/csr/id")
|
||||
List<CampaignRule> findActiveByIds(@RequestParam Map<String, String> params);
|
||||
@GetExchange("/api/campaign-rule/list-by-ids")
|
||||
List<CampaignRule> findActiveByIds(@RequestParam("idList") List<String> idList);
|
||||
|
||||
@GetExchange("/api/campaign-rule/csr/id/generate")
|
||||
String generateId();
|
||||
|
||||
@@ -5,8 +5,8 @@ import dev.sonpx.loyalty.mcp.model.Result;
|
||||
import dev.sonpx.loyalty.mcp.reward.client.CampaignRuleClient;
|
||||
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria;
|
||||
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
|
||||
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -23,72 +23,38 @@ public class CampaignRuleService {
|
||||
private final CampaignRuleClient campaignRuleClient;
|
||||
|
||||
public Result<List<CampaignRule>> getAll(CampaignRuleCriteria criteria) {
|
||||
try {
|
||||
List<CampaignRule> rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10));
|
||||
return Result.of(rules, MarkdownGenerator.generateCampaignRuleMD(rules));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(rules, "Format these campaign rules as a markdown table with columns ID, Campaign ID, Rule Code, Status.");
|
||||
}
|
||||
|
||||
public Result<Long> count(CampaignRuleCriteria criteria) {
|
||||
try {
|
||||
Long count = campaignRuleClient.count(criteria);
|
||||
return Result.of(count, MarkdownGenerator.generateCampaignRuleMD(count));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(count, "Present the campaign rule count.");
|
||||
}
|
||||
|
||||
public Result<CampaignRule> findActiveById(String id) {
|
||||
try {
|
||||
CampaignRule rule = campaignRuleClient.findActiveById(id);
|
||||
return Result.of(rule, MarkdownGenerator.generateCampaignRuleMD(rule));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(rule, "Format this campaign rule as a markdown table with its details.");
|
||||
}
|
||||
|
||||
public Result<List<CampaignRule>> findActiveByIds(String ids) {
|
||||
try {
|
||||
List<CampaignRule> rules = campaignRuleClient.findActiveByIds(java.util.Map.of("id", ids));
|
||||
return Result.of(rules, MarkdownGenerator.generateCampaignRuleMD(rules));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
List<String> idList = java.util.Arrays.asList(ids.split(",\\s*"));
|
||||
List<CampaignRule> rules = campaignRuleClient.findActiveByIds(idList);
|
||||
return Result.of(rules, "Format these campaign rules as a markdown table with columns ID, Campaign ID, Rule Code, Status.");
|
||||
}
|
||||
|
||||
public Result<String> generateId() {
|
||||
try {
|
||||
String id = campaignRuleClient.generateId();
|
||||
return Result.of(id, MarkdownGenerator.generateCampaignRuleIdMD(id));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(id, "Present the newly generated campaign rule ID.");
|
||||
}
|
||||
|
||||
public Result<String> checkId(String id) {
|
||||
try {
|
||||
String checkResult = campaignRuleClient.checkId(id);
|
||||
return Result.of(checkResult, MarkdownGenerator.generateCheckResultMD(checkResult));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(checkResult, "Present the result of the campaign rule ID check.");
|
||||
}
|
||||
|
||||
public Result<OperationResult<CampaignRule>> create(CampaignRule dto) {
|
||||
try {
|
||||
OperationResult<CampaignRule> opResult = campaignRuleClient.create(dto);
|
||||
return Result.of(opResult, MarkdownGenerator.generateCampaignRuleOperationResultMD(opResult));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(opResult, "Present the result of the campaign rule creation operation, formatting the created rule details if successful.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package dev.sonpx.loyalty.mcp.service;
|
||||
|
||||
import static dev.sonpx.loyalty.mcp.util.MarkdownGenerator.generateCampaignMD;
|
||||
|
||||
import dev.sonpx.loyalty.mcp.model.OperationResult;
|
||||
import dev.sonpx.loyalty.mcp.model.Result;
|
||||
import dev.sonpx.loyalty.mcp.reward.client.CampaignClient;
|
||||
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
|
||||
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
|
||||
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -25,73 +22,38 @@ public class CampaignService {
|
||||
private final CampaignClient campaignClient;
|
||||
|
||||
public Result<List<Campaign>> getAll(CampaignCriteria criteria) {
|
||||
try {
|
||||
List<Campaign> campaigns = campaignClient.getAll(criteria, Pageable.ofSize(10));
|
||||
|
||||
return Result.of(campaigns, generateCampaignMD(campaigns));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
List<Campaign> campaigns = campaignClient.getAll(criteria, Pageable.ofSize(5));
|
||||
return Result.of(campaigns, "Format these campaigns as a markdown table with columns ID, Name, Start Date, End Date, Status.");
|
||||
}
|
||||
|
||||
public Result<Long> count(CampaignCriteria criteria) {
|
||||
try {
|
||||
Long count = campaignClient.count(criteria);
|
||||
return Result.of(count, MarkdownGenerator.generateCampaignMD(count));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(count, "Present the campaign count.");
|
||||
}
|
||||
|
||||
public Result<Campaign> findActiveById(String id) {
|
||||
try {
|
||||
Campaign campaign = campaignClient.findActiveById(id);
|
||||
return Result.of(campaign, MarkdownGenerator.generateCampaignMD(campaign));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(campaign, "Format this campaign as a markdown table with its details.");
|
||||
}
|
||||
|
||||
public Result<List<Campaign>> findActiveByIds(String ids) {
|
||||
try {
|
||||
List<Campaign> campaigns = campaignClient.findActiveByIds(java.util.Map.of("id", ids));
|
||||
return Result.of(campaigns, MarkdownGenerator.generateCampaignMD(campaigns));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
List<String> idList = java.util.Arrays.asList(ids.split(",\\s*"));
|
||||
List<Campaign> campaigns = campaignClient.findActiveByIds(idList);
|
||||
return Result.of(campaigns, "Format these campaigns as a markdown table with columns ID, Name, Start Date, End Date, Status.");
|
||||
}
|
||||
|
||||
public Result<String> generateId() {
|
||||
try {
|
||||
String id = campaignClient.generateId();
|
||||
return Result.of(id, MarkdownGenerator.generateCampaignIdMD(id));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(id, "Present the newly generated campaign ID.");
|
||||
}
|
||||
|
||||
public Result<String> checkId(String id) {
|
||||
try {
|
||||
String checkResult = campaignClient.checkId(id);
|
||||
return Result.of(checkResult, MarkdownGenerator.generateCheckResultMD(checkResult));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(checkResult, "Present the result of the campaign ID check.");
|
||||
}
|
||||
|
||||
public Result<OperationResult<Campaign>> create(Campaign dto) {
|
||||
try {
|
||||
OperationResult<Campaign> opResult = campaignClient.create(dto);
|
||||
return Result.of(opResult, MarkdownGenerator.generateOperationResultMD(opResult));
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.of(null);
|
||||
}
|
||||
return Result.of(opResult, "Present the result of the campaign creation operation, formatting the created campaign details if successful.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class CampaignRuleTools {
|
||||
|
||||
private final CampaignRuleService campaignRuleService;
|
||||
|
||||
@McpTool(description = "Lấy danh sách các Campaign Rule, cho phép tìm kiếm với param search, hiển thị tối đa 10 bản ghi")
|
||||
@McpTool(description = "Lấy danh sách các Campaign Rule (Thể lệ chiến dịch). Dùng tool này khi người dùng hỏi về rule, thể lệ, quy tắc, điều kiện của chiến dịch. Cho phép tìm kiếm với param search (ví dụ: nhập tên chiến dịch hoặc tên rule vào đây), hiển thị tối đa 10 bản ghi.")
|
||||
public Result<List<CampaignRule>> campaignRules(String search) {
|
||||
CampaignRuleCriteria criteria = new CampaignRuleCriteria();
|
||||
criteria.setSearch(search);
|
||||
|
||||
@@ -19,7 +19,7 @@ public class CampaignTools {
|
||||
|
||||
private final CampaignService campaignService;
|
||||
|
||||
@McpTool(description = "Lấy danh sách chiến dịch, cho phép tìm kiếm với param search")
|
||||
@McpTool(description = "Lấy danh sách chiến dịch (Campaign), cho phép tìm kiếm với param search. CHỈ dùng tool này khi cần tìm thông tin về chính chiến dịch đó. NẾU người dùng hỏi về RULE / THỂ LỆ của chiến dịch, HÃY DÙNG tool campaignRules.")
|
||||
public Result<List<Campaign>> campaigns(String search) {
|
||||
CampaignCriteria criteria = new CampaignCriteria();
|
||||
criteria.setSearch(search);
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package dev.sonpx.loyalty.mcp.util;
|
||||
|
||||
import dev.sonpx.loyalty.mcp.model.OperationResult;
|
||||
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
|
||||
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
|
||||
import java.util.List;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
public class MarkdownGenerator {
|
||||
|
||||
public static String generateCampaignMD(Campaign campaign) {
|
||||
if (campaign == null) {
|
||||
return "No campaign data available.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("### 🏆 Campaign Details\n\n");
|
||||
sb.append("| Field | Value |\n");
|
||||
sb.append("| :--- | :--- |\n");
|
||||
sb.append("| **Campaign ID** | ").append(campaign.getCampaignId()).append(" |\n");
|
||||
sb.append("| **Name** | ").append(campaign.getName()).append(" |\n");
|
||||
|
||||
if (campaign.getDescription() != null) {
|
||||
sb.append("| **Description** | ").append(campaign.getDescription()).append(" |\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String generateCampaignMD(List<Campaign> campaigns) {
|
||||
if (CollectionUtils.isEmpty(campaigns)) {
|
||||
return "No campaigns data available.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("### 🏆 Campaign List\n\n");
|
||||
sb.append("| Id | Name | Description |\n");
|
||||
sb.append("| :--- | :--- | :--- |\n");
|
||||
|
||||
campaigns.forEach((campaign) -> {
|
||||
sb.append("| ").append(campaign.getCampaignId());
|
||||
sb.append(" | ").append(campaign.getName());
|
||||
sb.append(" | ").append(campaign.getDescription()).append(" |\n");
|
||||
});
|
||||
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String generateCampaignMD(Long count) {
|
||||
if (count == null) {
|
||||
return "Total campaigns: 0";
|
||||
}
|
||||
return "Total campaigns: " + count;
|
||||
}
|
||||
|
||||
public static String generateCampaignIdMD(String id) {
|
||||
if (id == null) {
|
||||
return "No campaign ID available.";
|
||||
}
|
||||
return "Campaign ID: " + id;
|
||||
}
|
||||
|
||||
public static String generateCampaignRuleMD(CampaignRule rule) {
|
||||
if (rule == null) {
|
||||
return "No campaign rule data available.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("### 📜 Campaign Rule Details\n\n");
|
||||
sb.append("| Field | Value |\n");
|
||||
sb.append("| :--- | :--- |\n");
|
||||
sb.append("| **Rule ID** | ").append(rule.getRuleId()).append(" |\n");
|
||||
sb.append("| **Name** | ").append(rule.getRuleName()).append(" |\n");
|
||||
if (rule.getRuleType() != null) {
|
||||
sb.append("| **Type** | ").append(rule.getRuleType()).append(" |\n");
|
||||
}
|
||||
if (rule.getCampaignId() != null) {
|
||||
sb.append("| **Campaign ID** | ").append(rule.getCampaignId().getCode()).append(" |\n");
|
||||
}
|
||||
if (rule.getDescription() != null) {
|
||||
sb.append("| **Description** | ").append(rule.getDescription()).append(" |\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String generateCampaignRuleMD(List<CampaignRule> rules) {
|
||||
if (CollectionUtils.isEmpty(rules)) {
|
||||
return "No campaign rules data available.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("### 📜 Campaign Rule List\n\n");
|
||||
sb.append("| Rule Id | Name | Type | Campaign Id | Description |\n");
|
||||
sb.append("| :--- | :--- | :--- | :--- | :--- |\n");
|
||||
|
||||
rules.forEach((rule) -> {
|
||||
sb.append("| ").append(rule.getRuleId());
|
||||
sb.append(" | ").append(rule.getRuleName());
|
||||
sb.append(" | ").append(rule.getRuleType() != null ? rule.getRuleType() : "");
|
||||
sb.append(" | ").append(rule.getCampaignId() != null ? rule.getCampaignId().getCode() : "");
|
||||
sb.append(" | ").append(rule.getDescription() != null ? rule.getDescription() : "").append(" |\n");
|
||||
});
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String generateCampaignRuleMD(Long count) {
|
||||
if (count == null) {
|
||||
return "Total campaign rules: 0";
|
||||
}
|
||||
return "Total campaign rules: " + count;
|
||||
}
|
||||
|
||||
public static String generateCampaignRuleIdMD(String id) {
|
||||
if (id == null) {
|
||||
return "No campaign rule ID available.";
|
||||
}
|
||||
return "Campaign Rule ID: " + id;
|
||||
}
|
||||
|
||||
public static String generateCheckResultMD(String result) {
|
||||
if (result == null) {
|
||||
return "No check result available.";
|
||||
}
|
||||
return "Check result: " + result;
|
||||
}
|
||||
|
||||
public static String generateOperationResultMD(OperationResult<Campaign> result) {
|
||||
if (result == null) {
|
||||
return "No operation result available.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if ("00".equals(result.getErrorCode()) || result.getErrorCode() == null) {
|
||||
sb.append("✅ Operation successful.\n\n");
|
||||
if (result.getEntity() != null) {
|
||||
sb.append(generateCampaignMD(result.getEntity()));
|
||||
}
|
||||
} else {
|
||||
sb.append("❌ Operation failed.\n\n");
|
||||
sb.append("**Error Code:** ").append(result.getErrorCode()).append("\n");
|
||||
sb.append("**Message:** ").append(result.getMessage()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public static String generateCampaignRuleOperationResultMD(OperationResult<CampaignRule> result) {
|
||||
if (result == null) {
|
||||
return "No operation result available.";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if ("00".equals(result.getErrorCode()) || result.getErrorCode() == null) {
|
||||
sb.append("✅ Operation successful.\n\n");
|
||||
if (result.getEntity() != null) {
|
||||
sb.append(generateCampaignRuleMD(result.getEntity()));
|
||||
}
|
||||
} else {
|
||||
sb.append("❌ Operation failed.\n\n");
|
||||
sb.append("**Error Code:** ").append(result.getErrorCode()).append("\n");
|
||||
sb.append("**Message:** ").append(result.getMessage()).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,9 @@ loyalty:
|
||||
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||
|
||||
logging:
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} %5p --- %-40.40logger{39} : %m%n"
|
||||
level:
|
||||
root: warn
|
||||
org.springframework.ai: INFO
|
||||
org.springframework.web: INFO
|
||||
dev.sonpx.loyalty: DEBUG
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
# Master Plan: Loyalty Agent Service
|
||||
|
||||
_Focus hiện tại: MCP Orchestrator cho Reward Core API, dựa trên `loyalty-mcp-server/src/main/resources/specs/reward.json`._
|
||||
|
||||
Core Reward API đang expose **11 CSR resources chính** theo cùng một pattern Maker-Checker:
|
||||
|
||||
1. `Transaction Category`
|
||||
2. `Transaction Code`
|
||||
3. `Statement Output Pool (SOP)`
|
||||
4. `Pool Definition`
|
||||
5. `Pool Conversion Rate`
|
||||
6. `Currency Rate`
|
||||
7. `Event Maintenance`
|
||||
8. `Deduction Sequences`
|
||||
9. `Counter Definition`
|
||||
10. `Campaign`
|
||||
11. `Campaign Rule`
|
||||
|
||||
Ngoài nhóm CSR trên, spec còn có các endpoint hỗ trợ rất quan trọng cho Agent như `id/generate`, `id/check`, `list`, `count`, `history`, `list-by-ids`, `list-by-codes`, `list-by-pool`, `precision-by-pools`, `find-default`, `exists-default`, `verifyCriteria`, `amount-to-use-view`, `decide-tier-view`.
|
||||
|
||||
Vì Core API không sửa được, Loyalty Agent Service/MCP Server sẽ đóng vai trò **orchestrator + DTO builder + validator**: hướng dẫn user điền thông tin, tra cứu dữ liệu phụ thuộc, build DTO hợp lệ, validate trước, rồi mới gọi endpoint CSR tương ứng.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Reward MCP Orchestrator
|
||||
|
||||
_Mục tiêu: biến Reward OpenAPI thành bộ MCP tools dùng được trong hội thoại, ưu tiên luồng tạo Campaign/Rule/Pool end-to-end._
|
||||
|
||||
### 1.1. Foundation Tools dùng chung
|
||||
|
||||
Các resource CSR có pattern giống nhau, nên cần build một lớp client/tool generator hoặc wrapper dùng chung thay vì viết thủ công từng API.
|
||||
|
||||
- `generate_id(resource)`: gọi `/api/{resource}/csr/id/generate`.
|
||||
- `check_id(resource, id)`: gọi `/api/{resource}/csr/id/check/{id}`.
|
||||
- `get_by_id(resource, id)` hoặc `get_by_record_no(resource, recordNo)`.
|
||||
- `list(resource, criteria, page, size, sort)`.
|
||||
- `count(resource, criteria)`.
|
||||
- `create_draft(resource, dto)`.
|
||||
- `edit_draft(resource, dto)`.
|
||||
- `approve/reject/bulk-approve/bulk-reject` chỉ expose sau, vì đây là hành động nhạy cảm.
|
||||
- `history(resource, criteria)` và `rejection_reason(recordNo)` dùng để giải thích trạng thái Maker-Checker cho user.
|
||||
|
||||
Nguyên tắc: trong giai đoạn đầu, Agent ưu tiên tạo draft, không tự approve.
|
||||
|
||||
### 1.2. DTO Builder & Validation Layer
|
||||
|
||||
MCP Server cần giữ local draft theo conversation/session để user có thể build DTO từng bước.
|
||||
|
||||
- Mỗi draft có `draftId`, `resource`, `dto`, `missingRequiredFields`, `warnings`, `lastCoreValidation`.
|
||||
- Không đưa các field `readOnly` vào request: `status`, `lastUpdateBy`, `lastUpdateDate`, `lastApproveBy`, `lastApproveDate`, `lastUpdateByName`, `lastApproveByName`.
|
||||
- Validate required fields theo schema trước khi gọi Core API.
|
||||
- Validate enum, maxLength/minLength, date/date-time format.
|
||||
- Với field `$ref: ReferenceData`, Agent phải tra cứu hoặc xác nhận object reference, không chỉ nhét string trần nếu Core client yêu cầu object.
|
||||
- Với nested DTO trong `CampaignRuleDto`, build theo template theo `ruleType` thay vì hỏi tất cả field.
|
||||
|
||||
Required fields quan trọng theo spec:
|
||||
|
||||
- `Campaign`: `campaignId`, `name`.
|
||||
- `CampaignRuleDto`: `campaignId`, `ruleId`, `ruleName`.
|
||||
- `PoolDefinitionDto`: `poolId`, `poolName`.
|
||||
- `TransactionCodeDto`: `code`, `description`.
|
||||
- `TransactionCategoryDto`: `code`, `name`.
|
||||
- `PoolConversionRateDto`: `code`, `description`.
|
||||
- `CurrencyRateDto`: `pcrCode`, `effectiveFrom`, `effectiveTo`, `buyRate`, `sellRate`.
|
||||
- `EventMaintenanceDto`: `eventId`, `description`, `occursNext`.
|
||||
- `DeductionSequencesDto`: `sequenceId`, `sequenceName`, `effectiveFrom`, `effectiveTo`, `isDefault`.
|
||||
- `CounterDefinitionDto`: `counterId`, `counterName`, `effectiveFrom`, `effectiveTo`, `entity`, `bucketPeriodUnit`, `whatToCount`, `resetType`, `resetValue`, `updateStateWhen`, `lateValuePosting`.
|
||||
|
||||
### 1.3. Dependency Graph thực tế
|
||||
|
||||
Không hard-code rằng Campaign Rule nối trực tiếp với Event. Trong schema hiện tại, `CampaignRuleDto` không có `eventId`; nó nối chủ yếu qua:
|
||||
|
||||
- `campaignId`
|
||||
- `poolId` / `subPoolId`
|
||||
- `campaignTcLinkages`
|
||||
- `campaignCriteria`
|
||||
- `campaignFormula*`
|
||||
- `campaignRuleSchedule`
|
||||
- `marketingRewardRequest`
|
||||
- reward content theo channel `SMS`, `EMAIL`, `NOTIFY`
|
||||
|
||||
Dependency graph đề xuất:
|
||||
|
||||
1. **Reference/Lookup layer**
|
||||
- `amount-to-use-view/list`
|
||||
- `decide-tier-view/list`
|
||||
- transaction code lookup: `/api/transaction-code/list-by-codes`
|
||||
- pool lookup: `/api/pool-definition/list-by-ids`, `/api/pool-definition/precision-by-pools`
|
||||
- deduction sequence default: `/api/deduction-seq/find-default`
|
||||
|
||||
2. **Parameter layer**
|
||||
- `Transaction Category`
|
||||
- `Transaction Code`
|
||||
- `Pool Conversion Rate`
|
||||
- `Currency Rate`
|
||||
|
||||
3. **Pool layer**
|
||||
- `Statement Output Pool`
|
||||
- `Pool Definition`
|
||||
- optional `Counter Definition` nếu rule dùng cap/limit/counter formula
|
||||
|
||||
4. **Campaign layer**
|
||||
- `Campaign`
|
||||
|
||||
5. **Rule layer**
|
||||
- `Campaign Rule`
|
||||
- validate criteria bằng `/api/campaign-rule/campaign-criteria/verify`
|
||||
- kiểm tra liên kết bằng `/api/campaign-rule/list-by-pool`, `/api/campaign-rule/rule-linked-by-transaction-code`, `/api/campaign-rule/find-matching-rules`
|
||||
|
||||
6. **Standalone/scheduled support**
|
||||
- `Event Maintenance`
|
||||
- `Deduction Sequences`
|
||||
|
||||
### 1.4. Scope triển khai Phase 1
|
||||
|
||||
Không làm cả 11 resource ngang nhau ngay. Ưu tiên theo giá trị end-to-end:
|
||||
|
||||
**Milestone 1: Read/Lookup Tools**
|
||||
|
||||
- Implement tool list/search cho `Campaign`, `Campaign Rule`, `Pool Definition`, `Transaction Code`.
|
||||
- Implement `generate_id` và `check_id` cho các resource chính.
|
||||
- Implement helper lookup: `list-by-codes`, `list-by-ids`, `precision-by-pools`, `amount-to-use-view/list`, `decide-tier-view/list`.
|
||||
- Output tool phải trả dữ liệu ngắn gọn để Agent dùng tiếp, không dump DTO lớn.
|
||||
|
||||
**Milestone 2: Pool Setup**
|
||||
|
||||
- Tool tạo draft `PoolConversionRateDto`.
|
||||
- Tool tạo draft `PoolDefinitionDto`.
|
||||
- Tool tạo draft `StatementOutputPoolDto` nếu workflow statement cần SOP.
|
||||
- Validate expiry policy: `FD`, `NE`, `ARD`, `MF`, `QF`, `YF`, `SP`, `AOM`.
|
||||
- Validate pool type: `BPT`, `CR`, `GFT`, `MI`, `LDR`.
|
||||
|
||||
**Milestone 3: Campaign Setup**
|
||||
|
||||
- Tool tạo draft `Campaign`.
|
||||
- Agent tự generate/check `campaignId` nếu user chưa có mã.
|
||||
- Giữ template tối thiểu: `campaignId`, `name`, `ownerName`, `description`, `campaignType`, `effectiveFrom`, `effectiveTo`.
|
||||
- Không yêu cầu user nhập `numOfRule`; field này nên derive/hiển thị từ Core nếu có.
|
||||
|
||||
**Milestone 4: Campaign Rule Setup**
|
||||
|
||||
- Tool tạo draft `CampaignRuleDto`.
|
||||
- Template theo `ruleType`: `AWD`, `RED`, `IRED`, `ADJ`, `CEP`, `REP`, `TEP`, `MAWD`.
|
||||
- Với earning/award flow, ưu tiên support:
|
||||
- `campaignId`
|
||||
- `ruleId`, `ruleName`
|
||||
- `poolId`
|
||||
- `campaignTcLinkages`
|
||||
- `campaignCriteria`
|
||||
- `campaignFormulaSetting`
|
||||
- một trong các `campaignFormulaOne/Two/Four/Five/Six/Eight/Ten/Eleven`
|
||||
- `campaignAwardLimits` nếu có cap/counter
|
||||
- Gọi `verifyCriteria` trước khi submit draft nếu user có nhập criteria expression.
|
||||
|
||||
**Milestone 5: Approval Visibility**
|
||||
|
||||
- Tool xem trạng thái draft, history, rejection reason.
|
||||
- Tool giải thích record đang ở trạng thái nào và cần maker/checker làm gì tiếp theo.
|
||||
- Chưa expose approve/reject mặc định; chỉ bật khi có phân quyền và xác nhận rõ.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Memory & Conversation State
|
||||
|
||||
_Mục tiêu: Agent nhớ chính xác DTO đang build, các reference đã chọn, và trạng thái Maker-Checker._
|
||||
|
||||
- Lưu conversation memory và local draft state trong DB.
|
||||
- Context locking theo draft: khi user đang build `CampaignRuleDto`, Agent không tự chuyển sang draft khác trừ khi user xác nhận.
|
||||
- Cho phép nested flow có kiểm soát: nếu đang build rule nhưng thiếu pool, Agent có thể tạo sub-draft `PoolDefinitionDto`, hoàn tất xong quay lại rule.
|
||||
- Lưu các reference đã tạo/chọn gần nhất: `campaignId`, `ruleId`, `poolId`, `transactionCode`, `counterId`, `pcrCode`.
|
||||
- Khi user nói "dùng pool vừa tạo", Agent phải resolve từ memory thành reference rõ ràng và hỏi lại nếu có nhiều pool gần đây.
|
||||
- Mỗi draft cần có audit trail: user intent, fields collected, validation result, endpoint đã gọi, response recordNo/status.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI/Dashboard hỗ trợ Agent
|
||||
|
||||
_Mục tiêu: giảm hội thoại dài bằng UI chuyên biệt cho DTO lớn._
|
||||
|
||||
- **Draft Inspector:** hiển thị DTO hiện tại, missing fields, warnings, Core response.
|
||||
- **Dependency Viewer:** hiển thị Campaign -> Campaign Rule -> Pool -> Transaction Code/Counter/Formula.
|
||||
- **Interactive Forms:** form động sinh từ schema cho các DTO lớn như `CampaignRuleDto`.
|
||||
- **Criteria Builder:** UI hỗ trợ build và verify `campaignCriteria.criteria`.
|
||||
- **Formula Builder:** UI chọn formula type và chỉ hiện field liên quan.
|
||||
- **Maker-Checker Timeline:** show draft/history/rejection reason để user biết đang chờ bước nào.
|
||||
- Dùng WebSocket hiện tại để sync draft state real-time giữa chat và UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Auto-Pilot & Simulation
|
||||
|
||||
_Mục tiêu: từ intent tự nhiên tạo được campaign/rule/pool hoàn chỉnh nhưng vẫn kiểm soát được rủi ro._
|
||||
|
||||
- Intent template: "Tạo campaign nhân đôi điểm sinh nhật", "Tạo campaign hoàn tiền 5%", "Tạo rule tặng điểm theo tier".
|
||||
- Agent tự lập execution plan trước khi gọi API: cần tạo/check resource nào, endpoint nào, payload summary nào.
|
||||
- Auto-create draft theo thứ tự phụ thuộc, dừng lại khi thiếu field bắt buộc hoặc Core reject.
|
||||
- Simulation trước approve:
|
||||
- sample transaction
|
||||
- matched transaction code
|
||||
- pool impacted
|
||||
- formula result
|
||||
- cap/limit/counter impact
|
||||
- Human confirmation bắt buộc trước các hành động tạo nhiều draft hoặc gửi approve/reject.
|
||||
|
||||
---
|
||||
|
||||
## Nguyên tắc triển khai
|
||||
|
||||
- OpenAPI spec là source of truth; không đoán DTO field ngoài schema.
|
||||
- Tool output phải ngắn, có cấu trúc, dễ đưa lại vào Agent context.
|
||||
- Mutating tools phải idempotent ở tầng Agent/MCP nếu có thể: có `draftId`, correlation id, request summary.
|
||||
- Mọi create/edit draft đều chạy local validation trước, rồi mới gọi Core.
|
||||
- Với action nhạy cảm (`approve`, `reject`, `bulk-*`, `delete`), bắt buộc xác nhận rõ từ user.
|
||||
- Không để Agent tự tạo/chỉnh quá nhiều resource ngầm nếu chưa trình bày execution plan.
|
||||
|
||||
---
|
||||
|
||||
## Backlog kỹ thuật gần nhất
|
||||
|
||||
- [ ] Parse `reward.json` thành metadata nội bộ: resources, schemas, required fields, enums, readOnly fields, endpoints.
|
||||
- [ ] Implement generic Reward API client cho CSR pattern.
|
||||
- [ ] Implement MCP tools read-only cho Campaign/Rule/Pool/Transaction Code.
|
||||
- [ ] Implement local draft store cho `Campaign`, `PoolDefinitionDto`, `CampaignRuleDto`.
|
||||
- [ ] Implement validator dùng OpenAPI schema.
|
||||
- [ ] Implement create-draft flow cho Pool Definition.
|
||||
- [ ] Implement create-draft flow cho Campaign.
|
||||
- [ ] Implement create-draft flow cho Campaign Rule với `verifyCriteria`.
|
||||
- [ ] Add integration tests dùng mock Reward Core API.
|
||||
- [ ] Add UI Draft Inspector sau khi backend flow ổn định.
|
||||
@@ -32,7 +32,7 @@ echo "=========================================="
|
||||
# 1. Start MCP Server
|
||||
echo "Starting loyalty-mcp-server..."
|
||||
cd "$PROJECT_ROOT/loyalty-mcp-server"
|
||||
../mvnw spring-boot:run -Pgen -Dmaven.test.skip=true > "$LOG_DIR/loyalty-mcp-server.log" 2>&1 &
|
||||
../mvnw spring-boot:run -Dmaven.test.skip=true > "$LOG_DIR/loyalty-mcp-server.log" 2>&1 &
|
||||
MCP_PID=$!
|
||||
echo "loyalty-mcp-server started with PID: $MCP_PID"
|
||||
|
||||
@@ -45,14 +45,14 @@ echo "loyalty-mcp-server is up!"
|
||||
|
||||
# Start nodemon watcher for MCP Server
|
||||
echo "Starting nodemon watcher for loyalty-mcp-server..."
|
||||
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "../mvnw compile -Pgen" > "$LOG_DIR/loyalty-mcp-server-nodemon.log" 2>&1 &
|
||||
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "../mvnw compile" > "$LOG_DIR/loyalty-mcp-server-nodemon.log" 2>&1 &
|
||||
MCP_NODEMON_PID=$!
|
||||
|
||||
|
||||
# 2. Start Agent Service
|
||||
echo "Starting loyalty-agent..."
|
||||
cd "$PROJECT_ROOT/loyalty-agent"
|
||||
../mvnw spring-boot:run -Pgen -Dmaven.test.skip=true > "$LOG_DIR/loyalty-agent.log" 2>&1 &
|
||||
../mvnw spring-boot:run -Dmaven.test.skip=true > "$LOG_DIR/loyalty-agent.log" 2>&1 &
|
||||
AGENT_PID=$!
|
||||
echo "loyalty-agent started with PID: $AGENT_PID"
|
||||
|
||||
@@ -65,7 +65,7 @@ echo "loyalty-agent is up!"
|
||||
|
||||
# Start nodemon watcher for Agent Service
|
||||
echo "Starting nodemon watcher for loyalty-agent..."
|
||||
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "../mvnw compile -Pgen" > "$LOG_DIR/loyalty-agent-nodemon.log" 2>&1 &
|
||||
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "../mvnw compile" > "$LOG_DIR/loyalty-agent-nodemon.log" 2>&1 &
|
||||
AGENT_NODEMON_PID=$!
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user