diff --git a/pom.xml b/pom.xml index fec9bb05af..41a1b7dd08 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,7 @@ spring-ai-alibaba-studio spring-ai-alibaba-jmanus spring-ai-alibaba-deepresearch + spring-ai-alibaba-agent-nacos community/tool-calls/spring-ai-alibaba-starter-tool-calling-common @@ -411,6 +412,9 @@ io.spring.javaformat spring-javaformat-maven-plugin ${spring-javaformat-maven-plugin.version} + + true + diff --git a/spring-ai-alibaba-agent-nacos/pom.xml b/spring-ai-alibaba-agent-nacos/pom.xml new file mode 100644 index 0000000000..e7d812c0e6 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/pom.xml @@ -0,0 +1,144 @@ + + + 4.0.0 + + com.alibaba.cloud.ai + spring-ai-alibaba + ${revision} + ../pom.xml + + spring-ai-alibaba-agent-nacos + Spring AI Alibaba Agent Nacos + spring-ai-alibaba-agent-nacos + + + + + + + + + + + + + + + 17 + 1.18.30 + + + + + org.springframework.boot + spring-boot-starter + + + + com.alibaba.nacos + nacos-maintainer-client + ${nacos3.version} + + + + com.alibaba.nacos + nacos-common + + + com.alibaba.nacos + nacos-api + + + + + + org.springframework.ai + spring-ai-commons + + + + org.springframework.ai + spring-ai-retry + + + + org.springframework.ai + spring-ai-autoconfigure-retry + + + + com.alibaba.cloud.ai + spring-ai-alibaba-core + ${revision} + + + + com.alibaba.cloud.ai + spring-ai-alibaba-mcp-registry + ${revision} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.alibaba.cloud.ai + spring-ai-alibaba-graph-core + ${revision} + + + + com.alibaba.cloud.ai + spring-ai-alibaba-mcp-router + ${revision} + + + com.alibaba.nacos + nacos-client + + + com.alibaba.nacos + nacos-common + + + + + + com.alibaba.nacos + nacos-client + ${nacos3.version} + + + + com.alibaba.nacos + nacos-common + + + + + + org.projectlombok + lombok + ${lombok.version} + + + + org.springframework.ai + spring-ai-openai + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosAgentBuilderFactory.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosAgentBuilderFactory.java new file mode 100644 index 0000000000..6404344c35 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosAgentBuilderFactory.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import com.alibaba.cloud.ai.graph.agent.Builder; +import com.alibaba.cloud.ai.graph.agent.factory.AgentBuilderFactory; + +public class NacosAgentBuilderFactory implements AgentBuilderFactory { + + private NacosOptions nacosOptions; + + public NacosAgentBuilderFactory(NacosOptions nacosOptions) { + this.nacosOptions = nacosOptions; + } + + @Override + public Builder builder() { + return new NacosReactAgentBuilder().nacosOptions(this.nacosOptions); + } +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosAgentInjector.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosAgentInjector.java new file mode 100644 index 0000000000..640d74b331 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosAgentInjector.java @@ -0,0 +1,108 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import com.alibaba.cloud.ai.agent.nacos.vo.AgentVO; +import com.alibaba.cloud.ai.agent.nacos.vo.ModelVO; +import com.alibaba.cloud.ai.agent.nacos.vo.PromptVO; +import com.alibaba.fastjson.JSON; +import com.alibaba.nacos.api.exception.NacosException; +import com.alibaba.nacos.client.config.NacosConfigService; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; + +public class NacosAgentInjector { + + public static void injectPrompt(NacosConfigService nacosConfigService, ChatClient chatClient, String promptKey) { + + try { + PromptVO promptVO = NacosPromptInjector.getPromptByKey(nacosConfigService, promptKey); + if (promptVO != null) { + NacosPromptInjector.replacePrompt(chatClient, promptVO); + } + NacosPromptInjector.registerPromptListener(nacosConfigService, chatClient, promptKey); + } + + catch (Exception e) { + throw new RuntimeException(e); + } + + } + + /** + * load prompt by agent id. + * + * @param nacosConfigService + * @param agentName + * @return + */ + public static AgentVO loadAgentVO(NacosConfigService nacosConfigService, String agentName) { + try { + String config = nacosConfigService.getConfig("agent-base.json", "ai-agent-" + agentName, + 3000L); + return JSON.parseObject(config, AgentVO.class); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + public static void injectPromptByAgentName(NacosConfigService nacosConfigService, ChatClient chatClient,String agentName,AgentVO agentVO) { + + try { + if (agentVO == null) { + return; + } + PromptVO promptVO = NacosPromptInjector.loadPromptByAgentId(nacosConfigService, agentVO); + if (promptVO != null) { + NacosPromptInjector.replacePrompt(chatClient, promptVO); + } + NacosPromptInjector.registryPromptByAgentId(chatClient, nacosConfigService, agentName, promptVO); + } + + catch (Exception e) { + throw new RuntimeException(e); + } + + } + + public static void injectModel(NacosOptions nacosOptions, ChatClient chatClient, String agentId) { + ModelVO modelVO = NacosModelInjector.getModelByAgentId(nacosOptions, agentId); + if (modelVO != null) { + try { + ChatModel chatModel = NacosModelInjector.initModel(nacosOptions, modelVO); + NacosModelInjector.replaceModel(chatClient, chatModel); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + NacosModelInjector.registerModelListener(chatClient, nacosOptions, agentId); + } + + + public static ChatModel initModel(NacosOptions nacosOptions, String agentId) { + ModelVO modelVo = NacosModelInjector.getModelByAgentId(nacosOptions, agentId); + if (modelVo == null) { + return null; + } + + return NacosModelInjector.initModel(nacosOptions, modelVo); + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosMcpToolsInjector.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosMcpToolsInjector.java new file mode 100644 index 0000000000..b2b4860497 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosMcpToolsInjector.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import java.util.List; + +import com.alibaba.cloud.ai.agent.nacos.tools.NacosMcpGatewayToolsInitializer; +import com.alibaba.cloud.ai.agent.nacos.vo.McpServersVO; +import com.alibaba.cloud.ai.graph.node.LlmNode; +import com.alibaba.cloud.ai.graph.node.ToolNode; +import com.alibaba.cloud.ai.mcp.gateway.nacos.properties.NacosMcpGatewayProperties; +import com.alibaba.fastjson.JSON; +import com.alibaba.nacos.api.config.listener.AbstractListener; +import com.alibaba.nacos.api.exception.NacosException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.tool.ToolCallback; + +public class NacosMcpToolsInjector { + + private static final Logger logger = LoggerFactory.getLogger(NacosMcpToolsInjector.class); + + public static List loadMcpTools(NacosOptions nacosOptions, String agentId) { + McpServersVO mcpServersVO = getMcpServersVO(nacosOptions, agentId); + if (mcpServersVO != null) { + return convert(nacosOptions, mcpServersVO); + } + return null; + } + + public static void registry(LlmNode llmNode, ToolNode toolNode, NacosOptions nacosOptions, String agentName) { + + try { + nacosOptions.getNacosConfigService() + .addListener("mcp-servers.json", "ai-agent-" + agentName, new AbstractListener() { + @Override + public void receiveConfigInfo(String configInfo) { + McpServersVO mcpServersVO = JSON.parseObject(configInfo, McpServersVO.class); + List toolCallbacks = convert(nacosOptions, mcpServersVO); + if (toolCallbacks != null) { + toolNode.setToolCallbacks(toolCallbacks); + llmNode.setToolCallbacks(toolCallbacks); + } + + } + }); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + + } + + public static McpServersVO getMcpServersVO(NacosOptions nacosOptions, String agentName) { + try { + String config = nacosOptions.getNacosConfigService() + .getConfig("mcp-servers.json", "ai-agent-" + agentName, 3000L); + return JSON.parseObject(config, McpServersVO.class); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + public static List convert(NacosOptions nacosOptions, McpServersVO mcpServersVO) { + + NacosMcpGatewayProperties nacosMcpGatewayProperties = new NacosMcpGatewayProperties(); + nacosMcpGatewayProperties.setServiceNames(mcpServersVO.getMcpServers().stream() + .map(McpServersVO.McpServerVO::getMcpServerName).toList()); + NacosMcpGatewayToolsInitializer nacosMcpGatewayToolsInitializer = new NacosMcpGatewayToolsInitializer( + nacosOptions.mcpOperationService, nacosMcpGatewayProperties, mcpServersVO.getMcpServers()); + List toolCallbacks = nacosMcpGatewayToolsInitializer.initializeTools(); + + return toolCallbacks; + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosModelInjector.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosModelInjector.java new file mode 100644 index 0000000000..4228e906fe --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosModelInjector.java @@ -0,0 +1,178 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import com.alibaba.cloud.ai.agent.nacos.vo.ModelVO; +import com.alibaba.fastjson.JSON; +import com.alibaba.nacos.api.config.listener.AbstractListener; +import com.alibaba.nacos.api.exception.NacosException; +import com.alibaba.nacos.common.utils.StringUtils; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; + +public class NacosModelInjector { + + + public static ModelVO getModelByAgentId(NacosOptions nacosOptions, String agentId) { + try { + String dataIdT = String.format(nacosOptions.isModelConfigEncrypted() ? "cipher-kms-aes-256-model-%s.json" : "model.json"); + String config = nacosOptions.getNacosConfigService().getConfig(dataIdT, "ai-agent-" + agentId, 3000L); + return JSON.parseObject(config, ModelVO.class); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + public static ChatModel initModel(NacosOptions nacosOptions, ModelVO model) { + + OpenAiApi openAiApi = OpenAiApi.builder() + .apiKey(model.getApiKey()).baseUrl(model.getBaseUrl()) + .build(); + + OpenAiChatOptions.Builder chatOptionsBuilder = OpenAiChatOptions.builder(); + if (model.getTemperature() != null) { + chatOptionsBuilder.temperature(Double.parseDouble(model.getTemperature())); + } + if (model.getMaxTokens() != null) { + chatOptionsBuilder.maxTokens(Integer.parseInt(model.getMaxTokens())); + } + chatOptionsBuilder.internalToolExecutionEnabled(false); + OpenAiChatOptions openaiChatOptions = chatOptionsBuilder + .model(model.getModel()) + .build(); + OpenAiChatModel.Builder builder = OpenAiChatModel.builder().defaultOptions(openaiChatOptions) + .openAiApi(openAiApi); + + //inject observation config. + ObservationConfigration observationConfigration = nacosOptions.getObservationConfigration(); + if (observationConfigration != null) { + if (observationConfigration.getToolCallingManager() != null) { + builder.toolCallingManager(observationConfigration.getToolCallingManager()); + } + if (observationConfigration.getObservationRegistry() != null) { + builder.observationRegistry(observationConfigration.getObservationRegistry()); + } + } + + OpenAiChatModel openAiChatModel = builder.build(); + if (observationConfigration != null && observationConfigration.getChatModelObservationConvention() != null) { + openAiChatModel.setObservationConvention(observationConfigration + .getChatModelObservationConvention()); + } + + return openAiChatModel; + } + + public static void registerModelListener(ChatClient chatClient, NacosOptions nacosOptions, String agentId) { + if (StringUtils.isBlank(agentId)) { + return; + } + try { + String dataIdT = String.format(nacosOptions.isModelConfigEncrypted() ? "cipher-kms-aes-256-model.json" : "model.json", agentId); + + nacosOptions.getNacosConfigService() + .addListener(dataIdT, "ai-agent-" + agentId, new AbstractListener() { + @Override + public void receiveConfigInfo(String configInfo) { + ModelVO modelVO = JSON.parseObject(configInfo, ModelVO.class); + try { + ChatModel chatModelNew = initModel(nacosOptions, modelVO); + replaceModel(chatClient, chatModelNew); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + public static void replaceModel(ChatClient chatClient, ChatModel chatModel) throws Exception { + Object defaultChatClientRequest = getField(chatClient, "defaultChatClientRequest"); + modifyFinalField(defaultChatClientRequest, "chatModel", chatModel); + } + + private static Object getField(Object obj, String fieldName) throws Exception { + Field field = obj.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(obj); + } + + public static void modifyFinalField(Object targetObject, String fieldName, Object newValue) throws Exception { + Field field = targetObject.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + + try { + // Java 8及以下版本的方式 + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(field, field.getModifiers() & ~java.lang.reflect.Modifier.FINAL); + field.set(targetObject, newValue); + } + catch (NoSuchFieldException e) { + // Java 9及以上版本的方式 + try { + // 使用反射修改final字段 + Field[] fields = field.getClass().getDeclaredFields(); + for (Field f : fields) { + if ("modifiers".equals(f.getName())) { + f.setAccessible(true); + f.setInt(field, field.getModifiers() & ~java.lang.reflect.Modifier.FINAL); + break; + } + } + field.set(targetObject, newValue); + } + catch (Exception ex) { + // 如果上述方式都不行,尝试使用Unsafe(不推荐但有时有效) + modifyFinalFieldWithUnsafe(field, targetObject, newValue); + } + } + } + + /** + * 使用Unsafe修改final字段(适用于Java 12+) + */ + private static void modifyFinalFieldWithUnsafe(Field field, Object targetObject, Object newValue) throws Exception { + try { + Class unsafeClass = Class.forName("sun.misc.Unsafe"); + Field unsafeInstanceField = unsafeClass.getDeclaredField("theUnsafe"); + unsafeInstanceField.setAccessible(true); + Object unsafeInstance = unsafeInstanceField.get(null); + + Method putObjectMethod = unsafeClass.getMethod("putObject", Object.class, long.class, Object.class); + Method staticFieldOffsetMethod = unsafeClass.getMethod("staticFieldOffset", Field.class); + + long offset = (Long) staticFieldOffsetMethod.invoke(unsafeInstance, field); + putObjectMethod.invoke(unsafeInstance, targetObject, offset, newValue); + } + catch (Exception e) { + throw new RuntimeException("无法修改final字段: " + field.getName(), e); + } + } +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosOptions.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosOptions.java new file mode 100644 index 0000000000..549d879c45 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosOptions.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import java.util.Properties; + +import com.alibaba.cloud.ai.mcp.nacos.service.NacosMcpOperationService; +import com.alibaba.nacos.api.exception.NacosException; +import com.alibaba.nacos.client.config.NacosConfigService; +import com.alibaba.nacos.maintainer.client.ai.AiMaintainerService; +import com.alibaba.nacos.maintainer.client.ai.NacosAiMaintainerServiceImpl; +import lombok.Data; + +@Data +public class NacosOptions { + + protected boolean modelSpecified; + + protected boolean modelConfigEncrypted; + + protected boolean promptSpecified; + + String promptKey; + + private NacosConfigService nacosConfigService; + + private AiMaintainerService nacosAiMaintainerService; + + NacosMcpOperationService mcpOperationService; + + private ObservationConfigration observationConfigration; + + private String agentName; + + private String mcpNamespace; + + public NacosOptions(Properties properties) throws NacosException { + nacosConfigService = new NacosConfigService(properties); + nacosAiMaintainerService = new NacosAiMaintainerServiceImpl(properties); + mcpOperationService = new NacosMcpOperationService(properties); + agentName = properties.getProperty("agentName"); + mcpNamespace = properties.getProperty("mcpNamespace", properties.getProperty("namespace")); + + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosPartenerAgentsInjector.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosPartenerAgentsInjector.java new file mode 100644 index 0000000000..51caf23f0c --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosPartenerAgentsInjector.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import com.alibaba.cloud.ai.agent.nacos.vo.PartnerAgentsVO; +import com.alibaba.cloud.ai.graph.node.LlmNode; +import com.alibaba.cloud.ai.graph.node.ToolNode; +import com.alibaba.fastjson.JSON; +import com.alibaba.nacos.api.config.listener.AbstractListener; +import com.alibaba.nacos.api.exception.NacosException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class NacosPartenerAgentsInjector { + + private static final Logger logger = LoggerFactory.getLogger(NacosPartenerAgentsInjector.class); + + public static void registry(LlmNode llmNode, ToolNode toolNode, NacosOptions nacosOptions, String agentName) { + + try { + nacosOptions.getNacosConfigService() + .addListener("parterner-agents.json", "ai-agent-" + agentName, new AbstractListener() { + @Override + public void receiveConfigInfo(String configInfo) { + PartnerAgentsVO partnerAgentsVO = JSON.parseObject(configInfo, PartnerAgentsVO.class); + System.out.println(partnerAgentsVO); + } + }); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + + } + + public static PartnerAgentsVO getPartenerVO(NacosOptions nacosOptions, String agentName) { + try { + String config = nacosOptions.getNacosConfigService() + .getConfig("parterner-agents.json", "ai-agent-" + agentName, 3000L); + return JSON.parseObject(config, PartnerAgentsVO.class); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosPromptInjector.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosPromptInjector.java new file mode 100644 index 0000000000..8bea066ae2 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosPromptInjector.java @@ -0,0 +1,195 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +import com.alibaba.cloud.ai.agent.nacos.vo.AgentVO; +import com.alibaba.cloud.ai.agent.nacos.vo.PromptVO; +import com.alibaba.fastjson.JSON; +import com.alibaba.nacos.api.config.listener.AbstractListener; +import com.alibaba.nacos.api.config.listener.Listener; +import com.alibaba.nacos.api.exception.NacosException; +import com.alibaba.nacos.client.config.NacosConfigService; +import com.alibaba.nacos.common.utils.StringUtils; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.DefaultChatClient; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.util.ReflectionUtils; + +public class NacosPromptInjector { + + static Map promptKeyListener = new HashMap<>(); + + + /** + * load prompt by agent id. + * + * @param nacosConfigService + * @param AgentVO + * @return + */ + public static PromptVO loadPromptByAgentId(NacosConfigService nacosConfigService, AgentVO agentVO) { + try { + return getPromptByKey(nacosConfigService, agentVO.getPromptKey()); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + + /** + * load promot by prompt key. + * + * @param nacosConfigService + * @param promptKey + * @return + * @throws NacosException + */ + public static PromptVO getPromptByKey(NacosConfigService nacosConfigService, String promptKey) + throws NacosException { + String promptConfig = nacosConfigService.getConfig(String.format("prompt-%s.json", promptKey), "nacos-ai-meta", + 3000L); + PromptVO promptVO = JSON.parseObject(promptConfig, PromptVO.class); + promptVO.setPromptKey(promptKey); + return promptVO; + } + + /** + * replace prompt info by key and registry. + * + * @param nacosConfigService + * @param chatClient + * @param promptKey + * @throws Exception + */ + public static void injectPromptByKey(NacosConfigService nacosConfigService, ChatClient chatClient, String promptKey) + throws Exception { + PromptVO promptVO = NacosPromptInjector.getPromptByKey(nacosConfigService, promptKey); + if (promptVO != null) { + NacosPromptInjector.replacePrompt(chatClient, promptVO); + } + NacosPromptInjector.registerPromptListener(nacosConfigService, chatClient, promptKey); + } + + public static void registryPromptByAgentId(ChatClient chatClient, NacosConfigService nacosConfigService, + String agentId, PromptVO promptVO) { + + try { + + // register agent prompt listener + nacosConfigService.addListener("agent-base.json", "ai-agent-" + agentId, + new AbstractListener() { + + String currentPromptKey; + + @Override + public void receiveConfigInfo(String configInfo) { + if (StringUtils.isBlank(configInfo)) { + return; + } + String newPromptKey = (String) JSON.parseObject(configInfo).get("promptKey"); + if (StringUtils.isBlank(newPromptKey) || newPromptKey.equals(currentPromptKey)) { + return; + } + try { + injectPromptByKey(nacosConfigService, chatClient, newPromptKey); + } + catch (Exception e) { + throw new RuntimeException(e); + } + if (promptKeyListener.containsKey(currentPromptKey)) { + Listener listener = promptKeyListener.remove(currentPromptKey); + nacosConfigService.removeListener(String.format("prompt-%s.json", currentPromptKey), + "nacos-ai-meta", listener); + } + currentPromptKey = newPromptKey; + } + }); + if (promptVO != null && promptVO.getPromptKey() != null) { + registerPromptListener(nacosConfigService, chatClient, promptVO.getPromptKey()); + } + + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + /** + * register prompt listener + * + * @param nacosConfigService + * @param chatClient + * @param promptKey + * @throws NacosException + */ + public static void registerPromptListener(NacosConfigService nacosConfigService, ChatClient chatClient, + String promptKey) throws NacosException { + try { + + Listener listener = new AbstractListener() { + @Override + public void receiveConfigInfo(String configInfo) { + PromptVO promptVO = JSON.parseObject(configInfo, PromptVO.class); + try { + replacePrompt(chatClient, promptVO); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + + nacosConfigService.addListener(String.format("prompt-%s.json", promptKey), "nacos-ai-meta", listener); + promptKeyListener.put(promptKey, listener); + } + catch (NacosException e) { + throw new RuntimeException(e); + } + } + + + public static void replacePrompt(ChatClient chatClient, PromptVO promptVO) throws Exception { + Field defaultChatClientRequest = chatClient.getClass().getDeclaredField("defaultChatClientRequest"); + ReflectionUtils.makeAccessible(defaultChatClientRequest); + DefaultChatClient.DefaultChatClientRequestSpec object = (DefaultChatClient.DefaultChatClientRequestSpec) defaultChatClientRequest.get( + chatClient); + Field systemText = object.getClass().getDeclaredField("systemText"); + ReflectionUtils.makeAccessible(systemText); + ReflectionUtils.setField(systemText, object, promptVO.getTemplate()); + + Field chatOptionsFeild = object.getClass().getDeclaredField("chatOptions"); + chatOptionsFeild.setAccessible(true); + ChatOptions chatOptions = (ChatOptions) chatOptionsFeild.get(object); + Field metadataFiled = chatOptions.getClass().getDeclaredField("metadata"); + metadataFiled.setAccessible(true); + Map metadata = (Map) metadataFiled.get(chatOptions); + if (metadata == null) { + metadata = new HashMap<>(); + } + metadata.put("promptKey", promptVO.getPromptKey()); + metadata.put("promptVersion", promptVO.getVersion()); + metadataFiled.set(chatOptions, metadata); + + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosReactAgentBuilder.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosReactAgentBuilder.java new file mode 100644 index 0000000000..5af967299b --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/NacosReactAgentBuilder.java @@ -0,0 +1,121 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import java.util.List; + +import com.alibaba.cloud.ai.agent.nacos.vo.AgentVO; +import com.alibaba.cloud.ai.graph.agent.Builder; +import com.alibaba.cloud.ai.graph.agent.DefaultBuilder; +import com.alibaba.cloud.ai.graph.agent.ReactAgent; +import com.alibaba.cloud.ai.graph.exception.GraphStateException; +import com.alibaba.cloud.ai.graph.node.LlmNode; +import com.alibaba.cloud.ai.graph.node.ToolNode; +import com.alibaba.nacos.common.utils.StringUtils; +import io.micrometer.observation.ObservationRegistry; +import org.apache.commons.collections4.CollectionUtils; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.tool.ToolCallback; + +public class NacosReactAgentBuilder extends DefaultBuilder { + + private NacosOptions nacosOptions; + + public NacosReactAgentBuilder nacosOptions(NacosOptions nacosOptions) { + this.nacosOptions = nacosOptions; return this; + } + + @Override + public Builder model(ChatModel model) { + super.model(model); nacosOptions.modelSpecified = true; return this; + } + + public Builder instruction(String instruction) { + super.instruction(instruction); nacosOptions.promptSpecified = true; return this; + } + + @Override + public ReactAgent build() throws GraphStateException { + if (super.name == null) { + this.name = nacosOptions.getAgentName(); + } if (model == null && StringUtils.isNotBlank(this.name)) { + this.model = NacosAgentInjector.initModel(nacosOptions, this.name); + } if (chatClient == null) { + ChatClient.Builder clientBuilder = null; + + ObservationConfigration observationConfigration = nacosOptions.getObservationConfigration(); + if (observationConfigration == null) { + clientBuilder = ChatClient.builder(model); + } + else { + clientBuilder = ChatClient.builder(model, observationConfigration.getObservationRegistry() == null ? ObservationRegistry.NOOP : observationConfigration.getObservationRegistry(), nacosOptions.getObservationConfigration() + .getChatClientObservationConvention()); + } + + if (chatOptions != null) { + clientBuilder.defaultOptions(chatOptions); + } if (instruction != null) { + clientBuilder.defaultSystem(instruction); + } + chatClient = clientBuilder.build(); + } + + if (!nacosOptions.modelSpecified) { + NacosAgentInjector.injectModel(nacosOptions, chatClient, this.name); + } + + if (!nacosOptions.promptSpecified) { + if (nacosOptions.promptKey != null) { + NacosAgentInjector.injectPrompt(nacosOptions.getNacosConfigService(), chatClient, nacosOptions.promptKey); + } + else { + AgentVO agentVO = NacosAgentInjector.loadAgentVO(nacosOptions.getNacosConfigService(), this.name); + this.description = agentVO.getDescription(); + NacosAgentInjector.injectPromptByAgentName(nacosOptions.getNacosConfigService(), chatClient, nacosOptions.getAgentName(), agentVO); + } + } + + List toolCallbacks = NacosMcpToolsInjector.loadMcpTools(nacosOptions, this.name); + + this.tools = toolCallbacks; + + LlmNode.Builder llmNodeBuilder = LlmNode.builder().stream(true).chatClient(chatClient) + .messagesKey(this.inputKey); if (outputKey != null && !outputKey.isEmpty()) { + llmNodeBuilder.outputKey(outputKey); + } + + if (CollectionUtils.isNotEmpty(tools)) { + llmNodeBuilder.toolCallbacks(tools); + } LlmNode llmNode = llmNodeBuilder.build(); + + ToolNode toolNode = null; + if (resolver != null) { + toolNode = ToolNode.builder().toolCallbackResolver(resolver).build(); + } + else if (tools != null) { + toolNode = ToolNode.builder().toolCallbacks(tools).build(); + } + else { + toolNode = ToolNode.builder().build(); + } + NacosMcpToolsInjector.registry(llmNode, toolNode, nacosOptions, this.name); + + return new ReactAgent(llmNode, toolNode, this); + } +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/ObservationConfigration.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/ObservationConfigration.java new file mode 100644 index 0000000000..f786f708df --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/ObservationConfigration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos; + +import io.micrometer.observation.ObservationRegistry; +import lombok.Data; + +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.ai.chat.observation.ChatModelObservationConvention; +import org.springframework.ai.model.tool.ToolCallingManager; + +@Data +public class ObservationConfigration { + + private ObservationRegistry observationRegistry; + + private ToolCallingManager toolCallingManager; + + private ChatModelObservationConvention chatModelObservationConvention; + + private ChatClientObservationConvention chatClientObservationConvention; + + public ObservationConfigration(ObservationRegistry observationRegistry, ToolCallingManager toolCallingManager, + ChatModelObservationConvention chatModelObservationConvention, + ChatClientObservationConvention chatClientObservationConvention) { + this.observationRegistry = observationRegistry; + this.toolCallingManager = toolCallingManager; + this.chatModelObservationConvention = chatModelObservationConvention; + this.chatClientObservationConvention = chatClientObservationConvention; + } +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/tools/NacosMcpGatewayToolCallback.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/tools/NacosMcpGatewayToolCallback.java new file mode 100644 index 0000000000..db47c69127 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/tools/NacosMcpGatewayToolCallback.java @@ -0,0 +1,593 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.tools; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.alibaba.cloud.ai.agent.nacos.vo.McpServersVO; +import com.alibaba.cloud.ai.mcp.gateway.core.McpGatewayToolDefinition; +import com.alibaba.cloud.ai.mcp.gateway.nacos.definition.NacosMcpGatewayToolDefinition; +import com.alibaba.cloud.ai.mcp.nacos.service.NacosMcpOperationService; +import com.alibaba.nacos.api.ai.model.mcp.McpEndpointInfo; +import com.alibaba.nacos.api.ai.model.mcp.McpServerRemoteServiceConfig; +import com.alibaba.nacos.api.ai.model.mcp.McpServiceRef; +import com.alibaba.nacos.api.config.listener.AbstractListener; +import com.alibaba.nacos.api.exception.NacosException; +import com.alibaba.nacos.common.utils.JacksonUtils; +import com.alibaba.nacos.shaded.com.google.common.collect.Maps; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.InitializeResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.apache.poi.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.lang.NonNull; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +public class NacosMcpGatewayToolCallback implements ToolCallback { + + private static final Logger logger = LoggerFactory.getLogger(com.alibaba.cloud.ai.mcp.gateway.nacos.callback.NacosMcpGatewayToolCallback.class); + + private static final Pattern TEMPLATE_PATTERN = Pattern.compile("\\{\\{\\s*(\\.[\\w]+(?:\\.[\\w]+)*)\\s*\\}\\}"); + + // 匹配 {{ ${nacos.dataId/group} }} 或 {{ ${nacos.dataId/group}.key1.key2 }} + private static final Pattern NACOS_TEMPLATE_PATTERN = Pattern + .compile("\\{\\{\\s*\\$\\{nacos\\.([^}]+)\\}(\\.[\\w]+(?:\\.[\\w]+)*)?\\s*}}"); + + /** + * The Object mapper. + */ + static ObjectMapper objectMapper = new ObjectMapper(); + + static { + objectMapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + objectMapper.setSerializationInclusion(Include.NON_NULL); + } + + private final NacosMcpGatewayToolDefinition toolDefinition; + + private final NacosMcpOperationService nacosMcpOperationService; + + private final HashMap nacosConfigListeners = new HashMap<>(); + + private final HashMap nacosConfigContent = new HashMap<>(); + + McpServersVO.McpServerVO mcpServerVO; + + /** + * Instantiates a new Nacos mcp gateway tool callback. + * @param toolDefinition the tool definition + */ + public NacosMcpGatewayToolCallback(final McpGatewayToolDefinition toolDefinition, NacosMcpOperationService nacosMcpOperationService, McpServersVO.McpServerVO mcpServersVO) { + this.toolDefinition = (NacosMcpGatewayToolDefinition) toolDefinition; + this.nacosMcpOperationService = nacosMcpOperationService; + this.mcpServerVO = mcpServersVO; + } + + /** + * Process nacos config ref template string. + * @param template the template + * @return the string + */ + public String processNacosConfigRefTemplate(String template) { + if (!org.springframework.util.StringUtils.hasText(template)) { + return template; + } + + StringBuffer result = new StringBuffer(); + Matcher matcher = NACOS_TEMPLATE_PATTERN.matcher(template); + + while (matcher.find()) { + String nacosRef = matcher.group(1); + String dotNotation = matcher.group(2); + String replacement = resolveNacosReference(nacosRef, dotNotation); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement != null ? replacement : "")); + } + matcher.appendTail(result); + + return result.toString(); + } + + /** + * 解析Nacos引用 + * @param nacosRef 引用字符串,格式为 dataId/group + * @param dotNotation 点语法部分,格式为 .key1.key2(可能为null) + * @return 解析后的值 + */ + private String resolveNacosReference(String nacosRef, String dotNotation) { + if (!org.springframework.util.StringUtils.hasText(nacosRef)) { + return null; + } + + try { + // 解析dataId和group + String[] configParts = nacosRef.split("/"); + if (configParts.length != 2) { + throw new IllegalArgumentException( + "Invalid Nacos config reference format: " + nacosRef + ". Expected format: dataId/group"); + } + + String dataId = configParts[0]; + String group = configParts[1]; + + // 获取配置内容 + String configContent = getConfigContent(dataId, group); + if (!org.springframework.util.StringUtils.hasText(configContent)) { + logger.warn("[resolveNacosReference] No content found for dataId: {}, group: {}", dataId, group); + return null; + } + + // 如果没有点语法,直接返回配置内容 + if (!org.springframework.util.StringUtils.hasText(dotNotation)) { + return configContent; + } + + // 如果有点语法,去掉开头的点号,然后解析JSON并提取指定字段 + String jsonPath = dotNotation.startsWith(".") ? dotNotation.substring(1) : dotNotation; + return extractJsonValueFromNacos(configContent, jsonPath); + + } + catch (Exception e) { + // 记录日志但不中断处理 + logger.error("[resolveNacosReference] Failed to resolve Nacos reference: {}", e.getMessage(), e); + throw new RuntimeException("Failed to resolve Nacos reference: " + e.getMessage(), e); + } + } + + /** + * 获取Nacos配置内容 + * @param dataId 配置ID + * @param group 分组 + * @return 配置内容 + * @throws NacosException Nacos异常 + */ + private String getConfigContent(String dataId, String group) throws NacosException { + String cacheKey = dataId + "@@" + group; + if (nacosConfigContent.containsKey(cacheKey)) { + return nacosConfigContent.get(cacheKey); + } + else { + AbstractListener listener = new AbstractListener() { + @Override + public void receiveConfigInfo(String configInfo) { + nacosConfigContent.put(cacheKey, configInfo); + } + }; + AbstractListener oldListener = nacosConfigListeners.putIfAbsent(cacheKey, listener); + if (oldListener == null) { + try { + nacosMcpOperationService.getConfigService().addListener(dataId, group, listener); + } + catch (Exception e) { + nacosConfigListeners.remove(cacheKey); + logger.error("Failed to add listener for Nacos config: {}", e.getMessage(), e); + } + } + return nacosMcpOperationService.getConfigService().getConfig(dataId, group, 3000); + } + } + + /** + * 从JSON字符串中提取指定路径的值 + * @param jsonString JSON字符串 + * @param jsonPath JSON路径,如 key1.key2 + * @return 提取的值 + */ + private String extractJsonValueFromNacos(String jsonString, String jsonPath) throws JsonProcessingException { + + try { + JsonNode rootNode = objectMapper.readTree(jsonString); + String[] pathParts = jsonPath.split("\\."); + + JsonNode currentNode = rootNode; + for (String part : pathParts) { + if (currentNode == null || currentNode.isMissingNode()) { + logger.warn("[extractJsonValueFromNacos] Path '{}' not found in JSON", jsonPath); + return null; + } + currentNode = currentNode.get(part); + } + + if (currentNode == null || currentNode.isMissingNode()) { + logger.warn("[extractJsonValueFromNacos] Final path '{}' not found in JSON", jsonPath); + return null; + } + + // 根据节点类型返回合适的值 + if (currentNode.isTextual()) { + return currentNode.asText(); + } + else if (currentNode.isNumber()) { + return currentNode.asText(); + } + else if (currentNode.isBoolean()) { + return String.valueOf(currentNode.asBoolean()); + } + else { + // 对于复杂对象,返回JSON字符串 + return currentNode.toString(); + } + } + catch (JsonProcessingException e) { + logger.error("[extractJsonValueFromNacos] Failed to parse JSON from Nacos config. Content: {}, Error: {}", + jsonString, e.getMessage()); + throw new RuntimeException( + "Nacos config content is not valid JSON, but dot notation was used. Please ensure the config is in JSON format or remove the dot notation. Content: " + + jsonString, + e); + } + catch (Exception e) { + logger.error("[extractJsonValueFromNacos] Failed to extract JSON value from Nacos config: {}", + e.getMessage(), e); + throw e; + } + } + + private String processTemplateString(String template, Map params) { + Map args = (Map) params.get("args"); + String extendedData = (String) params.get("extendedData"); + logger.debug("[processTemplateString] template: {} args: {} extendedData: {}", template, args, extendedData); + if (template == null || template.isEmpty()) { + return ""; + } + Matcher matcher = TEMPLATE_PATTERN.matcher(template); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + // 获取完整路径,如 .args.name 或 .data.key1.key2 + String fullPath = matcher.group(1); + String replacement = resolvePathValue(fullPath, args, extendedData); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(result); + String finalResult = result.toString(); + finalResult = processNacosConfigRefTemplate(finalResult); + logger.debug("[processTemplateString] final result: {}", finalResult); + + return finalResult; + } + + /** + * 根据路径解析值 + * @param fullPath 完整路径,如 .args.name 或 .data.key1.key2 + * @param args 参数数据映射 + * @param extendedData 扩展数据(JSON字符串) + * @return 解析后的值 + */ + private String resolvePathValue(String fullPath, Map args, String extendedData) { + if (fullPath == null || fullPath.isEmpty()) { + return ""; + } + // 移除开头的点号 + if (fullPath.startsWith(".")) { + fullPath = fullPath.substring(1); + } + + String[] pathParts = fullPath.split("\\."); + if (pathParts.length == 0) { + return ""; + } + + // 确定数据源 + Object dataSource; + if (pathParts[0].equals("args")) { + // 从args中取值 + dataSource = args; + // 如果只有args,没有具体字段名 + if (pathParts.length == 1) { + if (args != null && args.size() == 1) { + return String.valueOf(args.values().iterator().next()); + } + else if (args != null && !args.isEmpty()) { + return args.toString(); + } + else { + return ""; + } + } + } + else { + // 从extendedData中取值 + // 首先将extendedData字符串解析为JSON对象 + try { + if (StringUtils.hasText(extendedData)) { + dataSource = objectMapper.readValue(extendedData, Map.class); + } + else { + dataSource = null; + } + } + catch (Exception e) { + logger.warn("[resolvePathValue] Failed to parse extendedData as JSON: {}", e.getMessage()); + // 如果解析失败,将extendedData作为普通字符串处理 + if (pathParts.length == 1 && fullPath.equals("extendedData")) { + return extendedData != null ? extendedData : ""; + } + return ""; + } + + // 特殊处理直接访问extendedData的情况 + if (pathParts.length == 1 && fullPath.equals("extendedData")) { + return extendedData != null ? extendedData : ""; + } + } + + // 如果数据源为空 + if (dataSource == null) { + return ""; + } + // 处理嵌套路径 + Object currentValue = dataSource; + int startIndex = pathParts[0].equals("args") ? 1 : 0; + // 如果是args,从索引1开始;否则从索引0开始 + + for (int i = startIndex; i < pathParts.length; i++) { + String key = pathParts[i]; + if (currentValue instanceof Map) { + Map currentMap = (Map) currentValue; + currentValue = currentMap.get(key); + } + else { + logger.warn("[resolvePathValue] Cannot access key '{}' from non-map value", key); + return ""; + } + + if (currentValue == null) { + logger.warn("[resolvePathValue] Key '{}' not found in nested path", key); + return ""; + } + } + return currentValue.toString(); + } + + @Override + public ToolDefinition getToolDefinition() { + return this.toolDefinition; + } + + @Override + public String call(@NonNull final String input) { + return call(input, new ToolContext(Maps.newHashMap())); + } + + @Override + @SuppressWarnings("unchecked") + public String call(@NonNull final String input, final ToolContext toolContext) { + try { + logger.info("[call] input: {} toolContext: {}", input, JacksonUtils.toJson(toolContext)); + + // 参数验证 + if (this.toolDefinition == null) { + throw new IllegalStateException("Tool definition is null"); + } + + // input解析 + logger.info("[call] input string: {}", input); + Map args = new HashMap<>(); + if (!input.isEmpty()) { + try { + args = objectMapper.readValue(input, Map.class); + logger.info("[call] parsed args: {}", args); + } + catch (Exception e) { + logger.error("[call] Failed to parse input to args", e); + // 如果解析失败,尝试作为单个参数处理 + args.put("input", input); + } + } + + String protocol = this.toolDefinition.getProtocol(); + if (protocol == null) { + throw new IllegalStateException("Protocol is null"); + } + + if ("mcp-sse".equalsIgnoreCase(protocol)) { + McpServerRemoteServiceConfig remoteServerConfig = this.toolDefinition.getRemoteServerConfig(); + if (remoteServerConfig == null) { + throw new IllegalStateException("Remote server config is null"); + } + return handleMcpStreamProtocol(args, remoteServerConfig, protocol); + } + else if ("mcp-streamable".equalsIgnoreCase(protocol)) { + + logger.error("[call] Unsupported protocol: {}", protocol); + return "Error: Unsupported protocol " + protocol; + // McpServerRemoteServiceConfig remoteServerConfig = + // this.toolDefinition.getRemoteServerConfig(); + // if (remoteServerConfig == null) { + // throw new IllegalStateException("Remote server config is null"); + // } + // return handleMcpStreamableProtocol(args, remoteServerConfig, protocol); + } + else { + logger.error("[call] Unsupported protocol: {}", protocol); + return "Error: Unsupported protocol " + protocol; + } + } + catch (Exception e) { + logger.error("[call] Unexpected error occurred", e); + return "Error: " + e.getMessage(); + } + } + + /** + * 处理MCP流式协议的工具调用 (mcp-sse, mcp-streamable) + */ + private String handleMcpStreamProtocol(Map args, McpServerRemoteServiceConfig remoteServerConfig, + String protocol) throws NacosException { + McpServiceRef serviceRef = remoteServerConfig.getServiceRef(); + if (serviceRef != null) { + McpEndpointInfo mcpEndpointInfo = nacosMcpOperationService.selectEndpoint(serviceRef); + if (mcpEndpointInfo == null) { + throw new RuntimeException("No available endpoint found for service: " + serviceRef.getServiceName()); + } + + logger.info("[handleMcpStreamProtocol] Tool callback instance: {}", JacksonUtils.toJson(mcpEndpointInfo)); + String exportPath = remoteServerConfig.getExportPath(); + + // 构建基础URL,根据协议类型调整 + String transportProtocol = StringUtil.isNotBlank(serviceRef.getTransportProtocol()) ? serviceRef.getTransportProtocol() : "http"; + StringBuilder baseUrl; + if ("mcp-sse".equalsIgnoreCase(protocol)) { + baseUrl = new StringBuilder(transportProtocol + "://" + mcpEndpointInfo.getAddress() + ":" + mcpEndpointInfo.getPort()); + } + else { + // mcp-streamable 或其他协议 + baseUrl = new StringBuilder(transportProtocol + "://" + mcpEndpointInfo.getAddress() + ":" + mcpEndpointInfo.getPort()); + } + + logger.info("[handleMcpStreamProtocol] Processing {} protocol with args: {} and baseUrl: {}", protocol, + args, baseUrl.toString()); + + try { + // 获取工具名称 - 从工具定义名称中提取实际的工具名称 + String toolDefinitionName = this.toolDefinition.name(); + if (toolDefinitionName == null || toolDefinitionName.isEmpty()) { + throw new RuntimeException("Tool definition name is not available"); + } + + // 工具定义名称格式为: serverName_tools_toolName + // 需要提取最后的 toolName 部分 + String toolName; + if (toolDefinitionName.contains("_tools_")) { + toolName = toolDefinitionName.substring(toolDefinitionName.lastIndexOf("_tools_") + 7); + } + else { + // 如果没有 _tools_ 分隔符,使用整个名称 + toolName = toolDefinitionName; + } + + if (toolName.isEmpty()) { + throw new RuntimeException("Extracted tool name is empty"); + } + + // 构建传输层 + StringBuilder sseEndpoint = new StringBuilder("/sse"); + if (exportPath != null && !exportPath.isEmpty()) { + sseEndpoint = new StringBuilder(exportPath); + if (mcpServerVO.getPassQueryParams() != null) { + + + if (!sseEndpoint.toString().contains("?")) { + sseEndpoint.append("?"); + } + Iterator> iterator = mcpServerVO.getPassQueryParams().entrySet() + .iterator(); + while (iterator.hasNext()) { + Map.Entry next = iterator.next(); + sseEndpoint.append(next.getKey()).append("=").append(next.getValue()) + .append(iterator.hasNext() ? "&" : ""); + } + } + } + + HttpClientSseClientTransport.Builder transportBuilder = HttpClientSseClientTransport.builder(baseUrl.toString()) + .sseEndpoint(sseEndpoint.toString()); + + // 添加自定义请求头(如果需要) + // 这里可以根据需要添加认证头等 + HttpClientSseClientTransport transport = transportBuilder.build(); + + // 创建MCP同步客户端 + McpSyncClient client = McpClient.sync(transport).build(); + + try { + // 初始化客户端 + InitializeResult initializeResult = client.initialize(); + logger.info("[handleMcpStreamProtocol] MCP Client initialized: {}", initializeResult); + + // 调用工具 + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(toolName, args); + logger.info("[handleMcpStreamProtocol] CallToolRequest: {}", request); + + CallToolResult result = client.callTool(request); + logger.info("[handleMcpStreamProtocol] tool call result: {}", result); + + // 处理结果 + Object content = result.content(); + if (content instanceof List list && !CollectionUtils.isEmpty(list)) { + Object first = list.get(0); + // 兼容TextContent的text字段 + if (first instanceof TextContent textContent) { + return textContent.text(); + } + else if (first instanceof Map map && map.containsKey("text")) { + return map.get("text").toString(); + } + else { + return first.toString(); + } + } + else { + return content != null ? content.toString() : "No content returned"; + } + } + finally { + // 清理资源 + try { + if (client != null) { + client.close(); + } + } + catch (Exception e) { + logger.warn("[handleMcpStreamProtocol] Failed to close MCP client", e); + } + } + } + catch (Exception e) { + logger.error("[handleMcpStreamProtocol] MCP call failed:", e); + return "Error: MCP call failed - " + e.getMessage(); + } + } + else { + logger.error("[handleMcpStreamProtocol] serviceRef is null"); + return "Error: service reference is null"; + } + } + + /** + * Close. + */ + public void close() { + + for (Map.Entry entry : nacosConfigListeners.entrySet()) { + String cacheKey = entry.getKey(); + String dataId = cacheKey.split("@@")[0]; + String group = cacheKey.split("@@")[1]; + nacosMcpOperationService.getConfigService().removeListener(dataId, group, entry.getValue()); + } + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/tools/NacosMcpGatewayToolsInitializer.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/tools/NacosMcpGatewayToolsInitializer.java new file mode 100644 index 0000000000..e30bb4366b --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/tools/NacosMcpGatewayToolsInitializer.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.tools; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.alibaba.cloud.ai.agent.nacos.vo.McpServersVO; +import com.alibaba.cloud.ai.mcp.gateway.nacos.definition.NacosMcpGatewayToolDefinition; +import com.alibaba.cloud.ai.mcp.gateway.nacos.properties.NacosMcpGatewayProperties; +import com.alibaba.cloud.ai.mcp.nacos.service.NacosMcpOperationService; +import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo; +import com.alibaba.nacos.api.ai.model.mcp.McpServerRemoteServiceConfig; +import com.alibaba.nacos.api.ai.model.mcp.McpTool; +import com.alibaba.nacos.api.ai.model.mcp.McpToolMeta; +import com.alibaba.nacos.api.ai.model.mcp.McpToolSpecification; +import org.apache.commons.collections.CollectionUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.tool.ToolCallback; + +public class NacosMcpGatewayToolsInitializer { + + private static final Logger logger = LoggerFactory.getLogger(NacosMcpGatewayToolsInitializer.class); + + private final NacosMcpGatewayProperties nacosMcpGatewayProperties; + + private final NacosMcpOperationService nacosMcpOperationService; + + private List mcpServers; + + public NacosMcpGatewayToolsInitializer(NacosMcpOperationService nacosMcpOperationService, + NacosMcpGatewayProperties nacosMcpGatewayProperties, List mcpServers) { + this.nacosMcpGatewayProperties = nacosMcpGatewayProperties; + this.nacosMcpOperationService = nacosMcpOperationService; + this.mcpServers = mcpServers; + } + + public List initializeTools() { + List serviceNames = nacosMcpGatewayProperties.getServiceNames(); + if (serviceNames == null || serviceNames.isEmpty()) { + logger.warn("No service names configured, no tools will be initialized"); + return new ArrayList<>(); + } + List allTools = new ArrayList<>(); + for (McpServersVO.McpServerVO serverVO : mcpServers) { + String serviceName = serverVO.getMcpServerName(); + try { + McpServerDetailInfo serviceDetail = nacosMcpOperationService.getServerDetail(serviceName); + if (serviceDetail == null) { + logger.warn("No service detail info found for service: {}", serviceName); + continue; + } + String protocol = serviceDetail.getProtocol(); + if ("http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol) + || "mcp-sse".equalsIgnoreCase(protocol) || "mcp-streamable".equalsIgnoreCase(protocol)) { + List tools = parseToolsFromMcpServerDetailInfo(serviceDetail, serverVO); + if (CollectionUtils.isEmpty(tools)) { + logger.warn("No tools defined for service: {}", serviceName); + continue; + } + allTools.addAll(tools); + } + else { + logger.error("protocol {} is not supported yet. Check your configuration for valid tool protocols", + protocol); + } + + } + catch (Exception e) { + logger.error("Failed to initialize tools for service: {}", serviceName, e); + } + } + if (logger.isDebugEnabled()) { + logger.debug("Initial dynamic tools loading completed - Found {} tools", allTools.size()); + } + return allTools; + } + + private List parseToolsFromMcpServerDetailInfo(McpServerDetailInfo mcpServerDetailInfo, McpServersVO.McpServerVO serverVO) { + try { + McpToolSpecification toolSpecification = mcpServerDetailInfo.getToolSpec(); + String protocol = mcpServerDetailInfo.getProtocol(); + McpServerRemoteServiceConfig mcpServerRemoteServiceConfig = mcpServerDetailInfo.getRemoteServerConfig(); + List toolCallbacks = new ArrayList<>(); + if (toolSpecification != null) { + List toolsList = toolSpecification.getTools(); + Map toolsMeta = toolSpecification.getToolsMeta(); + if (toolsList == null || toolsMeta == null) { + return new ArrayList<>(); + } + for (McpTool tool : toolsList) { + + if (!CollectionUtils.isEmpty(serverVO.getWhiteTools()) && !serverVO.getWhiteTools() + .contains(tool.getName())) { + continue; + } + String toolName = tool.getName(); + String toolDescription = tool.getDescription(); + Map inputSchema = tool.getInputSchema(); + McpToolMeta metaInfo = toolsMeta.get(toolName); + boolean enabled = metaInfo == null || metaInfo.isEnabled(); + if (!enabled) { + logger.info("Tool {} is disabled by metaInfo, skipping.", toolName); + continue; + } + NacosMcpGatewayToolDefinition toolDefinition = NacosMcpGatewayToolDefinition.builder() + .name(mcpServerDetailInfo.getName() + "_tools_" + toolName) + .description(toolDescription) + .inputSchema(inputSchema) + .protocol(protocol) + .remoteServerConfig(mcpServerRemoteServiceConfig) + .toolsMeta(metaInfo) + .build(); + toolCallbacks.add(new NacosMcpGatewayToolCallback(toolDefinition, nacosMcpOperationService, serverVO)); + } + } + return toolCallbacks; + } + catch (Exception e) { + logger.warn("Failed to get or parse nacos mcp service tools info (mcpName {})", + mcpServerDetailInfo.getName() + mcpServerDetailInfo.getVersionDetail().getVersion(), e); + } + return null; + } + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/AgentVO.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/AgentVO.java new file mode 100644 index 0000000000..bbc23858f6 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/AgentVO.java @@ -0,0 +1,29 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.vo; + +import lombok.Data; + +@Data +public class AgentVO { + + String promptKey; + + String description; + + int maxIterations; +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/McpServersVO.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/McpServersVO.java new file mode 100644 index 0000000000..47539319ff --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/McpServersVO.java @@ -0,0 +1,47 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.vo; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import lombok.Data; + +@Data +public class McpServersVO { + + List mcpServers; + + @Data + public static class McpServerVO { + + String mcpServerName; + + String version; + + Set whiteTools; + + Map passHeaders; + + Map passQueryParams; + + } + +} + + diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/MemoryVO.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/MemoryVO.java new file mode 100644 index 0000000000..a830db4316 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/MemoryVO.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.vo; + +import lombok.Data; + +@Data +public class MemoryVO { + + String storageType; + + String address; + + String credential; + + String compressionStrategy; + + String searchStrategy; + +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/ModelVO.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/ModelVO.java new file mode 100644 index 0000000000..37f54ac3bf --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/ModelVO.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.vo; + +import lombok.Data; + +@Data +public class ModelVO { + private String baseUrl; + + private String apiKey; + + private String model; + + private String temperature; + + private String maxTokens; +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/PartnerAgentsVO.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/PartnerAgentsVO.java new file mode 100644 index 0000000000..4b5e0af3f0 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/PartnerAgentsVO.java @@ -0,0 +1,39 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.vo; + +import java.util.List; +import java.util.Map; + +import lombok.Data; + +@Data +public class PartnerAgentsVO { + + List agents; + + @Data + public static class PartnerAgentVO { + + String agentName; + + Map headers; + + Map queryPrams; + + } +} diff --git a/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/PromptVO.java b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/PromptVO.java new file mode 100644 index 0000000000..2238bfa477 --- /dev/null +++ b/spring-ai-alibaba-agent-nacos/src/main/java/com/alibaba/cloud/ai/agent/nacos/vo/PromptVO.java @@ -0,0 +1,34 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.agent.nacos.vo; + +import java.util.List; + +import lombok.Data; + +@Data +public class PromptVO { + + String promptKey; + + String version; + + String template; + + List variables; + +} diff --git a/spring-ai-alibaba-bom/pom.xml b/spring-ai-alibaba-bom/pom.xml index dc7af32592..c2d534d270 100644 --- a/spring-ai-alibaba-bom/pom.xml +++ b/spring-ai-alibaba-bom/pom.xml @@ -730,6 +730,9 @@ io.spring.javaformat spring-javaformat-maven-plugin ${spring-javaformat-maven-plugin.version} + + true + validate diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/Builder.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/Builder.java new file mode 100644 index 0000000000..863e35f5c8 --- /dev/null +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/Builder.java @@ -0,0 +1,179 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.graph.agent; + +import java.util.List; +import java.util.function.Function; + +import com.alibaba.cloud.ai.graph.CompileConfig; +import com.alibaba.cloud.ai.graph.KeyStrategyFactory; +import com.alibaba.cloud.ai.graph.OverAllState; +import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.alibaba.cloud.ai.graph.exception.GraphStateException; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.observation.ChatClientObservationConvention; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; + +public abstract class Builder { + + protected String name; + + protected String description; + + protected String instruction; + + protected String outputKey; + + protected ChatModel model; + + protected ChatOptions chatOptions; + + protected ChatClient chatClient; + + protected List tools; + + protected ToolCallbackResolver resolver; + + protected int maxIterations = 10; + + protected CompileConfig compileConfig; + + protected KeyStrategyFactory keyStrategyFactory; + + protected Function shouldContinueFunc; + + protected NodeAction preLlmHook; + + protected NodeAction postLlmHook; + + protected NodeAction preToolHook; + + protected NodeAction postToolHook; + + protected String inputKey = "messages"; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder chatClient(ChatClient chatClient) { + this.chatClient = chatClient; + return this; + } + + public Builder model(ChatModel model) { + this.model = model; + return this; + } + + public Builder chatOptions(ChatOptions chatOptions) { + this.chatOptions = chatOptions; + return this; + } + + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + public Builder resolver(ToolCallbackResolver resolver) { + this.resolver = resolver; + return this; + } + + public Builder maxIterations(int maxIterations) { + this.maxIterations = maxIterations; + return this; + } + + public Builder state(KeyStrategyFactory keyStrategyFactory) { + this.keyStrategyFactory = keyStrategyFactory; + return this; + } + + public Builder compileConfig(CompileConfig compileConfig) { + this.compileConfig = compileConfig; + return this; + } + + public Builder shouldContinueFunction(Function shouldContinueFunc) { + this.shouldContinueFunc = shouldContinueFunc; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder instruction(String instruction) { + this.instruction = instruction; + return this; + } + + public Builder outputKey(String outputKey) { + this.outputKey = outputKey; + return this; + } + + public Builder preLlmHook(NodeAction preLlmHook) { + this.preLlmHook = preLlmHook; + return this; + } + + public Builder postLlmHook(NodeAction postLlmHook) { + this.postLlmHook = postLlmHook; + return this; + } + + public Builder preToolHook(NodeAction preToolHook) { + this.preToolHook = preToolHook; + return this; + } + + public Builder postToolHook(NodeAction postToolHook) { + this.postToolHook = postToolHook; + return this; + } + + public Builder inputKey(String inputKey) { + this.inputKey = inputKey; + return this; + } + + protected ObservationRegistry observationRegistry; + + protected ChatClientObservationConvention customObservationConvention; + + public Builder observationRegistry(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + return this; + } + + public Builder customObservationConvention(ChatClientObservationConvention customObservationConvention) { + this.customObservationConvention = customObservationConvention; + return this; + } + + public abstract ReactAgent build() throws GraphStateException; + +} diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/DefaultBuilder.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/DefaultBuilder.java new file mode 100644 index 0000000000..83d206a543 --- /dev/null +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/DefaultBuilder.java @@ -0,0 +1,73 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.graph.agent; + +import com.alibaba.cloud.ai.graph.exception.GraphStateException; +import com.alibaba.cloud.ai.graph.node.LlmNode; +import com.alibaba.cloud.ai.graph.node.ToolNode; +import org.apache.commons.collections4.CollectionUtils; + +import org.springframework.ai.chat.client.ChatClient; + +public class DefaultBuilder extends Builder { + + @Override + public ReactAgent build() throws GraphStateException { + + if (chatClient == null) { + if (model == null) { + throw new IllegalArgumentException("Either chatClient or model must be provided"); + } + ChatClient.Builder clientBuilder = ChatClient.builder(model); + if (chatOptions != null) { + clientBuilder.defaultOptions(chatOptions); + } + if (instruction != null) { + clientBuilder.defaultSystem(instruction); + } + chatClient = clientBuilder.build(); + } + + LlmNode.Builder llmNodeBuilder = LlmNode.builder() + .stream(true) + .chatClient(chatClient) + .messagesKey(this.inputKey); + // For graph built from ReactAgent, the only legal key used inside must be + // messages. + // if (outputKey != null && !outputKey.isEmpty()) { + // llmNodeBuilder.outputKey(outputKey); + // } + if (CollectionUtils.isNotEmpty(tools)) { + llmNodeBuilder.toolCallbacks(tools); + } + LlmNode llmNode = llmNodeBuilder.build(); + + ToolNode toolNode = null; + if (resolver != null) { + toolNode = ToolNode.builder().toolCallbackResolver(resolver).build(); + } + else if (tools != null) { + toolNode = ToolNode.builder().toolCallbacks(tools).build(); + } + else { + toolNode = ToolNode.builder().build(); + } + + return new ReactAgent(llmNode, toolNode, this); + } + +} + diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/ReactAgent.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/ReactAgent.java index b350022d77..dca4cdd6c3 100644 --- a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/ReactAgent.java +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/ReactAgent.java @@ -15,6 +15,11 @@ */ package com.alibaba.cloud.ai.graph.agent; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + import com.alibaba.cloud.ai.graph.CompileConfig; import com.alibaba.cloud.ai.graph.CompiledGraph; import com.alibaba.cloud.ai.graph.GraphResponse; @@ -26,6 +31,8 @@ import com.alibaba.cloud.ai.graph.StateGraph; import com.alibaba.cloud.ai.graph.action.AsyncNodeAction; import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.alibaba.cloud.ai.graph.agent.factory.AgentBuilderFactory; +import com.alibaba.cloud.ai.graph.agent.factory.DefaultAgentBuilderFactory; import com.alibaba.cloud.ai.graph.exception.GraphStateException; import com.alibaba.cloud.ai.graph.node.LlmNode; import com.alibaba.cloud.ai.graph.node.ToolNode; @@ -33,23 +40,11 @@ import com.alibaba.cloud.ai.graph.scheduling.ScheduledAgentTask; import com.alibaba.cloud.ai.graph.state.strategy.AppendStrategy; import com.alibaba.cloud.ai.graph.state.strategy.ReplaceStrategy; +import reactor.core.publisher.Flux; -import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.prompt.ChatOptions; -import org.springframework.ai.tool.ToolCallback; -import org.springframework.ai.tool.resolution.ToolCallbackResolver; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Function; - -import org.apache.commons.collections4.CollectionUtils; -import reactor.core.publisher.Flux; import static com.alibaba.cloud.ai.graph.StateGraph.END; import static com.alibaba.cloud.ai.graph.StateGraph.START; @@ -88,7 +83,7 @@ public class ReactAgent extends BaseAgent { private String inputKey; - protected ReactAgent(LlmNode llmNode, ToolNode toolNode, Builder builder) throws GraphStateException { + public ReactAgent(LlmNode llmNode, ToolNode toolNode, Builder builder) throws GraphStateException { super(builder.name, builder.description, builder.outputKey); this.instruction = builder.instruction; this.llmNode = llmNode; @@ -103,10 +98,6 @@ protected ReactAgent(LlmNode llmNode, ToolNode toolNode, Builder builder) throws this.inputKey = builder.inputKey; } - public static Builder builder() { - return new Builder(); - } - @Override public ScheduledAgentTask schedule(ScheduleConfig scheduleConfig) throws GraphStateException { CompiledGraph compiledGraph = getAndCompileGraph(); @@ -298,177 +289,12 @@ void setShouldContinueFunc(Function shouldContinueFunc) { this.shouldContinueFunc = shouldContinueFunc; } - public static class Builder { - - private String name; - - private String description; - - private String instruction; - - private String outputKey; - - private ChatModel model; - - private ChatOptions chatOptions; - - private ChatClient chatClient; - - private List tools; - - private ToolCallbackResolver resolver; - - private int maxIterations = 10; - - private CompileConfig compileConfig; - - private KeyStrategyFactory keyStrategyFactory; - - private Function shouldContinueFunc; - - private NodeAction preLlmHook; - - private NodeAction postLlmHook; - - private NodeAction preToolHook; - - private NodeAction postToolHook; - - private String inputKey = "messages"; - - public Builder name(String name) { - this.name = name; - return this; - } - - public Builder chatClient(ChatClient chatClient) { - this.chatClient = chatClient; - return this; - } - - public Builder model(ChatModel model) { - this.model = model; - return this; - } - - public Builder chatOptions(ChatOptions chatOptions) { - this.chatOptions = chatOptions; - return this; - } - - public Builder tools(List tools) { - this.tools = tools; - return this; - } - - public Builder resolver(ToolCallbackResolver resolver) { - this.resolver = resolver; - return this; - } - - public Builder maxIterations(int maxIterations) { - this.maxIterations = maxIterations; - return this; - } - - public Builder state(KeyStrategyFactory keyStrategyFactory) { - this.keyStrategyFactory = keyStrategyFactory; - return this; - } - - public Builder compileConfig(CompileConfig compileConfig) { - this.compileConfig = compileConfig; - return this; - } - - public Builder shouldContinueFunction(Function shouldContinueFunc) { - this.shouldContinueFunc = shouldContinueFunc; - return this; - } - - public Builder description(String description) { - this.description = description; - return this; - } - - public Builder instruction(String instruction) { - this.instruction = instruction; - return this; - } - - public Builder outputKey(String outputKey) { - this.outputKey = outputKey; - return this; - } - - public Builder preLlmHook(NodeAction preLlmHook) { - this.preLlmHook = preLlmHook; - return this; - } - - public Builder postLlmHook(NodeAction postLlmHook) { - this.postLlmHook = postLlmHook; - return this; - } - - public Builder preToolHook(NodeAction preToolHook) { - this.preToolHook = preToolHook; - return this; - } - - public Builder postToolHook(NodeAction postToolHook) { - this.postToolHook = postToolHook; - return this; - } - - public Builder inputKey(String inputKey) { - this.inputKey = inputKey; - return this; - } - - public ReactAgent build() throws GraphStateException { - if (chatClient == null) { - if (model == null) { - throw new IllegalArgumentException("Either chatClient or model must be provided"); - } - ChatClient.Builder clientBuilder = ChatClient.builder(model); - if (chatOptions != null) { - clientBuilder.defaultOptions(chatOptions); - } - if (instruction != null) { - clientBuilder.defaultSystem(instruction); - } - chatClient = clientBuilder.build(); - } - - LlmNode.Builder llmNodeBuilder = LlmNode.builder() - .stream(true) - .chatClient(chatClient) - .messagesKey(this.inputKey); - // For graph built from ReactAgent, the only legal key used inside must be - // messages. - // if (outputKey != null && !outputKey.isEmpty()) { - // llmNodeBuilder.outputKey(outputKey); - // } - if (CollectionUtils.isNotEmpty(tools)) { - llmNodeBuilder.toolCallbacks(tools); - } - LlmNode llmNode = llmNodeBuilder.build(); - - ToolNode toolNode = null; - if (resolver != null) { - toolNode = ToolNode.builder().toolCallbackResolver(resolver).build(); - } - else if (tools != null) { - toolNode = ToolNode.builder().toolCallbacks(tools).build(); - } - else { - toolNode = ToolNode.builder().build(); - } - - return new ReactAgent(llmNode, toolNode, this); - } + public static Builder builder() { + return new DefaultAgentBuilderFactory().builder(); + } + public static Builder builder(AgentBuilderFactory agentBuilderFactory) { + return agentBuilderFactory.builder(); } public static class SubGraphNodeAdapter implements NodeAction { diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/factory/AgentBuilderFactory.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/factory/AgentBuilderFactory.java new file mode 100644 index 0000000000..4c89256915 --- /dev/null +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/factory/AgentBuilderFactory.java @@ -0,0 +1,23 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.graph.agent.factory; + +import com.alibaba.cloud.ai.graph.agent.Builder; + +public interface AgentBuilderFactory { + + Builder builder(); +} diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/factory/DefaultAgentBuilderFactory.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/factory/DefaultAgentBuilderFactory.java new file mode 100644 index 0000000000..df94cde017 --- /dev/null +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/agent/factory/DefaultAgentBuilderFactory.java @@ -0,0 +1,27 @@ +/* + * Copyright 2024-2025 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 + * + * https://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 com.alibaba.cloud.ai.graph.agent.factory; + +import com.alibaba.cloud.ai.graph.agent.Builder; +import com.alibaba.cloud.ai.graph.agent.DefaultBuilder; + +public class DefaultAgentBuilderFactory implements AgentBuilderFactory { + + @Override + public Builder builder() { + return new DefaultBuilder(); + } +} diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/LlmNode.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/LlmNode.java index 38ef8cafbb..9005a16c40 100644 --- a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/LlmNode.java +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/LlmNode.java @@ -15,8 +15,17 @@ */ package com.alibaba.cloud.ai.graph.node; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + import com.alibaba.cloud.ai.graph.OverAllState; import com.alibaba.cloud.ai.graph.action.NodeAction; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import reactor.core.publisher.Flux; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.api.Advisor; @@ -26,21 +35,9 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.PromptTemplate; -import org.springframework.ai.model.tool.ToolCallingChatOptions; import org.springframework.ai.tool.ToolCallback; - import org.springframework.util.StringUtils; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import reactor.core.publisher.Flux; - public class LlmNode implements NodeAction { public static final String LLM_RESPONSE_KEY = "llm_response"; @@ -158,6 +155,10 @@ private void initNodeWithState(OverAllState state) { } } + public void setToolCallbacks(List toolCallbacks) { + this.toolCallbacks = toolCallbacks; + } + private String renderPromptTemplate(String prompt, Map params) { PromptTemplate promptTemplate = new PromptTemplate(prompt); return promptTemplate.render(params); @@ -173,12 +174,9 @@ public ChatResponse call() { private ChatClient.ChatClientRequestSpec buildChatClientRequestSpec() { ChatClient.ChatClientRequestSpec chatClientRequestSpec = chatClient.prompt() - .options(ToolCallingChatOptions.builder() .toolCallbacks(toolCallbacks) - .internalToolExecutionEnabled(false) - .build()) - .messages(messages) - .advisors(advisors); + .messages(messages) + .advisors(advisors); if (StringUtils.hasLength(systemPrompt)) { if (!params.isEmpty()) { diff --git a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/ToolNode.java b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/ToolNode.java index 1405f7d9bf..b73c2a2941 100644 --- a/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/ToolNode.java +++ b/spring-ai-alibaba-graph-core/src/main/java/com/alibaba/cloud/ai/graph/node/ToolNode.java @@ -52,7 +52,7 @@ public ToolNode(List toolCallbacks, ToolCallbackResolver resolver) this.toolCallbackResolver = resolver; } - void setToolCallbacks(List toolCallbacks) { + public void setToolCallbacks(List toolCallbacks) { this.toolCallbacks = toolCallbacks; }