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..7f4096f8f --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/AbstractSkillSource.java @@ -0,0 +1,119 @@ +/* + * 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.checkArgument; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; + +/** + * Abstract base class for SkillSource implementations reading from markdown files. + * + * @param the type of path object + */ +public abstract class AbstractSkillSource implements SkillSource { + + private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + + @Override + public Frontmatter loadFrontmatter(String skillName) { + PathT skillMdPath = getSkillMdPath(skillName); + return loadFrontmatter(skillMdPath); + } + + protected final Frontmatter loadFrontmatter(PathT skillMdPath) { + try (BufferedReader reader = openSkillReader(skillMdPath)) { + String yaml = readFrontmatterYaml(reader); + Frontmatter frontmatter = parseFrontmatter(yaml); + String skillName = getSkillNameFromPath(skillMdPath); + checkArgument( + frontmatter.name().equals(skillName), + "Skill name '%s' does not match directory name '%s'.", + frontmatter.name(), + skillName); + return frontmatter; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public String loadInstructions(String skillName) { + PathT skillMdPath = getSkillMdPath(skillName); + try (BufferedReader reader = openSkillReader(skillMdPath)) { + return readInstructions(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Returns the path to the SKILL.md file for the given skill. */ + protected abstract PathT getSkillMdPath(String skillName); + + /** Returns the skill name based on the given SKILL.md path. */ + protected abstract String getSkillNameFromPath(PathT skillMdPath); + + /** Opens a reader for the SKILL.md file of the given skill. */ + protected abstract BufferedReader openSkillReader(PathT skillMdPath); + + private String readFrontmatterYaml(BufferedReader reader) throws IOException { + String line = reader.readLine(); + checkArgument(line != null && line.trim().equals("---"), "Skill file must start with ---"); + + StringBuilder sb = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (line.trim().equals("---")) { + return sb.toString(); + } + sb.append(line).append("\n"); + } + throw new IllegalArgumentException("Skill file frontmatter not properly closed with ---"); + } + + private String readInstructions(BufferedReader reader) throws IOException { + // Skip the frontmatter block + String line = reader.readLine(); + checkArgument(line != null && line.trim().equals("---"), "Skill file must start with ---"); + boolean dashClosed = false; + while ((line = reader.readLine()) != null) { + if (line.trim().equals("---")) { + dashClosed = true; + break; + } + } + checkArgument(dashClosed, "Skill file frontmatter not properly closed with ---"); + + // 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(); + } + + private Frontmatter parseFrontmatter(String yaml) { + try { + return yamlMapper.readValue(yaml, Frontmatter.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} 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..f601e06f6 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/Frontmatter.java @@ -0,0 +1,143 @@ +/* + * 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; + +/** L1 skill content: metadata parsed from SKILL.md for skill discovery. */ +@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/GcsSkillSource.java b/core/src/main/java/com/google/adk/skills/GcsSkillSource.java new file mode 100644 index 000000000..0eb8872c9 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/GcsSkillSource.java @@ -0,0 +1,145 @@ +/* + * 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.checkArgument; +import static com.google.common.base.Strings.isNullOrEmpty; +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.function.Function.identity; + +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.Storage.BlobListOption; +import com.google.cloud.storage.StorageOptions; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteSource; +import java.io.BufferedReader; +import java.io.InputStream; +import java.nio.channels.Channels; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.Nullable; + +/** Loads skills from a Google Cloud Storage bucket. */ +public final class GcsSkillSource extends AbstractSkillSource { + + private final Storage storage; + private final String bucketName; + private final String basePrefix; + + /** + * @param bucketName Name of the GCS bucket. + * @param skillsBasePath Base directory within the bucket. Can be null or empty for root. + */ + public GcsSkillSource(String bucketName, @Nullable String skillsBasePath) { + this(StorageOptions.getDefaultInstance().getService(), bucketName, skillsBasePath); + } + + /** + * @param storage Storage instance. + * @param bucketName Name of the GCS bucket. + * @param skillsBasePath Base directory within the bucket. Can be null or empty for root. + */ + public GcsSkillSource(Storage storage, String bucketName, @Nullable String skillsBasePath) { + this.storage = storage; + this.bucketName = bucketName; + String prefix = isNullOrEmpty(skillsBasePath) ? "" : skillsBasePath.trim(); + if (!prefix.isEmpty() && !prefix.endsWith("/")) { + prefix += "/"; + } + this.basePrefix = prefix; + } + + @Override + public ImmutableMap listFrontmatters() { + Bucket bucket = getBucket(); + return bucket + .list(BlobListOption.prefix(basePrefix), BlobListOption.matchGlob("**/{SKILL.md,skill.md}")) + .streamAll() + .map(this::loadFrontmatter) + .collect(toImmutableMap(Frontmatter::name, identity())); + } + + @Override + protected String getSkillNameFromPath(Blob skillMdPath) { + String name = skillMdPath.getName(); + List parts = Splitter.on('/').omitEmptyStrings().splitToList(name); + checkArgument( + parts.size() > 1, + "Invalid SKILL.md path '%s'. It should be under a directory of the skill name", + name); + return parts.get(parts.size() - 2); + } + + @Override + public ImmutableList listResources(String skillName, String resourceDirectory) { + Bucket bucket = getBucket(); + String prefix = basePrefix + skillName + "/" + resourceDirectory + "/"; + + ImmutableList.Builder builder = ImmutableList.builder(); + for (Blob blob : bucket.list(BlobListOption.prefix(prefix)).iterateAll()) { + if (!blob.isDirectory()) { + String fullName = blob.getName(); + // Return path relative to skill directory + builder.add(fullName.substring((basePrefix + skillName + "/").length())); + } + } + return builder.build(); + } + + @Override + public ByteSource loadResource(String skillName, String resourcePath) { + Bucket bucket = getBucket(); + String blobName = basePrefix + skillName + "/" + resourcePath; + Blob blob = bucket.get(blobName); + if (blob == null) { + throw new ResourceNotFoundException("Resource not found: " + blobName); + } + return new ByteSource() { + @Override + public InputStream openStream() { + return Channels.newInputStream(blob.reader()); + } + }; + } + + @Override + protected Blob getSkillMdPath(String skillName) { + Bucket bucket = getBucket(); + String skillDirPrefix = basePrefix + skillName + "/"; + Blob manifestBlob = + Optional.ofNullable(bucket.get(skillDirPrefix + "SKILL.md")) + .orElseGet(() -> bucket.get(skillDirPrefix + "skill.md")); + checkArgument(manifestBlob != null, "SKILL.md not found in GCS for skill: %s", skillName); + return manifestBlob; + } + + @Override + protected BufferedReader openSkillReader(Blob skillMdPath) { + return new BufferedReader(Channels.newReader(skillMdPath.reader(), UTF_8)); + } + + private Bucket getBucket() { + Bucket bucket = storage.get(bucketName); + checkArgument(bucket != null, "Bucket not found: %s", bucketName); + return bucket; + } +} 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..105f5f923 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/InMemorySkillSource.java @@ -0,0 +1,164 @@ +/* + * 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 java.util.HashMap; +import java.util.Map; + +/** + * 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 ImmutableMap listFrontmatters() { + return ImmutableMap.copyOf(Maps.transformValues(skills, SkillData::frontmatter)); + } + + @Override + public ImmutableList listResources(String skillName, String resourceDirectory) { + SkillData data = skills.get(skillName); + if (data == null) { + return ImmutableList.of(); + } + String prefix = resourceDirectory.endsWith("/") ? resourceDirectory : resourceDirectory + "/"; + return data.resources().keySet().stream() + .filter(path -> path.startsWith(prefix)) + .collect(toImmutableList()); + } + + private SkillData getSkillDataOrThrow(String skillName) { + SkillData data = skills.get(skillName); + if (data == null) { + throw new SkillNotFoundException("Skill not found: " + skillName); + } + return data; + } + + @Override + public Frontmatter loadFrontmatter(String skillName) { + return getSkillDataOrThrow(skillName).frontmatter(); + } + + @Override + public String loadInstructions(String skillName) { + return getSkillDataOrThrow(skillName).instructions(); + } + + @Override + public ByteSource loadResource(String skillName, String resourcePath) { + SkillData data = getSkillDataOrThrow(skillName); + ByteSource source = data.resources().get(resourcePath); + if (source == null) { + throw new ResourceNotFoundException("Resource not found: " + resourcePath); + } + return source; + } + + /** 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..887949195 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/LocalSkillSource.java @@ -0,0 +1,127 @@ +/* + * 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.ImmutableMap.toImmutableMap; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.Files.isDirectory; +import static java.util.function.Function.identity; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteSource; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Loads skills from the local file system. */ +public final class LocalSkillSource extends AbstractSkillSource { + private static final Logger logger = LoggerFactory.getLogger(LocalSkillSource.class); + + private final Path skillsBasePath; + + public LocalSkillSource(Path skillsBasePath) { + this.skillsBasePath = skillsBasePath; + } + + @Override + public ImmutableMap listFrontmatters() { + if (!isDirectory(skillsBasePath)) { + logger.warn("Skills base path is not a directory: {}", skillsBasePath); + return ImmutableMap.of(); + } + + try (Stream stream = Files.list(skillsBasePath)) { + return stream + .filter(Files::isDirectory) + .map(Path::getFileName) + .map(Path::toString) + .map(this::loadFrontmatter) + .collect(toImmutableMap(Frontmatter::name, identity())); + } catch (IOException e) { + logger.warn("Failed to list skills in directory: {}", skillsBasePath, e); + return ImmutableMap.of(); + } + } + + @Override + public ImmutableList listResources(String skillName, String resourceDirectory) { + Path skillDir = skillsBasePath.resolve(skillName); + Path resourceDir = skillDir.resolve(resourceDirectory); + + if (!isDirectory(resourceDir)) { + return ImmutableList.of(); + } + + ImmutableList.Builder builder = ImmutableList.builder(); + try (Stream paths = Files.walk(resourceDir)) { + paths + .filter(Files::isRegularFile) + .forEach(path -> builder.add(skillDir.relativize(path).toString())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to traverse directory: " + resourceDir, e); + } + return builder.build(); + } + + @Override + public ByteSource loadResource(String skillName, String resourcePath) { + Path file = skillsBasePath.resolve(skillName).resolve(resourcePath); + if (!Files.exists(file)) { + throw new ResourceNotFoundException("Resource not found: " + file); + } + return com.google.common.io.Files.asByteSource(file.toFile()); + } + + @Override + protected Path getSkillMdPath(String skillName) { + Path skillDir = skillsBasePath.resolve(skillName); + if (!isDirectory(skillDir)) { + throw new SkillNotFoundException("Skill directory not found: " + skillName); + } + return findSkillMd(skillDir) + .orElseThrow(() -> new SkillNotFoundException("SKILL.md not found in " + skillName)); + } + + @Override + protected String getSkillNameFromPath(Path skillMdPath) { + return skillMdPath.getParent().getFileName().toString(); + } + + @Override + protected BufferedReader openSkillReader(Path skillMdPath) { + try { + return Files.newBufferedReader(skillMdPath, UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private Optional findSkillMd(Path dir) { + Path skillMd = dir.resolve("SKILL.md"); + if (!Files.exists(skillMd)) { + skillMd = dir.resolve("skill.md"); + } + return Files.exists(skillMd) ? Optional.of(skillMd) : Optional.empty(); + } +} diff --git a/core/src/main/java/com/google/adk/skills/ResourceNotFoundException.java b/core/src/main/java/com/google/adk/skills/ResourceNotFoundException.java new file mode 100644 index 000000000..8a72abdd0 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/ResourceNotFoundException.java @@ -0,0 +1,29 @@ +/* + * 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; + +/** A runtime exception when trying to locate a non-existing resource. */ +public final class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/google/adk/skills/SkillNotFoundException.java b/core/src/main/java/com/google/adk/skills/SkillNotFoundException.java new file mode 100644 index 000000000..ffa35e243 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/SkillNotFoundException.java @@ -0,0 +1,29 @@ +/* + * 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; + +/** A runtime exception when trying to locate a non-existing skill. */ +public final class SkillNotFoundException extends RuntimeException { + + public SkillNotFoundException(String message) { + super(message); + } + + public SkillNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} 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..0a44dc566 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/SkillSource.java @@ -0,0 +1,71 @@ +/* + * 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; + +/** Interface for getting access to available skills. */ +public interface SkillSource { + + /** + * Lists all available {@link Frontmatter}s for skills. + * + * @return a map where keys are skill names and values are their {@link Frontmatter} + */ + ImmutableMap listFrontmatters(); + + /** + * Lists all resource files for a specific skill within a given directory. + * + * @param skillName the name of the skill + * @param resourceDirectory the relative directory within the skill to list (e.g., "assets", + * "scripts") + * @return a list of resource paths relative to the skill directory + */ + ImmutableList listResources(String skillName, String resourceDirectory); + + /** + * Loads the {@link Frontmatter} for a specific skill. + * + * @param skillName the name of the skill + * @return the {@link Frontmatter} for the skill + * @throws SkillNotFoundException if the skill is not found + */ + Frontmatter loadFrontmatter(String skillName); + + /** + * Loads the instructions (body of SKILL.md) for a specific skill. + * + * @param skillName the name of the skill + * @return the instructions as a String + * @throws SkillNotFoundException if the skill is not found + */ + String loadInstructions(String skillName); + + /** + * Loads a specific resource file content. + * + * @param skillName the name of the skill + * @param resourcePath the path to the resource file relative to the skill directory + * @return the {@link ByteSource} for the resource + * @throws SkillNotFoundException if the skill is not found + * @throws ResourceNotFoundException if the resource is not found + */ + ByteSource loadResource(String skillName, String resourcePath); +} 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..c5e4a7a2c --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/FrontmatterTest.java @@ -0,0 +1,64 @@ +/* + * 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 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/GcsSkillSourceTest.java b/core/src/test/java/com/google/adk/skills/GcsSkillSourceTest.java new file mode 100644 index 000000000..c7e77e09c --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/GcsSkillSourceTest.java @@ -0,0 +1,171 @@ +/* + * 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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.api.gax.paging.Page; +import com.google.cloud.ReadChannel; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.Storage; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteSource; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.util.stream.Stream; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public final class GcsSkillSourceTest { + + @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); + @Mock private Storage mockStorage; + @Mock private Bucket mockBucket; + @Mock private Page mockPage; + private GcsSkillSource skillSource; + + @Before + public void setUp() { + when(mockStorage.get("my-bucket")).thenReturn(mockBucket); + skillSource = new GcsSkillSource(mockStorage, "my-bucket", "skills"); + } + + @Test + public void listFrontmatters_success() throws Exception { + String skillMdContent = + """ + --- + name: test-skill + description: A list test + --- + Body + """; + Blob skillMdBlob = mockBlob("skills/test-skill/SKILL.md", skillMdContent); + + when(mockPage.streamAll()).thenReturn(Stream.of(skillMdBlob)); + when(mockBucket.list(any(), any())).thenReturn(mockPage); + when(mockBucket.get("skills/test-skill/SKILL.md")).thenReturn(skillMdBlob); + + ImmutableMap skills = skillSource.listFrontmatters(); + + assertThat(skills).hasSize(1); + assertThat(skills).containsKey("test-skill"); + assertThat(skills.get("test-skill").description()).isEqualTo("A list test"); + } + + @Test + public void loadFrontmatter_success() throws Exception { + String skillMdContent = + """ + --- + name: test-skill + description: test desc + --- + Skill Body + """; + Blob manifestBlob = mockBlob("skills/test-skill/SKILL.md", skillMdContent); + when(mockBucket.get("skills/test-skill/SKILL.md")).thenReturn(manifestBlob); + + Frontmatter fm = skillSource.loadFrontmatter("test-skill"); + + assertThat(fm.name()).isEqualTo("test-skill"); + assertThat(fm.description()).isEqualTo("test desc"); + } + + @Test + public void loadInstructions_success() throws Exception { + String skillMdContent = + """ + --- + name: test-skill + description: test desc + --- + Skill Body + """; + Blob manifestBlob = mockBlob("skills/test-skill/SKILL.md", skillMdContent); + when(mockBucket.get("skills/test-skill/SKILL.md")).thenReturn(manifestBlob); + + String instructions = skillSource.loadInstructions("test-skill"); + + assertThat(instructions).isEqualTo("Skill Body"); + } + + @Test + public void listResources_success() { + Blob fileBlob = mock(Blob.class); + when(fileBlob.isDirectory()).thenReturn(false); + when(fileBlob.getName()).thenReturn("skills/test-skill/references/doc.md"); + + when(mockPage.iterateAll()).thenReturn(ImmutableList.of(fileBlob)); + when(mockBucket.list(eq(Storage.BlobListOption.prefix("skills/test-skill/references/")))) + .thenReturn(mockPage); + + ImmutableList resources = skillSource.listResources("test-skill", "references"); + + assertThat(resources).containsExactly("references/doc.md"); + } + + @Test + public void loadResource_success() throws Exception { + Blob blob = mockBlob("skills/test-skill/references/doc.md", "content"); + when(mockBucket.get("skills/test-skill/references/doc.md")).thenReturn(blob); + + ByteSource source = skillSource.loadResource("test-skill", "references/doc.md"); + + assertThat(source.read()).isEqualTo("content".getBytes(UTF_8)); + } + + @Test + public void loadResource_notFound() { + when(mockBucket.get("skills/test-skill/references/missing.md")).thenReturn(null); + + assertThrows( + ResourceNotFoundException.class, + () -> skillSource.loadResource("test-skill", "references/missing.md")); + } + + private Blob mockBlob(String name, String content) throws IOException { + Blob manifestBlob = mock(Blob.class); + when(manifestBlob.getName()).thenReturn(name); + when(manifestBlob.exists()).thenReturn(true); + ReadableByteChannel channel = + Channels.newChannel(new ByteArrayInputStream(content.getBytes(UTF_8))); + ReadChannel mockReadChannel = mock(ReadChannel.class); + when(mockReadChannel.read(any(ByteBuffer.class))) + .thenAnswer(invocation -> channel.read(invocation.getArgument(0))); + when(manifestBlob.reader()).thenReturn(mockReadChannel); + return manifestBlob; + } +} 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..5ab5ef6ad --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/InMemorySkillSourceTest.java @@ -0,0 +1,159 @@ +/* + * 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(); + + InMemorySkillSource source = + InMemorySkillSource.builder() + .skill("skill-1") + .frontmatter(fm1) + .instructions("body1") + .skill("skill-2") + .frontmatter(fm2) + .instructions("body2") + .build(); + + ImmutableMap frontmatters = source.listFrontmatters(); + + 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(); + + InMemorySkillSource 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"); + + assertThat(resources).containsExactly("assets/file1.txt", "assets/subdir/file2.txt"); + } + + @Test + public void testLoadFrontmatter() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + InMemorySkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .build(); + + assertThat(source.loadFrontmatter("my-skill")).isEqualTo(fm); + } + + @Test + public void testLoadInstructions() { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + InMemorySkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("my instructions") + .build(); + + assertThat(source.loadInstructions("my-skill")).isEqualTo("my instructions"); + } + + @Test + public void testLoadResource() throws IOException { + Frontmatter fm = Frontmatter.builder().name("my-skill").description("desc").build(); + + InMemorySkillSource 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"); + + 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(); + + InMemorySkillSource source = + InMemorySkillSource.builder() + .skill("my-skill") + .frontmatter(fm) + .instructions("body") + .build(); + + assertThrows( + ResourceNotFoundException.class, () -> source.loadResource("my-skill", "non-existent.txt")); + } + + @Test + public void testLoadFrontmatter_skillNotFound() { + InMemorySkillSource source = InMemorySkillSource.builder().build(); + + assertThrows(SkillNotFoundException.class, () -> source.loadFrontmatter("non-existent")); + } + + @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..a7b2694f8 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java @@ -0,0 +1,208 @@ +/* + * 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 + """); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + ImmutableMap skills = source.listFrontmatters(); + + 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")); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + ImmutableList resources = source.listResources("my-skill", "assets"); + + assertThat(resources).containsExactly("assets/file1.txt", "assets/subdir/file2.txt"); + } + + @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 + """); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + Frontmatter fm = source.loadFrontmatter("my-skill"); + + 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 + """); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + String instructions = source.loadInstructions("my-skill"); + + 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 + """); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> source.loadInstructions("my-skill")); + assertThat(exception) + .hasMessageThat() + .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"); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + ByteSource resource = source.loadResource("my-skill", "assets/file1.txt"); + + 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); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + assertThrows( + ResourceNotFoundException.class, () -> source.loadResource("my-skill", "non-existent.txt")); + } + + @Test + public void testLoadFrontmatter_skillNotFound() { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + assertThrows(SkillNotFoundException.class, () -> source.loadFrontmatter("non-existent")); + } +}