Skip to content

Commit 1a288a8

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 d37f6ee commit 1a288a8

10 files changed

Lines changed: 1198 additions & 0 deletions
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
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.collect.ImmutableMap;
26+
import com.google.common.io.ByteSource;
27+
import java.io.BufferedReader;
28+
import java.io.IOException;
29+
import java.io.InputStream;
30+
import java.io.UncheckedIOException;
31+
import java.nio.channels.Channels;
32+
import java.nio.channels.ReadableByteChannel;
33+
import java.util.function.BiConsumer;
34+
35+
/**
36+
* Abstract base class for SkillSource implementations that load skills from path like object.
37+
*
38+
* @param <PathT> the type of path object
39+
*/
40+
public abstract class AbstractSkillSource<PathT> implements SkillSource {
41+
42+
private static final String THREE_DASHES = "---";
43+
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
44+
45+
@Override
46+
public ImmutableMap<String, Frontmatter> listFrontmatters() {
47+
ImmutableMap.Builder<String, Frontmatter> builder = ImmutableMap.builder();
48+
iterateSkills((name, path) -> builder.put(name, loadFrontmatter(name, path)));
49+
return builder.buildOrThrow();
50+
}
51+
52+
@Override
53+
public Frontmatter loadFrontmatter(String skillName) {
54+
PathT skillMdPath = findSkillMdPath(skillName);
55+
return loadFrontmatter(skillName, skillMdPath);
56+
}
57+
58+
private Frontmatter loadFrontmatter(String skillName, PathT skillMdPath) {
59+
try (BufferedReader reader = openReader(skillMdPath)) {
60+
String yaml = readFrontmatterYaml(reader);
61+
Frontmatter frontmatter = parseFrontmatter(yaml);
62+
checkArgument(
63+
frontmatter.name().equals(skillName),
64+
"Skill name '%s' does not match directory name '%s'.",
65+
frontmatter.name(),
66+
skillName);
67+
return frontmatter;
68+
} catch (IOException e) {
69+
throw new UncheckedIOException(e);
70+
}
71+
}
72+
73+
@Override
74+
public String loadInstructions(String skillName) {
75+
PathT skillMdPath = findSkillMdPath(skillName);
76+
77+
try (BufferedReader reader = openReader(skillMdPath)) {
78+
return readInstructions(reader);
79+
} catch (IOException e) {
80+
throw new UncheckedIOException(e);
81+
}
82+
}
83+
84+
@Override
85+
public ByteSource loadResource(String skillName, String resourcePath) {
86+
PathT path = findResourcePath(skillName, resourcePath);
87+
return new ByteSource() {
88+
@Override
89+
public InputStream openStream() throws IOException {
90+
return Channels.newInputStream(AbstractSkillSource.this.openChannel(path));
91+
}
92+
};
93+
}
94+
95+
/** Iterates through SKILL.md files for all the supported skills. */
96+
protected abstract void iterateSkills(BiConsumer<String, PathT> skillMdConsumer);
97+
98+
/** Returns the path to the SKILL.md file for the given skill. */
99+
protected abstract PathT findSkillMdPath(String skillName);
100+
101+
/** Returns the path to the resource for the given skill. */
102+
protected abstract PathT findResourcePath(String skillName, String resourcePath);
103+
104+
/** Opens a {@link InputStream} for reading the content of the given path. */
105+
protected abstract ReadableByteChannel openChannel(PathT path) throws IOException;
106+
107+
private BufferedReader openReader(PathT path) throws IOException {
108+
return new BufferedReader(newReader(openChannel(path), UTF_8));
109+
}
110+
111+
private String readFrontmatterYaml(BufferedReader reader) throws IOException {
112+
String line = reader.readLine();
113+
checkArgument(
114+
line != null && line.trim().equals(THREE_DASHES),
115+
"Skill file must start with %s",
116+
THREE_DASHES);
117+
118+
StringBuilder sb = new StringBuilder();
119+
while ((line = reader.readLine()) != null) {
120+
if (line.trim().equals(THREE_DASHES)) {
121+
return sb.toString();
122+
}
123+
sb.append(line).append("\n");
124+
}
125+
throw new IllegalArgumentException(
126+
"Skill file frontmatter not properly closed with " + THREE_DASHES);
127+
}
128+
129+
private String readInstructions(BufferedReader reader) throws IOException {
130+
// Skip the frontmatter block
131+
String line = reader.readLine();
132+
checkArgument(
133+
line != null && line.trim().equals(THREE_DASHES),
134+
"Skill file must start with %s",
135+
THREE_DASHES);
136+
boolean dashClosed = false;
137+
while ((line = reader.readLine()) != null) {
138+
if (line.trim().equals(THREE_DASHES)) {
139+
dashClosed = true;
140+
break;
141+
}
142+
}
143+
checkArgument(dashClosed, "Skill file frontmatter not properly closed with %s", THREE_DASHES);
144+
145+
// Read the instructions till the end of the file
146+
StringBuilder sb = new StringBuilder();
147+
while ((line = reader.readLine()) != null) {
148+
sb.append(line).append("\n");
149+
}
150+
return sb.toString().trim();
151+
}
152+
153+
private Frontmatter parseFrontmatter(String yaml) {
154+
try {
155+
return yamlMapper.readValue(yaml, Frontmatter.class);
156+
} catch (IOException e) {
157+
throw new UncheckedIOException(e);
158+
}
159+
}
160+
}
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)