diff --git a/agentscope-core/pom.xml b/agentscope-core/pom.xml
index b5bb9a12b4..f0ffe2d750 100644
--- a/agentscope-core/pom.xml
+++ b/agentscope-core/pom.xml
@@ -141,6 +141,11 @@
json-schema-validator
+
+ org.yaml
+ snakeyaml
+
+
io.opentelemetryopentelemetry-api
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java
index ee3027b343..0cdc49d491 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkill.java
@@ -19,6 +19,7 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@@ -62,8 +63,7 @@
* @see io.agentscope.core.skill.util.MarkdownSkillParser
*/
public class AgentSkill {
- private final String name;
- private final String description;
+ private final Map metadata;
private final String skillContent;
private final Map resources;
private final String source;
@@ -104,16 +104,37 @@ public AgentSkill(
String skillContent,
Map resources,
String source) {
- if (name == null || name.isEmpty() || description == null || description.isEmpty()) {
- throw new IllegalArgumentException(
- "The skill must have `name` and `description` fields.");
- }
+ this(createMetadata(name, description), skillContent, resources, source);
+ }
+
+ /**
+ * Creates an AgentSkill with explicit metadata.
+ *
+ *
The metadata must include non-empty string values for {@code name} and
+ * {@code description}. The metadata map is copied and stored as immutable.
+ *
+ * @param metadata Skill metadata including required {@code name} and {@code description}
+ * @param skillContent The skill implementation or instructions (must not be null or empty)
+ * @param resources Supporting resources referenced by the skill (can be null)
+ * @param source Source identifier for the skill (null defaults to "custom")
+ * @throws IllegalArgumentException if metadata is invalid or skillContent is null or empty
+ */
+ public AgentSkill(
+ Map metadata,
+ String skillContent,
+ Map resources,
+ String source) {
+ String name = getRequiredMetadataString(metadata, "name");
+ String description = getRequiredMetadataString(metadata, "description");
if (skillContent == null || skillContent.isEmpty()) {
throw new IllegalArgumentException("The skill must have content");
}
- this.name = name;
- this.description = description;
+ LinkedHashMap metadataCopy = new LinkedHashMap<>(metadata);
+ metadataCopy.put("name", name);
+ metadataCopy.put("description", description);
+
+ this.metadata = Collections.unmodifiableMap(metadataCopy);
this.skillContent = skillContent;
this.resources = resources != null ? new HashMap<>(resources) : new HashMap<>();
this.source = source != null ? source : "custom";
@@ -125,7 +146,7 @@ public AgentSkill(
* @return The skill name (never null)
*/
public String getName() {
- return name;
+ return (String) metadata.get("name");
}
/**
@@ -134,7 +155,26 @@ public String getName() {
* @return The skill description (never null)
*/
public String getDescription() {
- return description;
+ return (String) metadata.get("description");
+ }
+
+ /**
+ * Gets the skill metadata.
+ *
+ * @return The immutable metadata map (never null, may be empty except required fields)
+ */
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ /**
+ * Gets a metadata value by key.
+ *
+ * @param key The metadata key
+ * @return The metadata value, or null if not found
+ */
+ public Object getMetadataValue(String key) {
+ return metadata.get(key);
}
/**
@@ -193,7 +233,7 @@ public Set getResourcePaths() {
* @return Unique skill identifier (never null)
*/
public String getSkillId() {
- return name + "_" + source;
+ return getName() + "_" + source;
}
/**
@@ -224,9 +264,9 @@ public static Builder builder() {
@Override
public String toString() {
return "AgentSkill{name='"
- + name
+ + getName()
+ "', description='"
- + description
+ + getDescription()
+ "', source='"
+ source
+ "'}";
@@ -256,8 +296,7 @@ public String toString() {
* }
*/
public static class Builder {
- private String name;
- private String description;
+ private Map metadata;
private String skillContent;
private Map resources;
private String source;
@@ -266,6 +305,7 @@ public static class Builder {
* Creates an empty builder.
*/
private Builder() {
+ this.metadata = new LinkedHashMap<>();
this.resources = new HashMap<>();
}
@@ -275,8 +315,7 @@ private Builder() {
* @param baseSkill The skill to copy values from
*/
private Builder(AgentSkill baseSkill) {
- this.name = baseSkill.name;
- this.description = baseSkill.description;
+ this.metadata = new LinkedHashMap<>(baseSkill.metadata);
this.skillContent = baseSkill.skillContent;
this.resources = new HashMap<>(baseSkill.resources);
this.source = baseSkill.source;
@@ -289,7 +328,7 @@ private Builder(AgentSkill baseSkill) {
* @return This builder
*/
public Builder name(String name) {
- this.name = name;
+ this.metadata.put("name", name);
return this;
}
@@ -300,7 +339,42 @@ public Builder name(String name) {
* @return This builder
*/
public Builder description(String description) {
- this.description = description;
+ this.metadata.put("description", description);
+ return this;
+ }
+
+ /**
+ * Replaces all metadata with a new map.
+ *
+ * @param metadata The new metadata map
+ * @return This builder
+ */
+ public Builder metadata(Map metadata) {
+ this.metadata =
+ metadata != null ? new LinkedHashMap<>(metadata) : new LinkedHashMap<>();
+ return this;
+ }
+
+ /**
+ * Adds or updates a single metadata entry.
+ *
+ * @param key The metadata key
+ * @param value The metadata value
+ * @return This builder
+ */
+ public Builder putMetadata(String key, Object value) {
+ this.metadata.put(key, value);
+ return this;
+ }
+
+ /**
+ * Removes a metadata entry.
+ *
+ * @param key The metadata key to remove
+ * @return This builder
+ */
+ public Builder removeMetadata(String key) {
+ this.metadata.remove(key);
return this;
}
@@ -377,7 +451,28 @@ public Builder source(String source) {
* @throws IllegalArgumentException if required fields are missing
*/
public AgentSkill build() {
- return new AgentSkill(name, description, skillContent, resources, source);
+ return new AgentSkill(metadata, skillContent, resources, source);
+ }
+ }
+
+ private static Map createMetadata(String name, String description) {
+ LinkedHashMap metadata = new LinkedHashMap<>();
+ metadata.put("name", name);
+ metadata.put("description", description);
+ return metadata;
+ }
+
+ private static String getRequiredMetadataString(Map metadata, String key) {
+ if (metadata == null) {
+ throw new IllegalArgumentException(
+ "The skill must have `name` and `description` fields.");
+ }
+
+ Object value = metadata.get(key);
+ if (!(value instanceof String stringValue) || stringValue.isEmpty()) {
+ throw new IllegalArgumentException(
+ "The skill must have `name` and `description` fields.");
}
+ return stringValue;
}
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java
index a7e6a66255..5353d7c742 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/AgentSkillPromptProvider.java
@@ -16,6 +16,10 @@
package io.agentscope.core.skill;
import java.nio.file.Path;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
/**
* Generates skill system prompts for agents to understand available skills.
@@ -30,9 +34,12 @@
* }
*/
public class AgentSkillPromptProvider {
+ private static final String INDENT = " ";
+ private static final Pattern XML_TAG_NAME_PATTERN = Pattern.compile("[A-Za-z_][A-Za-z0-9_.-]*");
+
private final SkillRegistry skillRegistry;
private final String instruction;
- private final String template;
+ private boolean exposeAllMetadata = true;
private boolean codeExecutionEnabled;
private String uploadDir;
private String codeExecutionInstruction;
@@ -54,10 +61,11 @@ public class AgentSkillPromptProvider {
2. Load it: load_skill_through_path(skillId="data-analysis_builtin", path="SKILL.md")
3. Follow the instructions returned by the skill
- Template fields explanation:
- - : The skill's display name
- - : When and how to use this skill
- - : Unique identifier for load_skill_through_path tool
+ Metadata is rendered as XML under each element:
+ - scalar metadata becomes a simple child element
+ - nested maps become nested XML elements
+ - lists become repeated elements
+ - is always appended for tool loading
@@ -99,41 +107,27 @@ public class AgentSkillPromptProvider {
""";
- // skillName, skillDescription, skillId
- public static final String DEFAULT_AGENT_SKILL_TEMPLATE =
- """
-
- %s
- %s
- %s
-
-
- """;
-
/**
* Creates a skill prompt provider.
*
* @param registry The skill registry containing registered skills
*/
public AgentSkillPromptProvider(SkillRegistry registry) {
- this(registry, null, null);
+ this(registry, null);
}
/**
- * Creates a skill prompt provider with custom instruction and template.
+ * Creates a skill prompt provider with custom instruction.
*
* @param registry The skill registry containing registered skills
* @param instruction Custom instruction header (null or blank uses default)
- * @param template Custom skill template (null or blank uses default)
*/
- public AgentSkillPromptProvider(SkillRegistry registry, String instruction, String template) {
+ public AgentSkillPromptProvider(SkillRegistry registry, String instruction) {
this.skillRegistry = registry;
this.instruction =
instruction == null || instruction.isBlank()
? DEFAULT_AGENT_SKILL_INSTRUCTION
: instruction;
- this.template =
- template == null || template.isBlank() ? DEFAULT_AGENT_SKILL_TEMPLATE : template;
}
/**
@@ -144,28 +138,20 @@ public AgentSkillPromptProvider(SkillRegistry registry, String instruction, Stri
* @return The skill system prompt, or empty string if no skills exist
*/
public String getSkillSystemPrompt() {
- StringBuilder sb = new StringBuilder();
-
- // Check if there are any skills
if (skillRegistry.getAllRegisteredSkills().isEmpty()) {
return "";
}
- // Add instruction header
+ StringBuilder sb = new StringBuilder();
sb.append(instruction);
- // Add each skill
for (RegisteredSkill registered : skillRegistry.getAllRegisteredSkills().values()) {
AgentSkill skill = skillRegistry.getSkill(registered.getSkillId());
- sb.append(
- String.format(
- template, skill.getName(), skill.getDescription(), skill.getSkillId()));
+ appendSkill(sb, skill);
}
- // Close available_skills tag
sb.append("");
- // Conditionally append code execution instructions
if (codeExecutionEnabled && uploadDir != null) {
String template =
codeExecutionInstruction != null
@@ -211,4 +197,96 @@ public void setCodeExecutionInstruction(String codeExecutionInstruction) {
? null
: codeExecutionInstruction;
}
+
+ /**
+ * Sets whether all metadata fields are exposed to the LLM.
+ *
+ *
When disabled, only {@code name}, {@code description}, and {@code skill-id}
+ * are rendered into the skill prompt.
+ *
+ * @param exposeAllMetadata {@code true} to expose all metadata, {@code false} to expose only
+ * the core fields
+ */
+ public void setExposeAllMetadata(boolean exposeAllMetadata) {
+ this.exposeAllMetadata = exposeAllMetadata;
+ }
+
+ private void appendSkill(StringBuilder sb, AgentSkill skill) {
+ sb.append("\n");
+ for (Map.Entry entry : getPromptMetadata(skill).entrySet()) {
+ if (entry.getValue() == null) {
+ continue;
+ }
+ appendXmlNode(sb, entry.getKey(), entry.getValue(), 1);
+ }
+ appendXmlNode(sb, "skill-id", skill.getSkillId(), 1);
+ sb.append("\n\n");
+ }
+
+ private Map getPromptMetadata(AgentSkill skill) {
+ if (exposeAllMetadata) {
+ return skill.getMetadata();
+ }
+
+ LinkedHashMap metadata = new LinkedHashMap<>();
+ metadata.put("name", skill.getName());
+ metadata.put("description", skill.getDescription());
+ return metadata;
+ }
+
+ private void appendXmlNode(StringBuilder sb, String key, Object value, int indentLevel) {
+ if (value == null) {
+ return;
+ }
+
+ String indent = INDENT.repeat(indentLevel);
+ boolean validTagName = isValidXmlTagName(key);
+ String openTag = validTagName ? "<" + key + ">" : "";
+ String closeTag = validTagName ? "" + key + ">" : "";
+
+ if (isScalarValue(value)) {
+ sb.append(indent)
+ .append(openTag)
+ .append(escapeXml(String.valueOf(value)))
+ .append(closeTag)
+ .append("\n");
+ return;
+ }
+
+ sb.append(indent).append(openTag).append("\n");
+ if (value instanceof Map, ?> mapValue) {
+ for (Map.Entry, ?> entry : mapValue.entrySet()) {
+ appendXmlNode(
+ sb, String.valueOf(entry.getKey()), entry.getValue(), indentLevel + 1);
+ }
+ } else if (value instanceof Collection> collectionValue) {
+ for (Object item : collectionValue) {
+ appendXmlNode(sb, "item", item, indentLevel + 1);
+ }
+ } else {
+ sb.append(INDENT.repeat(indentLevel + 1))
+ .append(escapeXml(String.valueOf(value)))
+ .append("\n");
+ }
+ sb.append(indent).append(closeTag).append("\n");
+ }
+
+ private boolean isScalarValue(Object value) {
+ return !(value instanceof Map, ?>) && !(value instanceof Collection>);
+ }
+
+ private boolean isValidXmlTagName(String value) {
+ return value != null && XML_TAG_NAME_PATTERN.matcher(value).matches();
+ }
+
+ private String escapeXml(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
}
diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java
index 1198361cb3..4b2b299a3a 100644
--- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java
+++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillBox.java
@@ -58,29 +58,17 @@ public class SkillBox implements StateModule {
private static final ConcurrentHashMap FILE_LOCKS = new ConcurrentHashMap<>();
public SkillBox(Toolkit toolkit) {
- this(toolkit, null, null);
+ this(toolkit, null);
}
/**
- * Creates a SkillBox with custom skill prompt instruction and template.
- *
- * @param instruction Custom instruction header (null or blank uses default)
- * @param template Custom skill template (null or blank uses default)
- */
- public SkillBox(String instruction, String template) {
- this(null, instruction, template);
- }
-
- /**
- * Creates a SkillBox with a toolkit and custom skill prompt instruction and template.
+ * Creates a SkillBox with a toolkit and custom skill prompt instruction.
*
* @param toolkit The toolkit to bind
* @param instruction Custom instruction header (null or blank uses default)
- * @param template Custom skill template (null or blank uses default)
*/
- public SkillBox(Toolkit toolkit, String instruction, String template) {
- this.skillPromptProvider =
- new AgentSkillPromptProvider(skillRegistry, instruction, template);
+ public SkillBox(Toolkit toolkit, String instruction) {
+ this.skillPromptProvider = new AgentSkillPromptProvider(skillRegistry, instruction);
this.skillToolFactory = new SkillToolFactory(skillRegistry, toolkit);
this.toolkit = toolkit;
}
@@ -97,6 +85,19 @@ public String getSkillPrompt() {
return skillPromptProvider.getSkillSystemPrompt();
}
+ /**
+ * Controls whether the skill prompt exposes all metadata fields or only the core fields.
+ *
+ *
When disabled, only {@code name}, {@code description}, and {@code skill-id}
+ * are included in the skill prompt.
+ *
+ * @param exposeAllMetadata {@code true} to expose all metadata, {@code false} to expose only
+ * the core fields
+ */
+ public void setExposeAllSkillMetadata(boolean exposeAllMetadata) {
+ skillPromptProvider.setExposeAllMetadata(exposeAllMetadata);
+ }
+
/**
* Create a fluent builder for registering skills with optional configuration.
*
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 939b1b8142..b00bd088d8 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
@@ -16,12 +16,22 @@
package io.agentscope.core.skill.util;
+import java.lang.reflect.Array;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.yaml.snakeyaml.DumperOptions;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+import org.yaml.snakeyaml.representer.Representer;
/**
* Utility for parsing and generating Markdown files with YAML frontmatter.
@@ -47,7 +57,7 @@
*
{@code
* // Parse markdown with frontmatter
* ParsedMarkdown parsed = MarkdownSkillParser.parse(markdownContent);
- * Map metadata = parsed.getMetadata();
+ * Map metadata = parsed.getMetadata();
* String content = parsed.getContent();
*
* // Generate markdown with frontmatter
@@ -56,27 +66,23 @@
*/
public class MarkdownSkillParser {
+ private static final int FRONTMATTER_CODE_POINT_LIMIT = 16_384;
+
private static final Logger logger = LoggerFactory.getLogger(MarkdownSkillParser.class);
+ private static final Pattern FRONTMATTER_PATTERN =
+ Pattern.compile(
+ "^---\\s*[\\r\\n]+(.*?)[\\r\\n]*---(?:\\s*[\\r\\n]+)?(.*)", Pattern.DOTALL);
+
+ private static final LoaderOptions LOADER_OPTIONS = createLoaderOptions();
+
+ private static final DumperOptions DUMPER_OPTIONS = createDumperOptions();
+
/**
* Private constructor to prevent instantiation.
*/
private MarkdownSkillParser() {}
- // Pattern to match frontmatter: starts with ---, ends with ---
- // Pattern explanation:
- // ^--- : frontmatter starts with --- at the beginning of the string
- // \\s* : optional whitespace after opening ---
- // [\\r\\n]+ : one or more line breaks (handles \n, \r\n, \r)
- // (.*?) : captured group - frontmatter content (non-greedy, can be empty)
- // [\\r\\n]* : zero or more line breaks before closing ---
- // --- : closing --- delimiter
- // (?:\\s*[\\r\\n]+)? : optional whitespace and line breaks after closing ---
- // (.*) : captured group - remaining content (greedy)
- private static final Pattern FRONTMATTER_PATTERN =
- Pattern.compile(
- "^---\\s*[\\r\\n]+(.*?)[\\r\\n]*---(?:\\s*[\\r\\n]+)?(.*)", Pattern.DOTALL);
-
/**
* Parse markdown content with YAML frontmatter.
*
@@ -94,7 +100,6 @@ public static ParsedMarkdown parse(String markdown) {
Matcher matcher = FRONTMATTER_PATTERN.matcher(markdown);
if (!matcher.matches()) {
- // No frontmatter found, treat entire content as markdown
return new ParsedMarkdown(Map.of(), markdown);
}
@@ -105,8 +110,7 @@ public static ParsedMarkdown parse(String markdown) {
return new ParsedMarkdown(Map.of(), markdownContent);
}
- Map metadata = SimpleYamlParser.parse(yamlContent);
- return new ParsedMarkdown(metadata, markdownContent);
+ return new ParsedMarkdown(parseYamlMetadata(yamlContent), markdownContent);
}
/**
@@ -119,19 +123,16 @@ public static ParsedMarkdown parse(String markdown) {
* @param content Markdown content (can be null or empty)
* @return Complete markdown with frontmatter
*/
- public static String generate(Map metadata, String content) {
+ public static String generate(Map metadata, String content) {
StringBuilder sb = new StringBuilder();
- // Add frontmatter if metadata exists
if (metadata != null && !metadata.isEmpty()) {
sb.append("---\n");
- sb.append(SimpleYamlParser.generate(metadata));
+ sb.append(createDumperYaml().dump(metadata));
sb.append("---\n");
}
- // Add content
if (content != null && !content.isEmpty()) {
- // Add a blank line between frontmatter and content if frontmatter exists
if (metadata != null && !metadata.isEmpty()) {
sb.append("\n");
}
@@ -141,290 +142,119 @@ public static String generate(Map metadata, String content) {
return sb.toString();
}
- /**
- * Simple YAML parser for flat key-value structures.
- * Only supports String:String mappings.
- */
- 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*(.*)$");
-
- /**
- * Parse YAML string into a map of key-value pairs.
- *
- *
This is a simplified parser designed for flat string-to-string mappings.
- * Block-style complex YAML structures (such as multi-line lists or indented
- * nested objects) are not supported and will be gracefully skipped.
- * However, flow-style inline structures (e.g., single-line JSON strings)
- * are treated as standard scalar values and will be parsed as raw strings.
- *
- * @param yaml YAML content to parse
- * @return Map of key-value pairs
- */
- static Map parse(String yaml) {
- Map result = new LinkedHashMap<>();
-
- if (yaml == null || yaml.isEmpty()) {
- return result;
- }
-
- String[] lines = yaml.split("[\\r\\n]+");
-
- for (String line : lines) {
- // Skip empty lines
- if (line.trim().isEmpty()) {
- continue;
- }
-
- // Skip comments
- if (line.trim().startsWith("#")) {
- continue;
- }
-
- Matcher matcher = KEY_VALUE_PATTERN.matcher(line.trim());
- if (!matcher.matches()) {
- logger.debug(
- "Skipping unsupported YAML line (expected 'key: value' format): {}",
- line);
- continue;
- }
-
- String key = matcher.group(1);
- String rawValue = matcher.group(2);
-
- if (isBlockScalarModifier(rawValue)) {
- logger.debug(
- "Skipping key '{}': block-style values ('{}') are unsupported",
- key,
- rawValue.trim());
- continue;
- }
-
- result.put(key, parseValue(rawValue));
- }
-
- return result;
+ private static Map parseYamlMetadata(String yamlContent) {
+ if (yamlContent.codePointCount(0, yamlContent.length()) > FRONTMATTER_CODE_POINT_LIMIT) {
+ logger.debug(
+ "Skipping YAML frontmatter because it exceeds the code point limit: {}",
+ FRONTMATTER_CODE_POINT_LIMIT);
+ return Map.of();
}
- /**
- * Check if the raw value is a YAML block scalar modifier ('|' or '>').
- *
- * @param rawValue The raw string captured after the colon
- * @return true if it is a block scalar modifier
- */
- private static boolean isBlockScalarModifier(String rawValue) {
- if (rawValue == null) {
- return false;
- }
-
- String trimmed = rawValue.trim();
- return "|".equals(trimmed) || ">".equals(trimmed);
+ Object loaded;
+ try {
+ loaded = createParserYaml().load(yamlContent);
+ } catch (RuntimeException e) {
+ logger.debug("Failed to parse YAML frontmatter, returning empty metadata", e);
+ return Map.of();
}
- /**
- * Parse a YAML value, handling quoted strings.
- *
- * @param rawValue Raw value string from YAML
- * @return Parsed value with quotes removed if present
- */
- 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;
+ if (loaded == null) {
+ return Map.of();
}
- /**
- * Unescape a double-quoted YAML string.
- *
- * @param str String content without surrounding quotes
- * @return Unescaped string
- */
- 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();
+ if (!(loaded instanceof Map, ?> rawMap)) {
+ logger.debug(
+ "Skipping YAML frontmatter because top-level object is not a map: {}",
+ loaded.getClass());
+ return Map.of();
}
- /**
- * Generate YAML string from a map of key-value pairs.
- *
- * @param map Map to serialize
- * @return YAML string
- */
- static String generate(Map map) {
- if (map == null || map.isEmpty()) {
- return "";
+ LinkedHashMap metadata = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : rawMap.entrySet()) {
+ Object key = entry.getKey();
+ if (!(key instanceof String stringKey)) {
+ logger.debug("Skipping YAML metadata entry with non-string key: {}", key);
+ continue;
}
- StringBuilder sb = new StringBuilder();
+ metadata.put(stringKey, normalizeMetadataValue(entry.getValue()));
+ }
+ return metadata;
+ }
- for (Map.Entry entry : map.entrySet()) {
- String key = entry.getKey();
- String value = entry.getValue();
+ private static LoaderOptions createLoaderOptions() {
+ LoaderOptions options = new LoaderOptions();
+ options.setAllowDuplicateKeys(false);
+ options.setMaxAliasesForCollections(10);
+ options.setNestingDepthLimit(10);
+ options.setCodePointLimit(FRONTMATTER_CODE_POINT_LIMIT);
+ return options;
+ }
- sb.append(key).append(": ");
+ private static DumperOptions createDumperOptions() {
+ DumperOptions options = new DumperOptions();
+ options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
+ options.setPrettyFlow(true);
+ options.setSplitLines(false);
+ return options;
+ }
- if (value == null || value.isEmpty()) {
- sb.append("");
- } else if (needsQuoting(value)) {
- sb.append(quoteValue(value));
- } else {
- sb.append(value);
- }
+ private static Yaml createParserYaml() {
+ return new Yaml(new SafeConstructor(LOADER_OPTIONS));
+ }
- sb.append("\n");
- }
+ private static Yaml createDumperYaml() {
+ return new Yaml(new Representer(DUMPER_OPTIONS), DUMPER_OPTIONS);
+ }
- return sb.toString();
+ /**
+ * Normalizes YAML parser output into a stable metadata tree.
+ *
+ *
Why: {@link SafeConstructor} keeps parsing safe, but it still returns broad container
+ * types like {@code Map, ?>}, arbitrary {@link Collection} implementations, and arrays.
+ * The rest of the skill pipeline needs a predictable shape so nested metadata can be preserved
+ * and later rendered as XML without losing structure.
+ *
+ *
How: this method recursively rewrites nested values into three forms only:
+ *
+ *
scalar values are kept as-is
+ *
maps become {@code Map}
+ *
collections and arrays become {@code List
+ *
+ */
+ private static Object normalizeMetadataValue(Object value) {
+ if (value == null) {
+ return null;
}
- /**
- * Check if a value needs to be quoted in YAML.
- *
- * @param value Value to check
- * @return true if quoting is needed
- */
- private static boolean needsQuoting(String value) {
- if (value.isEmpty()) {
- return false;
- }
-
- // Quote if contains special characters
- if (value.contains(":")
- || value.contains("#")
- || value.contains("\n")
- || value.contains("\r")
- || value.contains("\t")) {
- return true;
- }
-
- // Quote if starts/ends with whitespace
- if (Character.isWhitespace(value.charAt(0))
- || Character.isWhitespace(value.charAt(value.length() - 1))) {
- return true;
+ if (value instanceof Map, ?> rawMap) {
+ LinkedHashMap normalized = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : rawMap.entrySet()) {
+ Object key = entry.getKey();
+ String normalizedKey = key instanceof String ? (String) key : String.valueOf(key);
+ normalized.put(normalizedKey, normalizeMetadataValue(entry.getValue()));
}
+ return normalized;
+ }
- // Quote if starts with special YAML characters
- char first = value.charAt(0);
- if (first == '"'
- || first == '\''
- || first == '['
- || first == ']'
- || first == '{'
- || first == '}'
- || first == '>'
- || first == '|'
- || first == '*'
- || first == '&'
- || first == '!'
- || first == '%'
- || first == '@'
- || first == '`') {
- return true;
+ if (value instanceof Collection> collection) {
+ List