diff --git a/pom.xml b/pom.xml index 3f673aa26e0..f7fd8208403 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ spring-ai-test spring-ai-vector-store spring-ai-rag + spring-ai-skill advisors/spring-ai-advisors-vector-store memory/repository/spring-ai-model-chat-memory-repository-cassandra diff --git a/spring-ai-skill/pom.xml b/spring-ai-skill/pom.xml new file mode 100644 index 00000000000..58ebb271eb6 --- /dev/null +++ b/spring-ai-skill/pom.xml @@ -0,0 +1,59 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + + spring-ai-skill + jar + Spring AI Skill + Skill management extension for Spring AI applications + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + 17 + 17 + + + + + org.springframework.ai + spring-ai-client-chat + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/SkillProxy.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/SkillProxy.java new file mode 100644 index 00000000000..b4dc27d650e --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/SkillProxy.java @@ -0,0 +1,259 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.adapter; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillMetadata; +import org.springframework.ai.skill.core.SkillRegistrar; +import org.springframework.ai.skill.exception.SkillInvocationException; +import org.springframework.ai.tool.ToolCallback; + +/** + * Proxy-based Skill implementation wrapping user POJOs annotated with {@code @Skill}. + * + *

+ * This class acts as a dynamic proxy that delegates method calls to the underlying + * annotated POJO instance using reflection. It supports capability extension through JDK + * dynamic proxies. + * + *

+ * INTERNAL USE ONLY: Framework internal class subject to change. + * + * @author LinPeng Zhang + * @see Skill + * @see SkillRegistrar + * @since 1.1.3 + */ +public final class SkillProxy implements Skill { + + private final SkillMetadata metadata; + + private final Object delegate; + + private final Map extensionMethods; + + private final Map, Object> capabilityProxies = new ConcurrentHashMap<>(); + + /** + * Constructor. + * @param metadata skill metadata + * @param delegate user POJO instance + * @param extensionMethods extension method mappings + */ + public SkillProxy(SkillMetadata metadata, Object delegate, Map extensionMethods) { + this.metadata = Objects.requireNonNull(metadata, "metadata cannot be null"); + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + this.extensionMethods = Objects.requireNonNull(extensionMethods, "extensionMethods cannot be null"); + } + + @Override + public SkillMetadata getMetadata() { + return this.metadata; + } + + /** + * Gets skill content. + * @return skill content + * @throws UnsupportedOperationException if no @SkillContent method found + * @throws SkillInvocationException if invocation fails + */ + @Override + public String getContent() { + Method contentMethod = this.extensionMethods.get("content"); + if (contentMethod == null) { + throw new UnsupportedOperationException( + "Skill '" + this.getName() + "' does not have @SkillContent annotated method"); + } + + try { + contentMethod.setAccessible(true); + Object result = contentMethod.invoke(this.delegate); + + // Validate return type + if (result == null) { + throw new SkillInvocationException(this.getName(), contentMethod.getName(), + "@SkillContent method '" + contentMethod.getName() + "' returned null. " + + "The method must return a non-null String value.", + null); + } + + if (!(result instanceof String)) { + throw new SkillInvocationException(this.getName(), contentMethod.getName(), + "@SkillContent method '" + contentMethod.getName() + "' must return String, " + "but returned " + + result.getClass().getName() + ". " + "Ensure the method signature is: public String " + + contentMethod.getName() + "()", + null); + } + + return (String) result; + } + catch (SkillInvocationException e) { + throw e; // Re-throw our custom exception + } + catch (Exception e) { + throw new SkillInvocationException(this.getName(), contentMethod.getName(), + "Failed to invoke @SkillContent method '" + contentMethod.getName() + "' on skill '" + + this.getName() + "'. " + "Ensure the method is accessible and does not throw exceptions.", + e); + } + } + + /** + * Gets tool callbacks. + * @return tool callbacks (empty if no @SkillTools method) + * @throws SkillInvocationException if invocation fails + */ + @Override + @SuppressWarnings("unchecked") + public List getTools() { + Method toolsMethod = this.extensionMethods.get("tools"); + if (toolsMethod == null) { + return Collections.emptyList(); // No @SkillTools method, return empty list + } + + try { + toolsMethod.setAccessible(true); + Object result = toolsMethod.invoke(this.delegate); + + // Validate return type + if (result == null) { + throw new SkillInvocationException(this.getName(), toolsMethod.getName(), + "@SkillTools method '" + toolsMethod.getName() + "' returned null. " + + "The method must return a non-null List.", + null); + } + + if (!(result instanceof List list)) { + throw new SkillInvocationException(this.getName(), toolsMethod.getName(), + "@SkillTools method '" + toolsMethod.getName() + "' must return List, " + + "but returned " + result.getClass().getName() + ". " + + "Ensure the method signature is: public List " + toolsMethod.getName() + + "()", + null); + } + + // Check list contents ( the best effort - generics are erased at runtime) + if (!list.isEmpty()) { + Object firstElement = list.get(0); + if (firstElement != null && !(firstElement instanceof ToolCallback)) { + throw new SkillInvocationException(this.getName(), toolsMethod.getName(), + "@SkillTools method '" + toolsMethod.getName() + + "' returned a list containing invalid elements. " + + "Expected List, but found element of type " + + firstElement.getClass().getName() + ". " + + "Ensure all list elements are instances of ToolCallback.", + null); + } + } + + return (List) result; + } + catch (SkillInvocationException e) { + throw e; // Re-throw our custom exception + } + catch (Exception e) { + throw new SkillInvocationException(this.getName(), toolsMethod.getName(), + "Failed to invoke @SkillTools method '" + toolsMethod.getName() + "' on skill '" + this.getName() + + "'. " + "Ensure the method is accessible and does not throw exceptions.", + e); + } + } + + @Override + public boolean supports(Class capabilityType) { + if (!capabilityType.isInterface()) { + return false; + } + + String expectedKey = this.getCapabilityKey(capabilityType); + return expectedKey != null && this.extensionMethods.containsKey(expectedKey); + } + + @Override + @SuppressWarnings("unchecked") + public T as(Class capabilityType) { + if (!capabilityType.isInterface()) { + throw new IllegalArgumentException("Capability must be an interface: " + capabilityType.getName()); + } + + if (!this.supports(capabilityType)) { + throw new UnsupportedOperationException( + "Skill '" + this.getName() + "' does not support capability: " + capabilityType.getName()); + } + + return (T) this.capabilityProxies.computeIfAbsent(capabilityType, this::createCapabilityProxy); + } + + /** + * Creates dynamic proxy for capability interface. + * @param capability capability interface + * @return proxy instance + */ + private Object createCapabilityProxy(Class capability) { + return Proxy.newProxyInstance(capability.getClassLoader(), new Class[] { capability }, + (proxy, method, args) -> { + String key = this.methodToExtensionKey(method); + Method extensionMethod = this.extensionMethods.get(key); + + if (extensionMethod == null) { + throw new UnsupportedOperationException("No implementation found for method '" + + method.getName() + "' in capability interface '" + capability.getName() + "' " + + "for skill '" + this.getName() + "'. " + "Expected an annotated method with key '" + + key + "'."); + } + + try { + extensionMethod.setAccessible(true); + return extensionMethod.invoke(this.delegate, args); + } + catch (Exception e) { + throw new SkillInvocationException(this.getName(), extensionMethod.getName(), + "Failed to invoke capability method '" + method.getName() + "' (mapped to '" + + extensionMethod.getName() + "') " + "for capability '" + capability.getName() + + "' " + "on skill '" + this.getName() + "'.", + e); + } + }); + } + + private @Nullable String getCapabilityKey(Class capability) { + Method[] methods = capability.getDeclaredMethods(); + if (methods.length == 0) { + return null; + } + return this.methodToExtensionKey(methods[0]); + } + + private String methodToExtensionKey(Method method) { + String methodName = method.getName(); + if (methodName.startsWith("get") && methodName.length() > 3) { + return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4); + } + return methodName; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/package-info.java new file mode 100644 index 00000000000..a36817f2d26 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/adapter/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Adapter classes for skill proxy and dynamic invocation. + */ + +@NullMarked +package org.springframework.ai.skill.adapter; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/Skill.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/Skill.java new file mode 100644 index 00000000000..f00af2d9375 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/Skill.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Class-level annotation for marking a class as a Skill. + * + *

+ * Supports annotation mode (POJO) and interface mode (Skill implementation). + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Skill { + + /** + * Skill name (required). + * @return skill name + */ + String name(); + + /** + * Skill description (required). + * @return skill description + */ + String description(); + + /** + * Source identifier (optional, default "custom"). + * @return source identifier + */ + String source() default "custom"; + + /** + * Extension properties (optional). + * + *

+ * Format: key=value + * @return extension properties array + */ + String[] extensions() default {}; + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillContent.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillContent.java new file mode 100644 index 00000000000..473e852c5ea --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillContent.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks method returning skill markdown content. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SkillExtension(key = "content", returnType = String.class, required = true) +public @interface SkillContent { + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillExtension.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillExtension.java new file mode 100644 index 00000000000..e285898c4c4 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillExtension.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Meta-annotation for custom skill extension methods. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface SkillExtension { + + /** + * Extension key. + * @return key value + */ + String key(); + + /** + * Expected return type. + * @return return type class + */ + Class returnType() default Object.class; + + /** + * Whether extension is required. + * @return true if required + */ + boolean required() default false; + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillInit.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillInit.java new file mode 100644 index 00000000000..dc6d1efe192 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillInit.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks skill initialization factory method. + * + * @author LinPeng Zhang + * @see Skill + * @see org.springframework.ai.skill.registration.ClassBasedSkillRegistrar + * @since 1.1.3 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface SkillInit { + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillTools.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillTools.java new file mode 100644 index 00000000000..b7c1ffe0ff3 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/SkillTools.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; + +/** + * Marks method returning skill tool list. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SkillExtension(key = "tools", returnType = List.class, required = false) +public @interface SkillTools { + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/package-info.java new file mode 100644 index 00000000000..2f9ba07dd97 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/annotation/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Annotations for declarative skill definition and configuration. + */ + +@NullMarked +package org.springframework.ai.skill.annotation; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/ReferencesLoader.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/ReferencesLoader.java new file mode 100644 index 00000000000..2e6b0aa3bde --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/ReferencesLoader.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.capability; + +import java.util.Map; + +/** + * Extension capability interface for providing reference resources. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public interface ReferencesLoader { + + /** + * Gets reference resources map. + * @return reference resources (never null, may be empty) + */ + Map getReferences(); + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/SkillReferences.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/SkillReferences.java new file mode 100644 index 00000000000..e35d1ab1450 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/SkillReferences.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.capability; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Map; + +import org.springframework.ai.skill.annotation.SkillExtension; + +/** + * Marks method returning skill reference resources as Map. + * + * @author LinPeng Zhang + * @see org.springframework.ai.skill.capability.ReferencesLoader + * @since 1.1.3 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SkillExtension(key = "references", returnType = Map.class, required = false) +public @interface SkillReferences { + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/package-info.java new file mode 100644 index 00000000000..d4ccae42f1c --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/capability/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Capability interfaces for skill references and content loading. + */ + +@NullMarked +package org.springframework.ai.skill.capability; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/LoadStrategy.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/LoadStrategy.java new file mode 100644 index 00000000000..24cbcb23b25 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/LoadStrategy.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.common; + +/** + * Skill loading strategy. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public enum LoadStrategy { + + /** + * Lazy loading (default) - instance created on first use. + */ + LAZY, + + /** + * Eager loading - instance created immediately during registration. + */ + EAGER + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/package-info.java new file mode 100644 index 00000000000..a26733fb52f --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/common/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Common types and enumerations shared across skill components. + */ + +@NullMarked +package org.springframework.ai.skill.common; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/DefaultSkillKit.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/DefaultSkillKit.java new file mode 100644 index 00000000000..2bc0fc2cf9e --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/DefaultSkillKit.java @@ -0,0 +1,471 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.skill.exception.SkillRegistrationException; +import org.springframework.ai.skill.exception.SkillValidationException; +import org.springframework.ai.skill.registration.ClassBasedSkillRegistrar; +import org.springframework.ai.skill.registration.InstanceBasedSkillRegistrar; +import org.springframework.ai.skill.support.DefaultSkillIdGenerator; +import org.springframework.ai.skill.tool.SimpleSkillLoaderTools; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; + +/** + * Default implementation of SkillKit coordinating SkillBox and SkillPoolManager. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class DefaultSkillKit implements SkillKit { + + private final SkillBox skillBox; + + private final SkillPoolManager poolManager; + + private final SkillIdGenerator idGenerator; + + private final SkillRegistrar> classRegistrar; + + private final SkillRegistrar instanceRegistrar; + + private final List skillLoaderTools; + + private DefaultSkillKit(Builder builder) { + this.skillBox = Objects.requireNonNull(builder.skillBox, "skillBox cannot be null"); + this.poolManager = Objects.requireNonNull(builder.poolManager, "poolManager cannot be null"); + this.idGenerator = (builder.idGenerator != null) ? builder.idGenerator : new DefaultSkillIdGenerator(); + this.classRegistrar = (builder.classRegistrar != null) ? builder.classRegistrar + : ClassBasedSkillRegistrar.builder().idGenerator(this.idGenerator).build(); + this.instanceRegistrar = (builder.instanceRegistrar != null) ? builder.instanceRegistrar + : InstanceBasedSkillRegistrar.builder().idGenerator(this.idGenerator).build(); + this.skillLoaderTools = (builder.tools != null) ? builder.tools + : List.of(ToolCallbacks.from(SimpleSkillLoaderTools.builder().skillKit(this).build())); + } + + /** + * Creates a new builder instance. + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public void register(SkillMetadata metadata, Supplier loader) { + this.validateRegistrationParameters(metadata, loader); + String skillId = this.buildSkillId(metadata); + + if (this.poolManager.hasDefinition(skillId)) { + throw new SkillRegistrationException(skillId, "Skill with ID '" + skillId + "' already registered. " + + "Each combination of (name, source) must be unique."); + } + + SkillDefinition definition = SkillDefinition.builder() + .skillId(skillId) + .source(metadata.getSource()) + .loader(loader) + .metadata(metadata) + .build(); + + this.poolManager.registerDefinition(definition); + this.skillBox.addSkill(metadata.getName(), metadata); + } + + // ==================== Skill Registration ==================== + + @Override + public void register(Object instance) { + SkillDefinition definition = this.instanceRegistrar.register(this.poolManager, instance); + this.skillBox.addSkill(definition.getMetadata().getName(), definition.getMetadata()); + } + + @Override + public void register(Class skillClass) { + SkillDefinition definition = this.classRegistrar.register(this.poolManager, skillClass); + this.skillBox.addSkill(definition.getMetadata().getName(), definition.getMetadata()); + } + + protected String buildSkillId(SkillMetadata metadata) { + return this.idGenerator.generateId(metadata); + } + + private void validateRegistrationParameters(SkillMetadata metadata, Supplier loader) { + if (metadata == null) { + throw new SkillValidationException(null, "metadata cannot be null"); + } + + String name = metadata.getName(); + if (name == null || name.trim().isEmpty()) { + throw new SkillValidationException(null, "metadata.name cannot be null or empty. " + + "Example: SkillMetadata.builder(\"calculator\", \"Calculator skill\", \"spring\").build()"); + } + + String description = metadata.getDescription(); + if (description == null || description.trim().isEmpty()) { + throw new SkillValidationException(null, "metadata.description cannot be null or empty. " + + "Example: SkillMetadata.builder(\"calculator\", \"A skill for calculations\", \"spring\").build()"); + } + + String source = metadata.getSource(); + if (source == null || source.trim().isEmpty()) { + throw new SkillValidationException(null, + "metadata.source cannot be null or empty. " + + "Common sources: \"spring\", \"official\", \"filesystem\", \"database\". " + + "Example: SkillMetadata.builder(\"calculator\", \"desc\", \"spring\").build()"); + } + + if (loader == null) { + throw new SkillValidationException(null, + "loader cannot be null. " + "Example: skillKit.registerSkill(metadata, () -> new MySkill())"); + } + } + + /** + * Gets skill instance by skillId. + * @param skillId unique skill ID (format: {name}_{source}) + * @return skill instance + * @throws org.springframework.ai.skill.exception.SkillNotFoundException if skill not + * found + * @throws org.springframework.ai.skill.exception.SkillLoadException if loading fails + */ + @Override + public Skill getSkill(String skillId) { + return this.poolManager.load(skillId); + } + + // ==================== Skill Access ==================== + + /** + * Gets skill instance by name from SkillBox. + * @param name skill name + * @return skill instance, null if not found + */ + @Override + public @Nullable Skill getSkillByName(String name) { + SkillMetadata metadata = this.skillBox.getMetadata(name); + if (metadata == null) { + return null; + } + String skillId = this.buildSkillId(metadata); + return this.poolManager.load(skillId); + } + + /** + * Gets skill metadata by name from SkillBox without triggering skill loading. + * @param name skill name + * @return skill metadata, null if not found + */ + @Override + public @Nullable SkillMetadata getMetadata(String name) { + return this.skillBox.getMetadata(name); + } + + /** + * Checks if skill exists in SkillBox. + * @param name skill name + * @return true if exists + */ + @Override + public boolean exists(String name) { + return this.skillBox.exists(name); + } + + /** + * Adds skill metadata to SkillBox from PoolManager. + * @param name skill name + * @throws IllegalArgumentException if skill not found in PoolManager or already + * exists in SkillBox + */ + public void addSkillToBox(String name) { + SkillDefinition definition = this.findDefinitionByName(name); + if (definition == null) { + throw new IllegalArgumentException("Skill not found in PoolManager: " + name + ". " + + "Please register the skill first using registerSkill()."); + } + + this.skillBox.addSkill(name, definition.getMetadata()); + } + + // ==================== SkillBox Management ==================== + + /** + * Finds skill definition by name, checking SkillBox sources in priority order. + * @param name skill name + * @return skill definition, null if not found + */ + protected @Nullable SkillDefinition findDefinitionByName(String name) { + SkillMetadata metadata = this.skillBox.getMetadata(name); + if (metadata != null) { + String metadataSource = metadata.getSource(); + if (!this.skillBox.getSources().contains(metadataSource)) { + return null; + } + + String skillId = this.buildSkillId(metadata); + return this.poolManager.getDefinition(skillId); + } + + List allDefinitions = this.poolManager.getDefinitions(); + + for (String source : this.skillBox.getSources()) { + for (SkillDefinition definition : allDefinitions) { + SkillMetadata defMetadata = definition.getMetadata(); + if (name.equals(defMetadata.getName()) && source.equals(defMetadata.getSource())) { + String expectedSkillId = this.buildSkillId(defMetadata); + if (expectedSkillId.equals(definition.getSkillId())) { + return definition; + } + } + } + } + return null; + } + + /** + * Activates skill by name. + * @param name skill name + * @throws IllegalArgumentException if skill not found in SkillBox + */ + @Override + public void activateSkill(String name) { + if (!this.skillBox.exists(name)) { + throw new IllegalArgumentException("Skill not found in SkillBox: " + name + ". " + + "Please register the skill first using registerSkill()."); + } + + this.skillBox.activateSkill(name); + } + + // ==================== Skill Activation ==================== + + /** + * Deactivates skill by name. + * @param name skill name + * @throws IllegalArgumentException if skill not found in SkillBox + */ + @Override + public void deactivateSkill(String name) { + if (!this.skillBox.exists(name)) { + throw new IllegalArgumentException("Skill not found in SkillBox: " + name); + } + + this.skillBox.deactivateSkill(name); + } + + /** + * Deactivates all skills. + */ + @Override + public void deactivateAllSkills() { + this.skillBox.deactivateAllSkills(); + } + + /** + * Checks if skill is activated. + * @param name skill name + * @return true if activated + */ + @Override + public boolean isActivated(String name) { + return this.skillBox.isActivated(name); + } + + /** + * Gets all tool callbacks from activated skills. + * @return list of tool callbacks from activated skills + * @throws IllegalStateException if activated skill not found in PoolManager + */ + @Override + public List getAllActiveTools() { + List allTools = new ArrayList<>(); + + Set activatedSkillNames = this.skillBox.getActivatedSkillNames(); + + for (String name : activatedSkillNames) { + SkillDefinition definition = this.findDefinitionByName(name); + if (definition == null) { + throw new IllegalStateException( + "Skill '" + name + "' is activated in SkillBox but not found in PoolManager. " + + "This indicates a data inconsistency. " + + "Please ensure the skill is properly registered in PoolManager."); + } + + String skillId = definition.getSkillId(); + Skill skill = this.poolManager.load(skillId); + + List tools = skill.getTools(); + if (tools != null && !tools.isEmpty()) { + allTools.addAll(tools); + } + } + + return allTools; + } + + /** + * Gets system prompt describing all skills in SkillBox. + * @return system prompt string, empty if no skills + */ + @Override + public String getSkillSystemPrompt() { + Map allMetadata = this.skillBox.getAllMetadata(); + + if (allMetadata.isEmpty()) { + return ""; + } + + StringBuilder prompt = new StringBuilder(); + prompt.append("You have access to the following skills:\n\n"); + + int index = 1; + for (SkillMetadata metadata : allMetadata.values()) { + prompt.append(String.format("%d. %s: %s\n", index++, metadata.getName(), metadata.getDescription())); + } + + prompt.append("\n"); + prompt.append("**How to Use Skills:**\n\n"); + prompt.append( + "1. **loadSkillContent(skillName)**: Load the full content of a skill to understand what it does and what tools it provides.\n"); + prompt.append(" - Use this when you need to know a skill's capabilities\n"); + prompt.append(" - The content will describe available tools and how to use them\n\n"); + prompt.append( + "2. **loadSkillReference(skillName, referenceKey)**: Load a specific reference material from a skill.\n"); + prompt.append(" - ONLY use this when a skill's content EXPLICITLY mentions it has reference materials\n"); + prompt.append(" - You must use the exact reference key mentioned in the skill's content\n"); + prompt.append(" - For regular skill operations, use the skill's own tools instead\n"); + + return prompt.toString(); + } + + /** + * Gets skill-related tool callbacks for progressive skill loading. + * @return list of skill tool callbacks + */ + @Override + public List getSkillLoaderTools() { + return this.skillLoaderTools; + } + + // ==================== Skill Tools ==================== + + /** + * Builder for DefaultSkillKit. + */ + public static class Builder { + + private @Nullable SkillBox skillBox; + + private @Nullable SkillPoolManager poolManager; + + private @Nullable List tools; + + private @Nullable SkillIdGenerator idGenerator; + + private @Nullable SkillRegistrar> classRegistrar; + + private @Nullable SkillRegistrar instanceRegistrar; + + private Builder() { + } + + /** + * Sets the skill box. + * @param skillBox skill box instance + * @return this builder + */ + public Builder skillBox(SkillBox skillBox) { + this.skillBox = skillBox; + return this; + } + + /** + * Sets the pool manager. + * @param poolManager pool manager instance + * @return this builder + */ + public Builder poolManager(SkillPoolManager poolManager) { + this.poolManager = poolManager; + return this; + } + + /** + * Sets custom tools. + * @param tools tool callbacks list + * @return this builder + */ + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + /** + * Sets custom ID generator. + * @param idGenerator ID generator instance + * @return this builder + */ + public Builder idGenerator(SkillIdGenerator idGenerator) { + this.idGenerator = idGenerator; + return this; + } + + /** + * Sets custom class-based registrar. + * @param classRegistrar class registrar instance + * @return this builder + */ + public Builder classRegistrar(SkillRegistrar> classRegistrar) { + this.classRegistrar = classRegistrar; + return this; + } + + /** + * Sets custom instance-based registrar. + * @param instanceRegistrar instance registrar instance + * @return this builder + */ + public Builder instanceRegistrar(SkillRegistrar instanceRegistrar) { + this.instanceRegistrar = instanceRegistrar; + return this; + } + + /** + * Builds the DefaultSkillKit instance. + * @return new DefaultSkillKit instance + */ + public DefaultSkillKit build() { + if (this.skillBox == null) { + throw new IllegalArgumentException("skillBox cannot be null"); + } + if (this.poolManager == null) { + throw new IllegalArgumentException("poolManager cannot be null"); + } + return new DefaultSkillKit(this); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/Skill.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/Skill.java new file mode 100644 index 00000000000..1c46a6e7376 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/Skill.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.Collections; +import java.util.List; + +import org.springframework.ai.tool.ToolCallback; + +/** + * Core Skill interface. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public interface Skill { + + /** + * Gets skill metadata. + * @return skill metadata + */ + SkillMetadata getMetadata(); + + /** + * Gets skill name. + * @return skill name + */ + default String getName() { + return this.getMetadata().getName(); + } + + /** + * Gets skill description. + * @return skill description + */ + default String getDescription() { + return this.getMetadata().getDescription(); + } + + /** + * Gets skill content in Markdown format. + * @return skill content + */ + String getContent(); + + /** + * Gets tool callbacks provided by this skill. + * @return tool callback list (never null, defaults to empty) + */ + default List getTools() { + return Collections.emptyList(); + } + + /** + * Checks if this skill supports the specified capability interface. + * @param capabilityType capability interface class + * @param capability type + * @return true if supported + */ + default boolean supports(Class capabilityType) { + return capabilityType.isInstance(this); + } + + /** + * Converts this skill to the specified capability interface. + * @param capabilityType capability interface class + * @param capability type + * @return capability instance + * @throws ClassCastException if capability not supported + */ + default T as(Class capabilityType) { + if (!this.supports(capabilityType)) { + throw new ClassCastException( + "Skill '" + this.getName() + "' does not support capability: " + capabilityType.getName()); + } + return capabilityType.cast(this); + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillBox.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillBox.java new file mode 100644 index 00000000000..eb2147ac11e --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillBox.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +/** + * Tenant-level skill metadata container. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public interface SkillBox { + + /** + * Adds skill metadata. + * @param name skill name + * @param metadata skill metadata + * @throws IllegalArgumentException if name already exists + */ + void addSkill(String name, SkillMetadata metadata); + + /** + * Gets skill metadata. + * @param name skill name + * @return skill metadata or null if not found + */ + @Nullable SkillMetadata getMetadata(String name); + + /** + * Gets all skill metadata. + * @return unmodifiable map of name to metadata + */ + Map getAllMetadata(); + + /** + * Checks if skill exists. + * @param name skill name + * @return true if exists + */ + boolean exists(String name); + + /** + * Gets skill count. + * @return skill count + */ + int getSkillCount(); + + /** + * Activates skill. + * @param name skill name + * @throws IllegalArgumentException if skill not found + */ + void activateSkill(String name); + + /** + * Deactivates skill. + * @param name skill name + * @throws IllegalArgumentException if skill not found + */ + void deactivateSkill(String name); + + /** + * Deactivates all skills. + */ + void deactivateAllSkills(); + + /** + * Checks if skill is activated. + * @param name skill name + * @return true if activated + */ + boolean isActivated(String name); + + /** + * Gets activated skill names. + * @return set of activated skill names (never null) + */ + java.util.Set getActivatedSkillNames(); + + /** + * Gets supported skill sources in priority order. + * @return source list (mutable, ordered by priority) + */ + List getSources(); + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillDefinition.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillDefinition.java new file mode 100644 index 00000000000..d5ced4be896 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillDefinition.java @@ -0,0 +1,163 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.Objects; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.skill.common.LoadStrategy; + +/** + * Skill definition containing metadata and loading strategy. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillDefinition { + + private final String skillId; + + private final String source; + + private final Supplier loader; + + private final LoadStrategy loadStrategy; + + private final SkillMetadata metadata; + + private SkillDefinition(Builder builder) { + this.skillId = Objects.requireNonNull(builder.skillId, "skillId cannot be null"); + this.source = Objects.requireNonNull(builder.source, "source cannot be null"); + this.loader = Objects.requireNonNull(builder.loader, "loader cannot be null"); + this.loadStrategy = Objects.requireNonNull(builder.loadStrategy, "loadStrategy cannot be null"); + this.metadata = Objects.requireNonNull(builder.metadata, "metadata cannot be null"); + } + + public static Builder builder() { + return new Builder(); + } + + public String getSkillId() { + return this.skillId; + } + + public String getSource() { + return this.source; + } + + public Supplier getLoader() { + return this.loader; + } + + public LoadStrategy getLoadStrategy() { + return this.loadStrategy; + } + + public SkillMetadata getMetadata() { + return this.metadata; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || this.getClass() != o.getClass()) { + return false; + } + SkillDefinition that = (SkillDefinition) o; + return Objects.equals(this.skillId, that.skillId); + } + + @Override + public int hashCode() { + return Objects.hash(this.skillId); + } + + @Override + public String toString() { + return "SkillDefinition{" + "skillId='" + this.skillId + '\'' + ", source='" + this.source + '\'' + + ", loadStrategy=" + this.loadStrategy + ", metadata=" + this.metadata + '}'; + } + + /** + * Builder for immutable SkillDefinition objects. + */ + public static class Builder { + + private @Nullable String skillId; + + private @Nullable String source; + + private @Nullable Supplier loader; + + private LoadStrategy loadStrategy = LoadStrategy.LAZY; + + private @Nullable SkillMetadata metadata; + + private Builder() { + } + + public Builder skillId(String skillId) { + this.skillId = skillId; + return this; + } + + public Builder source(String source) { + this.source = source; + return this; + } + + public Builder loader(Supplier loader) { + this.loader = loader; + return this; + } + + public Builder loadStrategy(LoadStrategy loadStrategy) { + this.loadStrategy = loadStrategy; + return this; + } + + public Builder metadata(SkillMetadata metadata) { + this.metadata = metadata; + return this; + } + + public SkillDefinition build() { + if (this.skillId == null) { + throw new IllegalArgumentException("skillId cannot be null"); + } + if (this.source == null) { + throw new IllegalArgumentException("source cannot be null"); + } + if (this.loader == null) { + throw new IllegalArgumentException("loader cannot be null"); + } + if (this.loadStrategy == null) { + throw new IllegalArgumentException("loadStrategy cannot be null"); + } + if (this.metadata == null) { + throw new IllegalArgumentException("metadata cannot be null"); + } + return new SkillDefinition(this); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillIdGenerator.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillIdGenerator.java new file mode 100644 index 00000000000..9084812c0e0 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillIdGenerator.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +/** + * Skill ID generator interface. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +@FunctionalInterface +public interface SkillIdGenerator { + + /** + * Generates unique skill ID from metadata. + * @param metadata skill metadata + * @return generated skill ID + * @throws IllegalArgumentException if metadata invalid + */ + String generateId(SkillMetadata metadata); + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillKit.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillKit.java new file mode 100644 index 00000000000..c4d9a05918a --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillKit.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.List; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.tool.ToolCallback; + +/** + * Unified coordination interface for skill management. + * + * @author LinPeng Zhang + * @see DefaultSkillKit + * @since 1.1.3 + */ +public interface SkillKit { + + // ==================== Skill Registration ==================== + + /** + * Registers skill with metadata and lazy loader. + * @param metadata skill metadata + * @param loader skill instance loader + * @throws org.springframework.ai.skill.exception.SkillValidationException if + * validation fails + * @throws org.springframework.ai.skill.exception.SkillRegistrationException if + * registration fails + */ + void register(SkillMetadata metadata, Supplier loader); + + /** + * Registers skill from instance. + * @param instance skill instance with @Skill annotation + * @throws IllegalArgumentException if instance class lacks @Skill annotation + */ + void register(Object instance); + + /** + * Registers skill from class with lazy loading. + * @param skillClass skill class with @Skill annotation + * @throws IllegalArgumentException if class lacks @Skill annotation + */ + void register(Class skillClass); + + // ==================== Skill Access ==================== + + /** + * Checks if skill exists by name. + * @param name skill name + * @return true if exists + */ + boolean exists(String name); + + /** + * Gets skill instance by ID. + * @param skillId skill ID + * @return skill instance + * @throws org.springframework.ai.skill.exception.SkillNotFoundException if not found + * @throws org.springframework.ai.skill.exception.SkillLoadException if loading fails + */ + Skill getSkill(String skillId); + + /** + * Gets skill instance by name. + * @param name skill name + * @return skill instance or null if not found + */ + @Nullable Skill getSkillByName(String name); + + /** + * Gets skill metadata by name. + * @param name skill name + * @return skill metadata or null if not found + */ + @Nullable SkillMetadata getMetadata(String name); + + // ==================== Skill Activation ==================== + + /** + * Activates skill by name. + * @param name skill name + * @throws IllegalArgumentException if skill not found + */ + void activateSkill(String name); + + /** + * Deactivates skill by name. + * @param name skill name + * @throws IllegalArgumentException if skill not found + */ + void deactivateSkill(String name); + + /** + * Deactivates all skills. + */ + void deactivateAllSkills(); + + /** + * Checks if skill is activated. + * @param name skill name + * @return true if activated + */ + boolean isActivated(String name); + + // ==================== Tools ==================== + + /** + * Gets framework skill tools for progressive loading. + * @return skill tool list + */ + List getSkillLoaderTools(); + + /** + * Gets all active skill tools. + * @return active tool list (never null) + * @throws IllegalStateException if data inconsistency detected + */ + List getAllActiveTools(); + + /** + * Gets system prompt describing available skills. + * @return system prompt (never null) + */ + String getSkillSystemPrompt(); + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillMetadata.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillMetadata.java new file mode 100644 index 00000000000..840f6d4a6b3 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillMetadata.java @@ -0,0 +1,168 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +/** + * Skill metadata. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillMetadata { + + private final String name; + + private final String description; + + private final String source; + + private final Map extensions; + + public SkillMetadata(String name, String description, String source) { + this(name, description, source, new HashMap<>()); + } + + public SkillMetadata(String name, String description, String source, Map extensions) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + if (description == null || description.isEmpty()) { + throw new IllegalArgumentException("description is required"); + } + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("source is required"); + } + + this.name = name; + this.description = description; + this.source = source; + this.extensions = new HashMap<>(extensions); + } + + // Builder + public static Builder builder(String name, String description, String source) { + return new Builder(name, description, source); + } + + public String getName() { + return this.name; + } + + public String getDescription() { + return this.description; + } + + public String getSource() { + return this.source; + } + + public Map getExtensions() { + return Collections.unmodifiableMap(this.extensions); + } + + public @Nullable Object getExtension(String key) { + return this.extensions.get(key); + } + + public @Nullable T getExtension(String key, Class type) { + Object value = this.extensions.get(key); + if (value == null) { + return null; + } + if (!type.isInstance(value)) { + throw new ClassCastException("Extension '" + key + "' is not of type " + type.getName()); + } + return type.cast(value); + } + + public boolean hasExtension(String key) { + return this.extensions.containsKey(key); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || this.getClass() != o.getClass()) { + return false; + } + SkillMetadata that = (SkillMetadata) o; + return Objects.equals(this.name, that.name) && Objects.equals(this.description, that.description) + && Objects.equals(this.source, that.source) && Objects.equals(this.extensions, that.extensions); + } + + @Override + public int hashCode() { + return Objects.hash(this.name, this.description, this.source, this.extensions); + } + + @Override + public String toString() { + return "SkillMetadata{" + "name='" + this.name + '\'' + ", description='" + this.description + '\'' + + ", source='" + this.source + '\'' + ", extensions=" + this.extensions + '}'; + } + + public static class Builder { + + private final String name; + + private final String description; + + private final String source; + + private final Map extensions = new HashMap<>(); + + private Builder(String name, String description, String source) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("name is required"); + } + if (description == null || description.isEmpty()) { + throw new IllegalArgumentException("description is required"); + } + if (source == null || source.isEmpty()) { + throw new IllegalArgumentException("source is required"); + } + this.name = name; + this.description = description; + this.source = source; + } + + public Builder extension(String key, Object value) { + this.extensions.put(key, value); + return this; + } + + public Builder extensions(Map extensions) { + this.extensions.putAll(extensions); + return this; + } + + public SkillMetadata build() { + return new SkillMetadata(this.name, this.description, this.source, this.extensions); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillPoolManager.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillPoolManager.java new file mode 100644 index 00000000000..b90bf8e5b62 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillPoolManager.java @@ -0,0 +1,92 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.skill.exception.SkillLoadException; +import org.springframework.ai.skill.exception.SkillNotFoundException; + +/** + * Skill pool manager for definition storage and instance lifecycle management. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public interface SkillPoolManager { + + /** + * Registers skill definition. + * @param definition skill definition + * @throws IllegalArgumentException if skillId already exists + */ + void registerDefinition(SkillDefinition definition); + + /** + * Unregisters skill completely (definition and instance). + * @param skillId skill ID + */ + void unregister(String skillId); + + /** + * Gets skill definition. + * @param skillId skill ID + * @return skill definition or null if not found + */ + @Nullable SkillDefinition getDefinition(String skillId); + + /** + * Checks if skill definition exists. + * @param skillId skill ID + * @return true if exists + */ + boolean hasDefinition(String skillId); + + /** + * Loads skill instance (singleton per skillId). + * @param skillId skill ID + * @return skill instance + * @throws SkillNotFoundException if skill not found + * @throws SkillLoadException if loading fails + */ + Skill load(String skillId); + + /** + * Gets all registered skill definitions. + * @return skill definition list (never null) + */ + List getDefinitions(); + + /** + * Evicts skill instance but retains definition. + * @param skillId skill ID + */ + void evict(String skillId); + + /** + * Evicts all skill instances but retains definitions. + */ + void evictAll(); + + /** + * Clears all definitions and instances. + */ + void clear(); + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillRegistrar.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillRegistrar.java new file mode 100644 index 00000000000..55c3f3b6a86 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/SkillRegistrar.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.core; + +/** + * Skill registrar interface for registering skills from various sources. + * + * @param source type (Class, Object, String, Path, etc.) + * @author LinPeng Zhang + * @since 1.1.3 + */ +public interface SkillRegistrar { + + /** + * Creates SkillDefinition from source and registers to SkillPoolManager. + * @param poolManager skill pool manager + * @param source registration source + * @return created SkillDefinition + * @throws IllegalArgumentException if source invalid + * @throws org.springframework.ai.skill.exception.SkillRegistrationException if + * registration fails + */ + SkillDefinition register(SkillPoolManager poolManager, T source); + + /** + * Checks if registrar supports given source. + * @param source source object + * @return true if supported + */ + boolean supports(Object source); + + /** + * Gets registrar name for logging. + * @return registrar name + */ + default String getName() { + return this.getClass().getSimpleName(); + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/package-info.java new file mode 100644 index 00000000000..486fbd5942b --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/core/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Core interfaces and implementations for skill management and lifecycle. + */ + +@NullMarked +package org.springframework.ai.skill.core; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillException.java new file mode 100644 index 00000000000..df31f38fe9e --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.exception; + +import org.jspecify.annotations.Nullable; + +/** + * Base exception for skill framework. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillException extends RuntimeException { + + public SkillException(String message) { + super(message); + } + + public SkillException(String message, @Nullable Throwable cause) { + super(message, cause); + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillInvocationException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillInvocationException.java new file mode 100644 index 00000000000..91486827e9d --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillInvocationException.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.exception; + +import org.jspecify.annotations.Nullable; + +/** + * Thrown when skill method invocation fails. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillInvocationException extends SkillException { + + private final String skillId; + + private final String methodName; + + public SkillInvocationException(String skillId, String methodName, String message, @Nullable Throwable cause) { + super("Failed to invoke method '" + methodName + "' on skill '" + skillId + "': " + message, cause); + this.skillId = skillId; + this.methodName = methodName; + } + + public String getSkillId() { + return this.skillId; + } + + public String getMethodName() { + return this.methodName; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillLoadException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillLoadException.java new file mode 100644 index 00000000000..d6b4d3acd53 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillLoadException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.exception; + +/** + * Thrown when skill loading fails. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillLoadException extends SkillException { + + private final String skillId; + + public SkillLoadException(String skillId, String message, Throwable cause) { + super("Failed to load skill '" + skillId + "': " + message, cause); + this.skillId = skillId; + } + + public String getSkillId() { + return this.skillId; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillNotFoundException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillNotFoundException.java new file mode 100644 index 00000000000..fe543c6425f --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillNotFoundException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.exception; + +/** + * Thrown when requested skill not found. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillNotFoundException extends SkillException { + + private final String skillId; + + public SkillNotFoundException(String skillId) { + super("Skill not found: '" + skillId + "'"); + this.skillId = skillId; + } + + public String getSkillId() { + return this.skillId; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillRegistrationException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillRegistrationException.java new file mode 100644 index 00000000000..48ed0df62a0 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillRegistrationException.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.exception; + +/** + * Thrown when skill registration fails. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillRegistrationException extends SkillException { + + private final String skillId; + + public SkillRegistrationException(String skillId, String message) { + super("Failed to register skill '" + skillId + "': " + message); + this.skillId = skillId; + } + + public SkillRegistrationException(String skillId, String message, Throwable cause) { + super("Failed to register skill '" + skillId + "': " + message, cause); + this.skillId = skillId; + } + + public String getSkillId() { + return this.skillId; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillValidationException.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillValidationException.java new file mode 100644 index 00000000000..05663b78657 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/SkillValidationException.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.exception; + +import org.jspecify.annotations.Nullable; + +/** + * Thrown when skill validation fails. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SkillValidationException extends SkillException { + + private final @Nullable String skillClass; + + public SkillValidationException(@Nullable String skillClass, String message) { + super("Validation failed" + (skillClass != null ? " for skill class '" + skillClass + "'" : "") + ": " + + message); + this.skillClass = skillClass; + } + + public @Nullable String getSkillClass() { + return this.skillClass; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/package-info.java new file mode 100644 index 00000000000..2e05632c485 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/exception/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Exception hierarchy for skill-related errors and failures. + */ + +@NullMarked +package org.springframework.ai.skill.exception; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/package-info.java new file mode 100644 index 00000000000..ebcba7edebd --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/package-info.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * The {@code org.springframework.ai.skill} package provides classes and interfaces for + * managing and executing skills (modular AI capabilities) in Spring AI applications. It + * includes core components for skill registration, lifecycle management, and progressive + * loading. + * + *

+ * Key classes and interfaces: + *

+ *
    + *
  • {@link org.springframework.ai.skill.core.SkillKit} - Main entry point for skill + * management, coordinating {@link org.springframework.ai.skill.core.SkillBox} and + * {@link org.springframework.ai.skill.core.SkillPoolManager}.
  • + *
  • {@link org.springframework.ai.skill.core.DefaultSkillKit} - Default implementation + * of {@link org.springframework.ai.skill.core.SkillKit}.
  • + *
  • {@link org.springframework.ai.skill.core.SkillBox} - Container for managing skill + * metadata and activation state.
  • + *
  • {@link org.springframework.ai.skill.support.SimpleSkillBox} - Default + * implementation of {@link org.springframework.ai.skill.core.SkillBox}.
  • + *
  • {@link org.springframework.ai.skill.core.SkillPoolManager} - Manages skill + * instances, definitions, and caching strategies.
  • + *
  • {@link org.springframework.ai.skill.support.DefaultSkillPoolManager} - Default + * implementation of {@link org.springframework.ai.skill.core.SkillPoolManager}.
  • + *
  • {@link org.springframework.ai.skill.core.Skill} - Core interface representing an + * executable skill with metadata, content, and tools.
  • + *
  • {@link org.springframework.ai.skill.core.SkillMetadata} - Immutable metadata + * describing a skill's name, description, source, and extensions.
  • + *
  • {@link org.springframework.ai.skill.core.SkillDefinition} - Defines how a skill + * should be loaded and managed.
  • + *
  • {@link org.springframework.ai.skill.core.SkillRegistrar} - SPI for pluggable skill + * registration strategies.
  • + *
  • {@link org.springframework.ai.skill.registration.ClassBasedSkillRegistrar} - + * Registers skills from annotated classes with {@code @SkillInit} factory methods.
  • + *
  • {@link org.springframework.ai.skill.registration.InstanceBasedSkillRegistrar} - + * Registers skills from pre-created instances annotated with {@code @Skill}.
  • + *
+ * + *

+ * Spring AI integration classes: + *

+ *
    + *
  • {@link org.springframework.ai.skill.spi.SkillAwareAdvisor} - Chat advisor for + * injecting skill system prompts and managing skill lifecycle.
  • + *
  • {@link org.springframework.ai.skill.spi.SkillAwareToolCallbackResolver} - Resolves + * tool callbacks dynamically from active skills.
  • + *
  • {@link org.springframework.ai.skill.spi.SkillAwareToolCallingManager} - Manages + * tool calling with skill-aware tool resolution.
  • + *
  • {@link org.springframework.ai.skill.tool.SimpleSkillLoaderTools} - Provides + * {@code loadSkillContent} and {@code loadSkillReference} tools for progressive skill + * loading.
  • + *
+ * + *

+ * Annotation-driven development: + *

+ *
    + *
  • {@link org.springframework.ai.skill.annotation.Skill} - Marks a class as a skill + * with metadata.
  • + *
  • {@link org.springframework.ai.skill.annotation.SkillInit} - Marks a static factory + * method for lazy skill instantiation.
  • + *
  • {@link org.springframework.ai.skill.annotation.SkillContent} - Marks a method that + * provides skill documentation.
  • + *
  • {@link org.springframework.ai.skill.annotation.SkillTools} - Marks a method that + * provides skill tool callbacks.
  • + *
+ * + *

+ * Exception hierarchy: + *

+ *
    + *
  • {@link org.springframework.ai.skill.exception.SkillException} - Base exception for + * all skill-related errors.
  • + *
  • {@link org.springframework.ai.skill.exception.SkillNotFoundException} - Thrown when + * a requested skill cannot be found.
  • + *
  • {@link org.springframework.ai.skill.exception.SkillLoadException} - Thrown when a + * skill fails to load.
  • + *
  • {@link org.springframework.ai.skill.exception.SkillRegistrationException} - Thrown + * when skill registration fails.
  • + *
  • {@link org.springframework.ai.skill.exception.SkillValidationException} - Thrown + * when skill validation fails.
  • + *
+ */ +@NullMarked +package org.springframework.ai.skill; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/ClassBasedSkillRegistrar.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/ClassBasedSkillRegistrar.java new file mode 100644 index 00000000000..de30cfc008d --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/ClassBasedSkillRegistrar.java @@ -0,0 +1,288 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.registration; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.skill.adapter.SkillProxy; +import org.springframework.ai.skill.annotation.SkillContent; +import org.springframework.ai.skill.annotation.SkillInit; +import org.springframework.ai.skill.annotation.SkillTools; +import org.springframework.ai.skill.capability.SkillReferences; +import org.springframework.ai.skill.common.LoadStrategy; +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillDefinition; +import org.springframework.ai.skill.core.SkillIdGenerator; +import org.springframework.ai.skill.core.SkillMetadata; +import org.springframework.ai.skill.core.SkillPoolManager; +import org.springframework.ai.skill.core.SkillRegistrar; +import org.springframework.ai.skill.exception.SkillRegistrationException; +import org.springframework.ai.skill.support.DefaultSkillIdGenerator; + +/** + * Class-based skill registrar for lazy-loaded POJO skills. + * + *

+ * Requirements: Class must be annotated with @Skill and have a @SkillInit static factory + * method. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class ClassBasedSkillRegistrar implements SkillRegistrar> { + + private final SkillIdGenerator idGenerator; + + private ClassBasedSkillRegistrar(Builder builder) { + this.idGenerator = (builder.idGenerator != null) ? builder.idGenerator : new DefaultSkillIdGenerator(); + } + + /** + * Creates a new builder instance. + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Finds and validates @SkillInit method. + * @param skillClass skill class + * @return validated @SkillInit method + * @throws IllegalArgumentException if method not found or invalid + */ + private static Method findAndValidateSkillInitMethod(Class skillClass) { + Method initMethod = null; + + for (Method method : skillClass.getDeclaredMethods()) { + if (method.isAnnotationPresent(SkillInit.class)) { + if (initMethod != null) { + throw new IllegalArgumentException("Class " + skillClass.getName() + + " must have exactly one @SkillInit method, but found multiple"); + } + initMethod = method; + } + } + + if (initMethod == null) { + throw new IllegalArgumentException( + "Class " + skillClass.getName() + " must have a @SkillInit annotated method. " + + "Example: @SkillInit public static MySkill create() { return new MySkill(); }"); + } + + Method method = initMethod; + + if (!Modifier.isStatic(method.getModifiers())) { + throw new IllegalArgumentException("@SkillInit method '" + method.getName() + "' in class " + + skillClass.getName() + " must be static. Example: public static MySkill create() { ... }"); + } + + Class returnType = method.getReturnType(); + if (!returnType.isAssignableFrom(skillClass)) { + throw new IllegalArgumentException("@SkillInit method '" + method.getName() + "' in class " + + skillClass.getName() + " must return type " + skillClass.getSimpleName() + + " (or its superclass), but returns " + returnType.getSimpleName()); + } + + method.setAccessible(true); + + return method; + } + + /** + * Builds Skill from instance. + * @param instance skill instance + * @param metadata skill metadata + * @return Skill instance (either direct or wrapped) + */ + protected static Skill buildSkillFromInstance(Object instance, SkillMetadata metadata) { + if (instance instanceof Skill) { + return (Skill) instance; + } + + Map extensionMethods = extractExtensionMethods(instance.getClass()); + return new SkillProxy(metadata, instance, extensionMethods); + } + + /** + * Extracts annotated extension methods from class. + * @param clazz class to scan + * @return extension method map + */ + protected static Map extractExtensionMethods(Class clazz) { + Map extensionMethods = new HashMap<>(); + + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(SkillContent.class)) { + extensionMethods.put("content", method); + } + if (method.isAnnotationPresent(SkillTools.class)) { + extensionMethods.put("tools", method); + } + if (method.isAnnotationPresent(SkillReferences.class)) { + extensionMethods.put("references", method); + } + } + + return extensionMethods; + } + + /** + * Extracts extension properties from @Skill annotation. + * @param skillAnnotation @Skill annotation + * @return extension properties map + */ + protected static Map extractExtensions( + org.springframework.ai.skill.annotation.Skill skillAnnotation) { + Map extensions = new HashMap<>(); + + for (String ext : skillAnnotation.extensions()) { + String[] parts = ext.split("=", 2); + if (parts.length == 2) { + extensions.put(parts[0].trim(), parts[1].trim()); + } + } + + return extensions; + } + + /** + * Checks if class has @SkillInit method. + * @param clazz class to check + * @return true if at least one @SkillInit method found + */ + private static boolean hasSkillInitMethod(Class clazz) { + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(SkillInit.class)) { + return true; + } + } + return false; + } + + /** + * Registers skill from class definition. + * @param poolManager skill pool manager + * @param skillClass skill class annotated with @Skill and @SkillInit + * @return created skill definition + * @throws IllegalArgumentException if class missing required annotations + * @throws SkillRegistrationException if registration fails + */ + @Override + public SkillDefinition register(SkillPoolManager poolManager, Class skillClass) { + Objects.requireNonNull(poolManager, "poolManager cannot be null"); + Objects.requireNonNull(skillClass, "skillClass cannot be null"); + + org.springframework.ai.skill.annotation.Skill skillAnnotation = skillClass + .getAnnotation(org.springframework.ai.skill.annotation.Skill.class); + + if (skillAnnotation == null) { + throw new IllegalArgumentException("Class " + skillClass.getName() + " must be annotated with @Skill"); + } + + Method initMethod = findAndValidateSkillInitMethod(skillClass); + + String source = skillAnnotation.source(); + + SkillMetadata metadata = SkillMetadata.builder(skillAnnotation.name(), skillAnnotation.description(), source) + .extensions(extractExtensions(skillAnnotation)) + .build(); + + String skillId = this.idGenerator.generateId(metadata); + + Supplier loader = () -> { + try { + Object instance = initMethod.invoke(null); + return buildSkillFromInstance(instance, metadata); + } + catch (Exception e) { + throw new SkillRegistrationException(metadata.getName(), + "Failed to instantiate skill class via @SkillInit method: " + skillClass.getName(), e); + } + }; + + SkillDefinition definition = SkillDefinition.builder() + .skillId(skillId) + .source(source) + .loader(loader) + .metadata(metadata) + .loadStrategy(LoadStrategy.LAZY) + .build(); + + poolManager.registerDefinition(definition); + + return definition; + } + + // ==================== SkillRegistrar Interface ==================== + + /** + * Checks if source is supported. + * @param source source object to check + * @return true if source is a Class with @Skill and @SkillInit + */ + @Override + public boolean supports(Object source) { + if (!(source instanceof Class clazz)) { + return false; + } + + if (!clazz.isAnnotationPresent(org.springframework.ai.skill.annotation.Skill.class)) { + return false; + } + + return hasSkillInitMethod(clazz); + } + + /** + * Builder for ClassBasedSkillRegistrar. + */ + public static class Builder { + + private @Nullable SkillIdGenerator idGenerator; + + private Builder() { + } + + /** + * Sets custom ID generator. + * @param idGenerator ID generator instance + * @return this builder + */ + public Builder idGenerator(SkillIdGenerator idGenerator) { + this.idGenerator = idGenerator; + return this; + } + + /** + * Builds the ClassBasedSkillRegistrar instance. + * @return new ClassBasedSkillRegistrar instance + */ + public ClassBasedSkillRegistrar build() { + return new ClassBasedSkillRegistrar(this); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/InstanceBasedSkillRegistrar.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/InstanceBasedSkillRegistrar.java new file mode 100644 index 00000000000..5aa0cf0c924 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/InstanceBasedSkillRegistrar.java @@ -0,0 +1,209 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.registration; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.ai.skill.adapter.SkillProxy; +import org.springframework.ai.skill.annotation.SkillContent; +import org.springframework.ai.skill.annotation.SkillTools; +import org.springframework.ai.skill.capability.SkillReferences; +import org.springframework.ai.skill.common.LoadStrategy; +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillDefinition; +import org.springframework.ai.skill.core.SkillIdGenerator; +import org.springframework.ai.skill.core.SkillMetadata; +import org.springframework.ai.skill.core.SkillPoolManager; +import org.springframework.ai.skill.core.SkillRegistrar; +import org.springframework.ai.skill.support.DefaultSkillIdGenerator; + +/** + * Instance-based skill registrar for pre-created POJO instances. + * + *

+ * Supports both interface mode (Skill implementation) and annotation mode (@Skill POJO). + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class InstanceBasedSkillRegistrar implements SkillRegistrar { + + private final SkillIdGenerator idGenerator; + + private InstanceBasedSkillRegistrar(Builder builder) { + this.idGenerator = (builder.idGenerator != null) ? builder.idGenerator : new DefaultSkillIdGenerator(); + } + + /** + * Creates a new builder instance. + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds Skill from instance. + * @param instance skill instance + * @param metadata skill metadata + * @return Skill instance (either direct or wrapped) + */ + protected static Skill buildSkillFromInstance(Object instance, SkillMetadata metadata) { + if (instance instanceof Skill) { + return (Skill) instance; + } + + Map extensionMethods = extractExtensionMethods(instance.getClass()); + return new SkillProxy(metadata, instance, extensionMethods); + } + + /** + * Extracts annotated extension methods from class. + * @param clazz class to scan + * @return extension method map + */ + protected static Map extractExtensionMethods(Class clazz) { + Map extensionMethods = new HashMap<>(); + + for (Method method : clazz.getDeclaredMethods()) { + if (method.isAnnotationPresent(SkillContent.class)) { + extensionMethods.put("content", method); + } + if (method.isAnnotationPresent(SkillTools.class)) { + extensionMethods.put("tools", method); + } + if (method.isAnnotationPresent(SkillReferences.class)) { + extensionMethods.put("references", method); + } + } + + return extensionMethods; + } + + /** + * Extracts extension properties from @Skill annotation. + * @param skillAnnotation @Skill annotation + * @return extension properties map + */ + protected static Map extractExtensions( + org.springframework.ai.skill.annotation.Skill skillAnnotation) { + Map extensions = new HashMap<>(); + + for (String ext : skillAnnotation.extensions()) { + String[] parts = ext.split("=", 2); + if (parts.length == 2) { + extensions.put(parts[0].trim(), parts[1].trim()); + } + } + + return extensions; + } + + /** + * Registers skill from instance. + * @param poolManager skill pool manager + * @param instance skill instance annotated with @Skill + * @return created skill definition + * @throws IllegalArgumentException if instance class missing @Skill annotation + */ + @Override + public SkillDefinition register(SkillPoolManager poolManager, Object instance) { + Objects.requireNonNull(poolManager, "poolManager cannot be null"); + Objects.requireNonNull(instance, "instance cannot be null"); + + Class skillClass = instance.getClass(); + org.springframework.ai.skill.annotation.Skill skillAnnotation = skillClass + .getAnnotation(org.springframework.ai.skill.annotation.Skill.class); + + if (skillAnnotation == null) { + throw new IllegalArgumentException( + "Instance class " + skillClass.getName() + " must be annotated with @Skill"); + } + + String source = skillAnnotation.source(); + + SkillMetadata metadata = SkillMetadata.builder(skillAnnotation.name(), skillAnnotation.description(), source) + .extensions(extractExtensions(skillAnnotation)) + .build(); + + String skillId = this.idGenerator.generateId(metadata); + + Supplier loader = () -> buildSkillFromInstance(instance, metadata); + + SkillDefinition definition = SkillDefinition.builder() + .skillId(skillId) + .source(source) + .loader(loader) + .metadata(metadata) + .loadStrategy(LoadStrategy.LAZY) + .build(); + + poolManager.registerDefinition(definition); + + return definition; + } + + /** + * Checks if source is supported. + * @param source source object to check + * @return true if source is non-null object with @Skill annotation + */ + @Override + public boolean supports(Object source) { + return source != null + && source.getClass().isAnnotationPresent(org.springframework.ai.skill.annotation.Skill.class); + } + + // ==================== SkillRegistrar Interface ==================== + + /** + * Builder for InstanceBasedSkillRegistrar. + */ + public static class Builder { + + private @Nullable SkillIdGenerator idGenerator; + + private Builder() { + } + + /** + * Sets custom ID generator. + * @param idGenerator ID generator instance + * @return this builder + */ + public Builder idGenerator(SkillIdGenerator idGenerator) { + this.idGenerator = idGenerator; + return this; + } + + /** + * Builds the InstanceBasedSkillRegistrar instance. + * @return new InstanceBasedSkillRegistrar instance + */ + public InstanceBasedSkillRegistrar build() { + return new InstanceBasedSkillRegistrar(this); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/package-info.java new file mode 100644 index 00000000000..94c2f2e9b28 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/registration/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Registration strategies for class-based and instance-based skill registration. + */ + +@NullMarked +package org.springframework.ai.skill.registration; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareAdvisor.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareAdvisor.java new file mode 100644 index 00000000000..a9980eee9ed --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareAdvisor.java @@ -0,0 +1,279 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.spi; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; + +import org.springframework.ai.chat.client.ChatClientMessageAggregator; +import org.springframework.ai.chat.client.ChatClientRequest; +import org.springframework.ai.chat.client.ChatClientResponse; +import org.springframework.ai.chat.client.advisor.api.CallAdvisor; +import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain; +import org.springframework.ai.chat.client.advisor.api.StreamAdvisor; +import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.skill.core.SkillKit; + +/** + * Skill Advisor - manages skill lifecycle and system prompt injection. + * + *

+ * This advisor implements CallAdvisor and StreamAdvisor to: + * + *

    + *
  • Inject skill system prompt before conversation + *
  • Clean up skill state after conversation + *
+ * + *

+ * Tool Injection: + *

    + *
  • Dynamic tool injection is handled by {@code SkillAwareToolCallingManager} + *
  • SkillAwareToolCallingManager.resolveToolDefinitions() provides tools to LLM + *
  • SkillAwareToolCallbackResolver.resolve() finds tools during execution + *
  • This advisor ONLY injects skill system prompt and manages skill lifecycle + *
+ * + *

+ * Usage with SkillAwareToolCallingManager (recommended): + * + *

{@code
+ * // 1. Create SkillKit and register skills
+ * SkillBox skillBox = new SimpleSkillBox();
+ * SkillPoolManager poolManager = new DefaultSkillPoolManager();
+ * SkillKit skillKit = new DefaultSkillKit(skillBox, poolManager);
+ * skillKit.registerSkill(metadata, loader);
+ *
+ * // 2. Create SkillAwareToolCallingManager for tool injection
+ * SkillAwareToolCallingManager toolManager =
+ *     SkillAwareToolCallingManager.builder()
+ *         .skillKit(skillKit)
+ *         .build();
+ *
+ * // 3. Create ChatModel with SkillAwareToolCallingManager
+ * OpenAiChatModel chatModel = OpenAiChatModel.builder()
+ *     .toolCallingManager(toolManager)  // Handles tool injection
+ *     .build();
+ *
+ * // 4. Create SkillAwareAdvisor for prompt injection
+ * SkillAwareAdvisor advisor = new SkillAwareAdvisor(skillKit);
+ *
+ * // Build ChatClient with ONLY the advisor (NO tools registered at build time!)
+ * ChatClient client = ChatClient.builder(chatModel)
+ *     .defaultSystem("Your base system prompt here")
+ *     .defaultAdvisors(advisor)
+ *     .build();
+ * }
+ * + *

+ * Alternative usage with static helpers (simpler): + * + *

{@code
+ * String systemPrompt = SkillAwareAdvisor.buildSystemPrompt(basePrompt, skillKit);
+ * ChatClient client = ChatClient.builder(chatModel)
+ *     .defaultSystem(systemPrompt)
+ *     .build();
+ * // Don't forget to cleanup after each turn:
+ * SkillAwareAdvisor.cleanupSkills(skillKit);
+ * }
+ */ +public class SkillAwareAdvisor implements CallAdvisor, StreamAdvisor { + + private static final Logger logger = LoggerFactory.getLogger(SkillAwareAdvisor.class); + + private final SkillKit skillKit; + + private SkillAwareAdvisor(Builder builder) { + this.skillKit = Objects.requireNonNull(builder.skillKit, "skillKit cannot be null"); + } + + /** + * Creates a new builder instance. + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Build system prompt with skill information (static utility method). + * + *

+ * This is a simplified helper method for cases where you don't want to use the full + * Advisor pattern. Useful for simple use cases or when you need manual control. + * @param basePrompt The base system prompt + * @param skillKit The SkillKit containing skill management and prompt generation + * @return Combined system prompt with skill information + */ + public static String buildSystemPrompt(String basePrompt, SkillKit skillKit) { + String skillPrompt = skillKit.getSkillSystemPrompt(); + + if (skillPrompt == null || skillPrompt.isEmpty()) { + return basePrompt; + } + + if (basePrompt == null || basePrompt.isEmpty()) { + return skillPrompt; + } + + return basePrompt + "\n\n" + skillPrompt; + } + + /** + * Cleanup skills after conversation (static utility method). + * + *

+ * This is a simplified helper method for cases where you don't want to use the full + * Advisor pattern. When using the Advisor pattern, cleanup is automatic. + * @param skillKit The SkillKit to clean up + */ + public static void cleanupSkills(SkillKit skillKit) { + skillKit.deactivateAllSkills(); + } + + @Override + public String getName() { + return "SpringAiSkillAdvisor"; + } + + @Override + public int getOrder() { + return 0; // Execute first to inject system prompt + } + + @Override + public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) { + ChatClientRequest modifiedRequest = this.injectSkillSystemPrompt(request); + + try { + ChatClientResponse response = chain.nextCall(modifiedRequest); + cleanupSkills(this.skillKit); + return response; + } + catch (Exception e) { + logger.error("Error in skill advisor", e); + cleanupSkills(this.skillKit); + throw e; + } + } + + @Override + public Flux adviseStream(ChatClientRequest request, StreamAdvisorChain chain) { + ChatClientRequest modifiedRequest = this.injectSkillSystemPrompt(request); + Flux responses = chain.nextStream(modifiedRequest); + return new ChatClientMessageAggregator().aggregateChatClientResponse(responses, + response -> cleanupSkills(this.skillKit)); + } + + // ==================== Static Helper Methods (Extension API) ==================== + + /** + * Inject skill system prompt into the request. + * + *

+ * This method: + *

    + *
  • Extracts base system prompt from request + *
  • Combines it with skill system prompt using buildSystemPrompt() + *
  • Returns modified request with updated system message + *
+ * + *

+ * Note: Tool injection is handled by + * {@code SkillAwareToolCallingManager.resolveToolDefinitions()}, not by this Advisor. + * @param request The original request + * @return Modified request with skill prompt + */ + private ChatClientRequest injectSkillSystemPrompt(ChatClientRequest request) { + // 1. Extract and combine system prompt + List messages = new ArrayList<>(request.prompt().getInstructions()); + + String basePrompt = ""; + boolean hasSystemMessage = false; + int systemMessageIndex = -1; + + for (int i = 0; i < messages.size(); i++) { + if (messages.get(i) instanceof SystemMessage systemMessage) { + String text = systemMessage.getText(); + basePrompt = (text != null) ? text : ""; + hasSystemMessage = true; + systemMessageIndex = i; + break; + } + } + + // Build combined prompt with skill information + String combinedPrompt = buildSystemPrompt(basePrompt, this.skillKit); + + // Replace existing system message or add new one + if (hasSystemMessage) { + messages.set(systemMessageIndex, new SystemMessage(combinedPrompt)); + } + else { + messages.add(0, new SystemMessage(combinedPrompt)); + } + + // 2. Create modified request with updated prompt + // Note: Tools are NOT injected here - SkillAwareToolCallingManager handles that! + ChatOptions originalOptions = request.prompt().getOptions(); + return request.mutate().prompt(new Prompt(messages, originalOptions)).build(); + } + + /** + * Builder for SkillAwareAdvisor. + */ + public static class Builder { + + private @Nullable SkillKit skillKit; + + private Builder() { + } + + /** + * Sets the skill kit. + * @param skillKit skill kit instance + * @return this builder + */ + public Builder skillKit(SkillKit skillKit) { + this.skillKit = skillKit; + return this; + } + + /** + * Builds the SkillAwareAdvisor instance. + * @return new SkillAwareAdvisor instance + */ + public SkillAwareAdvisor build() { + if (this.skillKit == null) { + throw new IllegalArgumentException("skillKit cannot be null"); + } + return new SkillAwareAdvisor(this); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallbackResolver.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallbackResolver.java new file mode 100644 index 00000000000..d09aa14d333 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallbackResolver.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.spi; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.skill.core.SkillKit; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.resolution.ToolCallbackResolver; + +/** + * Skill-aware ToolCallbackResolver - dynamically resolves tools from SkillKit. + * + *

+ * This resolver works with {@code SkillAwareToolCallingManager} to enable dynamic tool + * execution. When the LLM calls a tool, this resolver finds the tool callback from the + * SkillKit's active tools. + * + * @see ToolCallbackResolver + * @see SkillKit + */ +public class SkillAwareToolCallbackResolver implements ToolCallbackResolver { + + private static final Logger logger = LoggerFactory.getLogger(SkillAwareToolCallbackResolver.class); + + private final SkillKit skillKit; + + private SkillAwareToolCallbackResolver(SkillKit skillKit) { + this.skillKit = skillKit; + } + + /** + * Creates a new builder instance. + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public @Nullable ToolCallback resolve(String toolName) { + if (this.skillKit == null) { + logger.warn("No SkillKit configured"); + return null; + } + + List activeSkillCallbacks = this.skillKit.getAllActiveTools(); + for (ToolCallback callback : activeSkillCallbacks) { + if (callback != null && callback.getToolDefinition() != null + && toolName.equals(callback.getToolDefinition().name())) { + return callback; + } + } + + List defaultSkillTools = this.skillKit.getSkillLoaderTools(); + for (ToolCallback callback : defaultSkillTools) { + if (callback != null && callback.getToolDefinition() != null + && toolName.equals(callback.getToolDefinition().name())) { + return callback; + } + } + + logger.warn("Tool '{}' not found", toolName); + return null; + } + + /** + * Builder for SkillAwareToolCallbackResolver. + */ + public static class Builder { + + private @Nullable SkillKit skillKit; + + private Builder() { + } + + /** + * Sets the skill kit. + * @param skillKit skill kit instance + * @return this builder + */ + public Builder skillKit(SkillKit skillKit) { + this.skillKit = skillKit; + return this; + } + + /** + * Builds the SkillAwareToolCallbackResolver instance. + * @return new SkillAwareToolCallbackResolver instance + */ + public SkillAwareToolCallbackResolver build() { + if (this.skillKit == null) { + throw new IllegalArgumentException("skillKit cannot be null"); + } + return new SkillAwareToolCallbackResolver(this.skillKit); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallingManager.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallingManager.java new file mode 100644 index 00000000000..9b3973a3e11 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/SkillAwareToolCallingManager.java @@ -0,0 +1,235 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.spi; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.DefaultToolCallingManager; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.model.tool.ToolExecutionResult; +import org.springframework.ai.skill.core.SkillKit; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver; + +/** + * Skill-aware ToolCallingManager that automatically merges skill tools with base tools. + * + *

+ * Why We Need This: + * + *

+ * We implement {@code ToolCallingManager} interface and delegate to the underlying + * {@code ToolCallingManager} implementation while adding skill tool merging logic. + * + *

+ * How It Works: + * + *

+ * This class works together with {@code SkillAwareToolCallbackResolver}: + * + *

+ * 1. resolveToolDefinitions() - Provides tool definitions to LLM:
+ *    - Delegate to underlying ToolCallingManager to resolve base tool definitions
+ *    - Get default skill tools from skillKit.getSkillTools()
+ *    - Get active skill tools from skillKit.getAllActiveTools()
+ *    - Merge with deduplication by tool name (priority: base > default > active)
+ *    - Return merged ToolDefinition list
+ *
+ * 2. executeToolCalls() - Executes tool calls from LLM:
+ *    - Delegates to the underlying ToolCallingManager
+ *    - The delegate uses SkillAwareToolCallbackResolver
+ *    - SkillAwareToolCallbackResolver finds tools in skillKit.getAllActiveTools()
+ * 
+ * + * @see ToolCallingManager + * @see SkillKit + * @since 1.1.3 + */ +public class SkillAwareToolCallingManager implements ToolCallingManager { + + private static final Logger logger = LoggerFactory.getLogger(SkillAwareToolCallingManager.class); + + private final SkillKit skillKit; + + private final ToolCallingManager delegate; + + /** + * Create a SkillAwareToolCallingManager with custom delegate. + * @param skillKit The SkillKit containing skill management and tools + * @param delegate The underlying ToolCallingManager to delegate to + */ + public SkillAwareToolCallingManager(SkillKit skillKit, ToolCallingManager delegate) { + this.skillKit = skillKit; + this.delegate = delegate; + } + + /** + * Builder for SkillAwareToolCallingManager. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Resolve tool definitions by merging base tools with skill tools. + * + *

+ * This is the KEY method where we inject skill tools dynamically! + * @param chatOptions The chat options containing base tools + * @return Merged list of tool definitions (base + skill tools) + */ + @Override + public List resolveToolDefinitions(ToolCallingChatOptions chatOptions) { + // Delegate to the underlying ToolCallingManager to resolve base definitions + List baseDefinitions = this.delegate.resolveToolDefinitions(chatOptions); + + if (this.skillKit == null) { + logger.warn("No SkillKit configured, returning base tools only"); + return baseDefinitions != null ? baseDefinitions : List.of(); + } + + List defaultSkillToolCallbacks = this.skillKit.getSkillLoaderTools(); + List defaultSkillToolDefinitions = new ArrayList<>(); + for (ToolCallback callback : defaultSkillToolCallbacks) { + if (callback != null && callback.getToolDefinition() != null) { + defaultSkillToolDefinitions.add(callback.getToolDefinition()); + } + } + + List activeSkillCallbacks = this.skillKit.getAllActiveTools(); + + List skillDefinitions = new ArrayList<>(); + for (ToolCallback callback : activeSkillCallbacks) { + if (callback != null && callback.getToolDefinition() != null) { + skillDefinitions.add(callback.getToolDefinition()); + } + } + + return this.mergeToolDefinitions(baseDefinitions, defaultSkillToolDefinitions, skillDefinitions); + } + + /** + * Merge tool definitions with deduplication by tool name. + * + *

+ * Priority order: base > default > skills + *

    + *
  • Base tools take the highest precedence (from chatOptions)
  • + *
  • Default SkillTools added next (for progressive loading)
  • + *
  • Skill tools added last (from activated skills)
  • + *
+ * @param baseDefinitions Base tool definitions from chatOptions + * @param defaultSkillToolDefinitions Default SkillTools for progressive loading + * @param skillDefinitions Skill tool definitions from activated skills + * @return Merged list with no duplicates + */ + private List mergeToolDefinitions(List baseDefinitions, + List defaultSkillToolDefinitions, List skillDefinitions) { + + List merged = new ArrayList<>(); + Set toolNames = new HashSet<>(); + + for (ToolDefinition def : baseDefinitions) { + if (def != null && def.name() != null) { + merged.add(def); + toolNames.add(def.name()); + } + } + + for (ToolDefinition def : defaultSkillToolDefinitions) { + if (def != null && def.name() != null) { + if (!toolNames.contains(def.name())) { + merged.add(def); + toolNames.add(def.name()); + } + } + } + + for (ToolDefinition def : skillDefinitions) { + if (def != null && def.name() != null) { + if (!toolNames.contains(def.name())) { + merged.add(def); + toolNames.add(def.name()); + } + } + } + + return merged; + } + + /** + * Execute tool calls - delegate to the underlying ToolCallingManager. + * + *

+ * Tool execution logic is delegated to the underlying manager. The delegate uses + * {@code + * SkillAwareToolCallbackResolver} to find tool callbacks from both chatOptions and + * skillBox.getAllActiveTools(). + * @param prompt The prompt + * @param chatResponse The chat response containing tool calls + * @return Tool execution result + */ + @Override + public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse chatResponse) { + return this.delegate.executeToolCalls(prompt, chatResponse); + } + + public static class Builder { + + private @Nullable SkillKit skillKit; + + private @Nullable ToolCallingManager delegate; + + public Builder skillKit(SkillKit skillKit) { + this.skillKit = skillKit; + return this; + } + + public Builder delegate(ToolCallingManager delegate) { + this.delegate = delegate; + return this; + } + + public SkillAwareToolCallingManager build() { + if (this.skillKit == null) { + throw new IllegalArgumentException("skillKit cannot be null"); + } + ToolCallingManager delegateManager = this.delegate; + if (delegateManager == null) { + // Create delegate with SkillAwareToolCallbackResolver + delegateManager = DefaultToolCallingManager.builder() + .toolCallbackResolver(new DelegatingToolCallbackResolver( + List.of(SkillAwareToolCallbackResolver.builder().skillKit(this.skillKit).build()))) + .build(); + } + return new SkillAwareToolCallingManager(this.skillKit, delegateManager); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/package-info.java new file mode 100644 index 00000000000..a8d2dceee67 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/spi/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Service provider interfaces for Spring AI integration and extensibility. + */ + +@NullMarked +package org.springframework.ai.skill.spi; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillIdGenerator.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillIdGenerator.java new file mode 100644 index 00000000000..2b4a4b1099d --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillIdGenerator.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.support; + +import java.util.Objects; + +import org.springframework.ai.skill.core.SkillIdGenerator; +import org.springframework.ai.skill.core.SkillMetadata; + +/** + * Default skill ID generator using "{name}_{source}" format. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class DefaultSkillIdGenerator implements SkillIdGenerator { + + /** + * Generates skill ID in format: {name}_{source}. + * @param metadata skill metadata + * @return generated skill ID + * @throws IllegalArgumentException if metadata invalid + */ + @Override + public String generateId(SkillMetadata metadata) { + Objects.requireNonNull(metadata, "metadata cannot be null"); + + String name = metadata.getName(); + String source = metadata.getSource(); + + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("metadata.name cannot be null or empty"); + } + + if (source == null || source.trim().isEmpty()) { + throw new IllegalArgumentException("metadata.source cannot be null or empty"); + } + + return name + "_" + source; + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillPoolManager.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillPoolManager.java new file mode 100644 index 00000000000..5d51ff78aba --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/DefaultSkillPoolManager.java @@ -0,0 +1,173 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.support; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.skill.common.LoadStrategy; +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillDefinition; +import org.springframework.ai.skill.core.SkillPoolManager; +import org.springframework.ai.skill.exception.SkillLoadException; +import org.springframework.ai.skill.exception.SkillNotFoundException; + +/** + * Default implementation of SkillPoolManager with thread-safe caching. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class DefaultSkillPoolManager implements SkillPoolManager { + + private static final Logger logger = LoggerFactory.getLogger(DefaultSkillPoolManager.class); + + private final ConcurrentHashMap definitions = new ConcurrentHashMap<>(); + + private final ConcurrentHashMap skillPool = new ConcurrentHashMap<>(); + + @Override + public void registerDefinition(SkillDefinition definition) { + Objects.requireNonNull(definition, "definition cannot be null"); + + String skillId = definition.getSkillId(); + + if (this.definitions.containsKey(skillId)) { + throw new IllegalArgumentException("Skill with ID '" + skillId + "' is already registered"); + } + + this.definitions.put(skillId, definition); + + if (definition.getLoadStrategy() == LoadStrategy.EAGER) { + try { + Skill skill = this.createInstance(definition); + this.skillPool.put(skillId, skill); + } + catch (Exception e) { + this.definitions.remove(skillId); + throw new SkillLoadException(skillId, "EAGER loading failed during registration", e); + } + } + } + + @Override + public @Nullable SkillDefinition getDefinition(String skillId) { + return this.definitions.get(skillId); + } + + @Override + public boolean hasDefinition(String skillId) { + return this.definitions.containsKey(skillId); + } + + @Override + public Skill load(String skillId) { + Objects.requireNonNull(skillId, "skillId cannot be null"); + + SkillDefinition definition = this.definitions.get(skillId); + if (definition == null) { + throw new SkillNotFoundException(skillId); + } + + return this.doLoad(skillId, definition); + } + + private Skill doLoad(String skillId, SkillDefinition definition) { + return this.skillPool.computeIfAbsent(skillId, id -> this.createInstance(definition)); + } + + private Skill createInstance(SkillDefinition definition) { + try { + Skill skill = definition.getLoader().get(); + if (skill == null) { + throw new NullPointerException("Loader returned null"); + } + return skill; + } + catch (Exception e) { + throw new SkillLoadException(definition.getSkillId(), "Loader threw exception", e); + } + } + + @Override + public List getDefinitions() { + return List.copyOf(this.definitions.values()); + } + + /** + * Gets skill definitions by source. + * @param source source identifier + * @return list of skill definitions + */ + public List getDefinitionsBySource(String source) { + Objects.requireNonNull(source, "source cannot be null"); + + return this.definitions.values() + .stream() + .filter(def -> source.equals(def.getSource())) + .collect(Collectors.toList()); + } + + /** + * Gets skill definitions by name. + * @param name skill name + * @return list of skill definitions + */ + public List getDefinitionsByName(String name) { + Objects.requireNonNull(name, "name cannot be null"); + + return this.definitions.values() + .stream() + .filter(def -> name.equals(def.getMetadata().getName())) + .collect(Collectors.toList()); + } + + @Override + public void evict(String skillId) { + this.skillPool.remove(skillId); + } + + @Override + public void evictAll() { + this.skillPool.clear(); + } + + @Override + public void unregister(String skillId) { + Objects.requireNonNull(skillId, "skillId cannot be null"); + + this.skillPool.remove(skillId); + + SkillDefinition removedDefinition = this.definitions.remove(skillId); + if (removedDefinition == null) { + logger.warn("Attempted to unregister non-existent skill: {}", skillId); + } + } + + @Override + public void clear() { + this.definitions.clear(); + this.skillPool.clear(); + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/SimpleSkillBox.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/SimpleSkillBox.java new file mode 100644 index 00000000000..73111688af9 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/SimpleSkillBox.java @@ -0,0 +1,134 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.support; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.skill.core.SkillBox; +import org.springframework.ai.skill.core.SkillMetadata; + +/** + * Default implementation of SkillBox with thread-safe metadata storage. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SimpleSkillBox implements SkillBox { + + private static final Logger logger = LoggerFactory.getLogger(SimpleSkillBox.class); + + private final ConcurrentHashMap skills = new ConcurrentHashMap<>(); + + private final ConcurrentHashMap activated = new ConcurrentHashMap<>(); + + private final List sources = new ArrayList<>(); + + public SimpleSkillBox() { + this.sources.add("custom"); + } + + @Override + public void addSkill(String name, SkillMetadata metadata) { + Objects.requireNonNull(name, "name cannot be null"); + Objects.requireNonNull(metadata, "metadata cannot be null"); + + if (this.skills.containsKey(name)) { + throw new IllegalArgumentException("Skill with name '" + name + "' already exists in this SkillBox"); + } + + this.skills.put(name, metadata); + this.addSource(metadata.getSource()); + this.activated.putIfAbsent(name, false); + } + + @Override + public @Nullable SkillMetadata getMetadata(String name) { + return this.skills.get(name); + } + + @Override + public Map getAllMetadata() { + return Collections.unmodifiableMap(this.skills); + } + + @Override + public boolean exists(String name) { + return this.skills.containsKey(name); + } + + @Override + public int getSkillCount() { + return this.skills.size(); + } + + @Override + public void activateSkill(String name) { + this.activated.put(name, true); + } + + @Override + public void deactivateSkill(String name) { + this.activated.put(name, false); + } + + @Override + public void deactivateAllSkills() { + this.activated.replaceAll((k, v) -> false); + } + + @Override + public boolean isActivated(String name) { + return this.activated.getOrDefault(name, false); + } + + @Override + public Set getActivatedSkillNames() { + return this.activated.entrySet() + .stream() + .filter(Map.Entry::getValue) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + @Override + public List getSources() { + return this.sources; + } + + public void setSources(List sourceList) { + this.sources.clear(); + this.sources.addAll(sourceList); + } + + public void addSource(String source) { + if (!this.sources.contains(source)) { + this.sources.add(source); + } + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/package-info.java new file mode 100644 index 00000000000..b355eaaebec --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/support/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Support classes providing default implementations of core interfaces. + */ + +@NullMarked +package org.springframework.ai.skill.support; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/SimpleSkillLoaderTools.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/SimpleSkillLoaderTools.java new file mode 100644 index 00000000000..846cc12d7d9 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/SimpleSkillLoaderTools.java @@ -0,0 +1,180 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.tool; + +import java.util.Map; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.ai.skill.capability.ReferencesLoader; +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillKit; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; + +/** + * Tool for progressive skill loading via Spring AI @Tool annotations. + * + *

+ * Provides loadSkillContent and loadSkillReference methods for LLM to activate and access + * skills. + * + * @author LinPeng Zhang + * @since 1.1.3 + */ +public class SimpleSkillLoaderTools { + + private static final Logger logger = LoggerFactory.getLogger(SimpleSkillLoaderTools.class); + + private final SkillKit skillKit; + + private SimpleSkillLoaderTools(Builder builder) { + this.skillKit = Objects.requireNonNull(builder.skillKit, "skillKit cannot be null"); + } + + /** + * Creates a new builder instance. + * @return new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Loads skill content by name, activates skill, and returns its documentation. + * @param skillName skill name + * @return skill content or error message + */ + @Tool(description = "Load the content of a skill by its name. " + + "This activates the skill and returns its documentation. " + + "Use this when you need to understand what a skill does and how to use it.") + public String loadSkillContent(@ToolParam(description = "The name of the skill to load") String skillName) { + try { + if (skillName == null || skillName.trim().isEmpty()) { + return "Error: Missing or empty skill name"; + } + + if (!this.skillKit.exists(skillName)) { + return "Error: Skill not found in SkillBox: " + skillName + + ". Please use a skill that has been added to the SkillBox."; + } + + this.skillKit.activateSkill(skillName); + + Skill skill = this.skillKit.getSkillByName(skillName); + if (skill == null) { + return "Error: Skill not found: " + skillName; + } + + return skill.getContent(); + + } + catch (Exception e) { + logger.error("Error loading skill: {}", skillName, e); + return "Error loading skill: " + e.getMessage(); + } + } + + /** + * Loads specific reference from skill using reference key. + * @param skillName skill name with references + * @param referenceKey reference key from skill content + * @return reference content (URL, file path, or text) or error message + */ + @Tool(description = "ONLY use this tool when a skill's content explicitly mentions it has reference materials. " + + "Load a specific reference from a skill using the reference key mentioned in the skill's content. " + + "Returns reference content (URL, file path, or text string). " + + "Do NOT use this for regular skill operations - use skill's own tools instead.") + public String loadSkillReference(@ToolParam(description = "The skill name that has references") String skillName, + @ToolParam(description = "The exact reference key mentioned in the skill's content") String referenceKey) { + try { + if (skillName == null || skillName.trim().isEmpty()) { + return "Error: Missing or empty skill name"; + } + + if (referenceKey == null || referenceKey.trim().isEmpty()) { + return "Error: Missing or empty reference key"; + } + + if (!this.skillKit.exists(skillName)) { + return "Error: Skill not found in SkillBox: " + skillName + + ". Please use a skill that has been added to the SkillBox."; + } + + Skill skill = this.skillKit.getSkillByName(skillName); + if (skill == null) { + return "Error: Skill not found: " + skillName; + } + + if (!skill.supports(ReferencesLoader.class)) { + return "Error: Skill '" + skillName + "' does not have references. "; + } + + ReferencesLoader loader = skill.as(ReferencesLoader.class); + Map references = loader.getReferences(); + + if (!references.containsKey(referenceKey)) { + return "Error: Reference key '" + referenceKey + "' not found in skill '" + skillName + "'. " + + "Available keys: " + references.keySet(); + } + + return references.get(referenceKey); + + } + catch (Exception e) { + logger.error("Error loading skill reference: skill={}, key={}", skillName, referenceKey, e); + return "Error loading skill reference: " + e.getMessage(); + } + } + + /** + * Builder for SimpleSkillLoaderTool. + */ + public static class Builder { + + private @Nullable SkillKit skillKit; + + private Builder() { + } + + /** + * Sets the skill kit. + * @param skillKit skill kit instance + * @return this builder + */ + public Builder skillKit(SkillKit skillKit) { + this.skillKit = skillKit; + return this; + } + + /** + * Builds the SimpleSkillLoaderTool instance. + * @return new SimpleSkillLoaderTool instance + */ + public SimpleSkillLoaderTools build() { + if (this.skillKit == null) { + throw new IllegalArgumentException("skillKit cannot be null"); + } + return new SimpleSkillLoaderTools(this); + } + + } + +} diff --git a/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/package-info.java b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/package-info.java new file mode 100644 index 00000000000..c23e1ad5d07 --- /dev/null +++ b/spring-ai-skill/src/main/java/org/springframework/ai/skill/tool/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-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 + * + * 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. + */ + +/** + * Tool implementations for progressive skill loading and management. + */ + +@NullMarked +package org.springframework.ai.skill.tool; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/ClassBasedSkillRegistrarTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/ClassBasedSkillRegistrarTests.java new file mode 100644 index 00000000000..ec2dec87139 --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/ClassBasedSkillRegistrarTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.skill.core.SkillDefinition; +import org.springframework.ai.skill.core.SkillPoolManager; +import org.springframework.ai.skill.fixtures.WeatherSkill; +import org.springframework.ai.skill.registration.ClassBasedSkillRegistrar; +import org.springframework.ai.skill.support.DefaultSkillPoolManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link ClassBasedSkillRegistrar}. + * + * @author LinPeng Zhang + */ +class ClassBasedSkillRegistrarTests { + + private ClassBasedSkillRegistrar registrar; + + private SkillPoolManager poolManager; + + @BeforeEach + void setUp() { + this.registrar = ClassBasedSkillRegistrar.builder().build(); + this.poolManager = new DefaultSkillPoolManager(); + } + + @Test + void registerShouldRegisterValidSkillClass() { + SkillDefinition definition = this.registrar.register(this.poolManager, WeatherSkill.class); + + assertThat(definition).isNotNull(); + assertThat(definition.getMetadata().getName()).isEqualTo("weather"); + assertThat(this.poolManager.hasDefinition(definition.getSkillId())).isTrue(); + } + + @Test + void registerShouldValidateInputs() { + assertThatThrownBy(() -> this.registrar.register(null, WeatherSkill.class)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> this.registrar.register(this.poolManager, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> this.registrar.register(this.poolManager, String.class)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("@Skill"); + } + + @Test + void supportsShouldIdentifyValidClasses() { + assertThat(this.registrar.supports(WeatherSkill.class)).isTrue(); + assertThat(this.registrar.supports(String.class)).isFalse(); + assertThat(this.registrar.supports("not a class")).isFalse(); + assertThat(this.registrar.supports(null)).isFalse(); + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillKitTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillKitTests.java new file mode 100644 index 00000000000..f9a32b60efa --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillKitTests.java @@ -0,0 +1,239 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.skill.core.DefaultSkillKit; +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillBox; +import org.springframework.ai.skill.core.SkillMetadata; +import org.springframework.ai.skill.core.SkillPoolManager; +import org.springframework.ai.skill.exception.SkillRegistrationException; +import org.springframework.ai.skill.exception.SkillValidationException; +import org.springframework.ai.skill.fixtures.CalculatorSkill; +import org.springframework.ai.skill.fixtures.WeatherSkill; +import org.springframework.ai.skill.support.DefaultSkillPoolManager; +import org.springframework.ai.skill.support.SimpleSkillBox; +import org.springframework.ai.tool.ToolCallback; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link DefaultSkillKit}. + * + * @author LinPeng Zhang + */ +class DefaultSkillKitTests { + + private DefaultSkillKit skillKit; + + private SkillPoolManager poolManager; + + private SkillBox skillBox; + + @BeforeEach + void setUp() { + this.poolManager = new DefaultSkillPoolManager(); + SimpleSkillBox simpleSkillBox = new SimpleSkillBox(); + simpleSkillBox.setSources(List.of("custom", "example")); + this.skillBox = simpleSkillBox; + this.skillKit = DefaultSkillKit.builder().skillBox(this.skillBox).poolManager(this.poolManager).build(); + } + + @Test + void registerShouldValidateInputsAndRejectDuplicates() { + SkillMetadata metadata = SkillMetadata.builder("test", "description", "custom").build(); + + assertThatThrownBy(() -> this.skillKit.register(null, () -> new TestSkill(metadata))) + .isInstanceOf(SkillValidationException.class) + .hasMessageContaining("metadata cannot be null"); + assertThatThrownBy(() -> this.skillKit.register(metadata, null)).isInstanceOf(SkillValidationException.class) + .hasMessageContaining("loader cannot be null"); + + this.skillKit.register(metadata, () -> new TestSkill(metadata)); + assertThatThrownBy(() -> this.skillKit.register(metadata, () -> new TestSkill(metadata))) + .isInstanceOf(SkillRegistrationException.class) + .hasMessageContaining("already registered"); + } + + @Test + void registerFromInstanceShouldRegisterAnnotatedSkill() { + this.skillKit.register(new CalculatorSkill()); + + assertThat(this.skillKit.exists("calculator")).isTrue(); + assertThat(this.skillKit.getMetadata("calculator")).isNotNull(); + } + + @Test + void registerFromClassShouldRegisterClass() { + this.skillKit.register(WeatherSkill.class); + + assertThat(this.skillKit.exists("weather")).isTrue(); + Skill skill = this.skillKit.getSkillByName("weather"); + assertThat(skill).isNotNull(); + assertThat(skill.getTools()).isNotEmpty(); + } + + @Test + void deactivateAllSkillsShouldClearAllActivation() { + this.skillKit.register(new CalculatorSkill()); + this.skillKit.register(WeatherSkill.class); + this.skillKit.activateSkill("calculator"); + this.skillKit.activateSkill("weather"); + + this.skillKit.deactivateAllSkills(); + + assertThat(this.skillKit.isActivated("calculator")).isFalse(); + assertThat(this.skillKit.isActivated("weather")).isFalse(); + } + + @Test + void getSkillByNameShouldReturnSkillOrNull() { + this.skillKit.register(new CalculatorSkill()); + + Skill skill = this.skillKit.getSkillByName("calculator"); + assertThat(skill).isNotNull(); + assertThat(skill.getMetadata().getName()).isEqualTo("calculator"); + + Skill nonexistent = this.skillKit.getSkillByName("nonexistent"); + assertThat(nonexistent).isNull(); + } + + @Test + void getAllActiveToolsShouldReturnToolsFromActivatedSkills() { + this.skillKit.register(WeatherSkill.class); + this.skillKit.activateSkill("weather"); + + List tools = this.skillKit.getAllActiveTools(); + + assertThat(tools).isNotEmpty(); + } + + @Test + void getSkillSystemPromptShouldGeneratePromptContent() { + String emptyPrompt = this.skillKit.getSkillSystemPrompt(); + assertThat(emptyPrompt).isEmpty(); + + this.skillKit.register(new CalculatorSkill()); + String prompt = this.skillKit.getSkillSystemPrompt(); + assertThat(prompt).contains("calculator").contains("loadSkillContent"); + } + + @Test + void multiTenantShouldIsolateBoxButSharePoolManager() { + SkillBox tenant1Box = new SimpleSkillBox(); + SkillBox tenant2Box = new SimpleSkillBox(); + SkillPoolManager sharedPoolManager = new DefaultSkillPoolManager(); + + DefaultSkillKit tenant1Kit = DefaultSkillKit.builder() + .skillBox(tenant1Box) + .poolManager(sharedPoolManager) + .build(); + DefaultSkillKit tenant2Kit = DefaultSkillKit.builder() + .skillBox(tenant2Box) + .poolManager(sharedPoolManager) + .build(); + + tenant1Kit.register(new CalculatorSkill()); + + assertThat(tenant1Kit.exists("calculator")).isTrue(); + assertThat(tenant2Kit.exists("calculator")).isFalse(); + } + + @Test + void multiTenantShouldIsolateActivationState() { + SimpleSkillBox tenant1Box = new SimpleSkillBox(); + tenant1Box.setSources(List.of("example")); + SimpleSkillBox tenant2Box = new SimpleSkillBox(); + tenant2Box.setSources(List.of("example")); + SkillPoolManager sharedPoolManager = new DefaultSkillPoolManager(); + + DefaultSkillKit tenant1Kit = DefaultSkillKit.builder() + .skillBox(tenant1Box) + .poolManager(sharedPoolManager) + .build(); + DefaultSkillKit tenant2Kit = DefaultSkillKit.builder() + .skillBox(tenant2Box) + .poolManager(sharedPoolManager) + .build(); + + tenant1Kit.register(new CalculatorSkill()); + tenant1Kit.activateSkill("calculator"); + + tenant2Kit.addSkillToBox("calculator"); + + assertThat(tenant1Kit.isActivated("calculator")).isTrue(); + assertThat(tenant2Kit.isActivated("calculator")).isFalse(); + } + + @Test + void multiTenantShouldShareSingletonInstances() { + SimpleSkillBox tenant1Box = new SimpleSkillBox(); + tenant1Box.setSources(List.of("example")); + SimpleSkillBox tenant2Box = new SimpleSkillBox(); + tenant2Box.setSources(List.of("example")); + SkillPoolManager sharedPoolManager = new DefaultSkillPoolManager(); + + DefaultSkillKit tenant1Kit = DefaultSkillKit.builder() + .skillBox(tenant1Box) + .poolManager(sharedPoolManager) + .build(); + DefaultSkillKit tenant2Kit = DefaultSkillKit.builder() + .skillBox(tenant2Box) + .poolManager(sharedPoolManager) + .build(); + + tenant1Kit.register(new CalculatorSkill()); + tenant2Kit.addSkillToBox("calculator"); + + Skill skill1 = tenant1Kit.getSkillByName("calculator"); + Skill skill2 = tenant2Kit.getSkillByName("calculator"); + + assertThat(skill1).isSameAs(skill2); + } + + private static class TestSkill implements Skill { + + private final SkillMetadata metadata; + + TestSkill(SkillMetadata metadata) { + this.metadata = metadata; + } + + @Override + public SkillMetadata getMetadata() { + return this.metadata; + } + + @Override + public String getContent() { + return "Test content"; + } + + @Override + public List getTools() { + return List.of(); + } + + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillPoolManagerTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillPoolManagerTests.java new file mode 100644 index 00000000000..cab021eaaaf --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/DefaultSkillPoolManagerTests.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill; + +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.skill.common.LoadStrategy; +import org.springframework.ai.skill.core.Skill; +import org.springframework.ai.skill.core.SkillDefinition; +import org.springframework.ai.skill.core.SkillMetadata; +import org.springframework.ai.skill.exception.SkillLoadException; +import org.springframework.ai.skill.exception.SkillNotFoundException; +import org.springframework.ai.skill.support.DefaultSkillPoolManager; +import org.springframework.ai.tool.ToolCallback; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link DefaultSkillPoolManager}. + * + * @author LinPeng Zhang + */ +class DefaultSkillPoolManagerTests { + + private DefaultSkillPoolManager poolManager; + + private SkillMetadata testMetadata; + + @BeforeEach + void setUp() { + this.poolManager = new DefaultSkillPoolManager(); + this.testMetadata = SkillMetadata.builder("test", "Test skill", "spring").build(); + } + + @Test + void registerDefinitionShouldStoreDefinitionAndRejectDuplicates() { + SkillDefinition definition = createDefinition("test_spring"); + + this.poolManager.registerDefinition(definition); + + assertThat(this.poolManager.hasDefinition("test_spring")).isTrue(); + assertThat(this.poolManager.getDefinition("test_spring")).isNotNull(); + + assertThatThrownBy(() -> this.poolManager.registerDefinition(definition)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void lazyLoadingShouldCacheInstancesOnFirstCall() { + SkillDefinition definition = createDefinition("test_spring", LoadStrategy.LAZY); + this.poolManager.registerDefinition(definition); + + Skill skill1 = this.poolManager.load("test_spring"); + Skill skill2 = this.poolManager.load("test_spring"); + + assertThat(skill1).isNotNull(); + assertThat(skill1).isSameAs(skill2); + } + + @Test + void eagerLoadingShouldCacheInstancesImmediately() { + SkillDefinition definition = createDefinition("test_spring", LoadStrategy.EAGER); + + this.poolManager.registerDefinition(definition); + + Skill skill1 = this.poolManager.load("test_spring"); + Skill skill2 = this.poolManager.load("test_spring"); + assertThat(skill1).isSameAs(skill2); + } + + @Test + void loadShouldThrowExceptionForNonexistentSkill() { + assertThatThrownBy(() -> this.poolManager.load("nonexistent")).isInstanceOf(SkillNotFoundException.class); + } + + @Test + void loadShouldWrapLoaderExceptions() { + SkillDefinition definition = SkillDefinition.builder() + .skillId("test_spring") + .source("spring") + .metadata(this.testMetadata) + .loader(() -> { + throw new RuntimeException("Loader failed"); + }) + .build(); + this.poolManager.registerDefinition(definition); + + assertThatThrownBy(() -> this.poolManager.load("test_spring")).isInstanceOf(SkillLoadException.class) + .hasMessageContaining("Failed to load skill"); + } + + @Test + void evictShouldRemoveInstancesButKeepDefinitions() { + this.poolManager.registerDefinition(createDefinition("test1_spring")); + this.poolManager.registerDefinition(createDefinition("test2_spring")); + Skill skill1 = this.poolManager.load("test1_spring"); + + this.poolManager.evict("test1_spring"); + Skill skill1Reloaded = this.poolManager.load("test1_spring"); + assertThat(skill1).isNotSameAs(skill1Reloaded); + assertThat(this.poolManager.hasDefinition("test1_spring")).isTrue(); + + this.poolManager.evictAll(); + skill1Reloaded = this.poolManager.load("test1_spring"); + assertThat(this.poolManager.hasDefinition("test1_spring")).isTrue(); + } + + @Test + void unregisterAndClearShouldRemoveDefinitions() { + this.poolManager.registerDefinition(createDefinition("test1_spring")); + this.poolManager.registerDefinition(createDefinition("test2_spring")); + this.poolManager.load("test1_spring"); + + this.poolManager.unregister("test1_spring"); + assertThat(this.poolManager.hasDefinition("test1_spring")).isFalse(); + assertThatThrownBy(() -> this.poolManager.load("test1_spring")).isInstanceOf(SkillNotFoundException.class); + + this.poolManager.clear(); + assertThat(this.poolManager.getDefinitions()).isEmpty(); + } + + @Test + void getDefinitionsBySourceShouldFilterBySource() { + this.poolManager.registerDefinition(createDefinition("test1_spring", "spring")); + this.poolManager.registerDefinition(createDefinition("test2_spring", "spring")); + this.poolManager.registerDefinition(createDefinition("test3_official", "official")); + + List springSkills = this.poolManager.getDefinitionsBySource("spring"); + List officialSkills = this.poolManager.getDefinitionsBySource("official"); + + assertThat(springSkills).hasSize(2); + assertThat(officialSkills).hasSize(1); + } + + private SkillDefinition createDefinition(String skillId) { + return createDefinition(skillId, LoadStrategy.LAZY); + } + + private SkillDefinition createDefinition(String skillId, LoadStrategy strategy) { + return createDefinition(skillId, "spring", strategy); + } + + private SkillDefinition createDefinition(String skillId, String source) { + return createDefinition(skillId, source, LoadStrategy.LAZY); + } + + private SkillDefinition createDefinition(String skillId, String source, LoadStrategy strategy) { + return SkillDefinition.builder() + .skillId(skillId) + .source(source) + .metadata(this.testMetadata) + .loader(() -> new TestSkill(this.testMetadata)) + .loadStrategy(strategy) + .build(); + } + + private static class TestSkill implements Skill { + + private final SkillMetadata metadata; + + TestSkill(SkillMetadata metadata) { + this.metadata = metadata; + } + + @Override + public SkillMetadata getMetadata() { + return this.metadata; + } + + @Override + public String getContent() { + return "Test content"; + } + + @Override + public List getTools() { + return Collections.emptyList(); + } + + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/InstanceBasedSkillRegistrarTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/InstanceBasedSkillRegistrarTests.java new file mode 100644 index 00000000000..d26447aa86f --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/InstanceBasedSkillRegistrarTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.ai.skill.core.SkillDefinition; +import org.springframework.ai.skill.core.SkillPoolManager; +import org.springframework.ai.skill.fixtures.CalculatorSkill; +import org.springframework.ai.skill.registration.InstanceBasedSkillRegistrar; +import org.springframework.ai.skill.support.DefaultSkillPoolManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link InstanceBasedSkillRegistrar}. + * + * @author LinPeng Zhang + */ +class InstanceBasedSkillRegistrarTests { + + private InstanceBasedSkillRegistrar registrar; + + private SkillPoolManager poolManager; + + @BeforeEach + void setUp() { + this.registrar = InstanceBasedSkillRegistrar.builder().build(); + this.poolManager = new DefaultSkillPoolManager(); + } + + @Test + void registerShouldRegisterValidSkillInstance() { + CalculatorSkill skill = new CalculatorSkill(); + + SkillDefinition definition = this.registrar.register(this.poolManager, skill); + + assertThat(definition).isNotNull(); + assertThat(definition.getMetadata().getName()).isEqualTo("calculator"); + assertThat(this.poolManager.hasDefinition(definition.getSkillId())).isTrue(); + } + + @Test + void registerShouldValidateInputs() { + assertThatThrownBy(() -> this.registrar.register(null, new CalculatorSkill())) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> this.registrar.register(this.poolManager, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> this.registrar.register(this.poolManager, "not a skill")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("@Skill"); + } + + @Test + void supportsShouldIdentifyValidInstances() { + assertThat(this.registrar.supports(new CalculatorSkill())).isTrue(); + assertThat(this.registrar.supports("not a skill")).isFalse(); + assertThat(this.registrar.supports(null)).isFalse(); + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/SimpleSkillBoxTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SimpleSkillBoxTests.java new file mode 100644 index 00000000000..e2fc023c24f --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SimpleSkillBoxTests.java @@ -0,0 +1,136 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.skill.core.SkillMetadata; +import org.springframework.ai.skill.support.SimpleSkillBox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link SimpleSkillBox}. + * + * @author LinPeng Zhang + */ +class SimpleSkillBoxTests { + + @Test + void addSkillShouldStoreSkillMetadata() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + SkillMetadata metadata = SkillMetadata.builder("test", "description", "source").build(); + + skillBox.addSkill("test", metadata); + + assertThat(skillBox.exists("test")).isTrue(); + assertThat(skillBox.getMetadata("test")).isEqualTo(metadata); + assertThat(skillBox.getSkillCount()).isEqualTo(1); + } + + @Test + void addSkillShouldValidateInputs() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + SkillMetadata metadata = SkillMetadata.builder("test", "description", "source").build(); + + assertThatThrownBy(() -> skillBox.addSkill(null, metadata)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> skillBox.addSkill("test", null)).isInstanceOf(NullPointerException.class); + } + + @Test + void addSkillShouldRejectDuplicateName() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + SkillMetadata metadata1 = SkillMetadata.builder("test", "description1", "source").build(); + SkillMetadata metadata2 = SkillMetadata.builder("test", "description2", "source").build(); + + skillBox.addSkill("test", metadata1); + + assertThatThrownBy(() -> skillBox.addSkill("test", metadata2)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already exists"); + } + + @Test + void activationShouldToggleState() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + SkillMetadata metadata = SkillMetadata.builder("test", "description", "source").build(); + skillBox.addSkill("test", metadata); + + skillBox.activateSkill("test"); + assertThat(skillBox.isActivated("test")).isTrue(); + assertThat(skillBox.getActivatedSkillNames()).containsExactly("test"); + + skillBox.deactivateSkill("test"); + assertThat(skillBox.isActivated("test")).isFalse(); + assertThat(skillBox.getActivatedSkillNames()).isEmpty(); + } + + @Test + void deactivateAllSkillsShouldClearAllActivation() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + SkillMetadata metadata1 = SkillMetadata.builder("skill1", "description", "source").build(); + SkillMetadata metadata2 = SkillMetadata.builder("skill2", "description", "source").build(); + skillBox.addSkill("skill1", metadata1); + skillBox.addSkill("skill2", metadata2); + skillBox.activateSkill("skill1"); + skillBox.activateSkill("skill2"); + + skillBox.deactivateAllSkills(); + + assertThat(skillBox.isActivated("skill1")).isFalse(); + assertThat(skillBox.isActivated("skill2")).isFalse(); + assertThat(skillBox.getActivatedSkillNames()).isEmpty(); + } + + @Test + void isActivatedShouldReturnFalseForNonexistentSkill() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + + assertThat(skillBox.isActivated("nonexistent")).isFalse(); + } + + @Test + void sourceManagementShouldWorkCorrectly() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + + assertThat(skillBox.getSources()).containsExactly("custom"); + + skillBox.setSources(List.of("source1", "source2")); + assertThat(skillBox.getSources()).containsExactly("source1", "source2"); + + skillBox.addSource("source3"); + assertThat(skillBox.getSources()).contains("source1", "source2", "source3"); + } + + @Test + void getActivatedSkillNamesShouldReturnOnlyActiveSkills() { + SimpleSkillBox skillBox = new SimpleSkillBox(); + SkillMetadata metadata1 = SkillMetadata.builder("skill1", "description", "source").build(); + SkillMetadata metadata2 = SkillMetadata.builder("skill2", "description", "source").build(); + SkillMetadata metadata3 = SkillMetadata.builder("skill3", "description", "source").build(); + skillBox.addSkill("skill1", metadata1); + skillBox.addSkill("skill2", metadata2); + skillBox.addSkill("skill3", metadata3); + skillBox.activateSkill("skill1"); + skillBox.activateSkill("skill3"); + + assertThat(skillBox.getActivatedSkillNames()).containsExactlyInAnyOrder("skill1", "skill3"); + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/SkillMetadataTests.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SkillMetadataTests.java new file mode 100644 index 00000000000..758abe6caeb --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/SkillMetadataTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill; + +import org.junit.jupiter.api.Test; + +import org.springframework.ai.skill.core.SkillMetadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link SkillMetadata}. + * + * @author LinPeng Zhang + */ +class SkillMetadataTests { + + @Test + void builderShouldCreateMetadataWithRequiredFields() { + SkillMetadata metadata = SkillMetadata.builder("test-skill", "Test description", "spring").build(); + + assertThat(metadata.getName()).isEqualTo("test-skill"); + assertThat(metadata.getDescription()).isEqualTo("Test description"); + assertThat(metadata.getSource()).isEqualTo("spring"); + assertThat(metadata.getExtensions()).isEmpty(); + } + + @Test + void builderShouldStoreExtensions() { + SkillMetadata metadata = SkillMetadata.builder("skill", "description", "spring") + .extension("version", "1.0.0") + .extension("author", "test") + .build(); + + assertThat(metadata.getExtension("version")).isEqualTo("1.0.0"); + assertThat(metadata.getExtension("author")).isEqualTo("test"); + assertThat(metadata.getExtension("nonexistent")).isNull(); + } + + @Test + void builderShouldValidateRequiredFields() { + assertThatThrownBy(() -> SkillMetadata.builder(null, "description", "spring").build()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SkillMetadata.builder("", "description", "spring").build()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SkillMetadata.builder("name", null, "spring").build()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SkillMetadata.builder("name", "", "spring").build()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SkillMetadata.builder("name", "description", null).build()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SkillMetadata.builder("name", "description", "").build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void equalsShouldWorkCorrectly() { + SkillMetadata metadata1 = SkillMetadata.builder("name", "description", "spring") + .extension("key", "value") + .build(); + SkillMetadata metadata2 = SkillMetadata.builder("name", "description", "spring") + .extension("key", "value") + .build(); + SkillMetadata metadata3 = SkillMetadata.builder("different", "description", "spring").build(); + + assertThat(metadata1).isEqualTo(metadata2).hasSameHashCodeAs(metadata2); + assertThat(metadata1).isNotEqualTo(metadata3); + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/CalculatorSkill.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/CalculatorSkill.java new file mode 100644 index 00000000000..1b9a32375da --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/CalculatorSkill.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.fixtures; + +import org.springframework.ai.skill.annotation.Skill; +import org.springframework.ai.skill.annotation.SkillContent; + +/** + * Test Calculator Skill + * + *

+ * Simple POJO Skill for testing framework functionality + * + * @author Semir + */ +@Skill(name = "calculator", description = "A simple calculator skill for basic arithmetic operations", + source = "example", extensions = { "version=1.0.0", "author=Semir" }) +public class CalculatorSkill { + + public CalculatorSkill() { + } + + @SkillContent + public String content() { + return """ + # Calculator Skill + + A simple calculator skill that provides basic arithmetic operations. + + ## Features + + - Addition + - Subtraction + - Multiplication + - Division + + ## Usage + + Use this skill to perform basic calculations. + """; + } + +} diff --git a/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/WeatherSkill.java b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/WeatherSkill.java new file mode 100644 index 00000000000..92f121768dc --- /dev/null +++ b/spring-ai-skill/src/test/java/org/springframework/ai/skill/fixtures/WeatherSkill.java @@ -0,0 +1,141 @@ +/* + * Copyright 2025-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 + * + * 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 org.springframework.ai.skill.fixtures; + +import java.util.List; + +import org.springframework.ai.skill.annotation.Skill; +import org.springframework.ai.skill.annotation.SkillContent; +import org.springframework.ai.skill.annotation.SkillInit; +import org.springframework.ai.skill.annotation.SkillTools; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.annotation.Tool; + +/** + * Test Weather Skill POJO + * + *

+ * Weather skill using fixed data for testing + * + * @author Semir + */ +@Skill(name = "weather", description = "Provides weather information for cities around the world", source = "example", + extensions = { "version=1.0.0", "author=Semir", "category=information" }) +public class WeatherSkill { + + private WeatherSkill() { + } + + @SkillInit + public static WeatherSkill create() { + return new WeatherSkill(); + } + + @SkillContent + public String content() { + return """ + # Weather Skill + + Provides weather information for cities around the world. + + ## Features + + - Get current weather for any city + - Temperature in Celsius + - Weather conditions (Sunny, Rainy, Cloudy, etc.) + + ## Available Tools + + - `getWeather(city)` - Get current weather for a specific city + + ## Usage + + Ask me "What's the weather in Beijing?" or "Tell me the weather in New York". + """; + } + + @SkillTools + public List tools() { + return List.of(ToolCallbacks.from(this)); + } + + /** + * Get weather information for a specific city (uses fixed data for testing) + * @param city city name + * @return JSON string containing weather information + */ + @Tool(description = "Get current weather information for a specific city. Returns temperature in Celsius, weather condition, humidity percentage, and wind speed in km/h.") + public String getWeather(String city) { + if (city == null || city.trim().isEmpty()) { + return "{\"error\": \"City name is required\"}"; + } + + // Use fixed data for test verification + int temperature; + String condition; + int humidity; + int windSpeed; + + switch (city.toLowerCase()) { + case "beijing": + temperature = 25; + condition = "Sunny"; + humidity = 60; + windSpeed = 15; + break; + case "new york": + temperature = 20; + condition = "Cloudy"; + humidity = 70; + windSpeed = 20; + break; + case "london": + temperature = 15; + condition = "Rainy"; + humidity = 85; + windSpeed = 25; + break; + case "tokyo": + temperature = 22; + condition = "Partly Cloudy"; + humidity = 65; + windSpeed = 10; + break; + case "paris": + temperature = 18; + condition = "Foggy"; + humidity = 75; + windSpeed = 12; + break; + default: + temperature = 20; + condition = "Clear"; + humidity = 50; + windSpeed = 10; + break; + } + + return String.format( + "{\"city\": \"%s\", \"temperature\": %d, \"unit\": \"Celsius\", \"condition\": \"%s\", " + + "\"humidity\": %d, \"windSpeed\": %d, \"windUnit\": \"km/h\", " + + "\"description\": \"The weather in %s is %s with a temperature of %d°C, " + + "humidity at %d%%, and wind speed of %d km/h.\"}", + city, temperature, condition, humidity, windSpeed, city, condition, temperature, humidity, windSpeed); + } + +} diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index f277367b0dd..6551ce6d930 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -97,5 +97,7 @@ + +