Skip to content
Open
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
6 changes: 6 additions & 0 deletions agentscope-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,11 @@
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>

<!-- YAML frontmatter parsing for markdown-based skills -->
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -145,22 +148,19 @@ public static String generate(Map<String, String> 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<String, String> parse(String yaml) {
Map<String, String> result = new LinkedHashMap<>();
Expand All @@ -169,116 +169,54 @@ static Map<String, String> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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"));
}
}

Expand Down Expand Up @@ -498,7 +537,7 @@ void testGetters() {
@Test
@DisplayName("Should maintain immutability")
void testImmutability() {
Map<String, String> originalMetadata = new HashMap<>();
Map<String, String> originalMetadata = new java.util.HashMap<>();
originalMetadata.put("key", "value");

ParsedMarkdown parsed = new ParsedMarkdown(originalMetadata, "content");
Expand Down
Loading