diff --git a/agentscope-core/pom.xml b/agentscope-core/pom.xml index 54bf500a7..ed1f99515 100644 --- a/agentscope-core/pom.xml +++ b/agentscope-core/pom.xml @@ -140,5 +140,11 @@ com.networknt json-schema-validator + + + + org.yaml + snakeyaml + diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/util/MarkdownSkillParser.java b/agentscope-core/src/main/java/io/agentscope/core/skill/util/MarkdownSkillParser.java index 5bce28ced..faca2b958 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/util/MarkdownSkillParser.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/util/MarkdownSkillParser.java @@ -20,6 +20,9 @@ import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; /** * Utility for parsing and generating Markdown files with YAML frontmatter. @@ -145,22 +148,19 @@ public static String generate(Map metadata, String content) { } /** - * Simple YAML parser for flat key-value structures. - * Only supports String:String mappings. + * YAML parser that tolerates standard frontmatter syntax while exposing only top-level scalar + * metadata values to the rest of the skill system. */ private static class SimpleYamlParser { - - // Pattern to match key: value format - // Captures: group(1) = key, group(2) = value (may include quotes) - private static final Pattern KEY_VALUE_PATTERN = - Pattern.compile("^([a-zA-Z_][a-zA-Z0-9_-]*)\\s*:\\s*(.*)$"); + private static final Yaml YAML = new Yaml(new SafeConstructor(new LoaderOptions())); /** * Parse YAML string into a map of key-value pairs. * * @param yaml YAML content to parse * @return Map of key-value pairs - * @throws IllegalArgumentException if YAML syntax is invalid + * @throws IllegalArgumentException if YAML syntax is invalid or the top-level value is not a + * mapping */ static Map parse(String yaml) { Map result = new LinkedHashMap<>(); @@ -169,116 +169,54 @@ static Map parse(String yaml) { return result; } - String[] lines = yaml.split("[\\r\\n]+"); + Object parsed = YAML.load(yaml); + if (parsed == null) { + return result; + } - for (String line : lines) { - // Skip empty lines - if (line.trim().isEmpty()) { - continue; - } + if (!(parsed instanceof Map parsedMap)) { + throw new IllegalArgumentException( + "Invalid YAML frontmatter: expected a top-level mapping"); + } - // Skip comments - if (line.trim().startsWith("#")) { + for (Map.Entry entry : parsedMap.entrySet()) { + Object rawKey = entry.getKey(); + if (rawKey == null) { continue; } - Matcher matcher = KEY_VALUE_PATTERN.matcher(line.trim()); - if (!matcher.matches()) { - throw new IllegalArgumentException( - "Invalid YAML line (expected 'key: value' format): " + line); + Object value = entry.getValue(); + if (isScalarValue(value)) { + result.put(String.valueOf(rawKey), scalarToString(value)); } - - String key = matcher.group(1); - String value = parseValue(matcher.group(2)); - - result.put(key, value); } - return result; } /** - * Parse a YAML value, handling quoted strings. + * Check whether a parsed YAML value should be exposed as metadata. * - * @param rawValue Raw value string from YAML - * @return Parsed value with quotes removed if present + * @param value Parsed YAML value + * @return true if the value is a scalar that can be represented as a string */ - private static String parseValue(String rawValue) { - if (rawValue == null) { - return ""; - } - - String value = rawValue.trim(); - - if (value.isEmpty()) { - return ""; - } - - // Handle double-quoted strings - if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { - return unescapeString(value.substring(1, value.length() - 1)); - } - - // Handle single-quoted strings - if (value.startsWith("'") && value.endsWith("'") && value.length() >= 2) { - // Single-quoted strings don't process escapes, except '' for ' - return value.substring(1, value.length() - 1).replace("''", "'"); - } - - return value; + private static boolean isScalarValue(Object value) { + return value == null + || value instanceof String + || value instanceof Number + || value instanceof Boolean + || value instanceof Character + || value instanceof Enum; } /** - * Unescape a double-quoted YAML string. + * Convert a parsed YAML scalar value into the string representation expected by + * ParsedMarkdown metadata. * - * @param str String content without surrounding quotes - * @return Unescaped string + * @param value Parsed YAML scalar + * @return Metadata string value */ - private static String unescapeString(String str) { - if (str == null || str.isEmpty()) { - return str; - } - - StringBuilder result = new StringBuilder(); - boolean escape = false; - - for (int i = 0; i < str.length(); i++) { - char c = str.charAt(i); - - if (escape) { - switch (c) { - case 'n': - result.append('\n'); - break; - case 't': - result.append('\t'); - break; - case 'r': - result.append('\r'); - break; - case '\\': - result.append('\\'); - break; - case '"': - result.append('"'); - break; - default: - result.append('\\').append(c); - } - escape = false; - } else if (c == '\\') { - escape = true; - } else { - result.append(c); - } - } - - // Handle trailing backslash - if (escape) { - result.append('\\'); - } - - return result.toString(); + private static String scalarToString(Object value) { + return value == null ? "" : String.valueOf(value); } /** diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java index 3d4b642b4..eda8441f3 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java @@ -24,7 +24,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import io.agentscope.core.skill.util.MarkdownSkillParser.ParsedMarkdown; -import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -291,8 +290,7 @@ void testInvalidYaml() { assertThrows( IllegalArgumentException.class, () -> MarkdownSkillParser.parse(markdown)); - assertTrue(exception.getMessage().contains("Invalid YAML line")); - assertTrue(exception.getMessage().contains("expected 'key: value' format")); + assertTrue(exception.getMessage().contains("Invalid YAML frontmatter")); } @Test @@ -304,7 +302,48 @@ void testListFormat() { assertThrows( IllegalArgumentException.class, () -> MarkdownSkillParser.parse(markdown)); - assertTrue(exception.getMessage().contains("Invalid YAML line")); + assertTrue(exception.getMessage().contains("top-level mapping")); + } + + @Test + @DisplayName("Should tolerate nested YAML fields and keep scalar metadata") + void testNestedYamlFields() { + String markdown = + "---\n" + + "name: test-skill\n" + + "description: Simple description\n" + + "references:\n" + + " - https://example.com/spec\n" + + "metadata:\n" + + " owner: platform\n" + + "---\n" + + "Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertEquals("test-skill", parsed.getMetadata().get("name")); + assertEquals("Simple description", parsed.getMetadata().get("description")); + assertFalse(parsed.getMetadata().containsKey("references")); + assertFalse(parsed.getMetadata().containsKey("metadata")); + } + + @Test + @DisplayName("Should parse multiline scalar values") + void testMultilineScalarValues() { + String markdown = + "---\n" + + "name: test-skill\n" + + "description: |\n" + + " First line\n" + + " Second line\n" + + "---\n" + + "Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertEquals("test-skill", parsed.getMetadata().get("name")); + assertTrue(parsed.getMetadata().get("description").contains("First line")); + assertTrue(parsed.getMetadata().get("description").contains("Second line")); } } @@ -498,7 +537,7 @@ void testGetters() { @Test @DisplayName("Should maintain immutability") void testImmutability() { - Map originalMetadata = new HashMap<>(); + Map originalMetadata = new java.util.HashMap<>(); originalMetadata.put("key", "value"); ParsedMarkdown parsed = new ParsedMarkdown(originalMetadata, "content");