From 57bae9f58c76d544923ff5d00975ede68dcf95d2 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 2 Jun 2026 12:24:20 -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: 925512341 --- .../adk/skills/ClassPathSkillSource.java | 221 ++++++++++++++++++ .../adk/skills/ClassPathSkillSourceTest.java | 123 ++++++++++ .../skills/testdata/skills/skill-1/SKILL.md | 6 + .../skills/skill-1/resource/extra.txt | 1 + .../skills/testdata/skills/skill-2/SKILL.md | 6 + .../skills/skill-2/assets/spec/spec.txt | 1 + .../skills/testdata/skills/skill_3/SKILL.md | 6 + .../skills/skill_3/resource/dummy.txt | 1 + 8 files changed, 365 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/java/com/google/adk/skills/testdata/skills/skill-1/SKILL.md create mode 100644 core/src/test/java/com/google/adk/skills/testdata/skills/skill-1/resource/extra.txt create mode 100644 core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/SKILL.md create mode 100644 core/src/test/java/com/google/adk/skills/testdata/skills/skill-2/assets/spec/spec.txt create mode 100644 core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/SKILL.md create mode 100644 core/src/test/java/com/google/adk/skills/testdata/skills/skill_3/resource/dummy.txt 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