From 621624703dc988c2b949fbf26378bde3e0384165 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Tue, 17 Mar 2026 19:47:58 -0700 Subject: [PATCH] feat: Introduce a "Skills" framework for ADK agents This change adds a new "Skills" framework to the ADK, enabling agents to discover, load, and execute modular skills. Skills are defined by a `SKILL.md` file containing frontmatter and instructions, and can include additional resources like references, assets, and scripts. Key components include: - `SkillLoader`: An interface with implementations for loading skills from local files (`LocalSkillLoader`) and Google Cloud Storage (`GcsSkillLoader`). - `Frontmatter`, `Resources`, `Script`, and `Skill`: Data models representing the structure of a skill. - `SkillToolset`: A new `BaseToolset` that provides tools for interacting with skills: - `list_skills`: Lists available skills. - `load_skill`: Loads the main instructions of a skill. - `load_skill_resource`: Loads files from a skill's `references/`, `assets/`, or `scripts/` directories. - `run_skill_script`: Executes scripts within a skill's `scripts/` directory using a provided `BaseCodeExecutor`. - The `SkillToolset` also adds default system instructions to guide the LLM on how to use skills. - The `LlmAgent` and `BaseLlmFlow` are updated to properly integrate the `SkillToolset`'s request processing. PiperOrigin-RevId: 885332438 --- .../java/com/google/adk/agents/LlmAgent.java | 44 +++ .../adk/flows/llmflows/BaseLlmFlow.java | 17 +- .../adk/skills/AbstractSkillLoader.java | 60 +++ .../com/google/adk/skills/Frontmatter.java | 143 ++++++++ .../com/google/adk/skills/GcsSkillLoader.java | 141 ++++++++ .../google/adk/skills/LocalSkillLoader.java | 136 +++++++ .../java/com/google/adk/skills/Resources.java | 62 ++++ .../java/com/google/adk/skills/Script.java | 40 ++ .../java/com/google/adk/skills/Skill.java | 53 +++ .../com/google/adk/skills/SkillLoader.java | 51 +++ .../com/google/adk/tools/BaseToolset.java | 11 + .../adk/tools/skills/ListSkillsTool.java | 68 ++++ .../tools/skills/LoadSkillResourceTool.java | 144 ++++++++ .../adk/tools/skills/LoadSkillTool.java | 112 ++++++ .../adk/tools/skills/RunSkillScriptTool.java | 342 ++++++++++++++++++ .../google/adk/tools/skills/SkillToolset.java | 166 +++++++++ .../google/adk/skills/FrontmatterTest.java | 70 ++++ .../google/adk/skills/GcsSkillLoaderTest.java | 131 +++++++ .../adk/skills/LocalSkillLoaderTest.java | 139 +++++++ .../adk/tools/skills/ListSkillsToolTest.java | 89 +++++ .../skills/LoadSkillResourceToolTest.java | 238 ++++++++++++ .../adk/tools/skills/LoadSkillToolTest.java | 75 ++++ .../tools/skills/RunSkillScriptToolTest.java | 259 +++++++++++++ .../adk/tools/skills/SkillToolsetTest.java | 97 +++++ 24 files changed, 2672 insertions(+), 16 deletions(-) create mode 100644 core/src/main/java/com/google/adk/skills/AbstractSkillLoader.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/GcsSkillLoader.java create mode 100644 core/src/main/java/com/google/adk/skills/LocalSkillLoader.java create mode 100644 core/src/main/java/com/google/adk/skills/Resources.java create mode 100644 core/src/main/java/com/google/adk/skills/Script.java create mode 100644 core/src/main/java/com/google/adk/skills/Skill.java create mode 100644 core/src/main/java/com/google/adk/skills/SkillLoader.java create mode 100644 core/src/main/java/com/google/adk/tools/skills/ListSkillsTool.java create mode 100644 core/src/main/java/com/google/adk/tools/skills/LoadSkillResourceTool.java create mode 100644 core/src/main/java/com/google/adk/tools/skills/LoadSkillTool.java create mode 100644 core/src/main/java/com/google/adk/tools/skills/RunSkillScriptTool.java create mode 100644 core/src/main/java/com/google/adk/tools/skills/SkillToolset.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/GcsSkillLoaderTest.java create mode 100644 core/src/test/java/com/google/adk/skills/LocalSkillLoaderTest.java create mode 100644 core/src/test/java/com/google/adk/tools/skills/ListSkillsToolTest.java create mode 100644 core/src/test/java/com/google/adk/tools/skills/LoadSkillResourceToolTest.java create mode 100644 core/src/test/java/com/google/adk/tools/skills/LoadSkillToolTest.java create mode 100644 core/src/test/java/com/google/adk/tools/skills/RunSkillScriptToolTest.java create mode 100644 core/src/test/java/com/google/adk/tools/skills/SkillToolsetTest.java diff --git a/core/src/main/java/com/google/adk/agents/LlmAgent.java b/core/src/main/java/com/google/adk/agents/LlmAgent.java index 89024a59b..ed16e43e8 100644 --- a/core/src/main/java/com/google/adk/agents/LlmAgent.java +++ b/core/src/main/java/com/google/adk/agents/LlmAgent.java @@ -47,12 +47,16 @@ import com.google.adk.events.Event; import com.google.adk.flows.llmflows.AutoFlow; import com.google.adk.flows.llmflows.BaseLlmFlow; +import com.google.adk.flows.llmflows.RequestProcessor; +import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult; import com.google.adk.flows.llmflows.SingleFlow; import com.google.adk.models.BaseLlm; import com.google.adk.models.LlmRegistry; +import com.google.adk.models.LlmRequest; import com.google.adk.models.Model; import com.google.adk.tools.BaseTool; import com.google.adk.tools.BaseToolset; +import com.google.adk.tools.ToolContext; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; @@ -70,6 +74,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.Executor; +import java.util.function.BiFunction; import java.util.function.Function; import javax.annotation.Nullable; import org.slf4j.Logger; @@ -770,6 +775,45 @@ public Flowable canonicalTools(@Nullable ReadonlyContext context) { return Flowable.concat(toolFlowables); } + /** + * Constructs a {@link RequestProcessor} that sequentially applies the {@code processLlmRequest} + * methods of all tools and toolsets associated with this agent to the incoming {@link + * LlmRequest}. + * + * @return A {@link RequestProcessor} that applies tool-specific modifications to LLM requests. + */ + public RequestProcessor getRequestProcessorFromTools() { + return (context, request) -> { + ReadonlyContext readonlyContext = new ReadonlyContext(context); + List> processors = new ArrayList<>(); + + for (Object toolOrToolset : toolsUnion()) { + if (toolOrToolset instanceof BaseTool baseTool) { + processors.add(baseTool::processLlmRequest); + } else if (toolOrToolset instanceof BaseToolset baseToolset) { + processors.add( + (builder, ctx) -> + baseToolset + .processLlmRequest(builder, ctx) + .andThen(baseToolset.getTools(readonlyContext)) + .concatMapCompletable(b -> b.processLlmRequest(builder, ctx))); + } else { + throw new IllegalArgumentException( + "Object in tools list is not of a supported type: " + + toolOrToolset.getClass().getName()); + } + } + + LlmRequest.Builder builder = request.toBuilder(); + ToolContext toolContext = ToolContext.builder(context).build(); + return Flowable.fromIterable(processors) + .concatMapCompletable(f -> f.apply(builder, toolContext)) + .andThen( + Single.fromCallable( + () -> RequestProcessingResult.create(builder.build(), ImmutableList.of()))); + }; + } + public Instruction instruction() { return instruction; } diff --git a/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java b/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java index e00cf0cbf..06c173677 100644 --- a/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java +++ b/core/src/main/java/com/google/adk/flows/llmflows/BaseLlmFlow.java @@ -25,11 +25,9 @@ import com.google.adk.agents.InvocationContext; import com.google.adk.agents.LiveRequest; import com.google.adk.agents.LlmAgent; -import com.google.adk.agents.ReadonlyContext; import com.google.adk.agents.RunConfig.StreamingMode; import com.google.adk.events.Event; import com.google.adk.flows.BaseFlow; -import com.google.adk.flows.llmflows.RequestProcessor.RequestProcessingResult; import com.google.adk.flows.llmflows.ResponseProcessor.ResponseProcessingResult; import com.google.adk.models.BaseLlm; import com.google.adk.models.BaseLlmConnection; @@ -38,7 +36,6 @@ import com.google.adk.models.LlmRequest; import com.google.adk.models.LlmResponse; import com.google.adk.telemetry.Tracing; -import com.google.adk.tools.ToolContext; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.genai.types.FunctionResponse; @@ -96,20 +93,8 @@ private Flowable preprocess( Context currentContext = Context.current(); LlmAgent agent = (LlmAgent) context.agent(); - RequestProcessor toolsProcessor = - (ctx, req) -> { - LlmRequest.Builder builder = req.toBuilder(); - return agent - .canonicalTools(new ReadonlyContext(ctx)) - .concatMapCompletable( - tool -> tool.processLlmRequest(builder, ToolContext.builder(ctx).build())) - .andThen( - Single.fromCallable( - () -> RequestProcessingResult.create(builder.build(), ImmutableList.of()))); - }; - Iterable allProcessors = - Iterables.concat(requestProcessors, ImmutableList.of(toolsProcessor)); + Iterables.concat(requestProcessors, ImmutableList.of(agent.getRequestProcessorFromTools())); return Flowable.fromIterable(allProcessors) .concatMap( diff --git a/core/src/main/java/com/google/adk/skills/AbstractSkillLoader.java b/core/src/main/java/com/google/adk/skills/AbstractSkillLoader.java new file mode 100644 index 000000000..627a9058f --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/AbstractSkillLoader.java @@ -0,0 +1,60 @@ +package com.google.adk.skills; + +import static org.apache.arrow.util.Preconditions.checkArgument; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Base class containing common parsing logic for SkillLoaders. */ +public abstract class AbstractSkillLoader implements SkillLoader { + private static final Logger logger = LoggerFactory.getLogger(AbstractSkillLoader.class); + private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); + + /** Record to hold the parsed SKILL.md content. */ + protected record ParsedSkillMd(Frontmatter frontmatter, String body) {} + + /** + * Retrieves all available parsed SKILL.md files. + * + * @return A map where keys are skill IDs (directory names) and values are the parsed SKILL.md + * contents, or empty if none are found. + */ + protected abstract ImmutableMap loadAllParsedSkills(); + + @Override + public final ImmutableMap listSkills() { + ImmutableMap.Builder builder = ImmutableMap.builder(); + loadAllParsedSkills() + .forEach( + (skillId, parsedSkill) -> { + if (skillId.equals(parsedSkill.frontmatter().name())) { + builder.put(skillId, parsedSkill.frontmatter()); + } else { + logger.warn("Skipping invalid skill: Name does not match directory. {}", skillId); + } + }); + + return builder.buildOrThrow(); + } + + protected final ParsedSkillMd parseSkillMdContent(String content) { + checkArgument(content.startsWith("---"), "SKILL.md must start with YAML frontmatter (---)"); + + String[] parts = content.split("---", 3); + checkArgument(parts.length >= 3, "SKILL.md frontmatter not properly closed with ---"); + + String frontmatterStr = parts[1]; + String body = parts[2].trim(); + + try { + Frontmatter fm = yamlMapper.readValue(frontmatterStr, Frontmatter.class); + return new ParsedSkillMd(fm, body); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid YAML in frontmatter", 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..c05e1e70a --- /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("allowedTools") + 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/GcsSkillLoader.java b/core/src/main/java/com/google/adk/skills/GcsSkillLoader.java new file mode 100644 index 000000000..096528564 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/GcsSkillLoader.java @@ -0,0 +1,141 @@ +package com.google.adk.skills; + +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.function.Function.identity; +import static org.apache.arrow.util.Preconditions.checkArgument; + +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.Bucket; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Function; +import java.util.function.Predicate; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Loads skills from a Google Cloud Storage bucket. */ +public final class GcsSkillLoader extends AbstractSkillLoader { + private static final Logger logger = LoggerFactory.getLogger(GcsSkillLoader.class); + + 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 GcsSkillLoader(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 GcsSkillLoader(Storage storage, String bucketName, @Nullable String skillsBasePath) { + this.storage = storage; + this.bucketName = bucketName; + String prefix = + skillsBasePath != null && !skillsBasePath.isEmpty() ? skillsBasePath.trim() : ""; + if (!prefix.isEmpty() && !prefix.endsWith("/")) { + prefix += "/"; + } + this.basePrefix = prefix; + } + + @Override + public Skill loadSkill(String skillId) { + Bucket bucket = getBucket(); + String skillDirPrefix = basePrefix + skillId + "/"; + Blob manifestBlob = bucket.get(skillDirPrefix + "SKILL.md"); + + checkArgument( + manifestBlob != null && manifestBlob.exists(), + "SKILL.md not found at gs://%s/%sSKILL.md", + bucketName, + skillDirPrefix); + + ParsedSkillMd parsed = parseSkillMdContent(new String(manifestBlob.getContent(), UTF_8)); + + checkArgument( + skillId.equals(parsed.frontmatter().name()), + "Skill name '%s' does not match directory name '%s'.", + parsed.frontmatter().name(), + skillId); + + ImmutableMap references = + loadGcsDirFiles(bucket, skillDirPrefix + "references/"); + ImmutableMap assets = loadGcsDirFiles(bucket, skillDirPrefix + "assets/"); + ImmutableMap scripts = + loadGcsDirFiles( + bucket, skillDirPrefix + "scripts/", bytes -> Script.create(new String(bytes, UTF_8))); + + return Skill.builder() + .frontmatter(parsed.frontmatter()) + .instructions(parsed.body()) + .resources( + Resources.builder().references(references).assets(assets).scripts(scripts).build()) + .build(); + } + + @Override + protected ImmutableMap loadAllParsedSkills() { + Bucket bucket = getBucket(); + + ImmutableMap.Builder builder = ImmutableMap.builder(); + + for (Blob blob : + bucket + .list( + Storage.BlobListOption.prefix(basePrefix), + Storage.BlobListOption.currentDirectory()) + .iterateAll()) { + if (!blob.isDirectory()) { + continue; + } + String skillPrefix = blob.getName(); // Ends with / + List parts = Splitter.on('/').omitEmptyStrings().splitToList(skillPrefix); + String skillId = parts.get(parts.size() - 1); + + Blob manifestBlob = bucket.get(skillPrefix + "SKILL.md"); + if (manifestBlob != null && manifestBlob.exists()) { + try { + builder.put(skillId, parseSkillMdContent(new String(manifestBlob.getContent(), UTF_8))); + } catch (IllegalArgumentException e) { + logger.warn("Skipping invalid skill in bucket: {}", skillId, e); + } + } + } + return builder.buildOrThrow(); + } + + private Bucket getBucket() { + Bucket bucket = storage.get(bucketName); + checkArgument(bucket != null, "Bucket not found: %s", bucketName); + return bucket; + } + + private static ImmutableMap loadGcsDirFiles(Bucket bucket, String prefix) { + return loadGcsDirFiles(bucket, prefix, identity()); + } + + private static ImmutableMap loadGcsDirFiles( + Bucket bucket, String prefix, Function valueFunc) { + return bucket + .list(Storage.BlobListOption.prefix(prefix)) + .streamAll() + .filter(Predicate.not(Blob::isDirectory)) + .map(blob -> Map.entry(blob.getName().substring(prefix.length()), blob)) + .filter(e -> !e.getKey().isEmpty()) + .collect(toImmutableMap(Entry::getKey, e -> valueFunc.apply(e.getValue().getContent()))); + } +} diff --git a/core/src/main/java/com/google/adk/skills/LocalSkillLoader.java b/core/src/main/java/com/google/adk/skills/LocalSkillLoader.java new file mode 100644 index 000000000..a718492a9 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/LocalSkillLoader.java @@ -0,0 +1,136 @@ +package com.google.adk.skills; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.function.Function.identity; +import static org.apache.arrow.util.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Loads skills from a local directory. */ +public final class LocalSkillLoader extends AbstractSkillLoader { + private static final Logger logger = LoggerFactory.getLogger(LocalSkillLoader.class); + + private final Path skillsBasePath; + + /** + * @param skillsBasePath Path to the base directory containing skills. + */ + public LocalSkillLoader(Path skillsBasePath) { + this.skillsBasePath = skillsBasePath; + } + + @Override + public Skill loadSkill(String skillId) { + Path skillDir = skillsBasePath.resolve(skillId); + checkArgument(Files.isDirectory(skillDir), "Skill directory not found: %s", skillDir); + + Path skillMd = findSkillMd(skillDir); + checkArgument(skillMd != null, "SKILL.md not found in %s", skillDir); + + String content; + try { + content = Files.readString(skillMd, UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read SKILL.md", e); + } + + ParsedSkillMd parsed = parseSkillMdContent(content); + checkArgument( + skillId.equals(parsed.frontmatter().name()), + "Skill name '%s' does not match directory name '%s'.", + parsed.frontmatter().name(), + skillId); + + ImmutableMap references = loadDirFiles(skillDir.resolve("references")); + ImmutableMap assets = loadDirFiles(skillDir.resolve("assets")); + ImmutableMap scripts = + loadDirFiles(skillDir.resolve("scripts"), bytes -> Script.create(new String(bytes, UTF_8))); + + return Skill.builder() + .frontmatter(parsed.frontmatter()) + .instructions(parsed.body()) + .resources( + Resources.builder().references(references).assets(assets).scripts(scripts).build()) + .build(); + } + + @Override + protected ImmutableMap loadAllParsedSkills() { + if (!Files.isDirectory(skillsBasePath)) { + logger.warn("Skills base path is not a directory: {}", skillsBasePath); + return ImmutableMap.of(); + } + + ImmutableMap.Builder builder = ImmutableMap.builder(); + + try (Stream stream = Files.list(skillsBasePath)) { + stream + .filter(Files::isDirectory) + .map(LocalSkillLoader::findSkillMd) + .filter(Objects::nonNull) + .forEach( + skillMd -> { + try { + builder.put( + skillMd.getParent().getFileName().toString(), + parseSkillMdContent(Files.readString(skillMd, UTF_8))); + } catch (IllegalArgumentException | IOException e) { + logger.warn("Skipping invalid skill in directory: {}", skillMd.getParent(), e); + } + }); + } catch (IOException e) { + logger.warn("Failed to list skills in directory", e); + } + + return builder.buildOrThrow(); + } + + @Nullable + private static Path findSkillMd(Path dir) { + Path skillMd = dir.resolve("SKILL.md"); + if (!Files.exists(skillMd)) { + skillMd = dir.resolve("skill.md"); + } + return Files.exists(skillMd) ? skillMd : null; + } + + private static byte[] readAllBytes(Path file) { + try { + return Files.readAllBytes(file); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read file: " + file, e); + } + } + + private static ImmutableMap loadDirFiles(Path dir) { + return loadDirFiles(dir, identity()); + } + + private static ImmutableMap loadDirFiles(Path dir, Function valueFunc) { + if (!Files.isDirectory(dir)) { + return ImmutableMap.of(); + } + ImmutableMap.Builder builder = ImmutableMap.builder(); + try (Stream paths = Files.walk(dir)) { + paths + .filter(Files::isRegularFile) + .forEach( + path -> + builder.put( + dir.relativize(path).toString(), valueFunc.apply(readAllBytes(path)))); + } catch (IOException e) { + throw new UncheckedIOException("Failed to traverse directory: " + dir, e); + } + return builder.buildOrThrow(); + } +} diff --git a/core/src/main/java/com/google/adk/skills/Resources.java b/core/src/main/java/com/google/adk/skills/Resources.java new file mode 100644 index 000000000..340bf9b42 --- /dev/null +++ b/core/src/main/java/com/google/adk/skills/Resources.java @@ -0,0 +1,62 @@ +/* + * 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.auto.value.AutoValue; +import com.google.common.collect.ImmutableMap; +import java.util.Optional; + +/** L3 skill content: additional instructions, assets, and scripts. */ +@AutoValue +public abstract class Resources { + + public abstract ImmutableMap references(); + + public abstract ImmutableMap assets(); + + public abstract ImmutableMap scripts(); + + public Optional getReference(String referenceId) { + return Optional.ofNullable(references().get(referenceId)); + } + + public Optional getAsset(String assetId) { + return Optional.ofNullable(assets().get(assetId)); + } + + public Optional