Skip to content

Commit dffa16f

Browse files
authored
feat(skill): add git skill repository extension (#690)
## AgentScope-Java Version [The version of AgentScope-Java you are working on, e.g. 1.0.8, check your pom.xml dependency version or run `mvn dependency:tree | grep agentscope-parent:pom`(only mac/linux)] ## Description - add SkillFileSystemHelper and migrate filesystem behaviors/tests - refactor GitSkillRepository to use helper and support custom source - introduce ClasspathSkillRepository; deprecate Jar adapters - update docs for classpath/git repositories and source behavior ## Checklist Please check the following items before code is ready to be reviewed. - [x] Code has been formatted with `mvn spotless:apply` - [x] All tests are passing (`mvn test`) - [x] Javadoc comments are complete and follow project conventions - [x] Related documentation has been updated (e.g. links, examples, etc.) - [x] Code is ready for review
1 parent 5e0e69a commit dffa16f

File tree

17 files changed

+2391
-1008
lines changed

17 files changed

+2391
-1008
lines changed

agentscope-core/src/main/java/io/agentscope/core/skill/repository/AgentSkillRepository.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* AgentSkill skill = repo.getByName("calculate").orElseThrow();
3232
* }</pre>
3333
*/
34-
public interface AgentSkillRepository {
34+
public interface AgentSkillRepository extends AutoCloseable {
3535

3636
/**
3737
* Gets a skill by its name.
@@ -125,6 +125,7 @@ public interface AgentSkillRepository {
125125
* <p>Implementations should override this method if they need to release resources
126126
* such as network connections, file handles, or caches.
127127
*/
128+
@Override
128129
default void close() {
129130
// Default implementation does nothing
130131
}
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/*
2+
* Copyright 2024-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+
* http://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+
package io.agentscope.core.skill.repository;
17+
18+
import io.agentscope.core.skill.AgentSkill;
19+
import io.agentscope.core.skill.util.SkillFileSystemHelper;
20+
import java.io.IOException;
21+
import java.net.URI;
22+
import java.net.URISyntaxException;
23+
import java.net.URL;
24+
import java.nio.file.FileSystem;
25+
import java.nio.file.FileSystems;
26+
import java.nio.file.Path;
27+
import java.util.Collections;
28+
import java.util.List;
29+
import java.util.concurrent.atomic.AtomicBoolean;
30+
import org.slf4j.Logger;
31+
import org.slf4j.LoggerFactory;
32+
33+
/**
34+
* ClasspathSkillRepository - Loads skills from classpath resources and JAR files.
35+
*
36+
* <p>This repository bridges the gap between JAR resources and file system based skill loading by:
37+
* <ol>
38+
* <li>Creating a virtual file system for resources within JAR files</li>
39+
* <li>Obtaining Path objects for skill directories</li>
40+
* <li>Delegating skill I/O to {@link SkillFileSystemHelper}</li>
41+
* </ol>
42+
*
43+
* <p><b>Important:</b> Skills must be organized in a parent directory structure. You should pass
44+
* the parent directory name (not individual skill paths). This is required because the repository
45+
* scans a directory for multiple skill subdirectories.
46+
*
47+
* <p><b>Directory Structure:</b>
48+
* <pre>
49+
* resources/
50+
* └── skills/ ← Pass "skills" to repository
51+
* ├── skill-a/
52+
* │ └── SKILL.md
53+
* ├── skill-b/
54+
* │ └── SKILL.md
55+
* └── skill-c/
56+
* └── SKILL.md
57+
* </pre>
58+
*
59+
* <p><b>Usage example:</b>
60+
* <pre>{@code
61+
* // Load from parent directory containing multiple skills
62+
* try (ClasspathSkillRepository repository = new ClasspathSkillRepository("skills")) {
63+
* // Get all available skill names
64+
* List<String> skillNames = repository.getAllSkillNames();
65+
*
66+
* // Load a specific skill
67+
* AgentSkill skillA = repository.getSkill("skill-a");
68+
*
69+
* // Load all skills at once
70+
* List<AgentSkill> allSkills = repository.getAllSkills();
71+
* }
72+
* }</pre>
73+
*/
74+
public class ClasspathSkillRepository implements AgentSkillRepository {
75+
76+
private final Logger logger = LoggerFactory.getLogger(ClasspathSkillRepository.class);
77+
78+
private final FileSystem fileSystem;
79+
private final Path skillBasePath;
80+
private final boolean isJar;
81+
private final String source;
82+
private final String resourcePath;
83+
private final AtomicBoolean closed = new AtomicBoolean(false);
84+
85+
/**
86+
* Creates a repository for loading skills from resources.
87+
*
88+
* @param resourcePath The path to the skill under resources, e.g., "writing-skills"
89+
* @throws IOException if initialization fails
90+
*/
91+
public ClasspathSkillRepository(String resourcePath) throws IOException {
92+
this(resourcePath, null, ClasspathSkillRepository.class.getClassLoader());
93+
}
94+
95+
/**
96+
* Creates a repository for loading skills from resources with a custom source.
97+
*
98+
* @param resourcePath The path to the skill under resources, e.g., "writing-skills"
99+
* @param source The custom source identifier (null to use default)
100+
* @throws IOException if initialization fails
101+
*/
102+
public ClasspathSkillRepository(String resourcePath, String source) throws IOException {
103+
this(resourcePath, source, ClasspathSkillRepository.class.getClassLoader());
104+
}
105+
106+
/**
107+
* Creates a repository for loading skills from resources using a specific ClassLoader.
108+
*
109+
* @param resourcePath The path to the skill under resources, e.g., "writing-skills"
110+
* @param classLoader The ClassLoader to use for loading resources
111+
* @throws IOException if initialization fails
112+
*/
113+
protected ClasspathSkillRepository(String resourcePath, ClassLoader classLoader)
114+
throws IOException {
115+
this(resourcePath, null, classLoader);
116+
}
117+
118+
/**
119+
* Creates a repository for loading skills from resources using a specific ClassLoader.
120+
*
121+
* @param resourcePath The path to the skill under resources, e.g., "writing-skills"
122+
* @param source The custom source identifier (null to use default)
123+
* @param classLoader The ClassLoader to use for loading resources
124+
* @throws IOException if initialization fails
125+
*/
126+
protected ClasspathSkillRepository(String resourcePath, String source, ClassLoader classLoader)
127+
throws IOException {
128+
try {
129+
this.resourcePath = resourcePath;
130+
URL resourceUrl = classLoader.getResource(resourcePath);
131+
logger.info("Resource URL: {}", resourceUrl);
132+
133+
if (resourceUrl == null) {
134+
throw new IOException("Resource not found: " + resourcePath);
135+
}
136+
137+
URI uri = resourceUrl.toURI();
138+
139+
if ("jar".equals(uri.getScheme())) {
140+
this.isJar = true;
141+
this.fileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap());
142+
String actualResourcePath = uri.getSchemeSpecificPart().split("!")[1];
143+
logger.info("Actual resource path: {}", actualResourcePath);
144+
this.skillBasePath = fileSystem.getPath(actualResourcePath);
145+
} else {
146+
this.isJar = false;
147+
this.fileSystem = null;
148+
this.skillBasePath = Path.of(uri);
149+
}
150+
logger.info("is in Jar environment: {}", this.isJar);
151+
this.source = source != null ? source : buildDefaultSource(resourcePath);
152+
153+
} catch (URISyntaxException e) {
154+
throw new IOException("Invalid resource URI", e);
155+
}
156+
}
157+
158+
/**
159+
* Gets a skill by name.
160+
*
161+
* @param skillName The skill name (from SKILL.md metadata)
162+
* @return The loaded AgentSkill object
163+
* @throws IllegalStateException if the repository has been closed
164+
*/
165+
@Override
166+
public AgentSkill getSkill(String skillName) {
167+
checkNotClosed();
168+
return SkillFileSystemHelper.loadSkill(skillBasePath, skillName, source);
169+
}
170+
171+
/**
172+
* Gets all skill names available in the repository.
173+
*
174+
* @return A sorted list of skill names
175+
* @throws IllegalStateException if the repository has been closed
176+
*/
177+
@Override
178+
public List<String> getAllSkillNames() {
179+
checkNotClosed();
180+
return SkillFileSystemHelper.getAllSkillNames(skillBasePath);
181+
}
182+
183+
/**
184+
* Gets all skills available in the repository.
185+
*
186+
* @return A list of all loaded AgentSkill objects
187+
* @throws IllegalStateException if the repository has been closed
188+
*/
189+
@Override
190+
public List<AgentSkill> getAllSkills() {
191+
checkNotClosed();
192+
return SkillFileSystemHelper.getAllSkills(skillBasePath, source);
193+
}
194+
195+
@Override
196+
public boolean save(List<AgentSkill> skills, boolean force) {
197+
logger.warn("ClasspathSkillRepository is read-only, save operation ignored");
198+
return false;
199+
}
200+
201+
@Override
202+
public boolean delete(String skillName) {
203+
logger.warn("ClasspathSkillRepository is read-only, delete operation ignored");
204+
return false;
205+
}
206+
207+
@Override
208+
public boolean skillExists(String skillName) {
209+
checkNotClosed();
210+
return SkillFileSystemHelper.skillExists(skillBasePath, skillName);
211+
}
212+
213+
@Override
214+
public AgentSkillRepositoryInfo getRepositoryInfo() {
215+
return new AgentSkillRepositoryInfo("classpath", resourcePath, false);
216+
}
217+
218+
@Override
219+
public String getSource() {
220+
return source;
221+
}
222+
223+
private String buildDefaultSource(String resourcePath) {
224+
String normalized = resourcePath == null ? "" : resourcePath.replace('\\', '/');
225+
String trimmed =
226+
normalized.endsWith("/")
227+
? normalized.substring(0, normalized.length() - 1)
228+
: normalized;
229+
230+
if (trimmed.isEmpty()) {
231+
return "classpath:unknown";
232+
}
233+
234+
int lastSlash = trimmed.lastIndexOf('/');
235+
if (lastSlash < 0) {
236+
return "classpath:" + trimmed;
237+
}
238+
239+
int secondLastSlash = trimmed.lastIndexOf('/', lastSlash - 1);
240+
if (secondLastSlash < 0) {
241+
return "classpath:" + trimmed.substring(lastSlash + 1);
242+
}
243+
244+
return "classpath:" + trimmed.substring(secondLastSlash + 1);
245+
}
246+
247+
@Override
248+
public void setWriteable(boolean writeable) {
249+
logger.warn("ClasspathSkillRepository is read-only, set writeable operation ignored");
250+
}
251+
252+
@Override
253+
public boolean isWriteable() {
254+
return false;
255+
}
256+
257+
/**
258+
* Checks if running in a JAR environment.
259+
*
260+
* @return true if running from JAR, false if in development environment
261+
*/
262+
public boolean isJarEnvironment() {
263+
return isJar;
264+
}
265+
266+
private void checkNotClosed() {
267+
if (closed.get()) {
268+
throw new IllegalStateException("ClasspathSkillRepository has been closed");
269+
}
270+
}
271+
272+
/**
273+
* Closes the file system (if in JAR environment).
274+
*
275+
* <p>This method is idempotent - it can be safely called multiple times.
276+
* Designed for use with try-with-resources.
277+
*/
278+
@Override
279+
public void close() {
280+
if (!closed.compareAndSet(false, true)) {
281+
return;
282+
}
283+
if (fileSystem != null) {
284+
try {
285+
fileSystem.close();
286+
} catch (IOException e) {
287+
throw new RuntimeException("Failed to close classpath file system", e);
288+
}
289+
}
290+
}
291+
}

0 commit comments

Comments
 (0)