feat: implement agent core components including planner, executor, orchestrator, and memory management while refactoring tool result presentation
This commit is contained in:
@@ -45,6 +45,14 @@
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Netty DNS Resolver for MacOS (Apple Silicon) -->
|
||||
<dependency>
|
||||
<groupId>io.netty</groupId>
|
||||
<artifactId>netty-resolver-dns-native-macos</artifactId>
|
||||
<classifier>osx-aarch_64</classifier>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- OAuth2 Client -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
@Configuration
|
||||
public class AgentConfig {
|
||||
|
||||
private static final String AGENT_SYSTEM_PROMPT = """
|
||||
You are an expert Loyalty System AI Assistant.
|
||||
Your primary job is to orchestrate operations for our Loyalty core system.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Always answer the user in the language they used (e.g., Vietnamese).
|
||||
2. When a tool result contains an `agent_instruction`, you MUST strictly follow its formatting rules when presenting the relevant data to the user. Do not output raw JSON data unless explicitly asked.
|
||||
""";
|
||||
|
||||
@Value("${loyalty.core.base-url}")
|
||||
private String coreBaseUrl;
|
||||
|
||||
@Bean
|
||||
public RestClient coreRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
return RestClient.builder()
|
||||
.baseUrl(coreBaseUrl)
|
||||
.requestInterceptor((request, body, execution) -> {
|
||||
org.springframework.security.core.Authentication principal =
|
||||
new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||
"key", "loyalty-agent-service",
|
||||
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||
.withClientRegistrationId("keycloak")
|
||||
.principal(principal)
|
||||
.build();
|
||||
try {
|
||||
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return execution.execute(request, body);
|
||||
})
|
||||
public ChatClient loyaltyChatClient(ChatModel chatModel) {
|
||||
return ChatClient.builder(chatModel)
|
||||
.defaultSystem(AGENT_SYSTEM_PROMPT)
|
||||
.build();
|
||||
}
|
||||
|
||||
// @Bean
|
||||
// public RestClient coreRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||
// return RestClient.builder()
|
||||
// .baseUrl(coreBaseUrl)
|
||||
// .requestInterceptor((request, body, execution) -> {
|
||||
// org.springframework.security.core.Authentication principal =
|
||||
// new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||
// "key", "loyalty-agent-service",
|
||||
// org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||
// OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||
// .withClientRegistrationId("keycloak")
|
||||
// .principal(principal)
|
||||
// .build();
|
||||
// try {
|
||||
// OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||
// if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||
// request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||
// }
|
||||
// } catch (Exception e) {
|
||||
// e.printStackTrace();
|
||||
// }
|
||||
// return execution.execute(request, body);
|
||||
// })
|
||||
// .build();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Created by SonPhung on Tuesday, 21-Jul-2026
|
||||
*/
|
||||
@Configuration
|
||||
public class AppConfig {
|
||||
|
||||
@Bean
|
||||
public ObjectMapper objectMapper() {
|
||||
return new ObjectMapper();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package dev.sonpx.loyalty.agent.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
@@ -15,13 +14,31 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.authorizeHttpRequests(authz -> authz
|
||||
.requestMatchers("/api/v1/agent/**", "/api/v1/conversations/**", "/ws/**", "/error").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(Customizer.withDefaults())
|
||||
.oauth2Client(Customizer.withDefaults());
|
||||
);
|
||||
// .oauth2Login(Customizer.withDefaults())
|
||||
// .oauth2Client(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
// @Bean
|
||||
// public OAuth2AuthorizedClientManager authorizedClientManager(
|
||||
// ClientRegistrationRepository clientRegistrationRepository,
|
||||
// OAuth2AuthorizedClientService authorizedClientService) {
|
||||
//
|
||||
// OAuth2AuthorizedClientProvider authorizedClientProvider =
|
||||
// OAuth2AuthorizedClientProviderBuilder.builder()
|
||||
// .clientCredentials()
|
||||
// .build();
|
||||
//
|
||||
// AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
|
||||
// new AuthorizedClientServiceOAuth2AuthorizedClientManager(
|
||||
// clientRegistrationRepository, authorizedClientService);
|
||||
// authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
|
||||
//
|
||||
// return authorizedClientManager;
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,71 +1,64 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
|
||||
import java.security.Principal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@CrossOrigin(origins = "*")
|
||||
@RequiredArgsConstructor
|
||||
public class AgentController {
|
||||
|
||||
private final LoyaltyAgentService agentService;
|
||||
|
||||
private final org.springframework.web.client.RestClient coreRestClient;
|
||||
|
||||
public AgentController(LoyaltyAgentService agentService, org.springframework.web.client.RestClient coreRestClient) {
|
||||
this.agentService = agentService;
|
||||
this.coreRestClient = coreRestClient;
|
||||
}
|
||||
|
||||
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {}
|
||||
|
||||
@MessageMapping("/chat")
|
||||
public void chat(@Payload ChatRequest request, Principal principal) {
|
||||
agentService.chat(request, principal.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/agent/campaigns")
|
||||
public Object getCampaigns(
|
||||
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
|
||||
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||
@org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||
|
||||
String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
|
||||
if (status != null && !status.isEmpty()) url += "&status=" + status;
|
||||
if (name != null && !name.isEmpty()) url += "&name=" + name;
|
||||
|
||||
String response = coreRestClient.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return standardizeResponse(response);
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
|
||||
public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
|
||||
String response = coreRestClient.get()
|
||||
.uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
|
||||
.retrieve()
|
||||
.body(String.class);
|
||||
return standardizeResponse(response);
|
||||
}
|
||||
|
||||
Object standardizeResponse(String jsonStr) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
|
||||
com.fasterxml.jackson.databind.JsonNode arrayNode = root;
|
||||
if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
|
||||
else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
|
||||
|
||||
return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
|
||||
} catch (Exception e) {
|
||||
return java.util.List.of();
|
||||
}
|
||||
}
|
||||
// @GetMapping("/api/v1/agent/campaigns")
|
||||
// public Object getCampaigns(
|
||||
// @org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
|
||||
// @org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
|
||||
// @org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||
// @org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||
//
|
||||
// String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
|
||||
// if (status != null && !status.isEmpty()) url += "&status=" + status;
|
||||
// if (name != null && !name.isEmpty()) url += "&name=" + name;
|
||||
//
|
||||
// String response = coreRestClient.get()
|
||||
// .uri(url)
|
||||
// .retrieve()
|
||||
// .body(String.class);
|
||||
// return standardizeResponse(response);
|
||||
// }
|
||||
//
|
||||
// @GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
|
||||
// public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
|
||||
// String response = coreRestClient.get()
|
||||
// .uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
|
||||
// .retrieve()
|
||||
// .body(String.class);
|
||||
// return standardizeResponse(response);
|
||||
// }
|
||||
//
|
||||
// Object standardizeResponse(String jsonStr) {
|
||||
// try {
|
||||
// com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
// com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
|
||||
// com.fasterxml.jackson.databind.JsonNode arrayNode = root;
|
||||
// if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
|
||||
// else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
|
||||
//
|
||||
// return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
|
||||
// } catch (Exception e) {
|
||||
// return java.util.List.of();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package dev.sonpx.loyalty.agent.core.executor;
|
||||
|
||||
import dev.sonpx.loyalty.agent.core.planner.Plan;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
|
||||
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentExecutor {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final ToolInterceptor toolInterceptor;
|
||||
private final AgentEventPublisher eventPublisher;
|
||||
private final AgentMemoryManager memoryManager;
|
||||
|
||||
public void execute(Plan plan, ChatRequest request, String sessionId) {
|
||||
List<ToolCallback> wrappedTools = toolInterceptor.getWrappedTools(sessionId, request.messageId());
|
||||
|
||||
StringBuilder executionContext = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < plan.tasks().size(); i++) {
|
||||
Plan.Task task = plan.tasks().get(i);
|
||||
boolean isLastTask = (i == plan.tasks().size() - 1);
|
||||
|
||||
String executorPrompt = "You are an executor for an AI Agent.\n" +
|
||||
"Your current task is: " + task.description() + "\n" +
|
||||
"Context from previous tasks:\n" + executionContext + "\n" +
|
||||
"Use the provided tools if necessary to complete the task.\n";
|
||||
|
||||
if (isLastTask) {
|
||||
executorPrompt += "This is the final task. Formulate a final response to the user based on the results.\n" +
|
||||
"CRITICAL RULE: When a tool result contains an 'agent_instruction', you MUST strictly follow its formatting rules when presenting the relevant data to the user.";
|
||||
|
||||
log.info(executorPrompt);
|
||||
|
||||
chatClient.prompt()
|
||||
.advisors(memoryManager.getAdvisor())
|
||||
.advisors(a -> a.param("chat_memory_conversation_id", sessionId).param("chat_memory_response_size", 100))
|
||||
.system(executorPrompt)
|
||||
.user(request.prompt() != null ? request.prompt() : "Execute task.")
|
||||
.tools(wrappedTools)
|
||||
.stream()
|
||||
.content()
|
||||
.doOnComplete(() -> eventPublisher.done(sessionId, request.messageId()))
|
||||
.subscribe(
|
||||
content -> {
|
||||
if (!content.isEmpty()) {
|
||||
eventPublisher.token(sessionId, request.messageId(), content);
|
||||
}
|
||||
},
|
||||
error -> eventPublisher.error(sessionId, request.messageId(), error.getMessage())
|
||||
);
|
||||
} else {
|
||||
executorPrompt += "Do NOT address the user directly. Summarize the result of this task for the next steps.";
|
||||
|
||||
log.info(executorPrompt);
|
||||
|
||||
String stepResult = chatClient.prompt()
|
||||
.system(executorPrompt)
|
||||
.user("Execute task.")
|
||||
.tools(wrappedTools)
|
||||
.call()
|
||||
.content();
|
||||
executionContext.append("Task: ").append(task.description()).append("\nResult: ").append(stepResult).append("\n\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package dev.sonpx.loyalty.agent.core.executor;
|
||||
|
||||
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
|
||||
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolInterceptor {
|
||||
|
||||
private final List<ToolCallbackProvider> toolProviders;
|
||||
private final AgentEventPublisher eventPublisher;
|
||||
private final ToolResultPresentationProcessor toolResultProcessor;
|
||||
|
||||
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
|
||||
List<ToolCallback> wrappedTools = new ArrayList<>();
|
||||
|
||||
for (ToolCallbackProvider provider : toolProviders) {
|
||||
for (ToolCallback tool : provider.getToolCallbacks()) {
|
||||
wrappedTools.add(wrap(tool, sessionId, messageId));
|
||||
}
|
||||
}
|
||||
return wrappedTools;
|
||||
}
|
||||
|
||||
private ToolCallback wrap(ToolCallback tool, String sessionId, String messageId) {
|
||||
return new ToolCallback() {
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return tool.getToolDefinition();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
eventPublisher.toolRunning(sessionId, messageId, getToolDefinition().name());
|
||||
try {
|
||||
String result = tool.call(toolInput);
|
||||
if (result != null && (
|
||||
(result.contains("java.lang.") && result.contains("Exception") && result.contains("org.springframework.")) ||
|
||||
result.contains("HTTP Status 500 – Internal Server Error")
|
||||
)) {
|
||||
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
|
||||
}
|
||||
return toolResultProcessor.process(result);
|
||||
} catch (Exception e) {
|
||||
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
|
||||
} finally {
|
||||
eventPublisher.toolDone(sessionId, messageId, getToolDefinition().name());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.sonpx.loyalty.agent.core.memory;
|
||||
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
|
||||
public interface AgentMemoryManager {
|
||||
MessageChatMemoryAdvisor getAdvisor();
|
||||
ChatMemory getChatMemory();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.sonpx.loyalty.agent.core.memory;
|
||||
|
||||
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AgentMemoryManagerImpl implements AgentMemoryManager {
|
||||
|
||||
private final ChatMemory chatMemory;
|
||||
|
||||
public AgentMemoryManagerImpl(ChatMemory chatMemory) {
|
||||
this.chatMemory = chatMemory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageChatMemoryAdvisor getAdvisor() {
|
||||
return MessageChatMemoryAdvisor.builder(chatMemory).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMemory getChatMemory() {
|
||||
return chatMemory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package dev.sonpx.loyalty.agent.core.memory;
|
||||
|
||||
import org.springframework.ai.chat.memory.ChatMemory;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Component
|
||||
public class InMemoryChatMemory implements ChatMemory {
|
||||
|
||||
private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>();
|
||||
private static final int MAX_TOKENS = 4000;
|
||||
private static final int CHARS_PER_TOKEN = 4;
|
||||
|
||||
@Override
|
||||
public void add(String conversationId, List<Message> messages) {
|
||||
conversationHistory.compute(conversationId, (id, history) -> {
|
||||
if (history == null) {
|
||||
history = new ArrayList<>();
|
||||
}
|
||||
history.addAll(messages);
|
||||
|
||||
// Truncation logic: keep messages such that estimated tokens < MAX_TOKENS
|
||||
while (history.size() > 1 && estimateTokens(history) > MAX_TOKENS) {
|
||||
// Find first non-system message to remove
|
||||
boolean removed = false;
|
||||
for (int i = 0; i < history.size(); i++) {
|
||||
if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) {
|
||||
history.remove(i);
|
||||
removed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If only system messages are left (or we couldn't remove), break to avoid infinite loop
|
||||
if (!removed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return history;
|
||||
});
|
||||
}
|
||||
|
||||
private int estimateTokens(List<Message> messages) {
|
||||
int chars = messages.stream()
|
||||
.mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
|
||||
.sum();
|
||||
return chars / CHARS_PER_TOKEN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Message> get(String conversationId) {
|
||||
return conversationHistory.getOrDefault(conversationId, new ArrayList<>());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear(String conversationId) {
|
||||
conversationHistory.remove(conversationId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.sonpx.loyalty.agent.core.orchestrator;
|
||||
|
||||
import dev.sonpx.loyalty.agent.core.executor.AgentExecutor;
|
||||
import dev.sonpx.loyalty.agent.core.planner.AgentPlanner;
|
||||
import dev.sonpx.loyalty.agent.core.planner.Plan;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentOrchestrator {
|
||||
|
||||
private final AgentPlanner planner;
|
||||
private final AgentExecutor executor;
|
||||
|
||||
public void process(ChatRequest request, String sessionId) {
|
||||
try {
|
||||
log.info("Generating plan for session: {}", sessionId);
|
||||
Plan plan;
|
||||
try {
|
||||
plan = planner.generatePlan(request, sessionId);
|
||||
log.info("Generated plan for session: {}, plan: {}", sessionId, plan);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to generate plan from LLM, falling back to default plan. Error: {}", e.getMessage());
|
||||
plan = null;
|
||||
}
|
||||
|
||||
if (plan == null || plan.tasks() == null || plan.tasks().isEmpty()) {
|
||||
log.warn("Generated plan is empty or failed, falling back to a single task");
|
||||
plan = new Plan(List.of(new Plan.Task("1", "Answer user request")));
|
||||
}
|
||||
|
||||
log.info("Generated plan with {} tasks", plan.tasks().size());
|
||||
executor.execute(plan, request, sessionId);
|
||||
} catch (Exception e) {
|
||||
log.error("Error processing request: {}", e.getMessage(), e);
|
||||
// The previous orchestrator didn't handle general process exception through eventPublisher
|
||||
// directly here because it was in the stream onError, but let's just log it or rethrow.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.sonpx.loyalty.agent.core.orchestrator;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Component
|
||||
public class PromptBuilder {
|
||||
|
||||
public String build(ChatRequest request) {
|
||||
List<String> context = new ArrayList<>();
|
||||
|
||||
if (StringUtils.hasText(request.conversationId())) {
|
||||
context.add("System Context: The current conversationId (session ID) is "
|
||||
+ request.conversationId()
|
||||
+ ". You MUST provide this conversationId as a parameter to any tool that requires it.");
|
||||
}
|
||||
if (StringUtils.hasText(request.activeCampaignId())) {
|
||||
context.add("Context: Active Campaign ID is " + request.activeCampaignId() + ".");
|
||||
}
|
||||
|
||||
String prompt = request.prompt() == null ? "" : request.prompt();
|
||||
if (context.isEmpty()) {
|
||||
return prompt;
|
||||
}
|
||||
return String.join("\n", context) + "\n\n" + prompt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package dev.sonpx.loyalty.agent.core.planner;
|
||||
|
||||
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.converter.BeanOutputConverter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class AgentPlanner {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
private final AgentMemoryManager memoryManager;
|
||||
private final PromptBuilder promptBuilder;
|
||||
|
||||
public AgentPlanner(ChatClient chatClient, AgentMemoryManager memoryManager, PromptBuilder promptBuilder) {
|
||||
this.chatClient = chatClient;
|
||||
this.memoryManager = memoryManager;
|
||||
this.promptBuilder = promptBuilder;
|
||||
}
|
||||
|
||||
public Plan generatePlan(ChatRequest request, String sessionId) {
|
||||
var converter = new BeanOutputConverter<>(Plan.class);
|
||||
|
||||
List<Message> history = memoryManager.getChatMemory().get(sessionId);
|
||||
String historyText = history.stream()
|
||||
.map(m -> m.getMessageType().name() + ": " + m.getText())
|
||||
.collect(Collectors.joining("\n"));
|
||||
|
||||
String systemPrompt = "You are a task planner for an AI Agent.\n" +
|
||||
"Break down the user's request into a sequence of executable atomic tasks.\n" +
|
||||
"If it's a simple conversation or question, return a single task representing the answer generation.\n" +
|
||||
"Do NOT try to answer the question yourself, just output the Plan.\n" +
|
||||
"Conversation History:\n" + historyText + "\n\n" +
|
||||
converter.getFormat();
|
||||
|
||||
String userText = promptBuilder.build(request);
|
||||
|
||||
return chatClient.prompt()
|
||||
.system(systemPrompt)
|
||||
.user(userText)
|
||||
.call()
|
||||
.entity(converter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.sonpx.loyalty.agent.core.planner;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record Plan(List<Task> tasks) {
|
||||
public record Task(String id, String description) {}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package dev.sonpx.loyalty.agent.domain;
|
||||
|
||||
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import dev.sonpx.loyalty.agent.domain.AgentEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AgentEventPublisher {
|
||||
|
||||
private static final String CHAT_EVENTS_DESTINATION = "/queue/chat-events";
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public void token(String sessionId, String messageId, String content) {
|
||||
send(sessionId, AgentEvent.token(messageId, content));
|
||||
}
|
||||
|
||||
public void toolRunning(String sessionId, String messageId, String toolName) {
|
||||
send(sessionId, AgentEvent.toolStatus(messageId, toolName, "running"));
|
||||
}
|
||||
|
||||
public void toolDone(String sessionId, String messageId, String toolName) {
|
||||
send(sessionId, AgentEvent.toolStatus(messageId, toolName, "done"));
|
||||
}
|
||||
|
||||
public void done(String sessionId, String messageId) {
|
||||
send(sessionId, AgentEvent.done(messageId));
|
||||
}
|
||||
|
||||
public void error(String sessionId, String messageId, String errorMessage) {
|
||||
send(sessionId, AgentEvent.error(messageId, errorMessage));
|
||||
}
|
||||
|
||||
private void send(String sessionId, AgentEvent event) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, CHAT_EVENTS_DESTINATION, event);
|
||||
}
|
||||
}
|
||||
@@ -1,80 +1,17 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import dev.sonpx.loyalty.agent.controller.AgentController;
|
||||
import dev.sonpx.loyalty.agent.domain.AgentEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import dev.sonpx.loyalty.agent.core.orchestrator.AgentOrchestrator;
|
||||
import dev.sonpx.loyalty.agent.domain.ChatRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoyaltyAgentService {
|
||||
|
||||
private final ChatModel chatModel;
|
||||
private final List<ToolCallbackProvider> toolProviders;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final AgentOrchestrator agentOrchestrator;
|
||||
|
||||
public void chat(AgentController.ChatRequest request, String sessionId) {
|
||||
String prompt = request.prompt();
|
||||
if (request.activeCampaignId() != null && !request.activeCampaignId().isEmpty()) {
|
||||
prompt = "Context: Active Campaign ID is " + request.activeCampaignId() + ".\n\n" + prompt;
|
||||
}
|
||||
if (request.conversationId() != null && !request.conversationId().isEmpty()) {
|
||||
prompt = "System Context: The current conversationId (session ID) is " + request.conversationId() + ". You MUST provide this conversationId as a parameter to any tool that requires it.\n\n" + prompt;
|
||||
}
|
||||
|
||||
List<ToolCallback> wrappedTools = new ArrayList<>();
|
||||
for (ToolCallbackProvider provider : toolProviders) {
|
||||
for (ToolCallback tool : provider.getToolCallbacks()) {
|
||||
wrappedTools.add(new ToolCallback() {
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return tool.getToolDefinition();
|
||||
}
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "running"));
|
||||
try {
|
||||
return tool.call(toolInput);
|
||||
} finally {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "done"));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||
.defaultSystem("""
|
||||
You are an expert Loyalty System AI Assistant.
|
||||
Your primary job is to orchestrate operations for our Loyalty core system.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Always answer the user in the language they used (e.g., Vietnamese).
|
||||
2. If a tool response contains a `presentation.markdown` field, you MUST display its content exactly as provided to the user. Do not summarize or alter its formatting. Use the `data` field only for your internal reasoning.
|
||||
""")
|
||||
.defaultTools(wrappedTools)
|
||||
.build();
|
||||
|
||||
chatClient.prompt()
|
||||
.user(prompt)
|
||||
.stream()
|
||||
.content()
|
||||
.doOnComplete(() -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.done(request.messageId())))
|
||||
.subscribe(
|
||||
content -> {
|
||||
if (content != null && !content.isEmpty()) {
|
||||
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.token(request.messageId(), content));
|
||||
}
|
||||
},
|
||||
error -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", AgentEvent.error(request.messageId(), error.getMessage()))
|
||||
);
|
||||
public void chat(ChatRequest request, String sessionId) {
|
||||
agentOrchestrator.process(request, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ToolResultPresentationProcessor {
|
||||
|
||||
private static final String INTERNAL_INSTRUCTION_FIELD = "_agent_instruction";
|
||||
private static final String AGENT_INSTRUCTION_FIELD = "agent_instruction";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public String process(String result) {
|
||||
if (!StringUtils.hasText(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
return root.isArray() ? processMcpContentArray(root, result) : processJsonObject(root, result);
|
||||
} catch (JsonProcessingException e) {
|
||||
log.debug("Tool result is not JSON.");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private String processMcpContentArray(JsonNode root, String originalResult)
|
||||
throws JsonProcessingException {
|
||||
boolean modified = false;
|
||||
|
||||
for (JsonNode contentItem : root) {
|
||||
if (!(contentItem instanceof ObjectNode objectContentItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode textNode = objectContentItem.get("text");
|
||||
if (textNode == null || !textNode.isTextual()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Optional<JsonNode> parsedText = readJson(textNode.asText());
|
||||
if (parsedText.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonNode parsedNode = parsedText.get();
|
||||
if (processInstruction(parsedNode)) {
|
||||
modified = true;
|
||||
objectContentItem.put("text", objectMapper.writeValueAsString(parsedNode));
|
||||
}
|
||||
}
|
||||
|
||||
return modified ? objectMapper.writeValueAsString(root) : originalResult;
|
||||
}
|
||||
|
||||
private String processJsonObject(JsonNode root, String originalResult) throws JsonProcessingException {
|
||||
if (processInstruction(root)) {
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
return originalResult;
|
||||
}
|
||||
|
||||
private Optional<JsonNode> readJson(String content) {
|
||||
try {
|
||||
return Optional.of(objectMapper.readTree(content));
|
||||
} catch (JsonProcessingException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean processInstruction(JsonNode node) {
|
||||
if (!(node instanceof ObjectNode objectNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonNode instructionNode = objectNode.get(INTERNAL_INSTRUCTION_FIELD);
|
||||
if (instructionNode == null || !instructionNode.isTextual()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String instruction = instructionNode.asText();
|
||||
if (!StringUtils.hasText(instruction)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
objectNode.remove(INTERNAL_INSTRUCTION_FIELD);
|
||||
objectNode.put(AGENT_INSTRUCTION_FIELD, instruction);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@ spring:
|
||||
name: loyalty-agent-service
|
||||
ai:
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
base-url: http://192.168.99.10:11434
|
||||
chat:
|
||||
options:
|
||||
model: qwen3.5:2b
|
||||
model: qwen3.5:4b
|
||||
temperature: 0.3
|
||||
mcp:
|
||||
client:
|
||||
@@ -23,25 +23,26 @@ spring:
|
||||
connections:
|
||||
loyalty:
|
||||
url: ${MCP_SERVER_URL:http://localhost:9331}
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
keycloak:
|
||||
client-id: ols-cli
|
||||
client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||
authorization-grant-type: client_credentials
|
||||
provider:
|
||||
keycloak:
|
||||
token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||
# security:
|
||||
# oauth2:
|
||||
# client:
|
||||
# registration:
|
||||
# keycloak:
|
||||
# client-id: ols-cli
|
||||
# client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||
# authorization-grant-type: client_credentials
|
||||
# provider:
|
||||
# keycloak:
|
||||
# token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||
|
||||
loyalty:
|
||||
core:
|
||||
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||
#loyalty:
|
||||
# core:
|
||||
# base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||
|
||||
logging:
|
||||
pattern:
|
||||
console: "%d{HH:mm:ss.SSS} %5p --- %-40.40logger{39} : %m%n"
|
||||
level:
|
||||
root: warn
|
||||
org.springframework.ai: INFO
|
||||
org.springframework.web: INFO
|
||||
dev.sonpx.loyalty: DEBUG
|
||||
root: warn
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
package dev.sonpx.loyalty.agent.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AgentControllerTest {
|
||||
|
||||
private final AgentController controller = new AgentController(null, null);
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_Array() {
|
||||
String json = "[{\"id\":\"1\"}]";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_ContentWrapped() {
|
||||
String json = "{\"content\":[{\"id\":\"1\"}], \"page\":0}";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testStandardizeResponse_DataWrapped() {
|
||||
String json = "{\"data\":[{\"id\":\"1\"}]}";
|
||||
Object result = controller.standardizeResponse(json);
|
||||
assertTrue(result instanceof java.util.List);
|
||||
assertEquals(1, ((java.util.List<?>) result).size());
|
||||
void contextLoads() {
|
||||
// The original tests were for standardizeResponse which is now commented out in AgentController.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package dev.sonpx.loyalty.agent.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ToolResultPresentationProcessorTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final ToolResultPresentationProcessor processor = new ToolResultPresentationProcessor(objectMapper);
|
||||
|
||||
@Test
|
||||
void leavesPlainTextResultsUnchanged() {
|
||||
String result = processor.process("done");
|
||||
assertEquals("done", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsAgentInstructionFromDirectJsonResult() throws Exception {
|
||||
String toolResult = """
|
||||
{
|
||||
"data": [{"id": "1"}],
|
||||
"_agent_instruction": "Format this list of campaigns as a markdown table."
|
||||
}
|
||||
""";
|
||||
|
||||
String result = processor.process(toolResult);
|
||||
JsonNode sanitized = objectMapper.readTree(result);
|
||||
|
||||
assertFalse(sanitized.has("_agent_instruction"));
|
||||
assertTrue(sanitized.has("agent_instruction"));
|
||||
assertEquals("Format this list of campaigns as a markdown table.", sanitized.get("agent_instruction").asText());
|
||||
assertTrue(sanitized.has("data"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractsAgentInstructionFromMcpTextResult() throws Exception {
|
||||
String innerResult = objectMapper.writeValueAsString(objectMapper.readTree("""
|
||||
{
|
||||
"data": [{"id": "1"}],
|
||||
"_agent_instruction": "Format this list of campaigns as a markdown table."
|
||||
}
|
||||
"""));
|
||||
String toolResult = objectMapper.writeValueAsString(new Object[] {
|
||||
java.util.Map.of("type", "text", "text", innerResult)
|
||||
});
|
||||
|
||||
String result = processor.process(toolResult);
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
JsonNode sanitizedInner = objectMapper.readTree(root.get(0).get("text").asText());
|
||||
|
||||
assertFalse(sanitizedInner.has("_agent_instruction"));
|
||||
assertTrue(sanitizedInner.has("agent_instruction"));
|
||||
assertEquals("Format this list of campaigns as a markdown table.", sanitizedInner.get("agent_instruction").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ignoresNonTextualAgentInstruction() throws Exception {
|
||||
String toolResult = """
|
||||
{
|
||||
"data": [{"id": "1"}],
|
||||
"_agent_instruction": {"nested": "value"}
|
||||
}
|
||||
""";
|
||||
|
||||
String result = processor.process(toolResult);
|
||||
JsonNode parsed = objectMapper.readTree(result);
|
||||
|
||||
assertTrue(parsed.has("_agent_instruction"));
|
||||
assertFalse(parsed.has("agent_instruction"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user