Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 115 additions & 26 deletions agent/skills/fsskills/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ var (
)

var (
knownFrontmatterFields = map[string]string{
"name": "name",
"description": "description",
"license": "license",
"compatibility": "compatibility",
"metadata": "metadata",
"allowed-tools": "allowed-tools",
}
frontmatterRegex = regexp.MustCompile(`(?ms)\A^---\s*$(.+?)^---\s*$`)
yamlKeyValueRegex = regexp.MustCompile(`(?m)^([\w-]+)\s*:\s*(?:["'](.+?)["']|(.+?))\s*$`)
yamlMetadataBlockRegex = regexp.MustCompile(`(?m)^metadata\s*:\s*$\n((?:[ \t]+\S.*\n?|[ \t]*\r?\n)+)`)
yamlIndentedKeyValueRegex = regexp.MustCompile(`(?m)^\s+([\w-]+)\s*:\s*(?:["'](.+?)["']|(.+?))\s*$`)
yamlKeyValueRegex = regexp.MustCompile(`(?m)^([\w-]+|["'][\w-]+["'])[ \t]*:[ \t]*(?:["'](.*?)["']|([^\r\n]*?))[ \t]*\r?$`)
Comment thread
qmuntal marked this conversation as resolved.
yamlMetadataBlockRegex = regexp.MustCompile(`(?m)^(?:metadata|"metadata"|'metadata')\s*:\s*$\r?\n((?:[ \t]+\S.*\n?|[ \t]*\r?\n)+)`)
yamlIndentedKeyValueRegex = regexp.MustCompile(`(?m)^[ \t]+([\w-]+)[ \t]*:[ \t]*(?:["'](.+?)["']|(.+?))[ \t]*\r?$`)
)

// FilterContext provides contextual information about a discovered file to the
Expand Down Expand Up @@ -315,16 +323,30 @@ func (s *Source) tryParseFrontmatter(content, skillFilePath string) (skills.Fron

yamlContent := strings.TrimSpace(contentForParsing[match[2]:match[3]])
frontmatter := skills.Frontmatter{}
seenFields := make(map[string]struct{}, len(knownFrontmatterFields))

for _, kv := range yamlKeyValueRegex.FindAllStringSubmatchIndex(yamlContent, -1) {
key := yamlContent[kv[2]:kv[3]]
value := ""
if kv[4] >= 0 {
value = yamlContent[kv[4]:kv[5]]
} else if kv[6] >= 0 {
value = parseYamlScalarValue(yamlContent, kv)
}
switch strings.ToLower(key) {
key := normalizeFrontmatterKey(yamlContent[kv[2]:kv[3]])
canonicalKey, recognized := knownFrontmatterFields[strings.ToLower(key)]
if !recognized {
continue
}
if key != canonicalKey {
s.logger.Error("SKILL.md uses incorrectly cased frontmatter field", "skillFilePath", skillFilePath, "fieldName", key, "expectedFieldName", canonicalKey)
return skills.Frontmatter{}, false
}
if _, duplicated := seenFields[canonicalKey]; duplicated {
s.logger.Error("SKILL.md contains duplicate frontmatter field", "skillFilePath", skillFilePath, "fieldName", canonicalKey)
return skills.Frontmatter{}, false
}
seenFields[canonicalKey] = struct{}{}

value, hasValue := parseYamlValue(yamlContent, kv)
if !hasValue && canonicalKey != "name" && canonicalKey != "description" {
continue
}

switch canonicalKey {
case "name":
frontmatter.Name = value
case "description":
Expand All @@ -340,11 +362,18 @@ func (s *Source) tryParseFrontmatter(content, skillFilePath string) (skills.Fron

if metadataMatch := yamlMetadataBlockRegex.FindStringSubmatch(yamlContent); len(metadataMatch) == 2 {
metadata := make(map[string]any)
seenMetadataKeys := make(map[string]struct{})
for _, kv := range yamlIndentedKeyValueRegex.FindAllStringSubmatch(metadataMatch[1], -1) {
value := kv[2]
if value == "" {
value = kv[3]
}
lowerKey := strings.ToLower(kv[1])
if _, duplicated := seenMetadataKeys[lowerKey]; duplicated {
s.logger.Warn("SKILL.md contains duplicate metadata key; keeping the first value", "skillFilePath", skillFilePath, "key", kv[1])
continue
}
seenMetadataKeys[lowerKey] = struct{}{}
metadata[kv[1]] = value
}
if len(metadata) > 0 {
Expand All @@ -368,17 +397,83 @@ func (s *Source) tryParseFrontmatter(content, skillFilePath string) (skills.Fron
return frontmatter, true
}

func normalizeFrontmatterKey(key string) string {
if len(key) >= 2 && (key[0] == '"' || key[0] == '\'') && key[len(key)-1] == key[0] {
return key[1 : len(key)-1]
}
return key
}

func parseYamlValue(yamlContent string, kv []int) (string, bool) {
if kv[4] >= 0 {
return yamlContent[kv[4]:kv[5]], true
}

if kv[6] < kv[7] {
return parseYamlScalarValue(yamlContent, kv), true
}

return parseYamlIndentedValue(yamlContent, kv)
}

func parseYamlScalarValue(yamlContent string, kv []int) string {
value := yamlContent[kv[6]:kv[7]]
if value == "" || (value[0] != '|' && value[0] != '>') {
return value
}

scalarStyle := value[0]
keepTrailingNewline := len(value) > 1 && value[1] == '+'
blockLines, ok := collectIndentedBlockLines(yamlContent, kv)
if !ok {
return ""
}

return foldYamlBlockScalar(value, blockLines)
}

func parseYamlIndentedValue(yamlContent string, kv []int) (string, bool) {
blockLines, ok := collectIndentedBlockLines(yamlContent, kv)
if !ok {
return "", false
}

if len(blockLines) > 0 {
if indicator := strings.TrimSpace(blockLines[0]); indicator != "" && (indicator[0] == '|' || indicator[0] == '>') {
return foldYamlBlockScalar(indicator, blockLines[1:]), true
}
}

value := strings.TrimSpace(strings.Join(normalizeIndentedLines(blockLines), "\n"))
if value == "" {
return "", false
}

return normalizeFrontmatterKey(value), true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Parity gap: multi-line indented continuation value diverges from upstream .NET

parseYamlIndentedValue/collectIndentedBlockLines here join every contiguous indented line following a bare key: into the parsed value. Verified: description:\n first line\n second line\n third line → "first line\nsecond line\nthird line".

Upstream .NET (AgentFileSkillsSource.cs, s_yamlKeyValueRegex, https://github.com/microsoft/agent-framework/blob/2c46deb91e70ea6d7bbc99263147e0f470d52546/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs#L61) only allows a single [ \t]+ continuation prefix and a single-line value group ([^\r\n]*?), so it captures only the first indented continuation line — verified against the live .NET regex: same input → "first line", with the remaining lines silently unmatched.

This means a plain (non-|/>) multi-line indented description/field parses to a materially different value in Go vs. .NET. The new tests (TestFileSource_IndentedValueOnNextLine_IsParsed) only cover a single continuation line, so this divergence isn't caught.

Suggested resolution: limit the plain indented-continuation capture to a single line to match .NET's contract, or explicitly document the broader multi-line join as an intentional Go-specific enhancement.


// foldYamlBlockScalar folds blockLines according to the YAML block scalar
// indicator (e.g. "|", "|-", "|+", ">", ">-", ">+") given in indicator.
func foldYamlBlockScalar(indicator string, blockLines []string) string {
scalarStyle := indicator[0]
keepTrailingNewline := len(indicator) > 1 && indicator[1] == '+'
normalizedLines := normalizeIndentedLines(blockLines)

var parsedValue string
if scalarStyle == '|' {
parsedValue = strings.Join(normalizedLines, "\n")
} else {
parsedValue = foldYamlLines(normalizedLines)
}

if keepTrailingNewline {
return parsedValue + "\n"
}
return parsedValue
}

func collectIndentedBlockLines(yamlContent string, kv []int) ([]string, bool) {
lineBreak := strings.IndexByte(yamlContent[kv[1]:], '\n')
if lineBreak < 0 {
return value
return nil, false
}

remaining := yamlContent[kv[1]+lineBreak+1:]
Expand All @@ -400,9 +495,13 @@ func parseYamlScalarValue(yamlContent string, kv []int) string {
}

if len(blockLines) == 0 {
return ""
return nil, false
}

return blockLines, true
}

func normalizeIndentedLines(blockLines []string) []string {
commonIndent := -1
for _, line := range blockLines {
if line == "" {
Expand All @@ -427,17 +526,7 @@ func parseYamlScalarValue(yamlContent string, kv []int) string {
}
}

var parsedValue string
if scalarStyle == '|' {
parsedValue = strings.Join(normalizedLines, "\n")
} else {
parsedValue = foldYamlLines(normalizedLines)
}

if keepTrailingNewline {
return parsedValue + "\n"
}
return parsedValue
return normalizedLines
}

func foldYamlLines(lines []string) string {
Expand Down
Loading
Loading