Skip to content

Commit bb02fc1

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 bb02fc1

9 files changed

Lines changed: 1263 additions & 0 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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 java.nio.channels.Channels.newReader;
20+
import static java.nio.charset.StandardCharsets.UTF_8;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
24+
import com.google.common.collect.ImmutableMap;
25+
import com.google.common.io.ByteSource;
26+
import io.reactivex.rxjava3.core.Flowable;
27+
import io.reactivex.rxjava3.core.Single;
28+
import java.io.BufferedReader;
29+
import java.io.IOException;
30+
import java.io.InputStream;
31+
import java.nio.channels.Channels;
32+
import java.nio.channels.ReadableByteChannel;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
35+
36+
/**
37+
* Abstract base class for SkillSource implementations that load skills from path like object.
38+
*
39+
* @param <PathT> the type of path object
40+
*/
41+
public abstract class AbstractSkillSource<PathT> implements SkillSource {
42+
43+
private static final String THREE_DASHES = "---";
44+
private static final Logger logger = LoggerFactory.getLogger(AbstractSkillSource.class);
45+
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
46+
47+
/** A container class that holds a skill's name and the path to its SKILL.md file. */
48+
protected final class SkillMdPath {
49+
50+
private final String name;
51+
private final PathT mdPath;
52+
53+
/**
54+
* Constructs a {@code SkillMdPath}.
55+
*
56+
* @param name the name of the skill
57+
* @param mdPath the path to the SKILL.md file
58+
*/
59+
@SuppressWarnings("ProtectedMembersInFinalClass")
60+
protected SkillMdPath(String name, PathT mdPath) {
61+
this.name = name;
62+
this.mdPath = mdPath;
63+
}
64+
}
65+
66+
@Override
67+
public Single<ImmutableMap<String, Frontmatter>> listFrontmatters() {
68+
return listSkills()
69+
.map(skillMdPath -> loadFrontmatter(skillMdPath.name, skillMdPath.mdPath))
70+
.collectInto(
71+
ImmutableMap.<String, Frontmatter>builder(),
72+
(builder, frontmatter) -> builder.put(frontmatter.name(), frontmatter))
73+
.map(ImmutableMap.Builder::buildOrThrow);
74+
}
75+
76+
@Override
77+
public Single<Frontmatter> loadFrontmatter(String skillName) {
78+
return findSkillMdPath(skillName).map(path -> loadFrontmatter(skillName, path));
79+
}
80+
81+
private Frontmatter loadFrontmatter(String skillName, PathT skillMdPath)
82+
throws SkillSourceException {
83+
try (BufferedReader reader = openReader(skillMdPath)) {
84+
String yaml = readFrontmatterYaml(reader);
85+
Frontmatter frontmatter = yamlMapper.readValue(yaml, Frontmatter.class);
86+
if (!frontmatter.name().equals(skillName)) {
87+
throw new SkillSourceException(
88+
"Skill name '%s' does not match directory name '%s'."
89+
.formatted(frontmatter.name(), skillName));
90+
}
91+
return frontmatter;
92+
} catch (IOException e) {
93+
throw new SkillSourceException("Cannot load frontmatter for skill '" + skillName + "'", e);
94+
}
95+
}
96+
97+
@Override
98+
public Single<String> loadInstructions(String skillName) {
99+
return findSkillMdPath(skillName)
100+
.map(
101+
skillMdPath -> {
102+
try (BufferedReader reader = openReader(skillMdPath)) {
103+
return readInstructions(reader);
104+
} catch (IOException e) {
105+
throw new SkillSourceException(
106+
"Failed to load instruction for skill '" + skillName + "'", e);
107+
}
108+
});
109+
}
110+
111+
@Override
112+
public Single<ByteSource> loadResource(String skillName, String resourcePath) {
113+
return findResourcePath(skillName, resourcePath)
114+
.map(
115+
path ->
116+
new ByteSource() {
117+
@Override
118+
public InputStream openStream() throws IOException {
119+
return Channels.newInputStream(AbstractSkillSource.this.openChannel(path));
120+
}
121+
});
122+
}
123+
124+
/**
125+
* Returns a {@link Flowable} of skills as a pair of skill name and the path to the SKILL.md file.
126+
*/
127+
protected abstract Flowable<SkillMdPath> listSkills();
128+
129+
/** Returns the path to the SKILL.md file for the given skill. */
130+
protected abstract Single<PathT> findSkillMdPath(String skillName);
131+
132+
/** Returns the path to the resource for the given skill. */
133+
protected abstract Single<PathT> findResourcePath(String skillName, String resourcePath);
134+
135+
/** Opens a {@link InputStream} for reading the content of the given path. */
136+
protected abstract ReadableByteChannel openChannel(PathT path) throws IOException;
137+
138+
private BufferedReader openReader(PathT path) throws IOException {
139+
return new BufferedReader(newReader(openChannel(path), UTF_8));
140+
}
141+
142+
private String readFrontmatterYaml(BufferedReader reader)
143+
throws IOException, SkillSourceException {
144+
String line = reader.readLine();
145+
if (line == null || !line.trim().equals(THREE_DASHES)) {
146+
throw new SkillSourceException("Skill file must start with " + THREE_DASHES);
147+
}
148+
149+
StringBuilder sb = new StringBuilder();
150+
while ((line = reader.readLine()) != null) {
151+
if (line.trim().equals(THREE_DASHES)) {
152+
return sb.toString();
153+
}
154+
sb.append(line).append("\n");
155+
}
156+
throw new SkillSourceException(
157+
"Skill file frontmatter not properly closed with " + THREE_DASHES);
158+
}
159+
160+
private String readInstructions(BufferedReader reader) throws IOException, SkillSourceException {
161+
// Skip the frontmatter block
162+
String line = reader.readLine();
163+
if (line == null || !line.trim().equals(THREE_DASHES)) {
164+
throw new SkillSourceException("Skill file must start with " + THREE_DASHES);
165+
}
166+
boolean dashClosed = false;
167+
while ((line = reader.readLine()) != null) {
168+
if (line.trim().equals(THREE_DASHES)) {
169+
dashClosed = true;
170+
break;
171+
}
172+
}
173+
if (!dashClosed) {
174+
throw new SkillSourceException(
175+
"Skill file frontmatter not properly closed with " + THREE_DASHES);
176+
}
177+
// Read the instructions till the end of the file
178+
StringBuilder sb = new StringBuilder();
179+
while ((line = reader.readLine()) != null) {
180+
sb.append(line).append("\n");
181+
}
182+
return sb.toString().trim();
183+
}
184+
}
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)