Skip to content

Commit dccdae5

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: Add SkillSource interface and implementations for loading skills
This change introduces the SkillSource interface and its implementations to support loading skills from various sources in the ADK. Key changes: - SkillSource interface: Core abstraction for loading skills. - LocalSkillSource: Implementation for loading skills from local files. - InMemorySkillSource: Implementation for loading skills from memory. - Tests for all implementations. - Updated BUILD files for correct targets and visibility. PiperOrigin-RevId: 886330483
1 parent 8f20d56 commit dccdae5

11 files changed

Lines changed: 1256 additions & 0 deletions
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.skills;
18+
19+
import static com.google.common.base.Preconditions.checkArgument;
20+
import static java.nio.channels.Channels.newReader;
21+
import static java.nio.charset.StandardCharsets.UTF_8;
22+
23+
import com.fasterxml.jackson.databind.ObjectMapper;
24+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
25+
import com.google.common.base.Pair;
26+
import com.google.common.collect.ImmutableMap;
27+
import com.google.common.io.ByteSource;
28+
import io.reactivex.rxjava3.core.Flowable;
29+
import io.reactivex.rxjava3.core.Maybe;
30+
import io.reactivex.rxjava3.core.Single;
31+
import java.io.BufferedReader;
32+
import java.io.IOException;
33+
import java.io.InputStream;
34+
import java.io.UncheckedIOException;
35+
import java.nio.channels.Channels;
36+
import java.nio.channels.ReadableByteChannel;
37+
import org.slf4j.Logger;
38+
import org.slf4j.LoggerFactory;
39+
40+
/**
41+
* Abstract base class for SkillSource implementations that load skills from path like object.
42+
*
43+
* @param <PathT> the type of path object
44+
*/
45+
public abstract class AbstractSkillSource<PathT> implements SkillSource {
46+
47+
private static final String THREE_DASHES = "---";
48+
private static final Logger logger = LoggerFactory.getLogger(AbstractSkillSource.class);
49+
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
50+
51+
@Override
52+
public Single<ImmutableMap<String, Frontmatter>> listFrontmatters() {
53+
return listSkills()
54+
.flatMapMaybe(
55+
skillPath ->
56+
Maybe.fromCallable(() -> loadFrontmatter(skillPath.first, skillPath.second))
57+
.onErrorResumeNext(
58+
e -> {
59+
logger.warn(
60+
"Skipping skill '{}' due to error loading frontmatter",
61+
skillPath.first,
62+
e);
63+
return Maybe.empty();
64+
}))
65+
.collectInto(
66+
ImmutableMap.<String, Frontmatter>builder(),
67+
(builder, frontmatter) -> builder.put(frontmatter.name(), frontmatter))
68+
.map(ImmutableMap.Builder::buildOrThrow);
69+
}
70+
71+
@Override
72+
public Single<Frontmatter> loadFrontmatter(String skillName) {
73+
return findSkillMdPath(skillName).map(path -> loadFrontmatter(skillName, path));
74+
}
75+
76+
private Frontmatter loadFrontmatter(String skillName, PathT skillMdPath) {
77+
try (BufferedReader reader = openReader(skillMdPath)) {
78+
String yaml = readFrontmatterYaml(reader);
79+
Frontmatter frontmatter = parseFrontmatter(yaml);
80+
checkArgument(
81+
frontmatter.name().equals(skillName),
82+
"Skill name '%s' does not match directory name '%s'.",
83+
frontmatter.name(),
84+
skillName);
85+
return frontmatter;
86+
} catch (IOException e) {
87+
throw new UncheckedIOException(e);
88+
}
89+
}
90+
91+
@Override
92+
public Single<String> loadInstructions(String skillName) {
93+
return findSkillMdPath(skillName)
94+
.map(
95+
skillMdPath -> {
96+
try (BufferedReader reader = openReader(skillMdPath)) {
97+
return readInstructions(reader);
98+
}
99+
});
100+
}
101+
102+
@Override
103+
public Single<ByteSource> loadResource(String skillName, String resourcePath) {
104+
return findResourcePath(skillName, resourcePath)
105+
.map(
106+
path ->
107+
new ByteSource() {
108+
@Override
109+
public InputStream openStream() throws IOException {
110+
return Channels.newInputStream(AbstractSkillSource.this.openChannel(path));
111+
}
112+
});
113+
}
114+
115+
/**
116+
* Returns a {@link Flowable} of skills as a pair of skill name and the path to the SKILL.md file.
117+
*/
118+
protected abstract Flowable<Pair<String, PathT>> listSkills();
119+
120+
/** Returns the path to the SKILL.md file for the given skill. */
121+
protected abstract Single<PathT> findSkillMdPath(String skillName);
122+
123+
/** Returns the path to the resource for the given skill. */
124+
protected abstract Single<PathT> findResourcePath(String skillName, String resourcePath);
125+
126+
/** Opens a {@link InputStream} for reading the content of the given path. */
127+
protected abstract ReadableByteChannel openChannel(PathT path) throws IOException;
128+
129+
private BufferedReader openReader(PathT path) throws IOException {
130+
return new BufferedReader(newReader(openChannel(path), UTF_8));
131+
}
132+
133+
private String readFrontmatterYaml(BufferedReader reader) throws IOException {
134+
String line = reader.readLine();
135+
checkArgument(
136+
line != null && line.trim().equals(THREE_DASHES),
137+
"Skill file must start with %s",
138+
THREE_DASHES);
139+
140+
StringBuilder sb = new StringBuilder();
141+
while ((line = reader.readLine()) != null) {
142+
if (line.trim().equals(THREE_DASHES)) {
143+
return sb.toString();
144+
}
145+
sb.append(line).append("\n");
146+
}
147+
throw new IllegalArgumentException(
148+
"Skill file frontmatter not properly closed with " + THREE_DASHES);
149+
}
150+
151+
private String readInstructions(BufferedReader reader) throws IOException {
152+
// Skip the frontmatter block
153+
String line = reader.readLine();
154+
checkArgument(
155+
line != null && line.trim().equals(THREE_DASHES),
156+
"Skill file must start with %s",
157+
THREE_DASHES);
158+
boolean dashClosed = false;
159+
while ((line = reader.readLine()) != null) {
160+
if (line.trim().equals(THREE_DASHES)) {
161+
dashClosed = true;
162+
break;
163+
}
164+
}
165+
checkArgument(dashClosed, "Skill file frontmatter not properly closed with %s", THREE_DASHES);
166+
167+
// Read the instructions till the end of the file
168+
StringBuilder sb = new StringBuilder();
169+
while ((line = reader.readLine()) != null) {
170+
sb.append(line).append("\n");
171+
}
172+
return sb.toString().trim();
173+
}
174+
175+
private Frontmatter parseFrontmatter(String yaml) {
176+
try {
177+
return yamlMapper.readValue(yaml, Frontmatter.class);
178+
} catch (IOException e) {
179+
throw new UncheckedIOException(e);
180+
}
181+
}
182+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.adk.skills;
18+
19+
import com.fasterxml.jackson.annotation.JsonAlias;
20+
import com.fasterxml.jackson.annotation.JsonCreator;
21+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
22+
import com.fasterxml.jackson.annotation.JsonProperty;
23+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
24+
import com.google.adk.JsonBaseModel;
25+
import com.google.auto.value.AutoValue;
26+
import com.google.common.collect.ImmutableMap;
27+
import com.google.common.escape.Escaper;
28+
import com.google.common.html.HtmlEscapers;
29+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
30+
import java.util.Map;
31+
import java.util.Optional;
32+
import java.util.regex.Pattern;
33+
34+
/**
35+
* Frontmatter represents the YAML metadata at the top of a SKILL.md file. For more details, see
36+
* https://agentskills.io/specification#frontmatter.
37+
*/
38+
@AutoValue
39+
@JsonDeserialize(builder = Frontmatter.Builder.class)
40+
@JsonIgnoreProperties(ignoreUnknown = true)
41+
public abstract class Frontmatter extends JsonBaseModel {
42+
43+
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
44+
45+
/** Skill name in kebab-case. */
46+
@JsonProperty("name")
47+
public abstract String name();
48+
49+
/** What the skill does and when the model should use it. */
50+
@JsonProperty("description")
51+
public abstract String description();
52+
53+
/** License for the skill. */
54+
@JsonProperty("license")
55+
public abstract Optional<String> license();
56+
57+
/** Compatibility information for the skill. */
58+
@JsonProperty("compatibility")
59+
public abstract Optional<String> compatibility();
60+
61+
/** A space-delimited list of tools that are pre-approved to run. */
62+
@JsonProperty("allowed-tools")
63+
public abstract Optional<String> allowedTools();
64+
65+
/** Key-value pairs for client-specific properties. */
66+
@JsonProperty("metadata")
67+
public abstract ImmutableMap<String, Object> metadata();
68+
69+
public String toXml() {
70+
Escaper escaper = HtmlEscapers.htmlEscaper();
71+
return String.format(
72+
"""
73+
<skill>
74+
<name>
75+
%s
76+
</name>
77+
<description>
78+
%s
79+
</description>
80+
</skill>
81+
""",
82+
escaper.escape(name()), escaper.escape(description()));
83+
}
84+
85+
public static Builder builder() {
86+
return new AutoValue_Frontmatter.Builder().metadata(ImmutableMap.of());
87+
}
88+
89+
@AutoValue.Builder
90+
public abstract static class Builder {
91+
92+
@JsonCreator
93+
private static Builder create() {
94+
return builder();
95+
}
96+
97+
@CanIgnoreReturnValue
98+
@JsonProperty("name")
99+
public abstract Builder name(String name);
100+
101+
@CanIgnoreReturnValue
102+
@JsonProperty("description")
103+
public abstract Builder description(String description);
104+
105+
@CanIgnoreReturnValue
106+
@JsonProperty("license")
107+
public abstract Builder license(String license);
108+
109+
@CanIgnoreReturnValue
110+
@JsonProperty("compatibility")
111+
public abstract Builder compatibility(String compatibility);
112+
113+
@CanIgnoreReturnValue
114+
@JsonProperty("allowed-tools")
115+
@JsonAlias({"allowed_tools"})
116+
public abstract Builder allowedTools(String allowedTools);
117+
118+
@CanIgnoreReturnValue
119+
@JsonProperty("metadata")
120+
public abstract Builder metadata(Map<String, Object> metadata);
121+
122+
abstract Frontmatter autoBuild();
123+
124+
public Frontmatter build() {
125+
Frontmatter fm = autoBuild();
126+
if (fm.name().length() > 64) {
127+
throw new IllegalArgumentException("name must be at most 64 characters");
128+
}
129+
if (!NAME_PATTERN.matcher(fm.name()).matches()) {
130+
throw new IllegalArgumentException(
131+
"name must be lowercase kebab-case (a-z, 0-9, hyphens), with no leading, trailing, or"
132+
+ " consecutive hyphens");
133+
}
134+
if (fm.description().isEmpty()) {
135+
throw new IllegalArgumentException("description must not be empty");
136+
}
137+
if (fm.description().length() > 1024) {
138+
throw new IllegalArgumentException("description must be at most 1024 characters");
139+
}
140+
if (fm.compatibility().isPresent() && fm.compatibility().get().length() > 500) {
141+
throw new IllegalArgumentException("compatibility must be at most 500 characters");
142+
}
143+
return fm;
144+
}
145+
}
146+
}

0 commit comments

Comments
 (0)