From 587073a23ea781efd44990ad440b52caace3db4f Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Fri, 26 Jun 2026 11:45:41 -0700 Subject: [PATCH] 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: 938697445 --- .../adk/skills/ClassPathSkillSource.java | 228 ++++++++++++++++++ .../adk/skills/ClassPathSkillSourceTest.java | 205 ++++++++++++++++ core/src/test/resources/root-skill/SKILL.md | 5 + .../resources/skills/normal-skill/SKILL.md | 5 + .../skills/normal-skill/assets/spec/spec.txt | 1 + .../skills/normal-skill/resource/extra.txt | 1 + .../skills/underscore_skill/SKILL.md | 5 + .../underscore_skill/resource/dummy.txt | 1 + .../resources/skills_conflict/a-b/SKILL.md | 5 + .../resources/skills_conflict/a_b/SKILL.md | 5 + 10 files changed, 461 insertions(+) create mode 100644 core/src/main/java/com/google/adk/skills/ClassPathSkillSource.java create mode 100644 core/src/test/java/com/google/adk/skills/ClassPathSkillSourceTest.java create mode 100644 core/src/test/resources/root-skill/SKILL.md create mode 100644 core/src/test/resources/skills/normal-skill/SKILL.md create mode 100644 core/src/test/resources/skills/normal-skill/assets/spec/spec.txt create mode 100644 core/src/test/resources/skills/normal-skill/resource/extra.txt create mode 100644 core/src/test/resources/skills/underscore_skill/SKILL.md create mode 100644 core/src/test/resources/skills/underscore_skill/resource/dummy.txt create mode 100644 core/src/test/resources/skills_conflict/a-b/SKILL.md create mode 100644 core/src/test/resources/skills_conflict/a_b/SKILL.md 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..8cb5458fe --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/ClassPathSkillSource.java @@ -0,0 +1,228 @@ +/* + * 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 findSkillMdPath(skillName) + .flatMap( + ignored -> + 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()))) + .flatMap( + list -> + (!resourceDirectory.isEmpty() && list.isEmpty()) + ? Single.error( + new SkillSourceException( + "Resource directory '" + + resourceDirectory + + "' not found for skill '" + + logicalSkillName + + "'", + RESOURCE_NOT_FOUND)) + : Single.just(list)); + } + + @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 + .mapOptional(map -> Optional.ofNullable(map.get(logicalSkillName))) + .switchIfEmpty( + 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 + .mapOptional( + resources -> + resources.stream() + .filter( + info -> + info.getResourceName().equals(hyphenatedPath) + || info.getResourceName().equals(underscoredPath)) + .findFirst()) + .switchIfEmpty( + 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..3db9f668f --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/ClassPathSkillSourceTest.java @@ -0,0 +1,205 @@ +/* + * 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 = "skills/"; + + // ========================================================================= + // Constructor & Base Path Normalization + // ========================================================================= + + @Test + public void testConstructor_normalizesPathWithoutTrailingSlash() { + SkillSource source = new ClassPathSkillSource("skills"); + Frontmatter fm = source.loadFrontmatter("normal-skill").blockingGet(); + assertThat(fm.name()).isEqualTo("normal-skill"); + } + + @Test + public void testConstructor_emptyBasePath() { + SkillSource emptySource = new ClassPathSkillSource(""); + ImmutableMap skills = emptySource.listFrontmatters().blockingGet(); + assertThat(skills).containsKey("root-skill"); + } + + // ========================================================================= + // listFrontmatters + // ========================================================================= + + @Test + public void testListFrontmatters() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + ImmutableMap skills = source.listFrontmatters().blockingGet(); + + assertThat(skills).hasSize(2); + assertThat(skills).containsKey("normal-skill"); + assertThat(skills).containsKey("underscore-skill"); + + assertThat(skills.get("normal-skill").description()).isEqualTo("A normal skill with a hyphen"); + assertThat(skills.get("underscore-skill").description()) + .isEqualTo("A skill with an underscore"); + } + + // ========================================================================= + // loadFrontmatter + // ========================================================================= + + @Test + public void testLoadFrontmatter() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + Frontmatter fm = source.loadFrontmatter("normal-skill").blockingGet(); + + assertThat(fm.name()).isEqualTo("normal-skill"); + assertThat(fm.description()).isEqualTo("A normal skill with a hyphen"); + } + + @Test + public void testLoadFrontmatter_underscoreMapping() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + Frontmatter fm = source.loadFrontmatter("underscore-skill").blockingGet(); + + assertThat(fm.name()).isEqualTo("underscore-skill"); + assertThat(fm.description()).isEqualTo("A skill with an underscore"); + } + + @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); + } + + // ========================================================================= + // loadInstructions + // ========================================================================= + + @Test + public void testLoadInstructions() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + String instructions = source.loadInstructions("normal-skill").blockingGet(); + + assertThat(instructions).isEqualTo("body 1"); + } + + // ========================================================================= + // listResources + // ========================================================================= + + @Test + public void testListResources_skillNotFound() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + var single = source.listResources("non-existent", "assets"); + 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); + } + + @Test + public void testListResources_resourceDirectoryNotFound() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + var single = source.listResources("normal-skill", "non-existent-dir"); + 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 testListResources() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + ImmutableList resources = source.listResources("normal-skill", "assets").blockingGet(); + assertThat(resources).containsExactly("assets/spec/spec.txt"); + } + + @Test + public void testListResources_excludesSkillMd() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + ImmutableList resources = source.listResources("normal-skill", "").blockingGet(); + assertThat(resources).containsExactly("assets/spec/spec.txt", "resource/extra.txt"); + } + + @Test + public void testListResources_underscoreMapping() { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + ImmutableList resources = + source.listResources("underscore-skill", "resource").blockingGet(); + assertThat(resources).containsExactly("resource/dummy.txt"); + } + + // ========================================================================= + // loadResource + // ========================================================================= + + @Test + public void testLoadResource() throws Exception { + SkillSource source = new ClassPathSkillSource(BASE_PATH); + ByteSource byteSource = + source.loadResource("normal-skill", "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("underscore-skill", "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("normal-skill", "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); + } + + // ========================================================================= + // Error Handling & Conflicts + // ========================================================================= + + @Test + public void testConflictingSkillMds() { + SkillSource source = new ClassPathSkillSource("skills_conflict/"); + var single = source.listFrontmatters(); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception).hasCauseThat().isInstanceOf(SkillSourceException.class); + SkillSourceException cause = (SkillSourceException) exception.getCause(); + assertThat(cause.getErrorCode()).isEqualTo(SkillSourceException.SKILL_LOAD_ERROR); + assertThat(cause).hasMessageThat().contains("Conflicting SKILL.md files found for skill 'a-b'"); + } +} diff --git a/core/src/test/resources/root-skill/SKILL.md b/core/src/test/resources/root-skill/SKILL.md new file mode 100644 index 000000000..5f3943212 --- /dev/null +++ b/core/src/test/resources/root-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: root-skill +description: root skill +--- +body diff --git a/core/src/test/resources/skills/normal-skill/SKILL.md b/core/src/test/resources/skills/normal-skill/SKILL.md new file mode 100644 index 000000000..da402ba57 --- /dev/null +++ b/core/src/test/resources/skills/normal-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: normal-skill +description: A normal skill with a hyphen +--- +body 1 diff --git a/core/src/test/resources/skills/normal-skill/assets/spec/spec.txt b/core/src/test/resources/skills/normal-skill/assets/spec/spec.txt new file mode 100644 index 000000000..c6ec59269 --- /dev/null +++ b/core/src/test/resources/skills/normal-skill/assets/spec/spec.txt @@ -0,0 +1 @@ +A spec file diff --git a/core/src/test/resources/skills/normal-skill/resource/extra.txt b/core/src/test/resources/skills/normal-skill/resource/extra.txt new file mode 100644 index 000000000..0f2287157 --- /dev/null +++ b/core/src/test/resources/skills/normal-skill/resource/extra.txt @@ -0,0 +1 @@ +extra diff --git a/core/src/test/resources/skills/underscore_skill/SKILL.md b/core/src/test/resources/skills/underscore_skill/SKILL.md new file mode 100644 index 000000000..0f41cfe03 --- /dev/null +++ b/core/src/test/resources/skills/underscore_skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: underscore-skill +description: A skill with an underscore +--- +body 2 diff --git a/core/src/test/resources/skills/underscore_skill/resource/dummy.txt b/core/src/test/resources/skills/underscore_skill/resource/dummy.txt new file mode 100644 index 000000000..eaf5f7510 --- /dev/null +++ b/core/src/test/resources/skills/underscore_skill/resource/dummy.txt @@ -0,0 +1 @@ +dummy content diff --git a/core/src/test/resources/skills_conflict/a-b/SKILL.md b/core/src/test/resources/skills_conflict/a-b/SKILL.md new file mode 100644 index 000000000..28272045c --- /dev/null +++ b/core/src/test/resources/skills_conflict/a-b/SKILL.md @@ -0,0 +1,5 @@ +--- +name: a-b +description: conflicting skill 2 +--- +body diff --git a/core/src/test/resources/skills_conflict/a_b/SKILL.md b/core/src/test/resources/skills_conflict/a_b/SKILL.md new file mode 100644 index 000000000..5ba827d81 --- /dev/null +++ b/core/src/test/resources/skills_conflict/a_b/SKILL.md @@ -0,0 +1,5 @@ +--- +name: a_b +description: conflicting skill 1 +--- +body