diff --git a/context.md b/context.md
index 6113c49..180532e 100644
--- a/context.md
+++ b/context.md
@@ -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`.
diff --git a/loyalty-agent/pom.xml b/loyalty-agent/pom.xml
index eb7ed25..6dc5105 100644
--- a/loyalty-agent/pom.xml
+++ b/loyalty-agent/pom.xml
@@ -45,6 +45,14 @@
spring-boot-starter-webflux
+
+
+ io.netty
+ netty-resolver-dns-native-macos
+ osx-aarch_64
+ runtime
+
+
org.springframework.boot
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java
index 6d12218..8aa83e0 100644
--- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AgentConfig.java
@@ -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();
+// }
}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java
new file mode 100644
index 0000000..d7b5162
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/AppConfig.java
@@ -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();
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java
index 641c0f3..54f34e2 100644
--- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/SecurityConfig.java
@@ -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;
+// }
}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java
index 3b9af0b..f458d5e 100644
--- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/AgentController.java
@@ -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>>() {});
- } 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>>() {});
+// } catch (Exception e) {
+// return java.util.List.of();
+// }
+// }
}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentExecutor.java
new file mode 100644
index 0000000..c960540
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentExecutor.java
@@ -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 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");
+ }
+ }
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java
new file mode 100644
index 0000000..7ac3ed8
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java
@@ -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 toolProviders;
+ private final AgentEventPublisher eventPublisher;
+ private final ToolResultPresentationProcessor toolResultProcessor;
+
+ public List getWrappedTools(String sessionId, String messageId) {
+ List 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());
+ }
+ }
+ };
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/AgentMemoryManager.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/AgentMemoryManager.java
new file mode 100644
index 0000000..ed1dd6d
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/AgentMemoryManager.java
@@ -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();
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/AgentMemoryManagerImpl.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/AgentMemoryManagerImpl.java
new file mode 100644
index 0000000..05c27e8
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/AgentMemoryManagerImpl.java
@@ -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;
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java
new file mode 100644
index 0000000..e2cbdc7
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java
@@ -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> 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 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 messages) {
+ int chars = messages.stream()
+ .mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
+ .sum();
+ return chars / CHARS_PER_TOKEN;
+ }
+
+ @Override
+ public List get(String conversationId) {
+ return conversationHistory.getOrDefault(conversationId, new ArrayList<>());
+ }
+
+ @Override
+ public void clear(String conversationId) {
+ conversationHistory.remove(conversationId);
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java
new file mode 100644
index 0000000..ad40bc0
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/AgentOrchestrator.java
@@ -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.
+ }
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java
new file mode 100644
index 0000000..842a7ab
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java
@@ -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 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;
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/planner/AgentPlanner.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/planner/AgentPlanner.java
new file mode 100644
index 0000000..ce3d8f4
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/planner/AgentPlanner.java
@@ -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 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);
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/planner/Plan.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/planner/Plan.java
new file mode 100644
index 0000000..289f6d7
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/planner/Plan.java
@@ -0,0 +1,7 @@
+package dev.sonpx.loyalty.agent.core.planner;
+
+import java.util.List;
+
+public record Plan(List tasks) {
+ public record Task(String id, String description) {}
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java
new file mode 100644
index 0000000..f0a858d
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java
@@ -0,0 +1,4 @@
+package dev.sonpx.loyalty.agent.domain;
+
+public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java
new file mode 100644
index 0000000..771dfe3
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java
@@ -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);
+ }
+}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java
index 2e0b16d..a5c29ae 100644
--- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/LoyaltyAgentService.java
@@ -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 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 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);
}
}
diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/ToolResultPresentationProcessor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/ToolResultPresentationProcessor.java
new file mode 100644
index 0000000..a7fa711
--- /dev/null
+++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/ToolResultPresentationProcessor.java
@@ -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 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 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;
+ }
+}
diff --git a/loyalty-agent/src/main/resources/application.yml b/loyalty-agent/src/main/resources/application.yml
index e63bf22..2d90c9c 100644
--- a/loyalty-agent/src/main/resources/application.yml
+++ b/loyalty-agent/src/main/resources/application.yml
@@ -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
diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/controller/AgentControllerTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/controller/AgentControllerTest.java
index 07684e4..9b576ee 100644
--- a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/controller/AgentControllerTest.java
+++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/controller/AgentControllerTest.java
@@ -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.
}
}
diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/service/ToolResultPresentationProcessorTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/service/ToolResultPresentationProcessorTest.java
new file mode 100644
index 0000000..d061232
--- /dev/null
+++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/service/ToolResultPresentationProcessorTest.java
@@ -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"));
+ }
+}
diff --git a/loyalty-mcp-server/pom.xml b/loyalty-mcp-server/pom.xml
index ed8df38..db09fd1 100644
--- a/loyalty-mcp-server/pom.xml
+++ b/loyalty-mcp-server/pom.xml
@@ -31,12 +31,27 @@
spring-ai-starter-mcp-server-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-aop
+ 3.4.0
+
+
org.springframework.boot
spring-boot-starter-webflux
+
+
+ io.netty
+ netty-resolver-dns-native-macos
+ osx-aarch_64
+ runtime
+
+
org.springframework.boot
@@ -108,6 +123,7 @@
org.springframework.boot
spring-boot-maven-plugin
+ dev.sonpx.loyalty.mcp.McpServerApplication
org.projectlombok
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java
new file mode 100644
index 0000000..1f09b65
--- /dev/null
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/aspect/GlobalToolExceptionHandlerAspect.java
@@ -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.");
+ }
+ }
+}
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java
index 3510384..1d0f781 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/RestClientConfig.java
@@ -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();
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java
new file mode 100644
index 0000000..cecb81b
--- /dev/null
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/exception/GlobalMcpExceptionHandler.java
@@ -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 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.\"}");
+ }
+}
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/Result.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/Result.java
index 33d96cd..0d3f684 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/Result.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/model/Result.java
@@ -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 {
private final T data;
- private Map presentation;
+ @JsonProperty("_agent_instruction")
+ private String agentInstruction;
- public static Result of(T data, String content) {
+ public static Result of(T data, String instruction) {
Result 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;
}
}
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignClient.java
index 9ca55be..0df7e43 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignClient.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignClient.java
@@ -23,21 +23,21 @@ public interface CampaignClient {
@GetExchange("/api/campaign/csr/list")
List 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 findActiveByIds(@RequestParam Map params);
+ @GetExchange("/api/campaign/list-by-ids")
+ List findActiveByIds(@RequestParam("idList") List 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 create(@Valid @RequestBody Campaign dto);
}
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignRuleClient.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignRuleClient.java
index 2826e83..e9709b0 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignRuleClient.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/client/CampaignRuleClient.java
@@ -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 findActiveByIds(@RequestParam Map params);
+ @GetExchange("/api/campaign-rule/list-by-ids")
+ List findActiveByIds(@RequestParam("idList") List idList);
@GetExchange("/api/campaign-rule/csr/id/generate")
String generateId();
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java
index 675ae01..2a9f52f 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java
@@ -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> getAll(CampaignRuleCriteria criteria) {
- try {
- List 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);
- }
+ List rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10));
+ return Result.of(rules, "Format these campaign rules as a markdown table with columns ID, Campaign ID, Rule Code, Status.");
}
public Result 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);
- }
+ Long count = campaignRuleClient.count(criteria);
+ return Result.of(count, "Present the campaign rule count.");
}
public Result 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);
- }
+ CampaignRule rule = campaignRuleClient.findActiveById(id);
+ return Result.of(rule, "Format this campaign rule as a markdown table with its details.");
}
public Result> findActiveByIds(String ids) {
- try {
- List 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 idList = java.util.Arrays.asList(ids.split(",\\s*"));
+ List 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 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);
- }
+ String id = campaignRuleClient.generateId();
+ return Result.of(id, "Present the newly generated campaign rule ID.");
}
public Result 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);
- }
+ String checkResult = campaignRuleClient.checkId(id);
+ return Result.of(checkResult, "Present the result of the campaign rule ID check.");
}
public Result> create(CampaignRule dto) {
- try {
- OperationResult opResult = campaignRuleClient.create(dto);
- return Result.of(opResult, MarkdownGenerator.generateCampaignRuleOperationResultMD(opResult));
- } catch (Exception e) {
- log.error(e.getMessage(), e);
- return Result.of(null);
- }
+ OperationResult opResult = campaignRuleClient.create(dto);
+ return Result.of(opResult, "Present the result of the campaign rule creation operation, formatting the created rule details if successful.");
}
}
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java
index 130e969..8be2daf 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java
@@ -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> getAll(CampaignCriteria criteria) {
- try {
- List 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 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 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);
- }
+ Long count = campaignClient.count(criteria);
+ return Result.of(count, "Present the campaign count.");
}
public Result 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);
- }
+ Campaign campaign = campaignClient.findActiveById(id);
+ return Result.of(campaign, "Format this campaign as a markdown table with its details.");
}
public Result> findActiveByIds(String ids) {
- try {
- List 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 idList = java.util.Arrays.asList(ids.split(",\\s*"));
+ List 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 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);
- }
+ String id = campaignClient.generateId();
+ return Result.of(id, "Present the newly generated campaign ID.");
}
public Result 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);
- }
+ String checkResult = campaignClient.checkId(id);
+ return Result.of(checkResult, "Present the result of the campaign ID check.");
}
public Result> create(Campaign dto) {
- try {
- OperationResult opResult = campaignClient.create(dto);
- return Result.of(opResult, MarkdownGenerator.generateOperationResultMD(opResult));
- } catch (Exception e) {
- log.error(e.getMessage(), e);
- return Result.of(null);
- }
+ OperationResult opResult = campaignClient.create(dto);
+ return Result.of(opResult, "Present the result of the campaign creation operation, formatting the created campaign details if successful.");
}
}
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java
index 4db1fb8..54e129d 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java
@@ -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> campaignRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria();
criteria.setSearch(search);
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java
index f8c73c2..9ab150b 100644
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java
+++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java
@@ -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> campaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria();
criteria.setSearch(search);
diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/util/MarkdownGenerator.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/util/MarkdownGenerator.java
deleted file mode 100644
index 39c18d4..0000000
--- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/util/MarkdownGenerator.java
+++ /dev/null
@@ -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 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 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 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 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();
- }
-}
diff --git a/loyalty-mcp-server/src/main/resources/application.yml b/loyalty-mcp-server/src/main/resources/application.yml
index b46e5c6..38d0abc 100644
--- a/loyalty-mcp-server/src/main/resources/application.yml
+++ b/loyalty-mcp-server/src/main/resources/application.yml
@@ -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
diff --git a/plan/master_plan.md b/plan/master_plan.md
deleted file mode 100644
index 7b1114b..0000000
--- a/plan/master_plan.md
+++ /dev/null
@@ -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.
diff --git a/start-all.sh b/start-all.sh
index 6f93e49..6bee8e8 100755
--- a/start-all.sh
+++ b/start-all.sh
@@ -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=$!