Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions core/src/main/java/com/google/adk/skills/ClassPathSkillSource.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*
* Copyright 2026 Google LLC
*
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.skills;

import static com.google.adk.skills.SkillSourceException.RESOURCE_NOT_FOUND;
import static com.google.adk.skills.SkillSourceException.SKILL_LOAD_ERROR;
import static com.google.adk.skills.SkillSourceException.SKILL_NOT_FOUND;
import static com.google.common.collect.ImmutableList.toImmutableList;

import com.google.common.base.Ascii;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.reflect.ClassPath;
import com.google.common.reflect.ClassPath.ResourceInfo;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import java.io.IOException;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

/** Loads skills from the classpath. */
public final class ClassPathSkillSource extends AbstractSkillSource<ResourceInfo> {

private static final Splitter PATH_SPLITTER = Splitter.on('/');

private final String baseResourcePath;
private final ClassLoader classLoader;
private final Single<ImmutableMap<String, ResourceInfo>> skillMdsSingle;
private final Single<ImmutableList<ResourceInfo>> allResourcesSingle;

/**
* Creates a new {@link ClassPathSkillSource} that loads skills from the given base resource path
* using the current thread's context class loader.
*
* @param baseResourcePath the base classpath path to scan for skills (e.g., "skills/")
*/
public ClassPathSkillSource(String baseResourcePath) {
this(
baseResourcePath,
Objects.requireNonNullElse(
Thread.currentThread().getContextClassLoader(),
ClassPathSkillSource.class.getClassLoader()));
}

/**
* Creates a new {@link ClassPathSkillSource} that loads skills from the given base resource path
* using the specified {@link ClassLoader}.
*
* @param baseResourcePath the base classpath path to scan for skills
* @param classLoader the class loader to use for scanning resources
*/
public ClassPathSkillSource(String baseResourcePath, ClassLoader classLoader) {
this.baseResourcePath = normalizePath(baseResourcePath);
this.classLoader = classLoader;

// Scan classpath once (lazily)
Single<ImmutableList<ResourceInfo>> scanned = Single.fromCallable(this::scanClassPath).cache();
this.allResourcesSingle = scanned;
this.skillMdsSingle = scanned.map(this::extractSkillMds).cache();
}

private static String normalizePath(String path) {
if (path.isEmpty()) {
return "";
}
if (path.endsWith("/")) {
return path;
}
return path + "/";
}

private ImmutableList<ResourceInfo> scanClassPath() throws SkillSourceException {
try {
ClassPath classPath = ClassPath.from(classLoader);
return classPath.getResources().stream()
.filter(info -> info.getResourceName().startsWith(baseResourcePath))
.collect(toImmutableList());
} catch (IOException e) {
throw new SkillSourceException(
"Failed to scan classpath under " + baseResourcePath, SKILL_LOAD_ERROR, e);
}
}

private ImmutableMap<String, ResourceInfo> extractSkillMds(ImmutableList<ResourceInfo> resources)
throws SkillSourceException {
Map<String, ResourceInfo> skillMdMap = new HashMap<>();
for (ResourceInfo info : resources) {
String relPath = info.getResourceName().substring(baseResourcePath.length());
List<String> parts = PATH_SPLITTER.splitToList(relPath);
// Check if the path format matches exactly {skillName}/SKILL.md (or skill.md
// case-insensitively).
if (parts.size() == 2 && Ascii.equalsIgnoreCase(parts.get(1), "SKILL.md")) {
String skillName = parts.get(0);
String logicalName = skillName.replace('_', '-');
if (skillMdMap.containsKey(logicalName)) {
ResourceInfo existing = skillMdMap.get(logicalName);
throw new SkillSourceException(
"Conflicting SKILL.md files found for skill '"
+ logicalName
+ "': "
+ existing.getResourceName()
+ " and "
+ info.getResourceName(),
SKILL_LOAD_ERROR);
}
skillMdMap.put(logicalName, info);
}
}
return ImmutableMap.copyOf(skillMdMap);
}

@Override
public Single<ImmutableList<String>> listResources(String skillName, String resourceDirectory) {
String logicalSkillName = skillName.replace('_', '-');
String prefix =
resourceDirectory.isEmpty()
? ""
: (resourceDirectory.endsWith("/") ? resourceDirectory : resourceDirectory + "/");

// Support both standard ADK hyphenated directories and legacy underscore directories.
String hyphenatedDir = logicalSkillName;
String underscoredDir = logicalSkillName.replace('-', '_');

return allResourcesSingle.map(
resources ->
resources.stream()
.map(info -> info.getResourceName().substring(baseResourcePath.length()))
.filter(
relPath ->
relPath.startsWith(hyphenatedDir + "/" + prefix)
|| relPath.startsWith(underscoredDir + "/" + prefix))
.map(
relPath -> {
if (relPath.startsWith(hyphenatedDir + "/")) {
return relPath.substring(hyphenatedDir.length() + 1);
} else {
return relPath.substring(underscoredDir.length() + 1);
}
})
.filter(path -> !Ascii.equalsIgnoreCase(path, "SKILL.md"))
.collect(toImmutableList()));
}

@Override
protected Flowable<SkillMdPath<ResourceInfo>> listSkills() {
return skillMdsSingle
.flattenAsFlowable(ImmutableMap::entrySet)
.map(entry -> new SkillMdPath<>(entry.getKey(), entry.getValue()));
}

@Override
protected Single<ResourceInfo> findSkillMdPath(String skillName) {
String logicalSkillName = skillName.replace('_', '-');
return skillMdsSingle
.map(map -> Optional.ofNullable(map.get(logicalSkillName)))
.flatMap(
opt ->
opt.isPresent()
? Single.just(opt.get())
: Single.error(
new SkillSourceException(
"SKILL.md not found for skill: " + logicalSkillName, SKILL_NOT_FOUND)));
}

@Override
protected Single<ResourceInfo> findResourcePath(String skillName, String resourcePath) {
String logicalSkillName = skillName.replace('_', '-');
// Support both standard ADK hyphenated directories and legacy underscore directories.
String hyphenatedDir = logicalSkillName;
String underscoredDir = logicalSkillName.replace('-', '_');

String hyphenatedPath = baseResourcePath + hyphenatedDir + "/" + resourcePath;
String underscoredPath = baseResourcePath + underscoredDir + "/" + resourcePath;

return allResourcesSingle
.map(
resources ->
resources.stream()
.filter(
info ->
info.getResourceName().equals(hyphenatedPath)
|| info.getResourceName().equals(underscoredPath))
.findFirst())
.flatMap(
opt ->
opt.isPresent()
? Single.just(opt.get())
: Single.error(
new SkillSourceException(
"Resource not found: "
+ resourcePath
+ " for skill: "
+ logicalSkillName,
RESOURCE_NOT_FOUND)));
}

@Override
protected ReadableByteChannel openChannel(ResourceInfo path) throws IOException {
return Channels.newChannel(path.asByteSource().openStream());
}
}
123 changes: 123 additions & 0 deletions core/src/test/java/com/google/adk/skills/ClassPathSkillSourceTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2026 Google LLC
*
* 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.adk.skills;

import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertThrows;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.ByteSource;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

@RunWith(JUnit4.class)
public final class ClassPathSkillSourceTest {

private static final String BASE_PATH = "com/google/adk/skills/testdata/skills/";

@Test
public void testListFrontmatters() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
ImmutableMap<String, Frontmatter> skills = source.listFrontmatters().blockingGet();

assertThat(skills).hasSize(3);
assertThat(skills).containsKey("skill-1");
assertThat(skills).containsKey("skill-2");
assertThat(skills).containsKey("skill-3");
assertThat(skills.get("skill-1").description()).isEqualTo("test classpath skill 1");
assertThat(skills.get("skill-2").description()).isEqualTo("test classpath skill 2");
assertThat(skills.get("skill-3").description())
.isEqualTo("test classpath skill 3 with underscores");
}

@Test
public void testLoadFrontmatter() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
Frontmatter fm = source.loadFrontmatter("skill-1").blockingGet();

assertThat(fm.name()).isEqualTo("skill-1");
assertThat(fm.description()).isEqualTo("test classpath skill 1");
}

@Test
public void testLoadFrontmatter_underscoreMapping() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
Frontmatter fm = source.loadFrontmatter("skill-3").blockingGet();

assertThat(fm.name()).isEqualTo("skill-3");
assertThat(fm.description()).isEqualTo("test classpath skill 3 with underscores");
}

@Test
public void testLoadInstructions() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
String instructions = source.loadInstructions("skill-1").blockingGet();

assertThat(instructions).isEqualTo("body 1");
}

@Test
public void testListResources() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
ImmutableList<String> resources = source.listResources("skill-2", "assets").blockingGet();
assertThat(resources).containsExactly("assets/spec/spec.txt");
}

@Test
public void testListResources_excludesSkillMd() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
ImmutableList<String> resources = source.listResources("skill-1", "").blockingGet();
assertThat(resources).containsExactly("resource/extra.txt");
}

@Test
public void testLoadResource() throws Exception {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
ByteSource byteSource = source.loadResource("skill-2", "assets/spec/spec.txt").blockingGet();
assertThat(byteSource.asCharSource(UTF_8).read().trim()).isEqualTo("A spec file");
}

@Test
public void testLoadResource_underscoreMapping() throws Exception {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
ByteSource byteSource = source.loadResource("skill-3", "resource/dummy.txt").blockingGet();
assertThat(byteSource.asCharSource(UTF_8).read().trim()).isEqualTo("dummy content");
}

@Test
public void testLoadResource_notFound() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
var single = source.loadResource("skill-1", "non-existent.txt");
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
SkillSourceException cause = (SkillSourceException) exception.getCause();
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.RESOURCE_NOT_FOUND);
}

@Test
public void testLoadFrontmatter_skillNotFound() {
SkillSource source = new ClassPathSkillSource(BASE_PATH);
var single = source.loadFrontmatter("non-existent");
RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet);
assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class);
SkillSourceException cause = (SkillSourceException) exception.getCause();
assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.SKILL_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: skill-1
description: test classpath skill 1
---

body 1
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An extra resource
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: skill-2
description: test classpath skill 2
---

body 2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A spec file
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
name: skill-3
description: test classpath skill 3 with underscores
---

body 3
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dummy content
Loading