Skip to content

Commit 509c4aa

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: 912878477
1 parent 582cf7c commit 509c4aa

9 files changed

Lines changed: 1267 additions & 0 deletions

File tree

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