feat: implement agent core components including planner, executor, orchestrator, and memory management while refactoring tool result presentation

This commit is contained in:
2026-07-21 19:08:25 +07:00
parent 94588bb12f
commit 5b128d8965
37 changed files with 892 additions and 720 deletions

View File

@@ -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>

View File

@@ -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();
// }
}

View File

@@ -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();
}
}

View File

@@ -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;
// }
}

View File

@@ -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();
// }
// }
}

View File

@@ -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");
}
}
}
}

View File

@@ -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());
}
}
};
}
}

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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.
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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) {}
}

View File

@@ -0,0 +1,4 @@
package dev.sonpx.loyalty.agent.domain;
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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.
}
}

View File

@@ -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"));
}
}