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..4a4411261 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/AbstractSkillSource.java @@ -0,0 +1,160 @@ +/* + * 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 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 java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.util.function.BiConsumer; + +/** + * 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 ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + + @Override + public ImmutableMap listFrontmatters() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + iterateSkills((name, path) -> builder.put(name, loadFrontmatter(name, path))); + return builder.buildOrThrow(); + } + + @Override + public Frontmatter loadFrontmatter(String skillName) { + PathT skillMdPath = findSkillMdPath(skillName); + return loadFrontmatter(skillName, skillMdPath); + } + + private Frontmatter loadFrontmatter(String skillName, PathT skillMdPath) { + try (BufferedReader reader = openReader(skillMdPath)) { + String yaml = readFrontmatterYaml(reader); + Frontmatter frontmatter = parseFrontmatter(yaml); + 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 = findSkillMdPath(skillName); + + try (BufferedReader reader = openReader(skillMdPath)) { + return readInstructions(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public ByteSource loadResource(String skillName, String resourcePath) { + PathT path = findResourcePath(skillName, resourcePath); + return new ByteSource() { + @Override + public InputStream openStream() throws IOException { + return Channels.newInputStream(AbstractSkillSource.this.openChannel(path)); + } + }; + } + + /** Iterates through SKILL.md files for all the supported skills. */ + protected abstract void iterateSkills(BiConsumer skillMdConsumer); + + /** Returns the path to the SKILL.md file for the given skill. */ + protected abstract PathT findSkillMdPath(String skillName); + + /** Returns the path to the resource for the given skill. */ + protected abstract PathT 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 { + String line = reader.readLine(); + checkArgument( + line != null && line.trim().equals(THREE_DASHES), + "Skill file must start with %s", + 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 IllegalArgumentException( + "Skill file frontmatter not properly closed with " + THREE_DASHES); + } + + private String readInstructions(BufferedReader reader) throws IOException { + // Skip the frontmatter block + String line = reader.readLine(); + checkArgument( + line != null && line.trim().equals(THREE_DASHES), + "Skill file must start with %s", + THREE_DASHES); + boolean dashClosed = false; + while ((line = reader.readLine()) != null) { + if (line.trim().equals(THREE_DASHES)) { + dashClosed = true; + break; + } + } + checkArgument(dashClosed, "Skill file frontmatter not properly closed with %s", 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(); + } + + 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..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..c61488743 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/InMemorySkillSource.java @@ -0,0 +1,167 @@ +/* + * 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.isEmpty() + ? "" + : (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..3a4eff012 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/LocalSkillSource.java @@ -0,0 +1,109 @@ +/* + * 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.file.Files.isDirectory; + +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.channels.ReadableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.BiConsumer; +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 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 + protected void iterateSkills(BiConsumer skillMdConsumer) { + try (Stream stream = Files.list(skillsBasePath)) { + stream + .filter(Files::isDirectory) + .map(this::findSkillMd) + .flatMap(Optional::stream) + .forEach( + skillMdPath -> + skillMdConsumer.accept( + skillMdPath.getParent().getFileName().toString(), skillMdPath)); + } catch (IOException e) { + logger.warn("Failed to list skills in directory: {}", skillsBasePath, e); + } + } + + @Override + protected Path findResourcePath(String skillName, String resourcePath) { + Path file = skillsBasePath.resolve(skillName).resolve(resourcePath); + if (!Files.exists(file)) { + throw new ResourceNotFoundException("Resource not found: " + file); + } + return file; + } + + @Override + protected Path findSkillMdPath(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 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/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..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..9967ad7f9 --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/InMemorySkillSourceTest.java @@ -0,0 +1,168 @@ +/* + * 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 testListResources_skillNotFound() { + InMemorySkillSource source = InMemorySkillSource.builder().build(); + + ImmutableList resources = source.listResources("non-existent", "assets"); + + assertThat(resources).isEmpty(); + } + + @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..aa402745b --- /dev/null +++ b/core/src/test/java/com/google/adk/skills/LocalSkillSourceTest.java @@ -0,0 +1,238 @@ +/* + * 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 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 + + LocalSkillSource source = new LocalSkillSource(skillsBase); + ImmutableList resources = source.listResources("my-skill", "assets"); + + assertThat(resources).isEmpty(); + } + + @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")); + } + + @Test + public void testListSkillMdPaths_ioException() throws IOException { + Path skillsBase = tempFolder.getRoot().toPath().resolve("skills"); + Files.createDirectory(skillsBase); + + LocalSkillSource source = new LocalSkillSource(skillsBase); + + // Delete the directory to trigger IOException on Files.list + Files.delete(skillsBase); + + ImmutableMap skills = source.listFrontmatters(); + + assertThat(skills).isEmpty(); + } +}