Skip to content

fix(sandbox): parse YAML block scalars in skill frontmatter - #5040

Open
rygelouv wants to merge 2 commits into
openai:mainfrom
rygelouv:fix/skills-frontmatter-block-scalars
Open

rygelouv wants to merge 2 commits into
openai:mainfrom
rygelouv:fix/skills-frontmatter-block-scalars

Conversation

@rygelouv

@rygelouv rygelouv commented Sep 15, 2026

Copy link
Copy Markdown

Summary

_parse_frontmatter splits every frontmatter line on the first : and keeps the rest as the value, so a multi-line YAML scalar never survives. On main, compared with yaml.safe_load over the same frontmatter:

plain          ok=True  sdk='Use for GitHub issue triage.'
quoted         ok=True  sdk='Use for GitHub issue triage.'
folded >       ok=False sdk='>'                     yaml='Use for GitHub issue triage. Triggers: /triage, bug report\n'  extra_keys=['Triggers']
literal |      ok=False sdk='|'                     yaml='Use for GitHub issue triage.\n'
wrapped plain  ok=False sdk='Use for GitHub issue'  yaml='Use for GitHub issue triage, not for PR review.'
mismatches: 3/5

Both LocalDirLazySkillSource.list_skill_metadata and Skills._resolve_runtime_metadata build SkillMetadata.description from this parser, so a skill written with a folded description reaches the model as - triage: > (file: ...) — nothing to match a task against, and nothing warns. Folded descriptions are common in SKILL.md files written for other harnesses.

This teaches the parser the shapes that frontmatter actually uses:

  • > folded and | literal block scalars, including the - and + chomping indicators.
  • Indented continuation lines of a plain scalar.
  • A block scalar body must be indented past its own key, so description: > with nothing under it no longer swallows the next key.

Parsed values now agree with yaml.safe_load on every shape above. PyYAML stays out of the runtime dependencies — it is not one today, which is presumably why the parser is hand-rolled, and the issue notes the same.

One deliberate divergence from yaml.safe_load: an unquoted # is kept rather than treated as a comment, matching the current parser. description: use the #triage tag stays intact instead of truncating to use, which seems the better failure mode for a model-facing index.

Fixing the parser lets a | description carry real newlines, and the skill index is one line per skill, so instructions() now collapses whitespace in the description before rendering. Without that, a literal description would split one entry across several list lines.

Test plan

New tests/sandbox/capabilities/test_skills_frontmatter.py covers plain, quoted (both styles), empty, folded, literal, all four chomping combinations, paragraph breaks, wrapped plain scalars, comment lines, # inside a value, unterminated frontmatter, block-scalar boundaries, and the four cases from review below. Two tests in test_skills_capability.py assert the rendered index line for a folded and a literal description end to end.

Every one of the new tests fails on main and passes with this change.

uv run pytest tests/sandbox -q
1564 passed, 2 skipped

Checked against yaml.safe_load over 24 frontmatter shapes: 23 match, the 24th being the # divergence described above.

The review pass found four more cases where the parser disagreed with yaml.safe_load; each is fixed in 1f8fc54 with a test:

  • Quotes inside a stripped block scalar were removed by the shared unquoting step. Only inline scalars are unquoted now.
  • A plain scalar wrapped across a blank line lost everything after the blank. Blank lines fold into breaks, and are consumed only when an indented continuation follows.
  • An indented --- inside a block scalar ended the frontmatter scan, discarding the rest of the description and every later key. Only a column-zero marker closes it.
  • A folded scalar flattened a more-indented line instead of keeping the breaks around it. This one matters past the index, since LocalDirLazySkillSource.list_skill_metadata() returns the description to callers directly.

make format and make lint pass. make typecheck fails identically with and without this change — 8 pyright and 10 mypy errors, all in extensions/memory/redis_session.py, tests/mcp/, function_schema.py, models/openai_responses.py, testing/model.py, none in the files touched here. My local resolution picks up newer dependency versions than CI does, because exclude-newer = "7 days" in pyproject.toml is not a value uv can parse as a date:

TOML parse error at line 270, column 17
exclude-newer = "7 days"
                ^^^^^^^^
failed to parse year in date "7 days"

so the cooldown pin is skipped locally. mypy and pyright are clean on src/agents/sandbox/capabilities/skills.py and the new tests. A handful of unrelated tests also fail on a clean checkout in this environment (tests/mcp/test_caching.py, tests/mcp/test_mcp_util.py, tests/test_function_tool.py, tests/tracing/test_import_side_effects.py, tests/sandbox/test_run_cwd.py::test_python_skill_uses_absolute_root_from_nested_workdir) for the same reason.

Issue number

Closes #5026

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_01HYuwKi4xwDVSptmiipM5LL

`_parse_frontmatter` split every frontmatter line on the first `:`, so a
`>` folded or `|` literal block scalar became the value `">"` or `"|"`,
its indented continuation lines were parsed as extra top-level keys, and
a plain scalar wrapped across lines lost everything after the first line.
The skill index then showed the model `- triage: >` instead of the
description, with nothing to match a task against and no warning.

Teach the parser block scalars, including the `-` and `+` chomping
indicators, and indented continuation lines of plain scalars. The parsed
values now agree with `yaml.safe_load` on the shapes skill frontmatter
uses; PyYAML stays out of the runtime dependencies.

A block scalar body must be indented past its key, so a keyless `>` no
longer consumes the line that follows it. Descriptions are collapsed to a
single line when the skill index is rendered, so a `|` description cannot
split one skill entry across several list lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYuwKi4xwDVSptmiipM5LL

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7286791a3

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

metadata[parsed_key] = parsed_value
parsed_value, index = _read_wrapped_plain(body, index, parsed_value, key_indent)

metadata[parsed_key] = _unquote(parsed_value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve quotes in stripped block scalar content

When valid frontmatter uses a stripped block such as description: |- whose body is "Use for triage", _read_block_scalar correctly returns the quotes as literal block content, but the unconditional _unquote call removes them. Skills.instructions() therefore exposes a different description to the model than the YAML specifies; only inline quoted scalars should be unquoted.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1f8fc54. yaml.safe_load returns '"Use for triage"' for a |- body of "Use for triage", and the parser was returning Use for triage. _unquote now runs only on the inline branch, so block scalar content keeps its quotes. Worth noting it only bit the stripped indicators — with | or > the clip newline made the first/last character check fail, so the quotes survived by accident. Covered for all four indicators in test_quotes_inside_a_block_scalar_are_content.

Comment on lines +464 to +467
while index < len(lines):
line = lines[index]
if line.strip() == "" or _indent_width(line) <= key_indent:
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue wrapped plain scalars across blank lines

For a valid wrapped plain description containing a blank line, such as description: Use for triage.\n\n Avoid PR review., YAML retains the later indented text as part of the scalar, but this branch stops at the blank line and the outer parser subsequently discards Avoid PR review.. The rendered skill index therefore loses part of the description, potentially omitting an important trigger or non-goal.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1f8fc54. description: Use for triage.\n\n Avoid PR review. gave Use for triage. against yaml.safe_load's Use for triage.\nAvoid PR review. — the continuation line was dropped entirely, since the outer loop then skipped it for having no :. _read_wrapped_plain now folds blank lines into breaks. It only consumes them when an indented continuation actually follows, so a blank line before the next key still ends the scalar — test_wrapped_plain_scalar_stops_at_a_blank_line_before_a_new_key pins that side.

if end_index is None:
return {}

body = lines[1:end_index]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep indented document markers inside block scalars

When a valid literal or folded description contains an indented --- line, the preliminary delimiter scan treats that content line as the end of frontmatter, so the newly constructed body omits the marker, every subsequent description line, and any later metadata keys. For example, description: |\n first\n ---\n last\nname: triage should retain first\n---\nlast, but the skill index receives only first; the closing delimiter must be recognized only at the frontmatter's top level.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1f8fc54. Your example returned {"description": "first\n"} and lost name completely. The closing scan now matches on line.rstrip() == "---", so only a column-zero marker closes the frontmatter and an indented one stays block content. Pinned in test_indented_document_marker_stays_inside_a_block_scalar.

Comment on lines +441 to +444
elif folded and folded[-1] not in {"", "\n"} and not entry.startswith(" "):
folded.append(" " + entry)
else:
folded.append(entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve breaks around more-indented folded lines

For a valid folded scalar such as description: >\n first\n indented\n last, YAML preserves line breaks around the more-indented line and returns first\n indented\nlast\n, whereas this loop concatenates it into first indented last\n. Although Skills.instructions() later normalizes whitespace, the exported LocalDirLazySkillSource.list_skill_metadata() returns this incorrectly reshaped description directly, so callers do not receive the YAML value represented by the skill.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1f8fc54. That case returned first indented last\n against yaml.safe_load's first\n indented\nlast\n. Agreed on why it matters past the index — list_skill_metadata() hands the description to callers directly, so normalizing in instructions() does not cover it. The folded branch now tracks whether each line is blank, more-indented, or normal, and inserts a break around any more-indented run rather than folding it. Covered by test_folded_scalar_keeps_breaks_around_a_more_indented_line.

Follow-up to review feedback on the block scalar parser.

Quotes inside a block scalar are content, not a quoted scalar. `_unquote`
ran on every value, so a stripped block whose body was `"Use for triage"`
lost its quotes; only an inline scalar is unquoted now.

A plain scalar continues across a blank line, so
`description: a\n\n  b` keeps `b` instead of dropping it. Blank lines are
consumed only when an indented continuation follows, so a blank line
before the next key still ends the scalar.

Only a column-zero `---` closes the frontmatter. An indented `---` inside
a block scalar used to end the scan early, discarding the rest of the
description and every key after it.

A folded scalar preserves the breaks around a more-indented line and its
leading whitespace, instead of folding everything into one line.
`instructions()` normalizes whitespace for the index, but
`LocalDirLazySkillSource.list_skill_metadata()` returns the description
to callers directly, so the stored value has to be right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYuwKi4xwDVSptmiipM5LL

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f8fc54b82

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +444 to +447
if entry == "":
folded.append("\n")
previous = "blank"
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve blank breaks around more-indented folded lines

When a folded description has a blank line adjacent to a more-indented line, this branch resets previous to "blank" and loses one of the line breaks that YAML preserves. For example, description: >\n first\n\n note\n last should produce first\n\n note\nlast\n, but the parser returns first\n note\nlast\n; the exported LocalDirLazySkillSource.list_skill_metadata() therefore exposes a reshaped description. The fresh evidence after the earlier more-indented-line fix is this still-failing blank-line variant, which the new adjacent-line regression does not cover.

AGENTS.md reference: AGENTS.md:L113-L116

Useful? React with 👍 / 👎.

Comment on lines +405 to +408
if "-" in header[1:]:
chomping = "strip"
elif "+" in header[1:]:
chomping = "keep"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore comments when detecting chomping indicators

For a valid block header with an inline comment containing - or +, the substring checks treat punctuation in the comment as a chomping indicator. For example, description: > # multi-line text should use clip chomping and return "text\n", but this code selects strip and returns "text"; a + in the comment similarly enables keep unexpectedly. Parse the indicator portion before the YAML comment so the public metadata value is not altered by comment text.

AGENTS.md reference: AGENTS.md:L113-L116

Useful? React with 👍 / 👎.

if pending_breaks:
parts.append("\n" * pending_breaks)
pending_breaks = 0
parts.append(line.strip())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip indented comments after wrapped plain scalars

When a valid plain description is followed by an indented YAML comment, this loop treats the comment as scalar continuation text. For example, description: Use for triage.\n # maintainer note\nname: triage parses as "Use for triage. # maintainer note" instead of "Use for triage.", so both the exported metadata and the model-facing skill index expose text that YAML designates as a comment. Ignore indented comment-only lines rather than appending them to parts.

AGENTS.md reference: AGENTS.md:L113-L116

Useful? React with 👍 / 👎.

Comment on lines +422 to +425
if block_indent is None:
block_indent = indent
if indent < block_indent:
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor explicit block indentation indicators

For a valid block scalar with an explicit indentation indicator, the parser ignores the digit and instead fixes block_indent from the first nonblank line. For example, description: |2\n emergency triage\n avoid routine review should retain both lines as " emergency triage\navoid routine review\n", but this loop sets the indent to four, stops when it reaches the two-space line, and returns only "emergency triage\n"; the model-facing skill index consequently loses part of the description. Parse the indentation indicator from the header and use it as the scalar's base indentation.

AGENTS.md reference: AGENTS.md:L113-L116

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Skills frontmatter parser mangles multi-line descriptions (folded >, literal |, wrapped lines) in the skill index

1 participant