diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-a2a/src/main/java/io/agentscope/core/nacos/prompt/NacosPromptListener.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-a2a/src/main/java/io/agentscope/core/nacos/prompt/NacosPromptListener.java new file mode 100644 index 000000000..ce7e0b060 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-a2a/src/main/java/io/agentscope/core/nacos/prompt/NacosPromptListener.java @@ -0,0 +1,207 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.core.nacos.prompt; + +import com.alibaba.nacos.api.config.ConfigService; +import com.alibaba.nacos.api.config.listener.Listener; +import com.alibaba.nacos.api.exception.NacosException; +import com.alibaba.nacos.common.utils.JacksonUtils; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class NacosPromptListener { + + private static final Logger log = LoggerFactory.getLogger(NacosPromptListener.class); + + private static final String PROMPT_KEY_SUFFIX = ".json"; + private static final String FIELD_TEMPLATE = "template"; + private static final String FIELD_PROMPT_KEY = "promptKey"; + private static final String DEFAULT_GROUP = "nacos-ai-prompt"; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{(.+?)\\}\\}"); + + private final ConfigService configService; + + private final Map prompts; + + public NacosPromptListener(ConfigService configService) { + this.configService = configService; + this.prompts = new ConcurrentHashMap<>(10); + } + + public String getPrompt(String promptKey) throws NacosException { + return getPrompt(promptKey, null); + } + + public String getPrompt(String promptKey, Map args) throws NacosException { + return getPrompt(promptKey, args, null); + } + + /** + * Get prompt template with optional default value + * @param promptKey the prompt key + * @param args the template variables for rendering + * @param defaultValue the default value to use if prompt not found in Nacos + * @return rendered prompt string or default value + * @throws NacosException if Nacos service error occurs + */ + public String getPrompt(String promptKey, Map args, String defaultValue) + throws NacosException { + // Use computeIfAbsent to ensure atomic check-and-load operation + String template = + prompts.computeIfAbsent( + promptKey, + key -> { + try { + return getPromptFromNacosAndListener(key); + } catch (NacosException e) { + log.error("Failed to load prompt from Nacos for key: {}", key, e); + return ""; + } + }); + + // Use default value if template is empty + if (template == null || template.isEmpty()) { + if (defaultValue != null) { + log.info("Using default value for prompt key: {}", promptKey); + template = defaultValue; + } else { + return ""; + } + } + + // Render template with args if provided + if (args != null && !args.isEmpty()) { + return renderTemplate(template, args); + } + return template; + } + + private String getPromptFromNacosAndListener(String promptKey) throws NacosException { + String promptDataId = promptKey + PROMPT_KEY_SUFFIX; + String promptStr = + configService.getConfigAndSignListener( + promptDataId, DEFAULT_GROUP, 5000, this.promptListener); + + JsonNode node; + try { + node = JacksonUtils.toObj(promptStr, JsonNode.class); + } catch (Exception e) { + log.warn("Failed to parse prompt config JSON for key: {}", promptKey, e); + return ""; + } + + if (node == null || !node.has(FIELD_PROMPT_KEY) || !node.has(FIELD_TEMPLATE)) { + log.warn("Invalid prompt config for key: {}, missing required fields", promptKey); + return ""; + } + + JsonNode templateNode = node.get(FIELD_TEMPLATE); + if (templateNode == null || templateNode.isNull()) { + log.warn("Template field is null for prompt key: {}", promptKey); + return ""; + } + + String promptTemplate = templateNode.asText(); + log.info("Loaded prompt template for key: {}", promptKey); + return promptTemplate; + } + + /** + * Render template by replacing {{variableName}} with values from args. + * Uses single-pass regex replacement for better performance. + * Unmatched placeholders are preserved as-is. + * + * @param template the template string with {{}} placeholders + * @param args the variable map for replacement + * @return rendered string + */ + private String renderTemplate(String template, Map args) { + if (template == null || template.isEmpty()) { + return template; + } + + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String key = matcher.group(1); + if (args.containsKey(key)) { + String value = args.get(key) != null ? args.get(key) : ""; + matcher.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + } + matcher.appendTail(sb); + return sb.toString(); + } + + private final Listener promptListener = + new Listener() { + @Override + public Executor getExecutor() { + return null; + } + + @Override + public void receiveConfigInfo(String configInfo) { + try { + JsonNode node = JacksonUtils.toObj(configInfo, JsonNode.class); + if (node == null || !node.has(FIELD_PROMPT_KEY)) { + log.warn("Received invalid prompt config, missing promptKey field"); + return; + } + + JsonNode promptKeyNode = node.get(FIELD_PROMPT_KEY); + if (promptKeyNode == null || promptKeyNode.isNull()) { + log.warn("PromptKey field is null in configuration"); + return; + } + + String promptKey = promptKeyNode.asText(); + + if (!node.has(FIELD_TEMPLATE)) { + log.warn( + "No template field found in configuration for key: {}", + promptKey); + return; + } + + JsonNode templateNode = node.get(FIELD_TEMPLATE); + if (templateNode == null || templateNode.isNull()) { + log.warn( + "Template field is null in configuration for key: {}", + promptKey); + return; + } + + String newTemplate = templateNode.asText(); + prompts.put(promptKey, newTemplate); + log.info("Prompt template updated for key: {}", promptKey); + + } catch (Exception e) { + log.error( + "Failed to parse prompt configuration from config: {}", + configInfo, + e); + } + } + }; +} diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-a2a/src/test/java/io/agentscope/core/nacos/prompt/NacosPromptListenerTest.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-a2a/src/test/java/io/agentscope/core/nacos/prompt/NacosPromptListenerTest.java new file mode 100644 index 000000000..22a2dc348 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-a2a/src/test/java/io/agentscope/core/nacos/prompt/NacosPromptListenerTest.java @@ -0,0 +1,403 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.core.nacos.prompt; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.alibaba.nacos.api.config.ConfigService; +import com.alibaba.nacos.api.config.listener.Listener; +import com.alibaba.nacos.api.exception.NacosException; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Unit tests for {@link NacosPromptListener}. + */ +@ExtendWith(MockitoExtension.class) +class NacosPromptListenerTest { + + private static final String VALID_CONFIG = + "{\"promptKey\":\"test-agent\",\"template\":\"You are {{role}} in {{department}}\"}"; + private static final String VALID_CONFIG_NO_VARS = + "{\"promptKey\":\"simple\",\"template\":\"You are a helpful assistant\"}"; + private static final String DEFAULT_GROUP = "nacos-ai-prompt"; + + @Mock private ConfigService configService; + + private NacosPromptListener listener; + + @BeforeEach + void setUp() { + listener = new NacosPromptListener(configService); + } + + @Nested + @DisplayName("getPrompt - basic loading") + class GetPromptBasicTests { + + @Test + @DisplayName("should load prompt from Nacos and return rendered template") + void shouldLoadAndRenderPrompt() throws NacosException { + when(configService.getConfigAndSignListener( + eq("test-agent.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(VALID_CONFIG); + + Map args = Map.of("role", "AI Assistant", "department", "Engineering"); + String result = listener.getPrompt("test-agent", args); + + assertEquals("You are AI Assistant in Engineering", result); + } + + @Test + @DisplayName("should load prompt without variable rendering when args is null") + void shouldLoadPromptWithoutArgs() throws NacosException { + when(configService.getConfigAndSignListener( + eq("simple.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(VALID_CONFIG_NO_VARS); + + String result = listener.getPrompt("simple"); + + assertEquals("You are a helpful assistant", result); + } + + @Test + @DisplayName("should load prompt without variable rendering when args is empty") + void shouldLoadPromptWithEmptyArgs() throws NacosException { + when(configService.getConfigAndSignListener( + eq("simple.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(VALID_CONFIG_NO_VARS); + + String result = listener.getPrompt("simple", Map.of()); + + assertEquals("You are a helpful assistant", result); + } + + @Test + @DisplayName("should return empty string when prompt config is invalid JSON") + void shouldReturnEmptyForInvalidJson() throws NacosException { + when(configService.getConfigAndSignListener( + eq("bad.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("not valid json"); + + String result = listener.getPrompt("bad"); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should return empty string when config missing promptKey field") + void shouldReturnEmptyForMissingPromptKey() throws NacosException { + when(configService.getConfigAndSignListener( + eq("no-key.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"template\":\"some text\"}"); + + String result = listener.getPrompt("no-key"); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should return empty string when config missing template field") + void shouldReturnEmptyForMissingTemplate() throws NacosException { + when(configService.getConfigAndSignListener( + eq("no-tpl.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"no-tpl\"}"); + + String result = listener.getPrompt("no-tpl"); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should return empty string when template field is null") + void shouldReturnEmptyForNullTemplate() throws NacosException { + when(configService.getConfigAndSignListener( + eq("null-tpl.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"null-tpl\",\"template\":null}"); + + String result = listener.getPrompt("null-tpl"); + + assertTrue(result.isEmpty()); + } + } + + @Nested + @DisplayName("getPrompt - default value fallback") + class DefaultValueTests { + + @Test + @DisplayName("should use default value when Nacos returns empty config") + void shouldFallbackToDefaultValue() throws NacosException { + when(configService.getConfigAndSignListener( + eq("missing.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"missing\"}"); + + String result = listener.getPrompt("missing", null, "I am a fallback assistant"); + + assertEquals("I am a fallback assistant", result); + } + + @Test + @DisplayName("should render default value with args") + void shouldRenderDefaultValueWithArgs() throws NacosException { + when(configService.getConfigAndSignListener( + eq("missing.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"missing\"}"); + + Map args = Map.of("name", "Bob"); + String result = listener.getPrompt("missing", args, "Hello {{name}}"); + + assertEquals("Hello Bob", result); + } + + @Test + @DisplayName("should return empty string when no default value and Nacos empty") + void shouldReturnEmptyWhenNoDefaultAndNacosEmpty() throws NacosException { + when(configService.getConfigAndSignListener( + eq("missing.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"missing\"}"); + + String result = listener.getPrompt("missing", null, null); + + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("should use Nacos value over default value when both available") + void shouldPreferNacosOverDefault() throws NacosException { + when(configService.getConfigAndSignListener( + eq("test-agent.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(VALID_CONFIG); + + String result = + listener.getPrompt( + "test-agent", + Map.of("role", "Helper", "department", "Sales"), + "This is the fallback"); + + assertEquals("You are Helper in Sales", result); + } + + @Test + @DisplayName("should use default value when NacosException occurs during loading") + void shouldFallbackOnNacosException() throws NacosException { + when(configService.getConfigAndSignListener( + eq("error.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenThrow(new NacosException(500, "Nacos server error")); + + String result = listener.getPrompt("error", null, "Fallback on error"); + + assertEquals("Fallback on error", result); + } + } + + @Nested + @DisplayName("Template rendering") + class TemplateRenderingTests { + + @Test + @DisplayName("should replace multiple variables in template") + void shouldReplaceMultipleVariables() throws NacosException { + String config = + "{\"promptKey\":\"multi\",\"template\":\"{{greeting}} I am {{name}}, working at" + + " {{company}}\"}"; + when(configService.getConfigAndSignListener( + eq("multi.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(config); + + Map args = new HashMap<>(); + args.put("greeting", "Hello!"); + args.put("name", "Agent"); + args.put("company", "Alibaba"); + + String result = listener.getPrompt("multi", args); + + assertEquals("Hello! I am Agent, working at Alibaba", result); + } + + @Test + @DisplayName("should leave unmatched placeholders as-is") + void shouldLeaveUnmatchedPlaceholders() throws NacosException { + String config = + "{\"promptKey\":\"partial\"," + + "\"template\":\"Hello {{name}}, your role is {{role}}\"}"; + when(configService.getConfigAndSignListener( + eq("partial.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(config); + + Map args = Map.of("name", "Alice"); + String result = listener.getPrompt("partial", args); + + assertEquals("Hello Alice, your role is {{role}}", result); + } + + @Test + @DisplayName("should handle null value in args by replacing with empty string") + void shouldHandleNullArgValue() throws NacosException { + String config = "{\"promptKey\":\"nullval\"," + "\"template\":\"Hello {{name}}\"}"; + when(configService.getConfigAndSignListener( + eq("nullval.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(config); + + Map args = new HashMap<>(); + args.put("name", null); + String result = listener.getPrompt("nullval", args); + + assertEquals("Hello ", result); + } + } + + @Nested + @DisplayName("Caching via computeIfAbsent") + class CachingTests { + + @Test + @DisplayName("should only call Nacos once for the same key (cache hit)") + void shouldCachePromptAfterFirstLoad() throws NacosException { + when(configService.getConfigAndSignListener( + eq("cached.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn(VALID_CONFIG_NO_VARS); + + listener.getPrompt("cached"); + listener.getPrompt("cached"); + listener.getPrompt("cached"); + + verify(configService, times(1)) + .getConfigAndSignListener( + eq("cached.json"), eq(DEFAULT_GROUP), anyLong(), any()); + } + + @Test + @DisplayName("should call Nacos separately for different keys") + void shouldLoadDifferentKeysIndependently() throws NacosException { + when(configService.getConfigAndSignListener( + eq("key-a.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"key-a\",\"template\":\"Template A\"}"); + when(configService.getConfigAndSignListener( + eq("key-b.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"key-b\",\"template\":\"Template B\"}"); + + assertEquals("Template A", listener.getPrompt("key-a")); + assertEquals("Template B", listener.getPrompt("key-b")); + + verify(configService, times(1)) + .getConfigAndSignListener( + eq("key-a.json"), eq(DEFAULT_GROUP), anyLong(), any()); + verify(configService, times(1)) + .getConfigAndSignListener( + eq("key-b.json"), eq(DEFAULT_GROUP), anyLong(), any()); + } + } + + @Nested + @DisplayName("Listener callback - config update") + class ListenerCallbackTests { + + @Test + @DisplayName("should update cached prompt when listener receives new config") + void shouldUpdateCacheOnListenerCallback() throws Exception { + when(configService.getConfigAndSignListener( + eq("updatable.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn( + "{\"promptKey\":\"updatable\"," + + "\"template\":\"Original template\"}"); + + // First load + assertEquals("Original template", listener.getPrompt("updatable")); + + // Simulate listener callback with updated config + Listener nacosListener = getPromptListener(); + nacosListener.receiveConfigInfo( + "{\"promptKey\":\"updatable\"," + "\"template\":\"Updated template\"}"); + + // Should return updated template + assertEquals("Updated template", listener.getPrompt("updatable")); + } + + @Test + @DisplayName("should not crash when listener receives invalid JSON") + void shouldHandleInvalidJsonInCallback() throws Exception { + when(configService.getConfigAndSignListener( + eq("stable.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"stable\"," + "\"template\":\"Stable template\"}"); + + // First load + assertEquals("Stable template", listener.getPrompt("stable")); + + // Simulate listener callback with invalid JSON - should not throw + Listener nacosListener = getPromptListener(); + nacosListener.receiveConfigInfo("not valid json"); + + // Should still return original template + assertEquals("Stable template", listener.getPrompt("stable")); + } + + @Test + @DisplayName("should ignore callback when missing promptKey field") + void shouldIgnoreCallbackMissingPromptKey() throws Exception { + when(configService.getConfigAndSignListener( + eq("safe.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"safe\"," + "\"template\":\"Safe template\"}"); + + assertEquals("Safe template", listener.getPrompt("safe")); + + Listener nacosListener = getPromptListener(); + nacosListener.receiveConfigInfo("{\"template\":\"No key here\"}"); + + assertEquals("Safe template", listener.getPrompt("safe")); + } + + @Test + @DisplayName("should ignore callback when template field is missing") + void shouldIgnoreCallbackMissingTemplate() throws Exception { + when(configService.getConfigAndSignListener( + eq("keep.json"), eq(DEFAULT_GROUP), anyLong(), any())) + .thenReturn("{\"promptKey\":\"keep\"," + "\"template\":\"Keep this\"}"); + + assertEquals("Keep this", listener.getPrompt("keep")); + + Listener nacosListener = getPromptListener(); + nacosListener.receiveConfigInfo("{\"promptKey\":\"keep\"}"); + + assertEquals("Keep this", listener.getPrompt("keep")); + } + + /** + * Access the private promptListener field via reflection. + */ + private Listener getPromptListener() throws Exception { + Field field = NacosPromptListener.class.getDeclaredField("promptListener"); + field.setAccessible(true); + return (Listener) field.get(listener); + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/pom.xml b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/pom.xml index 672170528..6167938f4 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/pom.xml +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/pom.xml @@ -41,6 +41,20 @@ true + + io.agentscope + agentscope-spring-boot-starter + provided + true + + + + io.agentscope + agentscope-core + provided + true + + io.agentscope agentscope-extensions-nacos-a2a diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/AgentscopeNacosPromptAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/AgentscopeNacosPromptAutoConfiguration.java new file mode 100644 index 000000000..944973a71 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/AgentscopeNacosPromptAutoConfiguration.java @@ -0,0 +1,78 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.spring.boot.nacos; + +import com.alibaba.nacos.api.config.ConfigFactory; +import com.alibaba.nacos.api.config.ConfigService; +import com.alibaba.nacos.api.exception.NacosException; +import io.agentscope.core.nacos.prompt.NacosPromptListener; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import io.agentscope.spring.boot.nacos.constants.NacosConstants; +import io.agentscope.spring.boot.nacos.properties.AgentScopeNacosPromptProperties; +import io.agentscope.spring.boot.nacos.properties.AgentScopeNacosProperties; +import io.agentscope.spring.boot.properties.AgentscopeProperties; +import java.util.Properties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration that builds ReActAgent from prompts stored in Nacos. + * + *

When this starter is on the classpath and {@code agentscope.nacos.prompt.enabled=true}, the + * Agent's system prompt will be loaded from Nacos via {@link NacosPromptListener}. The Nacos + * prompt takes precedence over {@code agentscope.agent.sys-prompt} defined in YAML, with the YAML + * value still acting as a default fallback. + */ +@AutoConfiguration +@AutoConfigureBefore(AgentscopeAutoConfiguration.class) +@EnableConfigurationProperties({ + AgentScopeNacosProperties.class, + AgentScopeNacosPromptProperties.class, + AgentscopeProperties.class +}) +@ConditionalOnClass(NacosPromptListener.class) +@ConditionalOnProperty( + prefix = NacosConstants.NACOS_PROMPT_PREFIX, + name = "enabled", + havingValue = "true", + matchIfMissing = true) +public class AgentscopeNacosPromptAutoConfiguration { + + @Bean(name = "agentscopePromptConfigService") + @ConditionalOnMissingBean(name = "agentscopePromptConfigService") + public ConfigService agentscopePromptConfigService( + AgentScopeNacosProperties nacosProperties, + AgentScopeNacosPromptProperties promptNacosProperties) + throws NacosException { + // Start from the global Nacos configuration (with defaults) + Properties result = nacosProperties.getNacosProperties(); + // Only overlay explicitly configured prompt-specific fields (no defaults) + result.putAll(promptNacosProperties.getExplicitNacosProperties()); + return ConfigFactory.createConfigService(result); + } + + @Bean + @ConditionalOnMissingBean(NacosPromptListener.class) + public NacosPromptListener nacosPromptListener(ConfigService agentscopePromptConfigService) { + return new NacosPromptListener(agentscopePromptConfigService); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/AgentscopeNacosReActAgentAutoConfiguration.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/AgentscopeNacosReActAgentAutoConfiguration.java new file mode 100644 index 000000000..d9b49b72d --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/AgentscopeNacosReActAgentAutoConfiguration.java @@ -0,0 +1,104 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.spring.boot.nacos; + +import com.alibaba.nacos.api.exception.NacosException; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.model.Model; +import io.agentscope.core.nacos.prompt.NacosPromptListener; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.spring.boot.AgentscopeAutoConfiguration; +import io.agentscope.spring.boot.nacos.constants.NacosConstants; +import io.agentscope.spring.boot.nacos.properties.AgentScopeNacosPromptProperties; +import io.agentscope.spring.boot.properties.AgentProperties; +import io.agentscope.spring.boot.properties.AgentscopeProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; + +/** + * Auto-configuration that assembles {@link ReActAgent} instances backed by Nacos-managed prompts. + * + *

This configuration is responsible only for wiring the Agent from existing building blocks + * (model, memory, toolkit, Nacos prompt infrastructure). It is separated from + * {@code AgentscopeNacosPromptAutoConfiguration}, which focuses on Nacos prompt-related + * components such as {@code ConfigService} and {@code NacosPromptListener}. + */ +@AutoConfiguration +@AutoConfigureBefore(AgentscopeAutoConfiguration.class) +@EnableConfigurationProperties({AgentscopeProperties.class, AgentScopeNacosPromptProperties.class}) +@ConditionalOnClass(ReActAgent.class) +@ConditionalOnProperty( + prefix = NacosConstants.NACOS_PROMPT_PREFIX, + name = "enabled", + havingValue = "true", + matchIfMissing = true) +public class AgentscopeNacosReActAgentAutoConfiguration { + + private static final Logger log = + LoggerFactory.getLogger(AgentscopeNacosReActAgentAutoConfiguration.class); + + @Bean + @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) + @ConditionalOnMissingBean(ReActAgent.class) + public ReActAgent nacosPromptReActAgent( + Model model, + Memory memory, + Toolkit toolkit, + AgentscopeProperties properties, + AgentScopeNacosPromptProperties nacosPromptProperties, + NacosPromptListener nacosPromptListener) { + + AgentProperties agentConfig = properties.getAgent(); + String defaultSysPrompt = agentConfig.getSysPrompt(); + + String sysPrompt = defaultSysPrompt; + String promptKey = nacosPromptProperties.getSysPromptKey(); + + if (promptKey != null && !promptKey.isEmpty()) { + try { + sysPrompt = + nacosPromptListener.getPrompt( + promptKey, nacosPromptProperties.getVariables(), defaultSysPrompt); + } catch (NacosException e) { + log.warn( + "Failed to load sys prompt from Nacos for key: {}, fallback to default" + + " sys-prompt.", + promptKey, + e); + } + } + + return ReActAgent.builder() + .name(agentConfig.getName()) + .sysPrompt(sysPrompt) + .model(model) + .memory(memory) + .toolkit(toolkit) + .maxIters(agentConfig.getMaxIters()) + .build(); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/constants/NacosConstants.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/constants/NacosConstants.java index 246266319..8a763910c 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/constants/NacosConstants.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/constants/NacosConstants.java @@ -28,6 +28,8 @@ public class NacosConstants { public static final String NACOS_PREFIX = Constants.AGENTSCOPE_PREFIX + ".nacos"; + public static final String NACOS_PROMPT_PREFIX = NACOS_PREFIX + ".prompt"; + public static final String A2A_NACOS_PREFIX = Constants.A2A_PREFIX + ".nacos"; public static final String A2A_NACOS_REGISTRY_PREFIX = A2A_NACOS_PREFIX + ".registry"; diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/AgentScopeNacosPromptProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/AgentScopeNacosPromptProperties.java new file mode 100644 index 000000000..b4310d47f --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/AgentScopeNacosPromptProperties.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.spring.boot.nacos.properties; + +import io.agentscope.spring.boot.nacos.constants.NacosConstants; +import java.util.HashMap; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for AgentScope Nacos Prompt integration. + * + *

This configuration allows users to drive the Agent's system prompt from Nacos. The + * Nacos-stored prompt will take precedence over the YAML-defined {@code agentscope.agent.sys-prompt}, + * while the YAML value can still be used as a default fallback. + * + *

Example configuration: + *

{@code
+ * agentscope:
+ *   agent:
+ *     enabled: true
+ *     name: "Assistant"
+ *     sys-prompt: "You are a helpful AI assistant."
+ *
+ *   nacos:
+ *     server-addr: 127.0.0.1:8848
+ *     namespace: public
+ *
+ *     prompt:
+ *       enabled: true
+ *       sys-prompt-key: agent-main
+ *       variables:
+ *         env: prod
+ *         app: order-service
+ * }
+ */ +@ConfigurationProperties(prefix = NacosConstants.NACOS_PROMPT_PREFIX) +public class AgentScopeNacosPromptProperties extends BaseNacosProperties { + + /** + * Whether Nacos prompt integration is enabled. + */ + private boolean enabled = true; + + /** + * The promptKey used to locate the system prompt in Nacos. + *

The actual Nacos dataId will typically be {@code promptKey + ".json"}. + */ + private String sysPromptKey; + + /** + * Template variables used to render the Nacos prompt with {{}} placeholders. + */ + private Map variables = new HashMap<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getSysPromptKey() { + return sysPromptKey; + } + + public void setSysPromptKey(String sysPromptKey) { + this.sysPromptKey = sysPromptKey; + } + + public Map getVariables() { + return variables; + } + + public void setVariables(Map variables) { + this.variables = variables; + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/BaseNacosProperties.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/BaseNacosProperties.java index 06d57dcb2..a443f2998 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/BaseNacosProperties.java +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/java/io/agentscope/spring/boot/nacos/properties/BaseNacosProperties.java @@ -166,33 +166,24 @@ public void setProperties(Properties properties) { } /** - * Build a {@link Properties} instance for configuring the Nacos client. + * Build a {@link Properties} instance containing only the explicitly configured fields. * - *

This method merges the advanced {@link #properties} with the basic fields such as - * {@link #serverAddr}, {@link #namespace}, {@link #username}, {@link #password}, - * {@link #accessKey}, and {@link #secretKey}. If both the corresponding field and any - * pre-configured property are {@code null}, the default values - * {@link #DEFAULT_ADDRESS} and {@link #DEFAULT_NAMESPACE} are applied to the returned - * {@link Properties} instance without modifying this object's state. + *

Unlike {@link #getNacosProperties()}, this method does NOT apply default values + * for {@code serverAddr} and {@code namespace}. This is useful for overlay/merge scenarios + * where default values should not overwrite a parent configuration. * - * @return a {@link Properties} instance containing all resolved Nacos configuration + * @return a {@link Properties} instance containing only explicitly set Nacos configuration */ - public Properties getNacosProperties() { + public Properties getExplicitNacosProperties() { Properties result = new Properties(); if (null != properties && !properties.isEmpty()) { result.putAll(properties); } - // Resolve server address: prefer explicit field, otherwise use default if none present if (null != serverAddr) { result.put(PropertyKeyConst.SERVER_ADDR, serverAddr); - } else { - result.putIfAbsent(PropertyKeyConst.SERVER_ADDR, DEFAULT_ADDRESS); } - // Resolve namespace: prefer explicit field, otherwise use default if none present if (null != namespace) { result.put(PropertyKeyConst.NAMESPACE, namespace); - } else { - result.putIfAbsent(PropertyKeyConst.NAMESPACE, DEFAULT_NAMESPACE); } if (null != username) { result.put(PropertyKeyConst.USERNAME, username); @@ -208,4 +199,20 @@ public Properties getNacosProperties() { } return result; } + + /** + * Build a {@link Properties} instance for configuring the Nacos client. + * + *

This method builds on {@link #getExplicitNacosProperties()} and additionally applies + * default values ({@link #DEFAULT_ADDRESS} and {@link #DEFAULT_NAMESPACE}) when the + * corresponding fields are not explicitly configured. + * + * @return a {@link Properties} instance containing all resolved Nacos configuration + */ + public Properties getNacosProperties() { + Properties result = getExplicitNacosProperties(); + result.putIfAbsent(PropertyKeyConst.SERVER_ADDR, DEFAULT_ADDRESS); + result.putIfAbsent(PropertyKeyConst.NAMESPACE, DEFAULT_NAMESPACE); + return result; + } } diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 68c3c3615..a1a576365 100644 --- a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -14,3 +14,5 @@ # limitations under the License. # io.agentscope.spring.boot.nacos.AgentscopeA2aNacosAutoConfiguration +io.agentscope.spring.boot.nacos.AgentscopeNacosPromptAutoConfiguration +io.agentscope.spring.boot.nacos.AgentscopeNacosReActAgentAutoConfiguration diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/AgentscopeNacosPromptAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/AgentscopeNacosPromptAutoConfigurationTest.java new file mode 100644 index 000000000..d54fceba2 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/AgentscopeNacosPromptAutoConfigurationTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.spring.boot.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; + +import com.alibaba.nacos.api.PropertyKeyConst; +import com.alibaba.nacos.api.config.ConfigFactory; +import com.alibaba.nacos.api.config.ConfigService; +import io.agentscope.core.nacos.prompt.NacosPromptListener; +import io.agentscope.spring.boot.nacos.properties.AgentScopeNacosPromptProperties; +import io.agentscope.spring.boot.nacos.properties.AgentScopeNacosProperties; +import io.agentscope.spring.boot.properties.AgentscopeProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Unit tests for {@link AgentscopeNacosPromptAutoConfiguration}. + * + *

Verifies that ConfigService and NacosPromptListener beans are correctly created, + * conditional on properties and classpath conditions. + */ +class AgentscopeNacosPromptAutoConfigurationTest { + + private ConfigService mockConfigService; + + @BeforeEach + void setUp() { + mockConfigService = mock(ConfigService.class); + } + + @Test + @DisplayName("should create ConfigService and NacosPromptListener when enabled") + void shouldCreateBeansWhenEnabled() { + try (MockedStatic mocked = Mockito.mockStatic(ConfigFactory.class)) { + mocked.when(() -> ConfigFactory.createConfigService(any(java.util.Properties.class))) + .thenReturn(mockConfigService); + + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosPromptAutoConfiguration.class)) + .withPropertyValues( + "agentscope.nacos.server-addr=127.0.0.1:8848", + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.sys-prompt-key=test-agent") + .run( + context -> { + assertThat(context).hasSingleBean(AgentScopeNacosProperties.class); + assertThat(context) + .hasSingleBean(AgentScopeNacosPromptProperties.class); + assertThat(context).hasSingleBean(AgentscopeProperties.class); + assertThat(context).hasSingleBean(ConfigService.class); + assertThat(context).hasSingleBean(NacosPromptListener.class); + }); + } + } + + @Test + @DisplayName("should not create beans when prompt is disabled") + void shouldNotCreateBeansWhenDisabled() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosPromptAutoConfiguration.class)) + .withPropertyValues("agentscope.nacos.prompt.enabled=false") + .run( + context -> { + assertThat(context).doesNotHaveBean(ConfigService.class); + assertThat(context).doesNotHaveBean(NacosPromptListener.class); + }); + } + + @Test + @DisplayName("should bind prompt properties correctly") + void shouldBindProperties() { + try (MockedStatic mocked = Mockito.mockStatic(ConfigFactory.class)) { + mocked.when(() -> ConfigFactory.createConfigService(any(java.util.Properties.class))) + .thenReturn(mockConfigService); + + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosPromptAutoConfiguration.class)) + .withPropertyValues( + "agentscope.nacos.server-addr=10.0.0.1:8848", + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.sys-prompt-key=my-agent", + "agentscope.nacos.prompt.variables.role=Helper", + "agentscope.nacos.prompt.variables.env=prod") + .run( + context -> { + AgentScopeNacosPromptProperties props = + context.getBean(AgentScopeNacosPromptProperties.class); + assertThat(props.isEnabled()).isTrue(); + assertThat(props.getSysPromptKey()).isEqualTo("my-agent"); + assertThat(props.getVariables()) + .containsEntry("role", "Helper") + .containsEntry("env", "prod"); + }); + } + } + + @Test + @DisplayName("should not replace existing NacosPromptListener bean") + void shouldNotReplaceExistingBean() { + NacosPromptListener customListener = new NacosPromptListener(mockConfigService); + + try (MockedStatic mocked = Mockito.mockStatic(ConfigFactory.class)) { + mocked.when(() -> ConfigFactory.createConfigService(any(java.util.Properties.class))) + .thenReturn(mockConfigService); + + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosPromptAutoConfiguration.class)) + .withPropertyValues( + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.sys-prompt-key=test") + .withBean(NacosPromptListener.class, () -> customListener) + .run( + context -> { + assertThat(context).hasSingleBean(NacosPromptListener.class); + assertThat(context.getBean(NacosPromptListener.class)) + .isSameAs(customListener); + }); + } + } + + @Test + @DisplayName("should use prompt-specific Nacos config when both global and prompt config set") + void shouldUsePromptSpecificNacosConfig() { + try (MockedStatic mocked = Mockito.mockStatic(ConfigFactory.class)) { + mocked.when(() -> ConfigFactory.createConfigService(any(java.util.Properties.class))) + .thenReturn(mockConfigService); + + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosPromptAutoConfiguration.class)) + .withPropertyValues( + "agentscope.nacos.server-addr=global.example.com:8848", + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.server-addr=prompt.example.com:8848", + "agentscope.nacos.prompt.namespace=prompt-ns", + "agentscope.nacos.prompt.sys-prompt-key=test") + .run( + context -> { + AgentScopeNacosPromptProperties promptProps = + context.getBean(AgentScopeNacosPromptProperties.class); + assertThat(promptProps.getServerAddr()) + .isEqualTo("prompt.example.com:8848"); + assertThat(promptProps.getNamespace()).isEqualTo("prompt-ns"); + + AgentScopeNacosProperties globalProps = + context.getBean(AgentScopeNacosProperties.class); + assertThat(globalProps.getServerAddr()) + .isEqualTo("global.example.com:8848"); + }); + } + } + + @Test + @DisplayName("should not overwrite global server-addr when prompt server-addr is not set") + void shouldNotOverwriteGlobalServerAddrWhenPromptNotSet() { + try (MockedStatic mocked = Mockito.mockStatic(ConfigFactory.class)) { + java.util.concurrent.atomic.AtomicReference capturedProps = + new java.util.concurrent.atomic.AtomicReference<>(); + mocked.when(() -> ConfigFactory.createConfigService(any(java.util.Properties.class))) + .thenAnswer( + invocation -> { + capturedProps.set(invocation.getArgument(0)); + return mockConfigService; + }); + + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosPromptAutoConfiguration.class)) + .withPropertyValues( + "agentscope.nacos.server-addr=production.nacos.com:8848", + "agentscope.nacos.namespace=prod-ns", + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.sys-prompt-key=test") + .run( + context -> { + assertThat(capturedProps.get()).isNotNull(); + assertThat(capturedProps.get().get(PropertyKeyConst.SERVER_ADDR)) + .isEqualTo("production.nacos.com:8848"); + assertThat(capturedProps.get().get(PropertyKeyConst.NAMESPACE)) + .isEqualTo("prod-ns"); + }); + } + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/AgentscopeNacosReActAgentAutoConfigurationTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/AgentscopeNacosReActAgentAutoConfigurationTest.java new file mode 100644 index 000000000..cd12c1a7b --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/AgentscopeNacosReActAgentAutoConfigurationTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.spring.boot.nacos; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.alibaba.nacos.api.config.ConfigService; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.model.Model; +import io.agentscope.core.nacos.prompt.NacosPromptListener; +import io.agentscope.core.tool.Toolkit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Unit tests for {@link AgentscopeNacosReActAgentAutoConfiguration}. + * + *

Verifies that ReActAgent is correctly assembled with Nacos-driven prompts, + * fallback behavior, and conditional creation logic. + */ +class AgentscopeNacosReActAgentAutoConfigurationTest { + + private Model mockModel; + private Memory mockMemory; + private Toolkit mockToolkit; + private ConfigService mockConfigService; + + @BeforeEach + void setUp() { + mockModel = mock(Model.class); + mockMemory = mock(Memory.class); + mockToolkit = mock(Toolkit.class); + mockConfigService = mock(ConfigService.class); + } + + private ApplicationContextRunner contextRunner() { + return new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosReActAgentAutoConfiguration.class)) + .withBean(Model.class, () -> mockModel) + .withBean(Memory.class, () -> mockMemory) + .withBean(Toolkit.class, () -> mockToolkit) + .withBean( + NacosPromptListener.class, + () -> new NacosPromptListener(mockConfigService)); + } + + @Test + @DisplayName("should create ReActAgent with Nacos prompt when config is available") + void shouldCreateAgentWithNacosPrompt() throws Exception { + String nacosConfig = + "{\"promptKey\":\"test-agent\"," + "\"template\":\"You are {{role}} in {{dept}}\"}"; + when(mockConfigService.getConfigAndSignListener( + eq("test-agent.json"), eq("nacos-ai-prompt"), anyLong(), any())) + .thenReturn(nacosConfig); + + contextRunner() + .withPropertyValues( + "agentscope.agent.name=TestBot", + "agentscope.agent.sys-prompt=Default prompt", + "agentscope.agent.max-iters=5", + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.sys-prompt-key=test-agent", + "agentscope.nacos.prompt.variables.role=Helper", + "agentscope.nacos.prompt.variables.dept=Engineering") + .run( + context -> { + assertThat(context).hasSingleBean(ReActAgent.class); + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getName()).isEqualTo("TestBot"); + assertThat(agent.getSysPrompt()) + .isEqualTo("You are Helper in Engineering"); + }); + } + + @Test + @DisplayName("should fallback to YAML prompt when Nacos config is missing") + void shouldFallbackToYamlPrompt() throws Exception { + // Nacos returns config without template field -> empty prompt -> fallback + when(mockConfigService.getConfigAndSignListener( + eq("missing.json"), eq("nacos-ai-prompt"), anyLong(), any())) + .thenReturn("{\"promptKey\":\"missing\"}"); + + contextRunner() + .withPropertyValues( + "agentscope.agent.name=FallbackBot", + "agentscope.agent.sys-prompt=I am the YAML fallback prompt", + "agentscope.nacos.prompt.enabled=true", + "agentscope.nacos.prompt.sys-prompt-key=missing") + .run( + context -> { + assertThat(context).hasSingleBean(ReActAgent.class); + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getSysPrompt()) + .isEqualTo("I am the YAML fallback prompt"); + }); + } + + @Test + @DisplayName("should use YAML prompt when no sys-prompt-key is configured") + void shouldUseYamlPromptWhenNoKey() { + contextRunner() + .withPropertyValues( + "agentscope.agent.name=NoKeyBot", + "agentscope.agent.sys-prompt=YAML only prompt", + "agentscope.nacos.prompt.enabled=true") + .run( + context -> { + assertThat(context).hasSingleBean(ReActAgent.class); + ReActAgent agent = context.getBean(ReActAgent.class); + assertThat(agent.getSysPrompt()).isEqualTo("YAML only prompt"); + }); + } + + @Test + @DisplayName("should not create ReActAgent when prompt is disabled") + void shouldNotCreateAgentWhenDisabled() { + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AgentscopeNacosReActAgentAutoConfiguration.class)) + .withPropertyValues("agentscope.nacos.prompt.enabled=false") + .run( + context -> { + assertThat(context).doesNotHaveBean(ReActAgent.class); + }); + } + + @Test + @DisplayName("should not replace existing ReActAgent bean") + void shouldNotReplaceExistingAgent() { + ReActAgent existingAgent = + ReActAgent.builder() + .name("ExistingAgent") + .sysPrompt("Existing prompt") + .model(mockModel) + .memory(mockMemory) + .toolkit(mockToolkit) + .build(); + + contextRunner() + .withPropertyValues( + "agentscope.agent.name=NacosBot", + "agentscope.agent.sys-prompt=Nacos prompt", + "agentscope.nacos.prompt.enabled=true") + .withBean(ReActAgent.class, () -> existingAgent) + .run( + context -> { + assertThat(context).hasSingleBean(ReActAgent.class); + assertThat(context.getBean(ReActAgent.class).getName()) + .isEqualTo("ExistingAgent"); + }); + } +} diff --git a/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/properties/AgentScopeNacosPromptPropertiesTest.java b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/properties/AgentScopeNacosPromptPropertiesTest.java new file mode 100644 index 000000000..fdd8949f4 --- /dev/null +++ b/agentscope-extensions/agentscope-spring-boot-starters/agentscope-nacos-spring-boot-starter/src/test/java/io/agentscope/spring/boot/nacos/properties/AgentScopeNacosPromptPropertiesTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.agentscope.spring.boot.nacos.properties; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.nacos.api.PropertyKeyConst; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AgentScopeNacosPromptProperties}. + */ +class AgentScopeNacosPromptPropertiesTest { + + @Nested + @DisplayName("Default values") + class DefaultValueTests { + + @Test + @DisplayName("should have enabled=true by default") + void shouldBeEnabledByDefault() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + assertTrue(props.isEnabled()); + } + + @Test + @DisplayName("should have null sysPromptKey by default") + void shouldHaveNullSysPromptKeyByDefault() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + assertNull(props.getSysPromptKey()); + } + + @Test + @DisplayName("should have empty variables map by default") + void shouldHaveEmptyVariablesByDefault() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + assertNotNull(props.getVariables()); + assertTrue(props.getVariables().isEmpty()); + } + } + + @Nested + @DisplayName("Setters and getters") + class SetterGetterTests { + + @Test + @DisplayName("should set and get sysPromptKey") + void shouldSetAndGetSysPromptKey() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + props.setSysPromptKey("my-agent"); + assertEquals("my-agent", props.getSysPromptKey()); + } + + @Test + @DisplayName("should set and get variables") + void shouldSetAndGetVariables() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + Map vars = new HashMap<>(); + vars.put("role", "Helper"); + vars.put("env", "prod"); + props.setVariables(vars); + + assertEquals(2, props.getVariables().size()); + assertEquals("Helper", props.getVariables().get("role")); + assertEquals("prod", props.getVariables().get("env")); + } + + @Test + @DisplayName("should set and get enabled") + void shouldSetAndGetEnabled() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + props.setEnabled(false); + assertEquals(false, props.isEnabled()); + } + } + + @Nested + @DisplayName("Inherited BaseNacosProperties - getNacosProperties()") + class NacosPropertiesTests { + + @Test + @DisplayName("should return default server-addr and namespace when not set") + void shouldReturnDefaultsWhenNotSet() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + Properties nacosProps = props.getNacosProperties(); + + assertEquals("127.0.0.1:8848", nacosProps.get(PropertyKeyConst.SERVER_ADDR)); + assertEquals("public", nacosProps.get(PropertyKeyConst.NAMESPACE)); + } + + @Test + @DisplayName("should override server-addr when set") + void shouldOverrideServerAddr() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + props.setServerAddr("prompt.example.com:8848"); + Properties nacosProps = props.getNacosProperties(); + + assertEquals("prompt.example.com:8848", nacosProps.get(PropertyKeyConst.SERVER_ADDR)); + } + + @Test + @DisplayName("should include username and password when set") + void shouldIncludeCredentials() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + props.setUsername("admin"); + props.setPassword("secret"); + Properties nacosProps = props.getNacosProperties(); + + assertEquals("admin", nacosProps.get(PropertyKeyConst.USERNAME)); + assertEquals("secret", nacosProps.get(PropertyKeyConst.PASSWORD)); + } + + @Test + @DisplayName("should include accessKey and secretKey when set") + void shouldIncludeCloudCredentials() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + props.setAccessKey("ak-123"); + props.setSecretKey("sk-456"); + Properties nacosProps = props.getNacosProperties(); + + assertEquals("ak-123", nacosProps.get(PropertyKeyConst.ACCESS_KEY)); + assertEquals("sk-456", nacosProps.get(PropertyKeyConst.SECRET_KEY)); + } + + @Test + @DisplayName("should not include username/password when not set") + void shouldNotIncludeCredentialsWhenNotSet() { + AgentScopeNacosPromptProperties props = new AgentScopeNacosPromptProperties(); + Properties nacosProps = props.getNacosProperties(); + + assertNull(nacosProps.get(PropertyKeyConst.USERNAME)); + assertNull(nacosProps.get(PropertyKeyConst.PASSWORD)); + } + } +}