From c17e19a9e552923c36cdd884c718d096f2d7b963 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Thu, 19 Mar 2026 12:57:47 -0700 Subject: [PATCH] feat: Add SkillSource interface and implementations for loading skills This change introduces the SkillSource interface and its implementations to support loading skills from various sources in the ADK. Key changes: - SkillSource interface: Core abstraction for loading skills. - LocalSkillSource: Implementation for loading skills from local files. - InMemorySkillSource: Implementation for loading skills from memory. - Tests for all implementations. - Updated BUILD files for correct targets and visibility. PiperOrigin-RevId: 886330483 --- .../adk/skills/AbstractSkillSource.java | 190 +++++++++++++ .../com/google/adk/skills/Frontmatter.java | 146 ++++++++++ .../adk/skills/InMemorySkillSource.java | 177 ++++++++++++ .../google/adk/skills/LocalSkillSource.java | 118 ++++++++ .../com/google/adk/skills/SkillSource.java | 100 +++++++ .../adk/skills/SkillSourceException.java | 28 ++ .../google/adk/skills/FrontmatterTest.java | 81 ++++++ .../adk/skills/InMemorySkillSourceTest.java | 187 +++++++++++++ .../adk/skills/LocalSkillSourceTest.java | 251 ++++++++++++++++++ 9 files changed, 1278 insertions(+) create mode 100644 core/src/main/java/com/google/adk/skills/AbstractSkillSource.java create mode 100644 core/src/main/java/com/google/adk/skills/Frontmatter.java create mode 100644 core/src/main/java/com/google/adk/skills/InMemorySkillSource.java create mode 100644 core/src/main/java/com/google/adk/skills/LocalSkillSource.java create mode 100644 core/src/main/java/com/google/adk/skills/SkillSource.java create mode 100644 core/src/main/java/com/google/adk/skills/SkillSourceException.java create mode 100644 core/src/test/java/com/google/adk/skills/FrontmatterTest.java create mode 100644 core/src/test/java/com/google/adk/skills/InMemorySkillSourceTest.java create mode 100644 core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java diff --git a/core/src/main/java/com/google/adk/skills/AbstractSkillSource.java b/core/src/main/java/com/google/adk/skills/AbstractSkillSource.java new file mode 100644 index 000000000..42208adaf --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/AbstractSkillSource.java @@ -0,0 +1,190 @@ +/* + * 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 java.nio.channels.Channels.newReader; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteSource; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract base class for SkillSource implementations that load skills from path like object. + * + * @param the type of path object + */ +public abstract class AbstractSkillSource implements SkillSource { + + private static final String THREE_DASHES = "---"; + private static final Logger logger = LoggerFactory.getLogger(AbstractSkillSource.class); + private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + + /** A container class that holds a skill's name and the path to its SKILL.md file. */ + protected final class SkillMdPath { + + private final String name; + private final PathT mdPath; + + /** + * Constructs a {@code SkillMdPath}. + * + * @param name the name of the skill + * @param mdPath the path to the SKILL.md file + */ + @SuppressWarnings("ProtectedMembersInFinalClass") + protected SkillMdPath(String name, PathT mdPath) { + this.name = name; + this.mdPath = mdPath; + } + } + + @Override + public Single> listFrontmatters() { + return listSkills() + .flatMapMaybe( + skillMdPath -> + Maybe.fromCallable(() -> loadFrontmatter(skillMdPath.name, skillMdPath.mdPath)) + .onErrorResumeNext( + e -> { + logger.warn( + "Skipping skill '{}' due to error loading frontmatter", + skillMdPath.name, + e); + return Maybe.empty(); + })) + .collectInto( + ImmutableMap.builder(), + (builder, frontmatter) -> builder.put(frontmatter.name(), frontmatter)) + .map(ImmutableMap.Builder::buildOrThrow); + } + + @Override + public Single loadFrontmatter(String skillName) { + return findSkillMdPath(skillName).map(path -> loadFrontmatter(skillName, path)); + } + + private Frontmatter loadFrontmatter(String skillName, PathT skillMdPath) + throws IOException, SkillSourceException { + try (BufferedReader reader = openReader(skillMdPath)) { + String yaml = readFrontmatterYaml(reader); + Frontmatter frontmatter = yamlMapper.readValue(yaml, Frontmatter.class); + if (!frontmatter.name().equals(skillName)) { + throw new SkillSourceException( + "Skill name '%s' does not match directory name '%s'." + .formatted(frontmatter.name(), skillName)); + } + return frontmatter; + } + } + + @Override + public Single loadInstructions(String skillName) { + return findSkillMdPath(skillName) + .map( + skillMdPath -> { + try (BufferedReader reader = openReader(skillMdPath)) { + return readInstructions(reader); + } + }); + } + + @Override + public Single loadResource(String skillName, String resourcePath) { + return findResourcePath(skillName, resourcePath) + .map( + path -> + new ByteSource() { + @Override + public InputStream openStream() throws IOException { + return Channels.newInputStream(AbstractSkillSource.this.openChannel(path)); + } + }); + } + + /** + * Returns a {@link Flowable} of skills as a pair of skill name and the path to the SKILL.md file. + */ + protected abstract Flowable listSkills(); + + /** Returns the path to the SKILL.md file for the given skill. */ + protected abstract Single findSkillMdPath(String skillName); + + /** Returns the path to the resource for the given skill. */ + protected abstract Single findResourcePath(String skillName, String resourcePath); + + /** Opens a {@link InputStream} for reading the content of the given path. */ + protected abstract ReadableByteChannel openChannel(PathT path) throws IOException; + + private BufferedReader openReader(PathT path) throws IOException { + return new BufferedReader(newReader(openChannel(path), UTF_8)); + } + + private String readFrontmatterYaml(BufferedReader reader) + throws IOException, SkillSourceException { + String line = reader.readLine(); + if (line == null || !line.trim().equals(THREE_DASHES)) { + throw new SkillSourceException("Skill file must start with " + THREE_DASHES); + } + + StringBuilder sb = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (line.trim().equals(THREE_DASHES)) { + return sb.toString(); + } + sb.append(line).append("\n"); + } + throw new SkillSourceException( + "Skill file frontmatter not properly closed with " + THREE_DASHES); + } + + private String readInstructions(BufferedReader reader) throws IOException, SkillSourceException { + // Skip the frontmatter block + String line = reader.readLine(); + if (line == null || !line.trim().equals(THREE_DASHES)) { + throw new SkillSourceException("Skill file must start with " + THREE_DASHES); + } + boolean dashClosed = false; + while ((line = reader.readLine()) != null) { + if (line.trim().equals(THREE_DASHES)) { + dashClosed = true; + break; + } + } + if (!dashClosed) { + throw new SkillSourceException( + "Skill file frontmatter not properly closed with " + THREE_DASHES); + } + // Read the instructions till the end of the file + StringBuilder sb = new StringBuilder(); + while ((line = reader.readLine()) != null) { + sb.append(line).append("\n"); + } + return sb.toString().trim(); + } +} diff --git a/core/src/main/java/com/google/adk/skills/Frontmatter.java b/core/src/main/java/com/google/adk/skills/Frontmatter.java new file mode 100644 index 000000000..6f9b56e9e --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/Frontmatter.java @@ -0,0 +1,146 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.google.adk.JsonBaseModel; +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableMap; +import com.google.common.escape.Escaper; +import com.google.common.html.HtmlEscapers; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Frontmatter represents the YAML metadata at the top of a SKILL.md file. For more details, see + * https://agentskills.io/specification#frontmatter. + */ +@AutoValue +@JsonDeserialize(builder = Frontmatter.Builder.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public abstract class Frontmatter extends JsonBaseModel { + + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$"); + + /** Skill name in kebab-case. */ + @JsonProperty("name") + public abstract String name(); + + /** What the skill does and when the model should use it. */ + @JsonProperty("description") + public abstract String description(); + + /** License for the skill. */ + @JsonProperty("license") + public abstract Optional license(); + + /** Compatibility information for the skill. */ + @JsonProperty("compatibility") + public abstract Optional compatibility(); + + /** A space-delimited list of tools that are pre-approved to run. */ + @JsonProperty("allowed-tools") + public abstract Optional allowedTools(); + + /** Key-value pairs for client-specific properties. */ + @JsonProperty("metadata") + public abstract ImmutableMap metadata(); + + public String toXml() { + Escaper escaper = HtmlEscapers.htmlEscaper(); + return String.format( + """ + + + %s + + + %s + + + """, + escaper.escape(name()), escaper.escape(description())); + } + + public static Builder builder() { + return new AutoValue_Frontmatter.Builder().metadata(ImmutableMap.of()); + } + + @AutoValue.Builder + public abstract static class Builder { + + @JsonCreator + private static Builder create() { + return builder(); + } + + @CanIgnoreReturnValue + @JsonProperty("name") + public abstract Builder name(String name); + + @CanIgnoreReturnValue + @JsonProperty("description") + public abstract Builder description(String description); + + @CanIgnoreReturnValue + @JsonProperty("license") + public abstract Builder license(String license); + + @CanIgnoreReturnValue + @JsonProperty("compatibility") + public abstract Builder compatibility(String compatibility); + + @CanIgnoreReturnValue + @JsonProperty("allowed-tools") + @JsonAlias({"allowed_tools"}) + public abstract Builder allowedTools(String allowedTools); + + @CanIgnoreReturnValue + @JsonProperty("metadata") + public abstract Builder metadata(Map metadata); + + abstract Frontmatter autoBuild(); + + public Frontmatter build() { + Frontmatter fm = autoBuild(); + if (fm.name().length() > 64) { + throw new IllegalArgumentException("name must be at most 64 characters"); + } + if (!NAME_PATTERN.matcher(fm.name()).matches()) { + throw new IllegalArgumentException( + "name must be lowercase kebab-case (a-z, 0-9, hyphens), with no leading, trailing, or" + + " consecutive hyphens"); + } + if (fm.description().isEmpty()) { + throw new IllegalArgumentException("description must not be empty"); + } + if (fm.description().length() > 1024) { + throw new IllegalArgumentException("description must be at most 1024 characters"); + } + if (fm.compatibility().isPresent() && fm.compatibility().get().length() > 500) { + throw new IllegalArgumentException("compatibility must be at most 500 characters"); + } + return fm; + } + } +} diff --git a/core/src/main/java/com/google/adk/skills/InMemorySkillSource.java b/core/src/main/java/com/google/adk/skills/InMemorySkillSource.java new file mode 100644 index 000000000..42916e36a --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/InMemorySkillSource.java @@ -0,0 +1,177 @@ +/* + * 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.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.google.common.io.ByteSource; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.reactivex.rxjava3.core.Single; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * An in-memory implementation of {@link SkillSource}. + * + *

Everything is provided upfront using a builder pattern. + */ +public final class InMemorySkillSource implements SkillSource { + + private final ImmutableMap skills; + + private InMemorySkillSource(ImmutableMap skills) { + this.skills = skills; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + public Single> listFrontmatters() { + return Single.just(ImmutableMap.copyOf(Maps.transformValues(skills, SkillData::frontmatter))); + } + + @Override + public Single> listResources(String skillName, String resourceDirectory) { + SkillData data = skills.get(skillName); + if (data == null) { + return Single.error(new SkillSourceException("Skill not found: " + skillName)); + } + String prefix = + resourceDirectory.isEmpty() + ? "" + : (resourceDirectory.endsWith("/") ? resourceDirectory : resourceDirectory + "/"); + + if (!resourceDirectory.isEmpty() + && data.resources().keySet().stream().noneMatch(path -> path.startsWith(prefix))) { + return Single.error( + new SkillSourceException( + "Resource directory not found: " + resourceDirectory + " for skill: " + skillName)); + } + + return Single.just( + data.resources().keySet().stream() + .filter(path -> path.startsWith(prefix)) + .collect(toImmutableList())); + } + + @Override + public Single loadFrontmatter(String skillName) { + return getSkillData(skillName).map(SkillData::frontmatter); + } + + @Override + public Single loadInstructions(String skillName) { + return getSkillData(skillName).map(SkillData::instructions); + } + + @Override + public Single loadResource(String skillName, String resourcePath) { + return getSkillData(skillName) + .map(SkillData::resources) + .mapOptional(m -> Optional.ofNullable(m.get(resourcePath))) + .switchIfEmpty( + Single.error(new SkillSourceException("Resource not found: " + resourcePath))); + } + + private Single getSkillData(String skillName) { + SkillData data = skills.get(skillName); + if (data == null) { + return Single.error(new SkillSourceException("Skill not found: " + skillName)); + } + return Single.just(data); + } + + /** Builder for {@link InMemorySkillSource}. */ + public static class Builder { + private final Map skillBuilders = new HashMap<>(); + + /** Returns a {@link SkillBuilder} for the specified skill, creating it if it doesn't exist. */ + public SkillBuilder skill(String name) { + return skillBuilders.computeIfAbsent(name, k -> new SkillBuilder()); + } + + public InMemorySkillSource build() { + return new InMemorySkillSource( + ImmutableMap.copyOf(Maps.transformValues(skillBuilders, SkillBuilder::buildSkillData))); + } + + /** Builder for a specific skill. */ + public final class SkillBuilder { + private Frontmatter frontmatter; + private String instructions; + private final ImmutableMap.Builder resourcesBuilder = + ImmutableMap.builder(); + + private SkillBuilder() {} + + @CanIgnoreReturnValue + public SkillBuilder frontmatter(Frontmatter frontmatter) { + this.frontmatter = frontmatter; + return this; + } + + @CanIgnoreReturnValue + public SkillBuilder instructions(String instructions) { + this.instructions = instructions; + return this; + } + + @CanIgnoreReturnValue + public SkillBuilder addResource(String path, ByteSource content) { + this.resourcesBuilder.put(path, content); + return this; + } + + @CanIgnoreReturnValue + public SkillBuilder addResource(String path, byte[] content) { + return addResource(path, ByteSource.wrap(content)); + } + + @CanIgnoreReturnValue + public SkillBuilder addResource(String path, String content) { + return addResource(path, content.getBytes(UTF_8)); + } + + /** Switches context to configure another skill, creating it if it doesn't exist. */ + public SkillBuilder skill(String name) { + return Builder.this.skill(name); + } + + /** Builds the {@link InMemorySkillSource} containing all configured skills. */ + public InMemorySkillSource build() { + return Builder.this.build(); + } + + private SkillData buildSkillData() { + checkState(frontmatter != null, "Frontmatter is required"); + checkState(instructions != null, "Instructions are required"); + return new SkillData(frontmatter, instructions, resourcesBuilder.buildOrThrow()); + } + } + } + + private record SkillData( + Frontmatter frontmatter, String instructions, ImmutableMap resources) {} +} diff --git a/core/src/main/java/com/google/adk/skills/LocalSkillSource.java b/core/src/main/java/com/google/adk/skills/LocalSkillSource.java new file mode 100644 index 000000000..939c30b3c --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/LocalSkillSource.java @@ -0,0 +1,118 @@ +/* + * 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.collect.ImmutableList.toImmutableList; +import static java.nio.file.Files.isDirectory; + +import com.google.common.collect.ImmutableList; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Maybe; +import io.reactivex.rxjava3.core.Single; +import java.io.IOException; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.stream.Stream; + +/** Loads skills from the local file system. */ +public final class LocalSkillSource extends AbstractSkillSource { + + private final Path skillsBasePath; + + public LocalSkillSource(Path skillsBasePath) { + this.skillsBasePath = skillsBasePath; + } + + @Override + public Single> listResources(String skillName, String resourceDirectory) { + Path skillDir = skillsBasePath.resolve(skillName); + if (!isDirectory(skillDir)) { + return Single.error(new SkillSourceException("Skill not found: " + skillName)); + } + Path resourceDir = skillDir.resolve(resourceDirectory); + if (!isDirectory(resourceDir)) { + return Single.error( + new SkillSourceException( + "Resource directory '%s' not found for skill '%s'" + .formatted(resourceDirectory, skillName))); + } + + return Single.fromCallable( + () -> { + try (Stream paths = Files.walk(resourceDir)) { + return paths + .filter(Files::isRegularFile) + .map(skillDir::relativize) + .map(Path::toString) + .collect(toImmutableList()); + } + }) + .onErrorResumeNext( + t -> + Single.error( + new SkillSourceException( + "Failed to traverse resource directory: " + resourceDirectory, t))); + } + + @Override + @SuppressWarnings("StreamResourceLeak") + protected Flowable listSkills() { + return Flowable.using(() -> Files.list(skillsBasePath), Flowable::fromStream, Stream::close) + .onErrorResumeNext( + t -> + Flowable.error( + new SkillSourceException( + "Failed to list skills in directory: " + skillsBasePath, t))) + .filter(Files::isDirectory) + .mapOptional(this::findSkillMd) + .map(skillMd -> new SkillMdPath(skillMd.getParent().getFileName().toString(), skillMd)); + } + + @Override + protected Single findResourcePath(String skillName, String resourcePath) { + Path file = skillsBasePath.resolve(skillName).resolve(resourcePath); + if (!Files.exists(file)) { + return Single.error(new SkillSourceException("Resource not found: " + file)); + } + return Single.just(file); + } + + @Override + protected Single findSkillMdPath(String skillName) { + Path skillDir = skillsBasePath.resolve(skillName); + if (!isDirectory(skillDir)) { + return Single.error(new SkillSourceException("Skill directory not found: " + skillName)); + } + return Maybe.fromOptional(findSkillMd(skillDir)) + .switchIfEmpty( + Single.error(new SkillSourceException("SKILL.md not found in " + skillName))); + } + + @Override + protected ReadableByteChannel openChannel(Path path) throws IOException { + return Files.newByteChannel(path); + } + + private Optional findSkillMd(Path dir) { + return Optional.of(dir.resolve("SKILL.md")) + .filter(Files::exists) + .or(() -> Optional.of(dir.resolve("skill.md"))) + .filter(Files::exists); + } +} diff --git a/core/src/main/java/com/google/adk/skills/SkillSource.java b/core/src/main/java/com/google/adk/skills/SkillSource.java new file mode 100644 index 000000000..4f3ff901f --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/SkillSource.java @@ -0,0 +1,100 @@ +/* + * 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 com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteSource; +import io.reactivex.rxjava3.core.Single; + +/** + * Interface for getting access to available skills. + * + *

All operations are asynchronous and communicate failures reactively through the returned + * {@link Single} error channel (terminating with {@code onError}), rather than throwing exceptions + * synchronously. + */ +public interface SkillSource { + + /** + * Lists all available {@link Frontmatter}s for discovered skills. + * + *

Error Handling Contract: + * + *

    + *
  • Total Failure: Severe underlying issues that prevent listing the source + * entirely (e.g., root directory missing, network connection failure) will propagate as a + * terminal error signal (typically {@link SkillSourceException}) via the returned {@link + * Single}. + *
  • Item-level Failure: Individual failures to load or parse a specific + * skill's frontmatter (e.g., malformed YAML configuration in a single `SKILL.md`) are + * robustly isolated and tolerated. A warning is logged for the invalid skill, and it is + * skipped. The returned map will successfully contain all other valid discovered skills. + *
+ * + * @return a {@link Single} emitting a map where keys are skill names and values are their {@link + * Frontmatter} + */ + Single> listFrontmatters(); + + /** + * Lists all resource files for a specific skill within a given directory. + * + *

If the skill or the resource directory does not exist, the returned {@link Single} will + * terminate with a {@link SkillSourceException}. + * + * @param skillName the name of the skill + * @param resourceDirectory the relative directory within the skill to list (e.g., "assets", + * "scripts") + * @return a {@link Single} emitting a list of resource paths relative to the skill directory + */ + Single> listResources(String skillName, String resourceDirectory); + + /** + * Loads the {@link Frontmatter} for a specific skill. + * + *

If the skill is not found or its frontmatter is malformed, the returned {@link Single} will + * terminate with a {@link SkillSourceException} or parsing error. + * + * @param skillName the name of the skill + * @return a {@link Single} emitting the {@link Frontmatter} for the skill + */ + Single loadFrontmatter(String skillName); + + /** + * Loads the instructions (body of SKILL.md) for a specific skill. + * + *

If the skill is not found or its file structure is invalid (e.g., unclosed frontmatter + * blocks), the returned {@link Single} will terminate with a {@link SkillSourceException}. + * + * @param skillName the name of the skill + * @return a {@link Single} emitting the instructions as a String + */ + Single loadInstructions(String skillName); + + /** + * Loads a specific resource file content. + * + *

If the skill or the specific resource path cannot be found, the returned {@link Single} will + * terminate with a {@link SkillSourceException}. + * + * @param skillName the name of the skill + * @param resourcePath the path to the resource file relative to the skill directory + * @return a {@link Single} emitting the {@link ByteSource} for the resource content + */ + Single loadResource(String skillName, String resourcePath); +} diff --git a/core/src/main/java/com/google/adk/skills/SkillSourceException.java b/core/src/main/java/com/google/adk/skills/SkillSourceException.java new file mode 100644 index 000000000..a08fa077e --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/SkillSourceException.java @@ -0,0 +1,28 @@ +/* + * 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; + +public final class SkillSourceException extends Exception { + + public SkillSourceException(String message) { + super(message); + } + + public SkillSourceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/test/java/com/google/adk/skills/FrontmatterTest.java b/core/src/test/java/com/google/adk/skills/FrontmatterTest.java new file mode 100644 index 000000000..0f910eb46 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/FrontmatterTest.java @@ -0,0 +1,81 @@ +/* + * 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 org.junit.Assert.assertThrows; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class FrontmatterTest { + + private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + + @Test + public void testValidFrontmatter() throws Exception { + String yaml = + """ + name: test-skill + description: This is a test + allowed-tools: "tool1 tool2" + compatibility: "1.0" + """; + Frontmatter fm = yamlMapper.readValue(yaml, Frontmatter.class); + + assertThat(fm.name()).isEqualTo("test-skill"); + assertThat(fm.description()).isEqualTo("This is a test"); + assertThat(fm.allowedTools()).hasValue("tool1 tool2"); + assertThat(fm.compatibility()).hasValue("1.0"); + } + + @Test + public void testFrontmatterWithMetadata() throws Exception { + String yaml = + """ + name: test-skill-metadata + description: Test with metadata + metadata: + key1: value1 + key2: 123 + """; + Frontmatter fm = yamlMapper.readValue(yaml, Frontmatter.class); + + assertThat(fm.name()).isEqualTo("test-skill-metadata"); + assertThat(fm.metadata()).containsEntry("key1", "value1"); + assertThat(fm.metadata()).containsEntry("key2", 123); + } + + @Test + public void testInvalidName() { + Frontmatter.Builder builder = Frontmatter.builder().name("Invalid_Name").description("test"); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, builder::build); + assertThat(ex).hasMessageThat().contains("lowercase kebab-case"); + } + + @Test + public void testLongName() { + String longName = "a".repeat(65); + Frontmatter.Builder builder = Frontmatter.builder().name(longName).description("test"); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, builder::build); + assertThat(ex).hasMessageThat().contains("must be at most 64 characters"); + } +} diff --git a/core/src/test/java/com/google/adk/skills/InMemorySkillSourceTest.java b/core/src/test/java/com/google/adk/skills/InMemorySkillSourceTest.java new file mode 100644 index 000000000..6723dfe0c --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/InMemorySkillSourceTest.java @@ -0,0 +1,187 @@ +/* + * 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 java.io.IOException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class InMemorySkillSourceTest { + + @Test + public void testListFrontmatters() { + Frontmatter fm1 = Frontmatter.builder().name("skill-1").description("desc1").build(); + Frontmatter fm2 = Frontmatter.builder().name("skill-2").description("desc2").build(); + + SkillSource source = + InMemorySkillSource.builder() + .skill("skill-1") + .frontmatter(fm1) + .instructions("body1") + .skill("skill-2") + .frontmatter(fm2) + .instructions("body2") + .build(); + + ImmutableMap frontmatters = source.listFrontmatters().blockingGet(); + + assertThat(frontmatters).hasSize(2); + assertThat(frontmatters.get("skill-1")).isEqualTo(fm1); + assertThat(frontmatters.get("skill-2")).isEqualTo(fm2); + } + + @Test + public void testListResources() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + SkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .addResource("assets/file1.txt", "content1") + .addResource("assets/subdir/file2.txt", "content2") + .addResource("other/file3.txt", "content3") + .build(); + + ImmutableList resources = source.listResources("my-skill", "assets").blockingGet(); + + assertThat(resources).containsExactly("assets/file1.txt", "assets/subdir/file2.txt"); + } + + @Test + public void testListResources_skillNotFound() { + SkillSource source = InMemorySkillSource.builder().build(); + + var single = source.listResources("non-existent", "assets"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testListResources_directoryNotFound() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + SkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .addResource("assets/file1.txt", "content1") + .build(); + + var single = source.listResources("my-skill", "non-existent"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testLoadFrontmatter() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + SkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .build(); + + assertThat(source.loadFrontmatter("my-skill").blockingGet()).isEqualTo(fm); + } + + @Test + public void testLoadInstructions() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + SkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("my instructions") + .build(); + + assertThat(source.loadInstructions("my-skill").blockingGet()).isEqualTo("my instructions"); + } + + @Test + public void testLoadResource() throws IOException { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + SkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .addResource("assets/file1.txt", "hello content") + .build(); + + ByteSource resource = source.loadResource("my-skill", "assets/file1.txt").blockingGet(); + + assertThat(new String(resource.read(), UTF_8)).isEqualTo("hello content"); + } + + @Test + public void testLoadResource_notFound() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + SkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .build(); + + var single = source.loadResource("my-skill", "non-existent.txt"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testLoadFrontmatter_skillNotFound() { + SkillSource source = InMemorySkillSource.builder().build(); + + var single = source.loadFrontmatter("non-existent"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testBuilder_missingFrontmatter() { + InMemorySkillSource.Builder builder = InMemorySkillSource.builder(); + builder.skill("my-skill").addResource("path", "content"); + + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + public void testBuilder_missingInstructions() { + InMemorySkillSource.Builder builder = InMemorySkillSource.builder(); + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + builder.skill("my-skill").frontmatter(fm); + + assertThrows(IllegalStateException.class, builder::build); + } +} diff --git a/core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java b/core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java new file mode 100644 index 000000000..8ba3b644d --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java @@ -0,0 +1,251 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class LocalSkillSourceTest { + + @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void testListFrontmatters() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skill1 = skillsBase.resolve("skill-1"); + Files.createDirectory(skill1); + Files.writeString( + skill1.resolve("SKILL.md"), + """ + --- + name: skill-1 + description: test1 + --- + body + """); + + Path skill2 = skillsBase.resolve("skill-2"); + Files.createDirectory(skill2); + Files.writeString( + skill2.resolve("SKILL.md"), + """ + --- + name: skill-2 + description: test2 + --- + body + """); + + SkillSource source = new LocalSkillSource(skillsBase); + ImmutableMap skills = source.listFrontmatters().blockingGet(); + + assertThat(skills).hasSize(2); + assertThat(skills).containsKey("skill-1"); + assertThat(skills).containsKey("skill-2"); + assertThat(skills.get("skill-1").description()).isEqualTo("test1"); + } + + @Test + public void testListResources() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + Path assetsDir = skillDir.resolve("assets"); + Files.createDirectory(assetsDir); + + Files.createFile(assetsDir.resolve("file1.txt")); + Path subDir = assetsDir.resolve("subdir"); + Files.createDirectory(subDir); + Files.createFile(subDir.resolve("file2.txt")); + + SkillSource source = new LocalSkillSource(skillsBase); + ImmutableList resources = source.listResources("my-skill", "assets").blockingGet(); + + assertThat(resources).containsExactly("assets/file1.txt", "assets/subdir/file2.txt"); + } + + @Test + public void testListResources_notDirectory() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + // No assets directory created + + SkillSource source = new LocalSkillSource(skillsBase); + var single = source.listResources("my-skill", "assets"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testListResources_skillNotFound() { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + + SkillSource source = new LocalSkillSource(skillsBase); + var single = source.listResources("non-existent", "assets"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testLoadFrontmatter() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + Files.writeString( + skillDir.resolve("SKILL.md"), + """ + --- + name: my-skill + description: This is a test skill + --- + body + """); + + SkillSource source = new LocalSkillSource(skillsBase); + Frontmatter fm = source.loadFrontmatter("my-skill").blockingGet(); + + assertThat(fm.name()).isEqualTo("my-skill"); + assertThat(fm.description()).isEqualTo("This is a test skill"); + } + + @Test + public void testLoadInstructions() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + Files.writeString( + skillDir.resolve("SKILL.md"), + """ + --- + name: my-skill + description: Test + --- + Some Markdown Body + """); + + SkillSource source = new LocalSkillSource(skillsBase); + String instructions = source.loadInstructions("my-skill").blockingGet(); + + assertThat(instructions).isEqualTo("Some Markdown Body"); + } + + @Test + public void testLoadInstructions_unclosedFrontmatter() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + Files.writeString( + skillDir.resolve("SKILL.md"), + """ + --- + name: my-skill + description: Test + Some Markdown Body without closing dashes + """); + + SkillSource source = new LocalSkillSource(skillsBase); + var single = source.loadInstructions("my-skill"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + assertThat(exception.getCause().getMessage()) + .contains("Skill file frontmatter not properly closed with ---"); + } + + @Test + public void testLoadResource() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + Path assetsDir = skillDir.resolve("assets"); + Files.createDirectory(assetsDir); + Path file = assetsDir.resolve("file1.txt"); + Files.writeString(file, "hello content"); + + SkillSource source = new LocalSkillSource(skillsBase); + ByteSource resource = source.loadResource("my-skill", "assets/file1.txt").blockingGet(); + + assertThat(new String(resource.read(), UTF_8)).isEqualTo("hello content"); + } + + @Test + public void testLoadResource_notFound() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + Path skillDir = skillsBase.resolve("my-skill"); + Files.createDirectory(skillDir); + + SkillSource source = new LocalSkillSource(skillsBase); + var single = source.loadResource("my-skill", "non-existent.txt"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testLoadFrontmatter_skillNotFound() { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + + SkillSource source = new LocalSkillSource(skillsBase); + var single = source.loadFrontmatter("non-existent"); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } + + @Test + public void testListSkillMdPaths_skillSourceException() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + SkillSource source = new LocalSkillSource(skillsBase); + + // Delete the directory to trigger IOException on Files.list + Files.delete(skillsBase); + + var single = source.listFrontmatters(); + RuntimeException exception = assertThrows(RuntimeException.class, single::blockingGet); + assertThat(exception.getCause()).isInstanceOf(SkillSourceException.class); + } +}