Skip to content

Commit 63e222c

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add ClassPathSkillSource to load skills from the Java classpath
Introduces ClassPathSkillSource to the ADK core library to support loading skills directly from the Java classpath. This enables unified and incremental loading of skills, avoiding duplicate logic in downstream clients. PiperOrigin-RevId: 925512341
1 parent 0a40557 commit 63e222c

8 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
17+
package com.google.adk.skills;
18+
19+
import static com.google.adk.skills.SkillSourceException.RESOURCE_NOT_FOUND;
20+
import static com.google.adk.skills.SkillSourceException.SKILL_LOAD_ERROR;
21+
import static com.google.adk.skills.SkillSourceException.SKILL_NOT_FOUND;
22+
import static com.google.common.collect.ImmutableList.toImmutableList;
23+
24+
import com.google.common.base.Ascii;
25+
import com.google.common.base.Splitter;
26+
import com.google.common.collect.ImmutableList;
27+
import com.google.common.collect.ImmutableMap;
28+
import com.google.common.reflect.ClassPath;
29+
import com.google.common.reflect.ClassPath.ResourceInfo;
30+
import io.reactivex.rxjava3.core.Flowable;
31+
import io.reactivex.rxjava3.core.Single;
32+
import java.io.IOException;
33+
import java.nio.channels.Channels;
34+
import java.nio.channels.ReadableByteChannel;
35+
import java.util.HashMap;
36+
import java.util.List;
37+
import java.util.Map;
38+
import java.util.Objects;
39+
import java.util.Optional;
40+
41+
/** Loads skills from the classpath. */
42+
public final class ClassPathSkillSource extends AbstractSkillSource<ResourceInfo> {
43+
44+
private static final Splitter PATH_SPLITTER = Splitter.on('/');
45+
46+
private final String baseResourcePath;
47+
private final ClassLoader classLoader;
48+
private final Single<ImmutableMap<String, ResourceInfo>> skillMdsSingle;
49+
private final Single<ImmutableList<ResourceInfo>> allResourcesSingle;
50+
51+
/**
52+
* Creates a new {@link ClassPathSkillSource} that loads skills from the given base resource path
53+
* using the current thread's context class loader.
54+
*
55+
* @param baseResourcePath the base classpath path to scan for skills (e.g., "skills/")
56+
*/
57+
public ClassPathSkillSource(String baseResourcePath) {
58+
this(
59+
baseResourcePath,
60+
Objects.requireNonNullElse(
61+
Thread.currentThread().getContextClassLoader(),
62+
ClassPathSkillSource.class.getClassLoader()));
63+
}
64+
65+
/**
66+
* Creates a new {@link ClassPathSkillSource} that loads skills from the given base resource path
67+
* using the specified {@link ClassLoader}.
68+
*
69+
* @param baseResourcePath the base classpath path to scan for skills
70+
* @param classLoader the class loader to use for scanning resources
71+
*/
72+
public ClassPathSkillSource(String baseResourcePath, ClassLoader classLoader) {
73+
this.baseResourcePath = normalizePath(baseResourcePath);
74+
this.classLoader = classLoader;
75+
76+
// Scan classpath once (lazily)
77+
Single<ImmutableList<ResourceInfo>> scanned = Single.fromCallable(this::scanClassPath).cache();
78+
this.allResourcesSingle = scanned;
79+
this.skillMdsSingle = scanned.map(this::extractSkillMds).cache();
80+
}
81+
82+
private static String normalizePath(String path) {
83+
if (path.isEmpty()) {
84+
return "";
85+
}
86+
if (path.endsWith("/")) {
87+
return path;
88+
}
89+
return path + "/";
90+
}
91+
92+
private ImmutableList<ResourceInfo> scanClassPath() throws SkillSourceException {
93+
try {
94+
ClassPath classPath = ClassPath.from(classLoader);
95+
return classPath.getResources().stream()
96+
.filter(info -> info.getResourceName().startsWith(baseResourcePath))
97+
.collect(toImmutableList());
98+
} catch (IOException e) {
99+
throw new SkillSourceException(
100+
"Failed to scan classpath under " + baseResourcePath, SKILL_LOAD_ERROR, e);
101+
}
102+
}
103+
104+
private ImmutableMap<String, ResourceInfo> extractSkillMds(ImmutableList<ResourceInfo> resources)
105+
throws SkillSourceException {
106+
Map<String, ResourceInfo> skillMdMap = new HashMap<>();
107+
for (ResourceInfo info : resources) {
108+
String relPath = info.getResourceName().substring(baseResourcePath.length());
109+
List<String> parts = PATH_SPLITTER.splitToList(relPath);
110+
// Check if the path format matches exactly {skillName}/SKILL.md (or skill.md
111+
// case-insensitively).
112+
if (parts.size() == 2 && Ascii.equalsIgnoreCase(parts.get(1), "SKILL.md")) {
113+
String skillName = parts.get(0);
114+
String logicalName = skillName.replace('_', '-');
115+
if (skillMdMap.containsKey(logicalName)) {
116+
ResourceInfo existing = skillMdMap.get(logicalName);
117+
throw new SkillSourceException(
118+
"Conflicting SKILL.md files found for skill '"
119+
+ logicalName
120+
+ "': "
121+
+ existing.getResourceName()
122+
+ " and "
123+
+ info.getResourceName(),
124+
SKILL_LOAD_ERROR);
125+
}
126+
skillMdMap.put(logicalName, info);
127+
}
128+
}
129+
return ImmutableMap.copyOf(skillMdMap);
130+
}
131+
132+
@Override
133+
public Single<ImmutableList<String>> listResources(String skillName, String resourceDirectory) {
134+
String logicalSkillName = skillName.replace('_', '-');
135+
String prefix =
136+
resourceDirectory.isEmpty()
137+
? ""
138+
: (resourceDirectory.endsWith("/") ? resourceDirectory : resourceDirectory + "/");
139+
140+
// Support both standard ADK hyphenated directories and legacy underscore directories.
141+
String hyphenatedDir = logicalSkillName;
142+
String underscoredDir = logicalSkillName.replace('-', '_');
143+
144+
return allResourcesSingle.map(
145+
resources ->
146+
resources.stream()
147+
.map(info -> info.getResourceName().substring(baseResourcePath.length()))
148+
.filter(
149+
relPath ->
150+
relPath.startsWith(hyphenatedDir + "/" + prefix)
151+
|| relPath.startsWith(underscoredDir + "/" + prefix))
152+
.map(
153+
relPath -> {
154+
if (relPath.startsWith(hyphenatedDir + "/")) {
155+
return relPath.substring(hyphenatedDir.length() + 1);
156+
} else {
157+
return relPath.substring(underscoredDir.length() + 1);
158+
}
159+
})
160+
.filter(path -> !Ascii.equalsIgnoreCase(path, "SKILL.md"))
161+
.collect(toImmutableList()));
162+
}
163+
164+
@Override
165+
protected Flowable<SkillMdPath<ResourceInfo>> listSkills() {
166+
return skillMdsSingle
167+
.flattenAsFlowable(ImmutableMap::entrySet)
168+
.map(entry -> new SkillMdPath<>(entry.getKey(), entry.getValue()));
169+
}
170+
171+
@Override
172+
protected Single<ResourceInfo> findSkillMdPath(String skillName) {
173+
String logicalSkillName = skillName.replace('_', '-');
174+
return skillMdsSingle
175+
.map(map -> Optional.ofNullable(map.get(logicalSkillName)))
176+
.flatMap(
177+
opt ->
178+
opt.isPresent()
179+
? Single.just(opt.get())
180+
: Single.error(
181+
new SkillSourceException(
182+
"SKILL.md not found for skill: " + logicalSkillName, SKILL_NOT_FOUND)));
183+
}
184+
185+
@Override
186+
protected Single<ResourceInfo> findResourcePath(String skillName, String resourcePath) {
187+
String logicalSkillName = skillName.replace('_', '-');
188+
// Support both standard ADK hyphenated directories and legacy underscore directories.
189+
String hyphenatedDir = logicalSkillName;
190+
String underscoredDir = logicalSkillName.replace('-', '_');
191+
192+
String hyphenatedPath = baseResourcePath + hyphenatedDir + "/" + resourcePath;
193+
String underscoredPath = baseResourcePath + underscoredDir + "/" + resourcePath;
194+
195+
return allResourcesSingle
196+
.map(
197+
resources ->
198+
resources.stream()
199+
.filter(
200+
info ->
201+
info.getResourceName().equals(hyphenatedPath)
202+
|| info.getResourceName().equals(underscoredPath))
203+
.findFirst())
204+
.flatMap(
205+
opt ->
206+
opt.isPresent()
207+
? Single.just(opt.get())
208+
: Single.error(
209+
new SkillSourceException(
210+
"Resource not found: "
211+
+ resourcePath
212+
+ " for skill: "
213+
+ logicalSkillName,
214+
RESOURCE_NOT_FOUND)));
215+
}
216+
217+
@Override
218+
protected ReadableByteChannel openChannel(ResourceInfo path) throws IOException {
219+
return Channels.newChannel(path.asByteSource().openStream());
220+
}
221+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2026 Google LLC
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+
17+
package com.google.adk.skills;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertThrows;
21+
22+
import com.google.common.collect.ImmutableList;
23+
import com.google.common.collect.ImmutableMap;
24+
import com.google.common.io.ByteSource;
25+
import java.nio.charset.StandardCharsets;
26+
import org.junit.Test;
27+
import org.junit.runner.RunWith;
28+
import org.junit.runners.JUnit4;
29+
30+
@RunWith(JUnit4.class)
31+
public final class ClassPathSkillSourceTest {
32+
33+
private static final String BASE_PATH = "com/google/adk/skills/testdata/skills/";
34+
35+
@Test
36+
public void testListFrontmatters() {
37+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
38+
ImmutableMap<String, Frontmatter> skills = source.listFrontmatters().blockingGet();
39+
40+
assertThat(skills).hasSize(3);
41+
assertThat(skills).containsKey("skill-1");
42+
assertThat(skills).containsKey("skill-2");
43+
assertThat(skills).containsKey("skill-3");
44+
assertThat(skills.get("skill-1").description()).isEqualTo("test classpath skill 1");
45+
assertThat(skills.get("skill-2").description()).isEqualTo("test classpath skill 2");
46+
assertThat(skills.get("skill-3").description())
47+
.isEqualTo("test classpath skill 3 with underscores");
48+
}
49+
50+
@Test
51+
public void testLoadFrontmatter() {
52+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
53+
Frontmatter fm = source.loadFrontmatter("skill-1").blockingGet();
54+
55+
assertThat(fm.name()).isEqualTo("skill-1");
56+
assertThat(fm.description()).isEqualTo("test classpath skill 1");
57+
}
58+
59+
@Test
60+
public void testLoadFrontmatter_underscoreMapping() {
61+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
62+
Frontmatter fm = source.loadFrontmatter("skill-3").blockingGet();
63+
64+
assertThat(fm.name()).isEqualTo("skill-3");
65+
assertThat(fm.description()).isEqualTo("test classpath skill 3 with underscores");
66+
}
67+
68+
@Test
69+
public void testLoadInstructions() {
70+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
71+
String instructions = source.loadInstructions("skill-1").blockingGet();
72+
73+
assertThat(instructions).isEqualTo("body 1");
74+
}
75+
76+
@Test
77+
public void testListResources() {
78+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
79+
ImmutableList<String> resources = source.listResources("skill-2", "assets").blockingGet();
80+
assertThat(resources).containsExactly("assets/spec/spec.txt");
81+
}
82+
83+
@Test
84+
public void testListResources_excludesSkillMd() {
85+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
86+
ImmutableList<String> resources = source.listResources("skill-1", "").blockingGet();
87+
assertThat(resources).containsExactly("resource/extra.txt");
88+
}
89+
90+
@Test
91+
public void testLoadResource() throws Exception {
92+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
93+
ByteSource byteSource = source.loadResource("skill-2", "assets/spec/spec.txt").blockingGet();
94+
assertThat(byteSource.asCharSource(StandardCharsets.UTF_8).read().trim())
95+
.isEqualTo("A spec file");
96+
}
97+
98+
@Test
99+
public void testLoadResource_underscoreMapping() throws Exception {
100+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
101+
ByteSource byteSource = source.loadResource("skill-3", "resource/dummy.txt").blockingGet();
102+
assertThat(byteSource.asCharSource(StandardCharsets.UTF_8).read().trim())
103+
.isEqualTo("dummy content");
104+
}
105+
106+
@Test
107+
public void testLoadResource_notFound() {
108+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
109+
var single = source.loadResource("skill-1", "non-existent.txt");
110+
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
111+
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
112+
SkillSourceException cause = (SkillSourceException) exception.getCause();
113+
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
114+
}
115+
116+
@Test
117+
public void testLoadFrontmatter_skillNotFound() {
118+
SkillSource source = new ClassPathSkillSource(BASE_PATH);
119+
var single = source.loadFrontmatter("non-existent");
120+
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
121+
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
122+
SkillSourceException cause = (SkillSourceException) exception.getCause();
123+
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.SKILL_NOT_FOUND);
124+
}
125+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
name: skill-1
3+
description: test classpath skill 1
4+
---
5+
6+
body 1
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
An extra resource
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
name: skill-2
3+
description: test classpath skill 2
4+
---
5+
6+
body 2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A spec file
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
name: skill-3
3+
description: test classpath skill 3 with underscores
4+
---
5+
6+
body 3
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dummy content

0 commit comments

Comments
 (0)