feat: expand MCP server functionality with customer, catalogue, marketing, and user tools and integrate new health indicators and configuration models.

This commit is contained in:
2026-08-11 16:31:52 +07:00
parent 9a26c6ce2d
commit 8ecd27dd0e
188 changed files with 5672 additions and 983 deletions

View File

@@ -12,6 +12,8 @@ public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
return mapper;
}
}

View File

@@ -0,0 +1,30 @@
package dev.sonpx.loyalty.agent.config;
import org.slf4j.MDC;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
@Component
public class MdcChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && accessor.getSessionId() != null) {
MDC.put("sessionId", accessor.getSessionId());
}
// Extract conversationId from STOMP headers if available
if (accessor != null && accessor.getFirstNativeHeader("conversationId") != null) {
MDC.put("conversationId", accessor.getFirstNativeHeader("conversationId"));
}
return message;
}
@Override
public void afterSendCompletion(Message<?> message, MessageChannel channel, boolean sent, Exception ex) {
MDC.clear();
}
}

View File

@@ -6,6 +6,11 @@ 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;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@@ -15,10 +20,29 @@ public class SecurityConfig {
public SecurityFilterChain filterChain(HttpSecurity http) {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/actuator/health/**", "/actuator/info").permitAll()
.requestMatchers("/api/v1/agent/**", "/api/v1/conversations/**", "/ws/**", "/error").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of(
"http://localhost:*",
"http://127.0.0.1:*"
));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@@ -3,7 +3,9 @@ package dev.sonpx.loyalty.agent.config;
import java.security.Principal;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.WebSocketHandler;
@@ -14,8 +16,16 @@ import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final MdcChannelInterceptor mdcChannelInterceptor;
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(mdcChannelInterceptor);
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");

View File

@@ -4,13 +4,13 @@ import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
import java.security.Principal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.RestController;
@Slf4j
@RestController
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class AgentController {
@@ -18,6 +18,10 @@ public class AgentController {
@MessageMapping("/chat")
public void chat(@Payload ChatRequest request, Principal principal) {
if (request == null || request.prompt() == null || request.prompt().isBlank()) {
log.warn("Received empty or null chat request from session: {}", principal.getName());
return;
}
agentService.chat(request, principal.getName());
}

View File

@@ -5,6 +5,7 @@ import dev.sonpx.loyalty.agent.domain.Message;
import dev.sonpx.loyalty.agent.dto.ConversationSummaryDto;
import dev.sonpx.loyalty.agent.repository.ConversationRepository;
import dev.sonpx.loyalty.agent.repository.MessageRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
@@ -16,22 +17,16 @@ import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/conversations")
@CrossOrigin(origins = "*")
public class ConversationController {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
public ConversationController(ConversationRepository conversationRepository, MessageRepository messageRepository) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
}
@GetMapping
@Transactional
@Transactional(readOnly = true)
public ResponseEntity<List<ConversationSummaryDto>> getConversations() {
List<Conversation> conversations = conversationRepository.findAllByOrderByUpdatedAtDesc();
List<ConversationSummaryDto> dtos = new ArrayList<>();
@@ -39,9 +34,8 @@ public class ConversationController {
for (Conversation c : conversations) {
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(c.getId());
// Clean up empty conversations from DB (except if it's the only one and newly created)
// Skip empty conversations (don't delete in GET — side-effect-free)
if (messages.isEmpty()) {
conversationRepository.delete(c);
continue;
}
@@ -58,10 +52,9 @@ public class ConversationController {
}
}
String preview = "";
Message lastMsg = messages.getLast();
String content = lastMsg.getContent();
preview = content.length() > 100 ? content.substring(0, 100) + "..." : content;
String preview = content.length() > 100 ? content.substring(0, 100) + "..." : content;
dtos.add(ConversationSummaryDto.builder()
.id(c.getId())
@@ -100,9 +93,15 @@ public class ConversationController {
if (newTitle == null || newTitle.isBlank()) {
return ResponseEntity.badRequest().build();
}
// Enforce max title length
String sanitizedTitle = newTitle.trim();
if (sanitizedTitle.length() > 255) {
sanitizedTitle = sanitizedTitle.substring(0, 255);
}
final String finalTitle = sanitizedTitle;
return conversationRepository.findById(id).map(conv -> {
conv.setTitle(newTitle.trim());
conv.setTitle(finalTitle);
conv.setUpdatedAt(Instant.now());
Conversation saved = conversationRepository.save(conv);
@@ -134,5 +133,3 @@ public class ConversationController {
return ResponseEntity.noContent().build();
}
}

View File

@@ -17,6 +17,18 @@ public enum Intent {
/** Create a new rule (multi-turn workflow) */
RULE_CREATE,
/** Query pool definitions */
POOL_QUERY,
/** Query customers, points, tiers */
CUSTOMER_QUERY,
/** Query transactions */
TRANSACTION_QUERY,
/** Query reference data (transaction codes, counters, deduction sequences, catalogues, app params, dynamic attributes, journeys) */
REFERENCE_DATA_QUERY,
/** General conversation — greetings, about, help */
CONVERSATION,

View File

@@ -25,12 +25,14 @@ public class IntentClassifier {
// CREATE intents first (higher priority than query)
PATTERNS.put(Intent.RULE_CREATE, List.of(
Pattern.compile("(?i)(tạo|thêm|add|tạo mới|thêm mới|setup|cấu hình mới)\\s*.*(rule|thể lệ|quy tắc|quy chế)"),
Pattern.compile("(?i)(rule|thể lệ|quy tắc)\\s*.*(tạo|thêm|mới)")
Pattern.compile("(?i)(rule|thể lệ|quy tắc)\\s*.*(tạo|thêm|mới)"),
Pattern.compile("(?i)(create|new|setup)\\s+.*(rule|regulation)")
));
PATTERNS.put(Intent.CAMPAIGN_CREATE, List.of(
Pattern.compile("(?i)(tạo|thêm|add|tạo mới|thêm mới|setup)\\s*.*(chiến dịch|campaign)"),
Pattern.compile("(?i)(chiến dịch|campaign)\\s*.*(tạo|thêm|mới)")
Pattern.compile("(?i)(chiến dịch|campaign)\\s*.*(tạo|thêm|mới)"),
Pattern.compile("(?i)(create|new|setup)\\s+.*(campaign)")
));
// QUERY intents
@@ -38,14 +40,44 @@ public class IntentClassifier {
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|có bao nhiêu|mấy|đếm|show|list)\\s*.*(rule|thể lệ|quy tắc|quy chế)"),
Pattern.compile("(?i)(rule|thể lệ|quy tắc|quy chế)\\s*.*(nào|gì|nào đó|chi tiết|detail)"),
Pattern.compile("(?i)(thể lệ|rule|quy tắc|điều kiện|công thức thưởng|cách tính điểm|cách tính thưởng)"),
Pattern.compile("(?i)(chi tiết|detail|thông tin)\\s*.*(rule|thể lệ)")
Pattern.compile("(?i)(chi tiết|detail|thông tin)\\s*.*(rule|thể lệ)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(rule|regulation)")
));
PATTERNS.put(Intent.CAMPAIGN_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|có bao nhiêu|mấy|đếm|show|list)\\s*.*(chiến dịch|campaign)"),
Pattern.compile("(?i)(chiến dịch|campaign)\\s*.*(nào|gì|nào đó|chi tiết|detail)"),
Pattern.compile("(?i)(chi tiết|detail|thông tin)\\s*.*(chiến dịch|campaign)"),
Pattern.compile("(?i)(campaign|chiến dịch)")
Pattern.compile("(?i)(campaign|chiến dịch)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(campaign)")
));
// POOL queries
PATTERNS.put(Intent.POOL_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|show|list|chi tiết)\\s*.*(pool|quỹ điểm)"),
Pattern.compile("(?i)(pool|quỹ điểm)\\s*.*(nào|gì|chi tiết|detail)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(pool)")
));
// CUSTOMER queries
PATTERNS.put(Intent.CUSTOMER_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|tra cứu|show|list|thông tin)\\s*.*(khách hàng|customer|thẻ VIP|hạng thẻ)"),
Pattern.compile("(?i)(khách hàng|customer|thẻ VIP|hạng thẻ)\\s*.*(nào|gì|chi tiết|detail|tìm|tra cứu)"),
Pattern.compile("(?i)(điểm thưởng|loyalty points|điểm tích lũy|số điểm)\\s*(của|khách|customer)?"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(customer|member)")
));
// TRANSACTION queries
PATTERNS.put(Intent.TRANSACTION_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|show|list|lịch sử)\\s*.*(giao dịch|transaction)"),
Pattern.compile("(?i)(giao dịch|transaction)\\s*.*(nào|gì|gần đây|chi tiết|detail|lịch sử)"),
Pattern.compile("(?i)(find|get|show|list|search)\\s+.*(transaction)")
));
// REFERENCE DATA queries (catch-all for catalogue, transaction codes, counters, etc.)
PATTERNS.put(Intent.REFERENCE_DATA_QUERY, List.of(
Pattern.compile("(?i)(danh sách|tìm|xem|liệt kê|show|list)\\s*.*(mã giao dịch|transaction code|counter|bộ đếm|deduction|khấu trừ|catalogue|danh mục|tham số|app param|thuộc tính|dynamic attribute|journey|hành trình)"),
Pattern.compile("(?i)(mã giao dịch|transaction code|counter|bộ đếm|deduction|khấu trừ|catalogue|danh mục|tham số|app param|thuộc tính|dynamic attribute|journey|hành trình)\\s*.*(nào|gì|chi tiết|detail)")
));
// CONVERSATION intents (lowest priority)

View File

@@ -29,6 +29,9 @@ public class AgentProperties {
/** Characters per token estimate for Vietnamese text */
private int charsPerToken = 2;
/** Timeout in seconds for streaming LLM responses */
private int requestTimeoutSeconds = 120;
/** Agent-specific model routing. Defaults preserve the existing single-model setup. */
private Models models = new Models();

View File

@@ -57,7 +57,8 @@ public enum AgentToolScope {
}
if (normalized.startsWith("search") || normalized.contains("_search")
|| normalized.startsWith("count") || normalized.contains("_count")
|| normalized.startsWith("get") || normalized.contains("_get")) {
|| normalized.startsWith("get") || normalized.contains("_get")
|| normalized.startsWith("find") || normalized.contains("_find")) {
return READ;
}
return OTHER;

View File

@@ -1,6 +1,7 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.classifier.Intent;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
@@ -12,7 +13,9 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.TimeoutException;
/**
* Single-pass ReAct agent executor.
@@ -24,6 +27,7 @@ import java.util.List;
* - Memory advisor is always attached
* - Streams response from the start (no waiting for intermediate tasks)
* - Error events always sent to user
* - Configurable timeout to prevent runaway LLM sessions
*/
@Slf4j
@Component
@@ -36,6 +40,7 @@ public class SimpleAgentExecutor {
private final AgentMemoryManager memoryManager;
private final PromptBuilder promptBuilder;
private final AgentPersistenceService persistenceService;
private final AgentProperties agentProperties;
public SimpleAgentExecutor(@Qualifier("queryAgentChatClient") ChatClient queryChatClient,
@Qualifier("chatAgentChatClient") ChatClient chatChatClient,
@@ -43,7 +48,8 @@ public class SimpleAgentExecutor {
AgentEventPublisher eventPublisher,
AgentMemoryManager memoryManager,
PromptBuilder promptBuilder,
AgentPersistenceService persistenceService) {
AgentPersistenceService persistenceService,
AgentProperties agentProperties) {
this.queryChatClient = queryChatClient;
this.chatChatClient = chatChatClient;
this.toolInterceptor = toolInterceptor;
@@ -51,6 +57,7 @@ public class SimpleAgentExecutor {
this.memoryManager = memoryManager;
this.promptBuilder = promptBuilder;
this.persistenceService = persistenceService;
this.agentProperties = agentProperties;
}
/**
@@ -71,6 +78,8 @@ public class SimpleAgentExecutor {
persistenceService.saveUserMessage(conversationKey, request.messageId(), request.prompt());
StringBuilder responseAccumulator = new StringBuilder();
Duration timeout = Duration.ofSeconds(agentProperties.getRequestTimeoutSeconds());
queryChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -81,6 +90,7 @@ public class SimpleAgentExecutor {
.tools(wrappedTools)
.stream()
.content()
.timeout(timeout)
.doOnComplete(() -> {
log.info("SimpleAgent execution completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
@@ -95,10 +105,7 @@ public class SimpleAgentExecutor {
},
error -> {
log.error("Error during SimpleAgent execution for connection {}: {}", connectionSessionId, error.getMessage(), error);
String errorMessage = "Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.";
if (isConnectionError(error)) {
errorMessage = "Hiện tại dịch vụ AI (Ollama) đang tạm thời gián đoạn. Vui lòng thử lại sau.";
}
String errorMessage = resolveErrorMessage(error);
eventPublisher.error(connectionSessionId, request.messageId(), errorMessage);
}
);
@@ -112,19 +119,6 @@ public class SimpleAgentExecutor {
}
}
private boolean isConnectionError(Throwable t) {
if (t == null) return false;
Throwable current = t;
while (current != null) {
String name = current.getClass().getName();
if (name.contains("ConnectException") || name.contains("SocketException") || name.contains("ResourceAccessException")) {
return true;
}
current = current.getCause();
}
return false;
}
/**
* Execute a direct chat without tools (for CONVERSATION intent).
* No tools are loaded → faster response, less token usage.
@@ -138,6 +132,8 @@ public class SimpleAgentExecutor {
persistenceService.saveUserMessage(conversationKey, request.messageId(), request.prompt());
StringBuilder responseAccumulator = new StringBuilder();
Duration timeout = Duration.ofSeconds(agentProperties.getRequestTimeoutSeconds());
chatChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -147,6 +143,7 @@ public class SimpleAgentExecutor {
.user(userPrompt)
.stream()
.content()
.timeout(timeout)
.doOnComplete(() -> {
log.info("DirectChat completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
@@ -161,12 +158,32 @@ public class SimpleAgentExecutor {
},
error -> {
log.error("Error during DirectChat execution for connection {}: {}", connectionSessionId, error.getMessage(), error);
String errorMessage = "Xin lỗi, đã xảy ra lỗi. Vui lòng thử lại.";
if (isConnectionError(error)) {
errorMessage = "Hiện tại dịch vụ AI (Ollama) đang tạm thời gián đoạn. Vui lòng thử lại sau.";
}
String errorMessage = resolveErrorMessage(error);
eventPublisher.error(connectionSessionId, request.messageId(), errorMessage);
}
);
}
private String resolveErrorMessage(Throwable error) {
if (error instanceof TimeoutException) {
return "Yêu cầu đã vượt quá thời gian cho phép. Vui lòng thử lại với câu hỏi ngắn gọn hơn.";
}
if (isConnectionError(error)) {
return "Hiện tại dịch vụ AI (Ollama) đang tạm thời gián đoạn. Vui lòng thử lại sau.";
}
return "Xin lỗi, đã xảy ra lỗi khi xử lý yêu cầu. Vui lòng thử lại.";
}
private boolean isConnectionError(Throwable t) {
if (t == null) return false;
Throwable current = t;
while (current != null) {
String name = current.getClass().getName();
if (name.contains("ConnectException") || name.contains("SocketException") || name.contains("ResourceAccessException")) {
return true;
}
current = current.getCause();
}
return false;
}
}

View File

@@ -4,8 +4,10 @@ import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.exception.UnrecoverableToolExecutionException;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import io.micrometer.core.instrument.MeterRegistry;
import io.modelcontextprotocol.client.McpSyncClient;
import lombok.RequiredArgsConstructor;
import org.slf4j.MDC;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.ai.tool.ToolCallback;
@@ -38,6 +40,7 @@ public class ToolInterceptor {
private final ToolResultPresentationProcessor toolResultProcessor;
private final AgentProperties agentProperties;
private final ObjectProvider<List<McpSyncClient>> mcpSyncClientsProvider;
private final MeterRegistry meterRegistry;
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
return getWrappedTools(sessionId, messageId, AgentToolScope.ALL);
@@ -69,7 +72,9 @@ public class ToolInterceptor {
public String call(String toolInput) {
String toolName = getToolDefinition().name();
eventPublisher.toolRunning(sessionId, messageId, toolName);
long startTime = System.nanoTime();
try {
MDC.put("toolName", toolName);
String result = executeWithRetry(tool, toolInput, toolName);
// Unrecoverable raw system error responses (e.g. 404 HTML, connection reset)
@@ -93,10 +98,13 @@ public class ToolInterceptor {
} catch (UnrecoverableToolExecutionException e) {
throw e;
} catch (Exception e) {
log.error("Tool {} threw unrecoverable exception: {}", toolName, e.getMessage(), e);
throw new UnrecoverableToolExecutionException(
"Lỗi hệ thống không thể phục hồi khi gọi tool '" + toolName + "': " + e.getMessage(), e);
log.warn("Tool {} threw business/system exception: {}", toolName, e.getMessage());
// Instead of crashing the stream, return the error to the LLM
return "{\"error\": \"Lỗi khi gọi tool '" + toolName + "': " + e.getMessage() + "\"}";
} finally {
MDC.remove("toolName");
long duration = System.nanoTime() - startTime;
meterRegistry.timer("agent.tool.execution", "tool", toolName).record(duration, java.util.concurrent.TimeUnit.NANOSECONDS);
eventPublisher.toolDone(sessionId, messageId, toolName);
}
}
@@ -164,8 +172,7 @@ public class ToolInterceptor {
String msg = current.getMessage();
if (msg != null) {
String lower = msg.toLowerCase();
if (lower.contains("404")
|| lower.contains("session not found")
if (lower.contains("session not found")
|| lower.contains("session invalid")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
@@ -182,7 +189,6 @@ public class ToolInterceptor {
if (result == null) return false;
String lower = result.toLowerCase();
return lower.contains("404 session not found")
|| lower.contains("http status 404")
|| lower.contains("connection refused")
|| lower.contains("eofexception");
}

View File

@@ -61,7 +61,7 @@ public class AgentOrchestrator {
log.info("Starting ConversationWorkflow for: {}", intent);
workflowExecutor.startWorkflow(intent, request, connectionSessionId, conversationKey);
}
case CAMPAIGN_QUERY, RULE_QUERY, UNKNOWN -> {
case CAMPAIGN_QUERY, RULE_QUERY, POOL_QUERY, CUSTOMER_QUERY, TRANSACTION_QUERY, REFERENCE_DATA_QUERY, UNKNOWN -> {
log.info("Routing to SimpleAgent with tools");
simpleAgent.executeWithTools(request, connectionSessionId, conversationKey, intent);
}

View File

@@ -32,13 +32,54 @@ public class PromptBuilder {
private static final String TOOL_GUIDANCE = """
HƯỚNG DẪN CHỌN TOOL VÀ TRUYỀN THAM SỐ:
- Hỏi xem/lấy danh sách chiến dịch chung → dùng searchCampaigns. KHÔNG truyền từ khóa chung ("chiến dịch", "danh sách", "campaign") vào param `search`, hãy để `search` = null hoặc rỗng. Chỉ truyền `search` khi tìm kiếm tên/mã chiến dịch cụ thể.
- Hỏi xem/lấy danh sách thể lệ/rule chung → dùng searchRules. KHÔNG truyền từ khóa chung ("rule", "thể lệ", "danh sách") vào param `search`, hãy để `search` = null hoặc rỗng.
[Chiến dịch]
- Hỏi xem/lấy danh sách chiến dịch chung → dùng searchCampaigns. KHÔNG truyền từ khóa chung ("chiến dịch", "danh sách", "campaign") vào filter, hãy để null. Chỉ truyền filter khi tìm kiếm tên/mã cụ thể.
- Đếm số lượng chiến dịch → dùng countCampaigns
- Lấy chi tiết 1 chiến dịch theo ID → dùng getCampaignById
- Lấy nhiều chiến dịch theo danh sách ID → dùng getCampaignsByIds
- Kiểm tra mã chiến dịch → dùng checkCampaignId
[Thể lệ / Rule]
- Hỏi xem danh sách thể lệ/rule → dùng searchRules. Để filter null nếu lấy tất cả.
- Đếm số lượng rule → dùng countRules
- Kiểm tra mã → dùng checkCampaignId hoặc checkRuleId
- Lấy chi tiết → dùng getCampaignById hoặc getRuleById
- Lấy chi tiết 1 rule theo ID → dùng getRuleById
- Lấy nhiều rule theo danh sách ID → dùng getRulesByIds
- Kiểm tra mã rule → dùng checkRuleId
- Tìm rule phù hợp với giao dịch → dùng findMatchingRules (cần: ruleType, transactionCode, transactionDate, postDate, languageCode)
[Pool điểm]
- Tìm kiếm pool → dùng searchPools
- Lấy chi tiết pool theo ID → dùng getPoolDefinitionById
- Lấy nhiều pool theo ID → dùng getPoolDefinitionsByIds
[Khách hàng]
- Tìm kiếm khách hàng → dùng searchCustomers
- Lấy chi tiết khách hàng theo ID → dùng getCustomerById
[Giao dịch]
- Tìm kiếm giao dịch → dùng searchTransactions
- Lấy chi tiết giao dịch theo ID → dùng getTransactionById
[Dữ liệu tham chiếu]
- Lấy mã giao dịch theo mã → dùng getTransactionCode
- Lấy nhiều mã giao dịch → dùng getTransactionCodes
- Kiểm tra mã giao dịch tồn tại → dùng checkTransactionCodeExists
- Lấy bộ đếm theo ID → dùng getCounterDefinitionById
- Lấy nhiều bộ đếm → dùng getCounterDefinitionsByIds
- Lấy trình tự khấu trừ theo ID → dùng getDeductionSequenceById
- Lấy trình tự khấu trừ mặc định → dùng getDefaultDeductionSequence
- Tìm danh mục/catalogue → dùng searchCatalogues
- Lấy chi tiết catalogue → dùng getCatalogueById
- Tìm tham số hệ thống → dùng searchAppParam
- Lấy chi tiết tham số → dùng getAppParamById
- Tìm thuộc tính động → dùng searchDynamicAttribute
- Lấy chi tiết thuộc tính → dùng getDynamicAttributeById
- Tìm hành trình khách hàng → dùng searchJourney
- Lấy chi tiết hành trình → dùng getJourneyById
- Tìm người dùng hệ thống → dùng searchUser
- Lấy chi tiết người dùng → dùng getUserById
Bạn đang ở Query Agent: KHÔNG tạo mới dữ liệu. Nếu người dùng muốn tạo chiến dịch/rule,
hãy nói họ xác nhận yêu cầu tạo để chuyển sang workflow tạo.""";
@@ -78,11 +119,4 @@ public class PromptBuilder {
return String.join("\n", context) + "\n\n" + prompt;
}
/**
* @deprecated Use {@link #buildUserPrompt(ChatRequest)} instead.
*/
@Deprecated
public String build(ChatRequest request) {
return buildUserPrompt(request);
}
}

View File

@@ -35,7 +35,7 @@ public class WorkflowDataExtractor {
default -> "Thông tin bổ sung";
};
extracted.put(phaseKey, userInput.trim());
extractKeyValues(userInput, extracted);
extractKeyValues(userInput, extracted, state.getWorkflowType());
extractKnownFields(state.getWorkflowType(), userInput, extracted);
return extracted;
}
@@ -47,14 +47,23 @@ public class WorkflowDataExtractor {
if (state.getWorkflowType() == Intent.RULE_CREATE && state.getCurrentPhase() == WorkflowState.Phase.REWARD_CONFIG) {
return true;
}
return userInput.trim().length() >= 20 || state.getCollectedData().size() >= 3;
Map<String, Object> data = state.getCollectedData();
if (state.getWorkflowType() == Intent.CAMPAIGN_CREATE && state.getCurrentPhase() == WorkflowState.Phase.BASIC_INFO) {
return data.containsKey("Tên chiến dịch") && data.containsKey("Loại chiến dịch") && data.containsKey("Thời gian hiệu lực");
} else if (state.getWorkflowType() == Intent.RULE_CREATE && state.getCurrentPhase() == WorkflowState.Phase.BASIC_INFO) {
return data.containsKey("Tên rule") && data.containsKey("Campaign ID") && data.containsKey("Loại rule") && data.containsKey("Thời gian hiệu lực");
}
return userInput.trim().length() >= 20 || data.size() >= 3;
}
private void extractKeyValues(String userInput, Map<String, Object> extracted) {
private void extractKeyValues(String userInput, Map<String, Object> extracted, Intent workflowType) {
for (String line : userInput.split("\\R")) {
Matcher matcher = KEY_VALUE.matcher(line);
if (matcher.matches()) {
String key = normalizeKey(matcher.group(1));
key = contextualizeKey(key, workflowType);
String value = matcher.group(2).trim();
if (StringUtils.hasText(key) && StringUtils.hasText(value)) {
extracted.put(key, value);
@@ -102,4 +111,24 @@ public class WorkflowDataExtractor {
.replaceAll("\\s+", " ")
.replaceAll("^[0-9.)\\s-]+", "");
}
private String contextualizeKey(String key, Intent workflowType) {
String lower = key.toLowerCase().trim();
if (lower.equals("tên") || lower.equals("name") || lower.equals("tên hiển thị")) {
return workflowType == Intent.CAMPAIGN_CREATE ? "Tên chiến dịch" : "Tên rule";
}
if (lower.equals("mô tả") || lower.equals("description")) {
return workflowType == Intent.CAMPAIGN_CREATE ? "Mô tả chiến dịch" : "Mô tả rule";
}
if (lower.equals("loại") || lower.equals("type")) {
return workflowType == Intent.CAMPAIGN_CREATE ? "Loại chiến dịch" : "Loại rule";
}
if (lower.equals("hiệu lực") || lower.equals("thời gian") || lower.equals("ngày")) {
return "Thời gian hiệu lực";
}
if (lower.equals("owner") || lower.equals("người sở hữu") || lower.equals("phòng ban")) {
return "Owner";
}
return key;
}
}

View File

@@ -30,6 +30,15 @@ import java.util.regex.Pattern;
* - LLM is used to extract data from natural language and generate responses
* - Tool calls only happen in the final EXECUTE phase
* - User can cancel at any time
*
* KNOWN LIMITATION — In-Memory Workflow State:
* WorkflowState is stored in a ConcurrentHashMap and lost on server restart.
* This is acceptable because:
* 1. Workflows are short-lived (typically 2-5 minutes for campaign/rule creation)
* 2. Server restarts during active workflows are rare in production
* 3. Persisting to PostgreSQL would require schema migration, JSON serialization
* of collectedData, and recovery logic — complexity not justified by the risk
* 4. If state is lost, the user receives a friendly message to restart the workflow
*/
@Slf4j
@Component
@@ -102,7 +111,7 @@ public class WorkflowExecutor {
String systemPrompt = getPhasePrompt(state);
String userPrompt = request.prompt() != null ? request.prompt() : "Bắt đầu tạo";
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, request.messageId(), false);
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
}
/**
@@ -112,7 +121,11 @@ public class WorkflowExecutor {
public void continueWorkflow(ChatRequest request, String connectionSessionId, String conversationKey) {
WorkflowState state = activeWorkflows.get(conversationKey);
if (state == null || !state.isActive()) {
log.warn("No active workflow for conversation: {}", conversationKey);
log.warn("No active workflow for conversation: {} (state may have been lost on restart)", conversationKey);
streamResponse(
"Phiên tạo trước đó đã hết hạn hoặc bị mất do hệ thống khởi động lại. "
+ "Hãy thông báo cho người dùng rằng phiên tạo đã kết thúc và gợi ý họ bắt đầu lại nếu cần.",
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
return;
}
@@ -154,14 +167,14 @@ public class WorkflowExecutor {
if (!dataExtractor.hasEnoughForNextPhase(state, request.prompt())) {
streamResponse(WorkflowPromptBuilder.missingInfoPrompt(state),
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
// LLM extracts data and confirms — no tools needed yet
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
// LLM extracts data, validates IDs with tools, and confirms
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
// Advance phase after LLM responds
// The LLM will indicate if data is complete; user's next message drives phase transition
@@ -184,12 +197,12 @@ public class WorkflowExecutor {
if (!dataExtractor.hasEnoughForNextPhase(state, request.prompt())) {
streamResponse(WorkflowPromptBuilder.missingInfoPrompt(state),
request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
return;
}
String systemPrompt = WorkflowPromptBuilder.extractDataPrompt(state);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), false);
streamResponse(systemPrompt, request.prompt(), connectionSessionId, conversationKey, request.messageId(), true, AgentToolScope.READ_ONLY, null, null);
// Advance to CONFIRMING
state.advancePhase();
@@ -252,7 +265,7 @@ public class WorkflowExecutor {
private void streamResponse(String systemPrompt, String userPrompt,
String connectionSessionId, String conversationKey,
String messageId, boolean withTools) {
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, messageId, withTools, AgentToolScope.ALL, null, null);
streamResponse(systemPrompt, userPrompt, connectionSessionId, conversationKey, messageId, withTools, AgentToolScope.READ_ONLY, null, null);
}
private void streamResponse(String systemPrompt, String userPrompt,
@@ -278,9 +291,24 @@ public class WorkflowExecutor {
builder.stream()
.content()
.doOnComplete(() -> {
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
String fullResponse = responseAccumulator.toString();
persistenceService.saveAssistantMessage(conversationKey, fullResponse);
if (onComplete != null) {
onComplete.run();
// Check if this was an execution phase with tools
if (withTools) {
if (fullResponse.contains("[ERROR]") || isExecutionError(fullResponse)) {
log.warn("Tool execution reported error for conversation: {}", conversationKey);
if (onError != null) {
onError.run();
}
} else {
log.info("Tool execution succeeded for conversation: {}", conversationKey);
onComplete.run();
}
} else {
onComplete.run();
}
}
eventPublisher.done(connectionSessionId, messageId);
})
@@ -301,4 +329,20 @@ public class WorkflowExecutor {
}
);
}
/**
* Heuristic error detection for cases where the LLM forgets the [ERROR] prefix.
* Checks for Vietnamese error keywords that indicate a failed tool execution.
*/
private boolean isExecutionError(String response) {
if (response == null || response.isEmpty()) return false;
String lower = response.toLowerCase();
// Only match if response does NOT contain [SUCCESS] — avoid false positives
if (lower.contains("[success]")) return false;
return lower.contains("lỗi xác thực")
|| lower.contains("lỗi từ core api")
|| lower.contains("lỗi hệ thống")
|| lower.contains("không thể tạo")
|| lower.contains("thất bại")
|| lower.contains("đã tồn tại");
}
}

View File

@@ -51,12 +51,24 @@ public class WorkflowPromptBuilder {
return """
Người dùng đã xác nhận tạo chiến dịch. Hãy thực hiện:
1. Gọi tool `generateCampaignId` để tạo mã chiến dịch
2. Gọi tool `createCampaign` với thông tin sau:
1. Gọi tool `generateCampaignId` để tạo mã chiến dịch (nếu chưa có)
2. Gọi tool `createCampaign` với dữ liệu đã thu thập.
HƯỚNG DẪN MAP DỮ LIỆU sang JSON:
- Tên chiến dịch → trường "name"
- Mô tả → trường "description"
- Loại chiến dịch: Base → "B", Tactical → "T" → trường "campaignType"
- Ngày bắt đầu (yyyy-MM-dd) → trường "effectiveFrom"
- Ngày kết thúc (yyyy-MM-dd) → trường "effectiveTo"
- Owner/người sở hữu → trường "ownerName"
- Mã chiến dịch (từ generateCampaignId) → trường "campaignId"
Dữ liệu đã thu thập:
""" + formatData(data) + """
Sau khi tạo xong, thông báo kết quả cho người dùng.""";
QUAN TRỌNG:
Sau khi gọi tool, nếu kết quả trả về báo lỗi (ví dụ: trùng ID, thiếu dữ liệu), hãy bắt đầu câu trả lời bằng [ERROR] và giải thích lỗi cho người dùng để họ có thể nhập lại.
Nếu kết quả báo thành công (ví dụ: errorCode = 00 hoặc không có lỗi), hãy bắt đầu câu trả lời bằng [SUCCESS] và thông báo kết quả cho người dùng.""";
}
// ========== RULE CREATION PROMPTS ==========
@@ -81,7 +93,7 @@ public class WorkflowPromptBuilder {
5. Thời gian hiệu lực: từ ngày → đến ngày
Hỏi TẤT CẢ các thông tin trên trong MỘT lần. Liệt kê rõ ràng từng mục.
KHÔNG gọi tool nào. Chỉ hỏi thông tin.""";
Nếu người dùng đã cung cấp mã chiến dịch, hãy dùng tool `checkCampaignId` hoặc `getCampaignById` để xác thực ngay. KHÔNG gọi tool createCampaign hay createRule.""";
}
static String ruleRewardConfigPrompt(Map<String, Object> data) {
@@ -120,12 +132,26 @@ public class WorkflowPromptBuilder {
return """
Người dùng đã xác nhận tạo rule. Hãy thực hiện:
1. Gọi tool `generateRuleId` để tạo mã rule
2. Gọi tool `createRule` với thông tin sau:
1. Gọi tool `generateRuleId` để tạo mã rule (nếu chưa có)
2. Gọi tool `createRule` với dữ liệu đã thu thập.
HƯỚNG DẪN MAP DỮ LIỆU sang JSON:
- Tên rule → trường "ruleName"
- Mô tả → trường "description"
- Mã chiến dịch → trường "campaignId"
- Loại rule (AWD/RED/IRED/ADJ/CEP/REP/TEP/MAWD) → trường "ruleType"
- Thời gian bắt đầu (yyyy-MM-dd'T'HH:mm:ss) → trường "effectiveFrom"
- Thời gian kết thúc (yyyy-MM-dd'T'HH:mm:ss) → trường "effectiveTo"
- Pool ID → trường "poolId"
- Giá trị thưởng (số) → trường "rewardAmount"
- Mã rule (từ generateRuleId) → trường "ruleId"
Dữ liệu đã thu thập:
""" + formatData(data) + """
Sau khi tạo xong, thông báo kết quả cho người dùng.""";
QUAN TRỌNG:
Sau khi gọi tool, nếu kết quả trả về báo lỗi (ví dụ: trùng ID, thiếu dữ liệu), hãy bắt đầu câu trả lời bằng [ERROR] và giải thích lỗi cho người dùng để họ có thể nhập lại.
Nếu kết quả báo thành công (ví dụ: errorCode = 00 hoặc không có lỗi), hãy bắt đầu câu trả lời bằng [SUCCESS] và thông báo kết quả cho người dùng.""";
}
// ========== COMMON PROMPTS ==========
@@ -136,12 +162,13 @@ public class WorkflowPromptBuilder {
Người dùng đang cung cấp thông tin để tạo %s.
Dựa trên câu trả lời của người dùng, hãy trích xuất thông tin và xác nhận lại.
Nếu thiếu thông tin quan trọng, hãy hỏi lại phần còn thiếu.
Nếu người dùng cung cấp một mã (như Campaign ID), hãy dùng tool `checkCampaignId` hoặc `getCampaignById` để xác thực xem mã đó có hợp lệ/tồn tại không. Nếu không tồn tại, hãy báo lỗi và yêu cầu nhập lại.
Thông tin đã có trước đó:
%s
KHÔNG gọi tool nào. Chỉ xác nhận thông tin và hỏi phần còn thiếu (nếu có).
Nếu đã đủ thông tin cơ bản, hãy nói "Tôi đã ghi nhận đủ thông tin" và hỏi người dùng muốn tiếp tục cấu hình chi tiết hay tạo luôn.
Chỉ gọi tool để kiểm tra (READ_ONLY). KHÔNG gọi tool createCampaign hay createRule lúc này.
Nếu đã đủ thông tin cơ bản và hợp lệ, hãy nói "Tôi đã ghi nhận đủ thông tin" và hỏi người dùng muốn tiếp tục cấu hình chi tiết hay tạo luôn.
""".formatted(target, formatData(state.getCollectedData()));
}
@@ -163,7 +190,8 @@ public class WorkflowPromptBuilder {
static boolean isConfirmation(String userInput) {
if (userInput == null) return false;
String lower = userInput.trim().toLowerCase();
return lower.matches("(?i).*(xác nhận|đồng ý|ok|oke|được|tạo đi|tạo luôn|yes|confirm|đúng rồi|chính xác).*");
return lower.matches("(?i)^\\s*(xác nhận|đồng ý|tạo đi|tạo luôn|yes|confirm|đúng rồi|chính xác|ok|oke|được)(\\s*[.!?]*)$")
|| lower.matches("(?i).*(xác nhận|đồng ý|tạo đi|tạo luôn|confirm|đúng rồi|chính xác).*");
}
/**
@@ -172,7 +200,8 @@ public class WorkflowPromptBuilder {
static boolean isCancellation(String userInput) {
if (userInput == null) return false;
String lower = userInput.trim().toLowerCase();
return lower.matches("(?i)^\\s*(hủy|cancel|thôi|bỏ qua workflow|dừng|stop|không tạo|không tạo nữa|hủy tạo)(\\s|[.!?])*.*");
return lower.matches("(?i)^\\s*(hủy|cancel|thôi|bỏ qua workflow|dừng|stop|không tạo|không tạo nữa|hủy tạo)(\\s|[.!?])*.*")
|| lower.matches("(?i).*(hủy tạo|hủy workflow|cancel workflow|không muốn tạo|dừng tạo|bỏ tạo).*");
}
/**

View File

@@ -0,0 +1,36 @@
package dev.sonpx.loyalty.agent.health;
import io.modelcontextprotocol.client.McpSyncClient;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class McpServerHealthIndicator implements HealthIndicator {
private final ObjectProvider<List<McpSyncClient>> mcpClientsProvider;
public McpServerHealthIndicator(ObjectProvider<List<McpSyncClient>> mcpClientsProvider) {
this.mcpClientsProvider = mcpClientsProvider;
}
@Override
public Health health() {
List<McpSyncClient> clients = mcpClientsProvider.getIfAvailable();
if (clients == null || clients.isEmpty()) {
return Health.unknown().withDetail("message", "No MCP clients configured").build();
}
try {
for (McpSyncClient client : clients) {
client.listTools();
}
return Health.up().withDetail("clients", clients.size()).build();
} catch (Exception e) {
return Health.down().withException(e).build();
}
}
}

View File

@@ -0,0 +1,31 @@
package dev.sonpx.loyalty.agent.health;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class OllamaHealthIndicator implements HealthIndicator {
private final RestClient restClient;
public OllamaHealthIndicator(AgentProperties properties) {
String baseUrl = properties.getModels().getQuery().getBaseUrl();
this.restClient = RestClient.builder().baseUrl(baseUrl).build();
}
@Override
public Health health() {
try {
restClient.get()
.uri("/api/tags")
.retrieve()
.toBodilessEntity();
return Health.up().build();
} catch (Exception e) {
return Health.down().withException(e).build();
}
}
}

View File

@@ -0,0 +1,24 @@
spring:
devtools:
restart:
enabled: false
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
logging:
level:
root: WARN
dev.sonpx.loyalty: INFO
org.springframework.ai: WARN
pattern:
console:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus

View File

@@ -3,6 +3,8 @@ server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
devtools:
restart:
quiet-period: 1000ms
@@ -14,9 +16,9 @@ spring:
application:
name: loyalty-agent-service
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://192.168.99.242:5433/loyalty_agent}
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5433/loyalty_agent}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:Sonpx@1234}
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
@@ -32,15 +34,15 @@ spring:
locations: classpath:db/migration
ai:
ollama:
base-url: http://192.168.99.10:11434
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
chat:
options:
model: qwen3.5:4b
model: ${OLLAMA_MODEL:qwen3.5:4b}
temperature: 0.3
num_ctx: 32768
mcp:
client:
request-timeout: 3000
request-timeout: 30000
enabled: true
type: sync
sse:
@@ -48,11 +50,27 @@ spring:
loyalty:
url: ${MCP_SERVER_URL:http://localhost:9331/sse}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
logging:
pattern:
console: "%d{HH:mm:ss.SSS} %5p --- %-40.40logger{39} : %m%n"
level:
org.flywaydb: DEBUG
org.flywaydb: INFO
org.springframework.ai: INFO
dev.sonpx.loyalty: DEBUG
root: warn
@@ -66,15 +84,15 @@ agent:
num-ctx: ${CHAT_MODEL_NUM_CTX:8192}
disable-thinking: ${CHAT_MODEL_DISABLE_THINKING:true}
query:
base-url: ${QUERY_OLLAMA_BASE_URL:http://192.168.99.10:11434}
base-url: ${QUERY_OLLAMA_BASE_URL:http://localhost:11434}
model: ${QUERY_MODEL:qwen3.5:4b}
temperature: ${QUERY_MODEL_TEMPERATURE:0.3}
num-ctx: ${QUERY_MODEL_NUM_CTX:32768}
disable-thinking: ${QUERY_MODEL_DISABLE_THINKING:false}
creator:
base-url: ${CREATOR_OLLAMA_BASE_URL:http://192.168.99.10:11434}
base-url: ${CREATOR_OLLAMA_BASE_URL:http://localhost:11434}
model: ${CREATOR_MODEL:qwen3.5:4b}
temperature: ${CREATOR_MODEL_TEMPERATURE:0.3}
num-ctx: ${CREATOR_MODEL_NUM_CTX:32768}
disable-thinking: ${CREATOR_MODEL_DISABLE_THINKING:false}
request-timeout-seconds: ${AGENT_REQUEST_TIMEOUT:120}