Skip to content

Commit 0bef203

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. - GcsSkillSource: Implementation for loading skills from Google Cloud Storage. - Tests for all implementations. - Updated BUILD files for correct targets and visibility. PiperOrigin-RevId: 886330483
1 parent 51f4d1f commit 0bef203

File tree

12 files changed

+1405
-0
lines changed

12 files changed

+1405
-0
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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+
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
23+
import java.io.BufferedReader;
24+
import java.io.IOException;
25+
import java.io.UncheckedIOException;
26+
27+
/**
28+
* Abstract base class for SkillSource implementations reading from markdown files.
29+
*
30+
* @param <PathT> the type of path object
31+
*/
32+
public abstract class AbstractSkillSource<PathT> implements SkillSource {
33+
34+
private static final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
35+
36+
@Override
37+
public Frontmatter loadFrontmatter(String skillName) {
38+
PathT skillMdPath = getSkillMdPath(skillName);
39+
return loadFrontmatter(skillMdPath);
40+
}
41+
42+
protected final Frontmatter loadFrontmatter(PathT skillMdPath) {
43+
try (BufferedReader reader = openSkillReader(skillMdPath)) {
44+
String yaml = readFrontmatterYaml(reader);
45+
Frontmatter frontmatter = parseFrontmatter(yaml);
46+
String skillName = getSkillNameFromPath(skillMdPath);
47+
checkArgument(
48+
frontmatter.name().equals(skillName),
49+
"Skill name '%s' does not match directory name '%s'.",
50+
frontmatter.name(),
51+
skillName);
52+
return frontmatter;
53+
} catch (IOException e) {
54+
throw new UncheckedIOException(e);
55+
}
56+
}
57+
58+
@Override
59+
public String loadInstructions(String skillName) {
60+
PathT skillMdPath = getSkillMdPath(skillName);
61+
try (BufferedReader reader = openSkillReader(skillMdPath)) {
62+
return readInstructions(reader);
63+
} catch (IOException e) {
64+
throw new UncheckedIOException(e);
65+
}
66+
}
67+
68+
/** Returns the path to the SKILL.md file for the given skill. */
69+
protected abstract PathT getSkillMdPath(String skillName);
70+
71+
/** Returns the skill name based on the given SKILL.md path. */
72+
protected abstract String getSkillNameFromPath(PathT skillMdPath);
73+
74+
/** Opens a reader for the SKILL.md file of the given skill. */
75+
protected abstract BufferedReader openSkillReader(PathT skillMdPath);
76+
77+
private String readFrontmatterYaml(BufferedReader reader) throws IOException {
78+
String line = reader.readLine();
79+
checkArgument(line != null && line.trim().equals("---"), "Skill file must start with ---");
80+
81+
StringBuilder sb = new StringBuilder();
82+
while ((line = reader.readLine()) != null) {
83+
if (line.trim().equals("---")) {
84+
return sb.toString();
85+
}
86+
sb.append(line).append("\n");
87+
}
88+
throw new IllegalArgumentException("Skill file frontmatter not properly closed with ---");
89+
}
90+
91+
private String readInstructions(BufferedReader reader) throws IOException {
92+
// Skip the frontmatter block
93+
String line = reader.readLine();
94+
checkArgument(line != null && line.trim().equals("---"), "Skill file must start with ---");
95+
boolean dashClosed = false;
96+
while ((line = reader.readLine()) != null) {
97+
if (line.trim().equals("---")) {
98+
dashClosed = true;
99+
break;
100+
}
101+
}
102+
checkArgument(dashClosed, "Skill file frontmatter not properly closed with ---");
103+
104+
// Read the instructions till the end of the file
105+
StringBuilder sb = new StringBuilder();
106+
while ((line = reader.readLine()) != null) {
107+
sb.append(line).append("\n");
108+
}
109+
return sb.toString().trim();
110+
}
111+
112+
private Frontmatter parseFrontmatter(String yaml) {
113+
try {
114+
return yamlMapper.readValue(yaml, Frontmatter.class);
115+
} catch (IOException e) {
116+
throw new UncheckedIOException(e);
117+
}
118+
}
119+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
/** L1 skill content: metadata parsed from SKILL.md for skill discovery. */
35+
@AutoValue
36+
@JsonDeserialize(builder = Frontmatter.Builder.class)
37+
@JsonIgnoreProperties(ignoreUnknown = true)
38+
public abstract class Frontmatter extends JsonBaseModel {
39+
40+
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9]+(-[a-z0-9]+)*$");
41+
42+
/** Skill name in kebab-case. */
43+
@JsonProperty("name")
44+
public abstract String name();
45+
46+
/** What the skill does and when the model should use it. */
47+
@JsonProperty("description")
48+
public abstract String description();
49+
50+
/** License for the skill. */
51+
@JsonProperty("license")
52+
public abstract Optional<String> license();
53+
54+
/** Compatibility information for the skill. */
55+
@JsonProperty("compatibility")
56+
public abstract Optional<String> compatibility();
57+
58+
/** A space-delimited list of tools that are pre-approved to run. */
59+
@JsonProperty("allowed-tools")
60+
public abstract Optional<String> allowedTools();
61+
62+
/** Key-value pairs for client-specific properties. */
63+
@JsonProperty("metadata")
64+
public abstract ImmutableMap<String, Object> metadata();
65+
66+
public String toXml() {
67+
Escaper escaper = HtmlEscapers.htmlEscaper();
68+
return String.format(
69+
"""
70+
<skill>
71+
<name>
72+
%s
73+
</name>
74+
<description>
75+
%s
76+
</description>
77+
</skill>
78+
""",
79+
escaper.escape(name()), escaper.escape(description()));
80+
}
81+
82+
public static Builder builder() {
83+
return new AutoValue_Frontmatter.Builder().metadata(ImmutableMap.of());
84+
}
85+
86+
@AutoValue.Builder
87+
public abstract static class Builder {
88+
89+
@JsonCreator
90+
private static Builder create() {
91+
return builder();
92+
}
93+
94+
@CanIgnoreReturnValue
95+
@JsonProperty("name")
96+
public abstract Builder name(String name);
97+
98+
@CanIgnoreReturnValue
99+
@JsonProperty("description")
100+
public abstract Builder description(String description);
101+
102+
@CanIgnoreReturnValue
103+
@JsonProperty("license")
104+
public abstract Builder license(String license);
105+
106+
@CanIgnoreReturnValue
107+
@JsonProperty("compatibility")
108+
public abstract Builder compatibility(String compatibility);
109+
110+
@CanIgnoreReturnValue
111+
@JsonProperty("allowed-tools")
112+
@JsonAlias({"allowed_tools"})
113+
public abstract Builder allowedTools(String allowedTools);
114+
115+
@CanIgnoreReturnValue
116+
@JsonProperty("metadata")
117+
public abstract Builder metadata(Map<String, Object> metadata);
118+
119+
abstract Frontmatter autoBuild();
120+
121+
public Frontmatter build() {
122+
Frontmatter fm = autoBuild();
123+
if (fm.name().length() > 64) {
124+
throw new IllegalArgumentException("name must be at most 64 characters");
125+
}
126+
if (!NAME_PATTERN.matcher(fm.name()).matches()) {
127+
throw new IllegalArgumentException(
128+
"name must be lowercase kebab-case (a-z, 0-9, hyphens), with no leading, trailing, or"
129+
+ " consecutive hyphens");
130+
}
131+
if (fm.description().isEmpty()) {
132+
throw new IllegalArgumentException("description must not be empty");
133+
}
134+
if (fm.description().length() > 1024) {
135+
throw new IllegalArgumentException("description must be at most 1024 characters");
136+
}
137+
if (fm.compatibility().isPresent() && fm.compatibility().get().length() > 500) {
138+
throw new IllegalArgumentException("compatibility must be at most 500 characters");
139+
}
140+
return fm;
141+
}
142+
}
143+
}

0 commit comments

Comments
 (0)