diff --git a/core/src/main/java/com/google/adk/skills/ClassPathSkillSource.java b/core/src/main/java/com/google/adk/skills/ClassPathSkillSource.java new file mode 100644 index 000000000..d089f2fb7 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/ClassPathSkillSource.java @@ -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 { + + private static final Splitter PATH_SPLITTER = Splitter.on('/'); + + private final String baseResourcePath; + private final ClassLoader classLoader; + private final Single> skillMdsSingle; + private final Single> 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> 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 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 extractSkillMds(ImmutableList resources) + throws SkillSourceException { + Map skillMdMap = new HashMap<>(); + for (ResourceInfo info : resources) { + String relPath = info.getResourceName().substring(baseResourcePath.length()); + List 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> 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> listSkills() { + return skillMdsSingle + .flattenAsFlowable(ImmutableMap::entrySet) + .map(entry -> new SkillMdPath<>(entry.getKey(), entry.getValue())); + } + + @Override + protected Single 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 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()); + } +} diff --git a/core/src/test/java/com/google/adk/skills/ClassPathSkillSourceTest.java b/core/src/test/java/com/google/adk/skills/ClassPathSkillSourceTest.java new file mode 100644 index 000000000..5e739f3f4 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/ClassPathSkillSourceTest.java @@ -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 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 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 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); + } +} diff --git a/core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/SKILL.md b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/SKILL.md new file mode 100644 index 000000000..b0a94084d --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-1 +description: test classpath skill 1 +--- + +body 1 diff --git a/core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/resource/extra.txt b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/resource/extra.txt new file mode 100644 index 000000000..1384745d6 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/resource/extra.txt @@ -0,0 +1 @@ +An extra resource diff --git a/core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/SKILL.md b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/SKILL.md new file mode 100644 index 000000000..8d20c32bb --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-2 +description: test classpath skill 2 +--- + +body 2 diff --git a/core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/assets/spec/spec.txt b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/assets/spec/spec.txt new file mode 100644 index 000000000..c6ec59269 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/assets/spec/spec.txt @@ -0,0 +1 @@ +A spec file diff --git a/core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/SKILL.md b/core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/SKILL.md new file mode 100644 index 000000000..547919e09 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-3 +description: test classpath skill 3 with underscores +--- + +body 3 diff --git a/core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/resource/dummy.txt b/core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/resource/dummy.txt new file mode 100644 index 000000000..eaf5f7510 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/resource/dummy.txt @@ -0,0 +1 @@ +dummy content