Skip to content

Commit 25105b4

Browse files
alphasafalcopybara-github
authored andcommitted
fix: match SKILL.md frontmatter delimiters as whole lines
Merge #7726 A `---` inside a frontmatter value no longer ends the frontmatter. The closing delimiter must now be a line of only `---` (trailing spaces allowed): a SKILL.md whose closing `---` has other text on the same line, which was previously accepted with that text moved into the instructions, now raises ValueError. Fixes #7725 PiperOrigin-RevId: 1007645341
1 parent d3a199d commit 25105b4

3 files changed

Lines changed: 92 additions & 6 deletions

File tree

‎docs/guides/skills/skill/index.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ The loader is strict about the frontmatter, and every rule below raises rather
8989
than warns, so a mistake shows up as a `ValueError` at load time instead of as
9090
odd behavior later:
9191

92-
* The file must start with `---`, and a second `---` must close the
93-
frontmatter. Anything else raises `ValueError`.
92+
* The file must start with a `---` line, and the next line that is only
93+
`---` closes the frontmatter, so a `---` inside a value does not. Anything
94+
else raises `ValueError`.
9495
* The frontmatter must parse as a YAML mapping.
9596
* `name` and `description` are both required.
9697
* `name` must be lowercase kebab-case, meaning `a-z`, `0-9`, and single

‎src/google/adk/skills/_utils.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io
2121
import logging
2222
import pathlib
23+
import re
2324
from typing import Any
2425
from typing import Callable
2526
from typing import cast
@@ -42,6 +43,12 @@
4243
# transient buffer this size however much the member really expands.
4344
_ZIP_READ_CHUNK_BYTES = 64 * 1024
4445

46+
# The frontmatter delimiters are whole `---` lines, so a `---` inside a value
47+
# (for example "A --- B" in a description) does not end the frontmatter.
48+
_FRONTMATTER_PATTERN = re.compile(
49+
r"\A---[ \t]*\r?\n(.*?)^---[ \t]*\r?$(.*)\Z", re.DOTALL | re.MULTILINE
50+
)
51+
4552
_ALLOWED_FRONTMATTER_KEYS = frozenset({
4653
"name",
4754
"description",
@@ -153,12 +160,12 @@ def _parse_skill_md_content(content: str) -> tuple[dict[str, Any], str]:
153160
if not content.startswith("---"):
154161
raise ValueError("SKILL.md must start with YAML frontmatter (---)")
155162

156-
parts = content.split("---", 2)
157-
if len(parts) < 3:
163+
match = _FRONTMATTER_PATTERN.match(content)
164+
if match is None:
158165
raise ValueError("SKILL.md frontmatter not properly closed with ---")
159166

160-
frontmatter_str = parts[1]
161-
body = parts[2].strip()
167+
frontmatter_str = match.group(1)
168+
body = match.group(2).strip()
162169

163170
try:
164171
parsed = yaml.safe_load(frontmatter_str)

‎tests/unittests/skills/test__utils.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,84 @@ def _write_nested_skill(tmp_path):
113113
return skill_dir
114114

115115

116+
@pytest.mark.parametrize(
117+
"description_line, closing, expected",
118+
[
119+
("description: Compare A --- B", "---", "Compare A --- B"),
120+
(
121+
'description: "Draft notes --- never publish"',
122+
"---",
123+
"Draft notes --- never publish",
124+
),
125+
(
126+
"description: |\n Summarise.\n ---\n Then stop.",
127+
"---",
128+
"Summarise.\n---\nThen stop.\n",
129+
),
130+
("description: Compare A --- B", "--- ", "Compare A --- B"),
131+
("description: Compare A --- B", "---\t", "Compare A --- B"),
132+
],
133+
ids=[
134+
"unquoted",
135+
"quoted",
136+
"block_scalar",
137+
"trailing_space",
138+
"trailing_tab",
139+
],
140+
)
141+
def test__load_skill_from_dir_dashes_inside_frontmatter_value(
142+
tmp_path, description_line, closing, expected
143+
):
144+
"""A `---` inside a frontmatter value does not close the frontmatter."""
145+
skill_dir = tmp_path / "test-skill"
146+
skill_dir.mkdir()
147+
(skill_dir / "SKILL.md").write_text(
148+
f"---\nname: test-skill\n{description_line}\nlicense: MIT\n"
149+
f"{closing}\nBody\n"
150+
)
151+
152+
skill = _load_skill_from_dir(skill_dir)
153+
154+
assert skill.frontmatter.description == expected
155+
assert skill.frontmatter.license == "MIT"
156+
assert skill.instructions == "Body"
157+
158+
159+
def test__load_skill_from_zip_bytes_crlf_frontmatter():
160+
"""Windows line endings around the delimiters still parse."""
161+
# A zip member is decoded without newline translation, so the `\r` reaches
162+
# the parser, unlike `read_text` in the directory loader.
163+
zip_buffer = io.BytesIO()
164+
with zipfile.ZipFile(zip_buffer, "w") as z:
165+
z.writestr(
166+
"SKILL.md",
167+
b"---\r\nname: test-skill\r\ndescription: Test\r\n---\r\nBody\r\n",
168+
)
169+
170+
skill = _load_skill_from_zip_bytes(zip_buffer.getvalue())
171+
172+
assert skill.frontmatter.description == "Test"
173+
assert skill.instructions == "Body"
174+
175+
176+
@pytest.mark.parametrize(
177+
"content",
178+
[
179+
"---\nname: test-skill\ndescription: Test\nBody\n",
180+
"---\nname: test-skill\ndescription: Test\n--- Body\n",
181+
],
182+
ids=["no_closing_line", "text_after_closing_dashes"],
183+
)
184+
def test__load_skill_from_dir_unclosed_frontmatter_raises(tmp_path, content):
185+
"""Frontmatter without a whole closing `---` line is rejected."""
186+
skill_dir = tmp_path / "test-skill"
187+
skill_dir.mkdir()
188+
(skill_dir / "SKILL.md").write_text(content)
189+
190+
with pytest.raises(ValueError, match="not properly closed"):
191+
_load_skill_from_dir(skill_dir)
192+
193+
116194
def test__load_skill_from_dir_nested_resources_use_forward_slash_keys(tmp_path):
117195
"""Resources in subdirectories are keyed with forward slashes."""
118196
skill = _load_skill_from_dir(_write_nested_skill(tmp_path))

0 commit comments

Comments
 (0)