Skip to content

Commit f743240

Browse files
feat: Add Skill extension for Spring AI
Fixes #5293 Introduce a comprehensive skill extension that enables dynamic skill loading, activation, and progressive skill integration with LLM conversations. Key Features: - SkillKit: Central coordination of skill lifecycle management - SkillBox: Skill metadata storage and activation state tracking - SkillPoolManager: Lazy/eager skill instance loading and caching - Progressive loading: LLM can discover and load skills on-demand - Multi-tenant support: Isolated skill activation per conversation - Flexible registration: Support class-based and instance-based skills Components: - Core: SkillKit, SkillBox, SkillPoolManager, SkillRegistrar - SPI: SkillAwareAdvisor, SkillAwareToolCallingManager - Annotations: @Skill, @skillinit, @SkillContent, @skilltools - Tools: Progressive skill loader tools for LLM interaction == Semir Group Contribution == Developed by Semir Lab Team Part of Semir's open source technology initiative Original implementation: https://github.com/semir-lab/spring-ai-skill-extension Signed-off-by: LinPeng Zhang <zhanglinpeng@semir.com>
1 parent 3a17631 commit f743240

55 files changed

Lines changed: 5261 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
<module>spring-ai-test</module>
4040
<module>spring-ai-vector-store</module>
4141
<module>spring-ai-rag</module>
42+
<module>spring-ai-skill</module>
4243
<module>advisors/spring-ai-advisors-vector-store</module>
4344

4445
<module>memory/repository/spring-ai-model-chat-memory-repository-cassandra</module>

spring-ai-skill/pom.xml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!--
3+
~ Copyright 2025-2026 Semir Group and the original author or authors.
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ https://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
18+
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
19+
xmlns="http://maven.apache.org/POM/4.0.0"
20+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
21+
<modelVersion>4.0.0</modelVersion>
22+
<parent>
23+
<groupId>org.springframework.ai</groupId>
24+
<artifactId>spring-ai-parent</artifactId>
25+
<version>2.0.0-SNAPSHOT</version>
26+
</parent>
27+
<artifactId>spring-ai-skill</artifactId>
28+
<packaging>jar</packaging>
29+
<name>Spring AI Skill</name>
30+
<description>Skill management extension for Spring AI applications</description>
31+
<url>https://github.com/spring-projects/spring-ai</url>
32+
33+
<scm>
34+
<url>https://github.com/spring-projects/spring-ai</url>
35+
<connection>git://github.com/spring-projects/spring-ai.git</connection>
36+
<developerConnection>git@github.com:spring-projects/spring-ai.git</developerConnection>
37+
</scm>
38+
39+
<properties>
40+
<maven.compiler.target>17</maven.compiler.target>
41+
<maven.compiler.source>17</maven.compiler.source>
42+
</properties>
43+
44+
<dependencies>
45+
<dependency>
46+
<groupId>org.springframework.ai</groupId>
47+
<artifactId>spring-ai-client-chat</artifactId>
48+
<version>${project.parent.version}</version>
49+
</dependency>
50+
51+
<!-- test dependencies -->
52+
<dependency>
53+
<groupId>org.springframework.boot</groupId>
54+
<artifactId>spring-boot-starter-test</artifactId>
55+
<scope>test</scope>
56+
</dependency>
57+
</dependencies>
58+
59+
</project>
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
* Copyright 2025-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.skill.adapter;
18+
19+
import java.lang.reflect.Method;
20+
import java.lang.reflect.Proxy;
21+
import java.util.Collections;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.Objects;
25+
import java.util.concurrent.ConcurrentHashMap;
26+
27+
import org.jspecify.annotations.Nullable;
28+
29+
import org.springframework.ai.skill.core.Skill;
30+
import org.springframework.ai.skill.core.SkillMetadata;
31+
import org.springframework.ai.skill.core.SkillRegistrar;
32+
import org.springframework.ai.skill.exception.SkillInvocationException;
33+
import org.springframework.ai.tool.ToolCallback;
34+
35+
/**
36+
* Proxy-based Skill implementation wrapping user POJOs annotated with {@code @Skill}.
37+
*
38+
* <p>
39+
* This class acts as a dynamic proxy that delegates method calls to the underlying
40+
* annotated POJO instance using reflection. It supports capability extension through JDK
41+
* dynamic proxies.
42+
*
43+
* <p>
44+
* INTERNAL USE ONLY: Framework internal class subject to change.
45+
*
46+
* @author LinPeng Zhang
47+
* @see Skill
48+
* @see SkillRegistrar
49+
* @since 1.1.3
50+
*/
51+
public final class SkillProxy implements Skill {
52+
53+
private final SkillMetadata metadata;
54+
55+
private final Object delegate;
56+
57+
private final Map<String, Method> extensionMethods;
58+
59+
private final Map<Class<?>, Object> capabilityProxies = new ConcurrentHashMap<>();
60+
61+
/**
62+
* Constructor.
63+
* @param metadata skill metadata
64+
* @param delegate user POJO instance
65+
* @param extensionMethods extension method mappings
66+
*/
67+
public SkillProxy(SkillMetadata metadata, Object delegate, Map<String, Method> extensionMethods) {
68+
this.metadata = Objects.requireNonNull(metadata, "metadata cannot be null");
69+
this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null");
70+
this.extensionMethods = Objects.requireNonNull(extensionMethods, "extensionMethods cannot be null");
71+
}
72+
73+
@Override
74+
public SkillMetadata getMetadata() {
75+
return this.metadata;
76+
}
77+
78+
/**
79+
* Gets skill content.
80+
* @return skill content
81+
* @throws UnsupportedOperationException if no @SkillContent method found
82+
* @throws SkillInvocationException if invocation fails
83+
*/
84+
@Override
85+
public String getContent() {
86+
Method contentMethod = this.extensionMethods.get("content");
87+
if (contentMethod == null) {
88+
throw new UnsupportedOperationException(
89+
"Skill '" + this.getName() + "' does not have @SkillContent annotated method");
90+
}
91+
92+
try {
93+
contentMethod.setAccessible(true);
94+
Object result = contentMethod.invoke(this.delegate);
95+
96+
// Validate return type
97+
if (result == null) {
98+
throw new SkillInvocationException(this.getName(), contentMethod.getName(),
99+
"@SkillContent method '" + contentMethod.getName() + "' returned null. "
100+
+ "The method must return a non-null String value.",
101+
null);
102+
}
103+
104+
if (!(result instanceof String)) {
105+
throw new SkillInvocationException(this.getName(), contentMethod.getName(),
106+
"@SkillContent method '" + contentMethod.getName() + "' must return String, " + "but returned "
107+
+ result.getClass().getName() + ". " + "Ensure the method signature is: public String "
108+
+ contentMethod.getName() + "()",
109+
null);
110+
}
111+
112+
return (String) result;
113+
}
114+
catch (SkillInvocationException e) {
115+
throw e; // Re-throw our custom exception
116+
}
117+
catch (Exception e) {
118+
throw new SkillInvocationException(this.getName(), contentMethod.getName(),
119+
"Failed to invoke @SkillContent method '" + contentMethod.getName() + "' on skill '"
120+
+ this.getName() + "'. " + "Ensure the method is accessible and does not throw exceptions.",
121+
e);
122+
}
123+
}
124+
125+
/**
126+
* Gets tool callbacks.
127+
* @return tool callbacks (empty if no @SkillTools method)
128+
* @throws SkillInvocationException if invocation fails
129+
*/
130+
@Override
131+
@SuppressWarnings("unchecked")
132+
public List<ToolCallback> getTools() {
133+
Method toolsMethod = this.extensionMethods.get("tools");
134+
if (toolsMethod == null) {
135+
return Collections.emptyList(); // No @SkillTools method, return empty list
136+
}
137+
138+
try {
139+
toolsMethod.setAccessible(true);
140+
Object result = toolsMethod.invoke(this.delegate);
141+
142+
// Validate return type
143+
if (result == null) {
144+
throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
145+
"@SkillTools method '" + toolsMethod.getName() + "' returned null. "
146+
+ "The method must return a non-null List<ToolCallback>.",
147+
null);
148+
}
149+
150+
if (!(result instanceof List<?> list)) {
151+
throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
152+
"@SkillTools method '" + toolsMethod.getName() + "' must return List<ToolCallback>, "
153+
+ "but returned " + result.getClass().getName() + ". "
154+
+ "Ensure the method signature is: public List<ToolCallback> " + toolsMethod.getName()
155+
+ "()",
156+
null);
157+
}
158+
159+
// Check list contents ( the best effort - generics are erased at runtime)
160+
if (!list.isEmpty()) {
161+
Object firstElement = list.get(0);
162+
if (firstElement != null && !(firstElement instanceof ToolCallback)) {
163+
throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
164+
"@SkillTools method '" + toolsMethod.getName()
165+
+ "' returned a list containing invalid elements. "
166+
+ "Expected List<ToolCallback>, but found element of type "
167+
+ firstElement.getClass().getName() + ". "
168+
+ "Ensure all list elements are instances of ToolCallback.",
169+
null);
170+
}
171+
}
172+
173+
return (List<ToolCallback>) result;
174+
}
175+
catch (SkillInvocationException e) {
176+
throw e; // Re-throw our custom exception
177+
}
178+
catch (Exception e) {
179+
throw new SkillInvocationException(this.getName(), toolsMethod.getName(),
180+
"Failed to invoke @SkillTools method '" + toolsMethod.getName() + "' on skill '" + this.getName()
181+
+ "'. " + "Ensure the method is accessible and does not throw exceptions.",
182+
e);
183+
}
184+
}
185+
186+
@Override
187+
public <T> boolean supports(Class<T> capabilityType) {
188+
if (!capabilityType.isInterface()) {
189+
return false;
190+
}
191+
192+
String expectedKey = this.getCapabilityKey(capabilityType);
193+
return expectedKey != null && this.extensionMethods.containsKey(expectedKey);
194+
}
195+
196+
@Override
197+
@SuppressWarnings("unchecked")
198+
public <T> T as(Class<T> capabilityType) {
199+
if (!capabilityType.isInterface()) {
200+
throw new IllegalArgumentException("Capability must be an interface: " + capabilityType.getName());
201+
}
202+
203+
if (!this.supports(capabilityType)) {
204+
throw new UnsupportedOperationException(
205+
"Skill '" + this.getName() + "' does not support capability: " + capabilityType.getName());
206+
}
207+
208+
return (T) this.capabilityProxies.computeIfAbsent(capabilityType, this::createCapabilityProxy);
209+
}
210+
211+
/**
212+
* Creates dynamic proxy for capability interface.
213+
* @param capability capability interface
214+
* @return proxy instance
215+
*/
216+
private Object createCapabilityProxy(Class<?> capability) {
217+
return Proxy.newProxyInstance(capability.getClassLoader(), new Class<?>[] { capability },
218+
(proxy, method, args) -> {
219+
String key = this.methodToExtensionKey(method);
220+
Method extensionMethod = this.extensionMethods.get(key);
221+
222+
if (extensionMethod == null) {
223+
throw new UnsupportedOperationException("No implementation found for method '"
224+
+ method.getName() + "' in capability interface '" + capability.getName() + "' "
225+
+ "for skill '" + this.getName() + "'. " + "Expected an annotated method with key '"
226+
+ key + "'.");
227+
}
228+
229+
try {
230+
extensionMethod.setAccessible(true);
231+
return extensionMethod.invoke(this.delegate, args);
232+
}
233+
catch (Exception e) {
234+
throw new SkillInvocationException(this.getName(), extensionMethod.getName(),
235+
"Failed to invoke capability method '" + method.getName() + "' (mapped to '"
236+
+ extensionMethod.getName() + "') " + "for capability '" + capability.getName()
237+
+ "' " + "on skill '" + this.getName() + "'.",
238+
e);
239+
}
240+
});
241+
}
242+
243+
private @Nullable String getCapabilityKey(Class<?> capability) {
244+
Method[] methods = capability.getDeclaredMethods();
245+
if (methods.length == 0) {
246+
return null;
247+
}
248+
return this.methodToExtensionKey(methods[0]);
249+
}
250+
251+
private String methodToExtensionKey(Method method) {
252+
String methodName = method.getName();
253+
if (methodName.startsWith("get") && methodName.length() > 3) {
254+
return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
255+
}
256+
return methodName;
257+
}
258+
259+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2025-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Adapter classes for skill proxy and dynamic invocation.
19+
*/
20+
21+
@NullMarked
22+
package org.springframework.ai.skill.adapter;
23+
24+
import org.jspecify.annotations.NullMarked;

0 commit comments

Comments
 (0)