From e6e0359af8a3b9522c033513d8d62c176b90255f Mon Sep 17 00:00:00 2001 From: sunwg2 Date: Wed, 20 May 2026 10:55:53 +0800 Subject: [PATCH] fix(nacos-skill): handle block-sequence arrays in SKILL.md frontmatter Nacos exports use multi-line `- item` syntax for array fields. The old normalizer concatenated items into an invalid scalar, causing SnakeYAML to throw "sequence entries are not allowed here in 'string'". Collect `- item` lines into a list and emit them as an inline YAML array `["a", "b"]`; strip YAML quoting from raw items via unquoteYamlString(). Closes #1438 --- .../nacos/skill/NacosSkillRepository.java | 86 +++++++++++-- .../nacos/skill/NacosSkillRepositoryTest.java | 118 ++++++++++++++++++ 2 files changed, 195 insertions(+), 9 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/main/java/io/agentscope/core/nacos/skill/NacosSkillRepository.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/main/java/io/agentscope/core/nacos/skill/NacosSkillRepository.java index 09ef7fce3..99f488493 100644 --- a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/main/java/io/agentscope/core/nacos/skill/NacosSkillRepository.java +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/main/java/io/agentscope/core/nacos/skill/NacosSkillRepository.java @@ -74,6 +74,9 @@ public class NacosSkillRepository implements AgentSkillRepository { private static final Pattern YAML_KV = Pattern.compile("^([a-zA-Z_][a-zA-Z0-9_-]*)\\s*:\\s*(.*)$"); + /** Matches a YAML block-sequence entry line, e.g. " - some value" or "- item". */ + private static final Pattern YAML_LIST_ITEM = Pattern.compile("^-\\s+(.*)$"); + /** Skill package entry: exactly one path segment then {@value #SKILL_MD}. */ private static final Pattern ROOT_SKILL_MD = Pattern.compile("^([^/]+)/" + SKILL_MD + "$"); @@ -367,12 +370,15 @@ private static List splitLinesPreserveTrailing(String text) { /** * Fold lines that are not {@code key: value} into the previous key's value (Nacos / loose YAML - * style descriptions). + * style descriptions). Block-sequence entry lines ({@code - item}) under a key whose value is + * empty are collected into a list and serialised as an inline YAML array so that + * {@code MarkdownSkillParser}'s SnakeYAML parser can read them without error. */ private static String normalizeFoldedFlatYaml(List yamlLines) { StringBuilder emit = new StringBuilder(); String pendingKey = null; String pendingVal = null; + List pendingList = null; for (String raw : yamlLines) { String t = raw.trim(); @@ -384,26 +390,60 @@ private static String normalizeFoldedFlatYaml(List yamlLines) { } Matcher m = YAML_KV.matcher(t); if (m.matches()) { - flushYamlKvLine(emit, pendingKey, pendingVal); + flushYamlKvLine(emit, pendingKey, pendingVal, pendingList); pendingKey = m.group(1); pendingVal = m.group(2).trim(); - } else if (pendingKey != null) { - if (pendingVal == null || pendingVal.isEmpty()) { - pendingVal = t; - } else { - pendingVal = pendingVal + " " + t; + pendingList = null; + } else { + Matcher listMatcher = YAML_LIST_ITEM.matcher(t); + if (listMatcher.matches() + && pendingKey != null + && (pendingVal == null || pendingVal.isEmpty())) { + // Block-sequence entry under a key with no inline value: collect as list. + if (pendingList == null) { + pendingList = new ArrayList<>(); + } + pendingList.add(unquoteYamlString(listMatcher.group(1).trim())); + } else if (pendingKey != null) { + // Plain folded continuation line (original behaviour). + if (pendingVal == null || pendingVal.isEmpty()) { + pendingVal = t; + } else { + pendingVal = pendingVal + " " + t; + } } } } - flushYamlKvLine(emit, pendingKey, pendingVal); + flushYamlKvLine(emit, pendingKey, pendingVal, pendingList); return emit.toString(); } - private static void flushYamlKvLine(StringBuilder sb, String key, String value) { + private static void flushYamlKvLine( + StringBuilder sb, String key, String value, List list) { if (key == null) { return; } sb.append(key).append(": "); + + // Serialise block-sequence items as an inline YAML array: ["a", "b"]. + if (list != null && !list.isEmpty()) { + sb.append('['); + boolean first = true; + for (String item : list) { + if (!first) { + sb.append(", "); + } + first = false; + sb.append('"'); + appendYamlDoubleQuotedEscaped(sb, item); + sb.append('"'); + } + sb.append(']'); + sb.append('\n'); + return; + } + + // Scalar value (original behaviour). if (value == null || value.isEmpty()) { sb.append('\n'); return; @@ -451,6 +491,34 @@ private static boolean yamlValueNeedsQuoting(String value) { || first == '`'; } + /** + * Strips a single layer of YAML quoting from a string value if present. + * + *

Handles the two YAML scalar quoting styles that Nacos exports use: + *

    + *
  • Double-quoted: {@code "value"} → {@code value} (also unescapes {@code \"} → {@code "} + * and {@code \\} → {@code \})
  • + *
  • Single-quoted: {@code 'value'} → {@code value} (also unescapes {@code ''} → {@code '})
  • + *
+ * If the value is not quoted, it is returned unchanged. + */ + private static String unquoteYamlString(String value) { + if (value == null || value.length() < 2) { + return value; + } + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if (first == '"' && last == '"') { + String inner = value.substring(1, value.length() - 1); + return inner.replace("\\\"", "\"").replace("\\\\", "\\"); + } + if (first == '\'' && last == '\'') { + String inner = value.substring(1, value.length() - 1); + return inner.replace("''", "'"); + } + return value; + } + private static void appendYamlDoubleQuotedEscaped(StringBuilder sb, String value) { for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java index 6ce47ba0b..d91056cc6 100644 --- a/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java +++ b/agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -418,6 +419,123 @@ void testDelete() { assertFalse(repository.delete("any-skill")); } + // -------- Bug #1438: multi-line block-sequence array in frontmatter -------- + + @Test + @DisplayName("Should load skill when trigger_intents uses multi-line block-sequence syntax") + void testGetSkillWithMultiLineArrayFrontmatter() throws NacosException, IOException { + String skillMd = + "---\n" + + "name: order-skill\n" + + "description: Order management skill\n" + + "trigger_intents:\n" + + " - \"创建个单子\"\n" + + " - \"创建单子\"\n" + + "---\n" + + "# Order Skill\n"; + when(aiService.downloadSkillZip("order-skill")) + .thenReturn(createSkillZipWithRawMd(skillMd)); + + AgentSkill skill = repository.getSkill("order-skill"); + + assertNotNull(skill); + assertEquals("order-skill", skill.getName()); + assertEquals("Order management skill", skill.getDescription()); + // Core assertion: array field must be parsed as a List, not silently dropped or mangled. + List intents = assertInstanceOf(List.class, skill.getMetadataValue("trigger_intents")); + assertEquals(List.of("创建个单子", "创建单子"), intents); + } + + @Test + @DisplayName("Should load skill when array field has only one item in block-sequence syntax") + void testGetSkillWithSingleItemBlockSequence() throws NacosException, IOException { + String skillMd = + "---\n" + + "name: single-item-skill\n" + + "description: Single item skill\n" + + "tags:\n" + + " - java\n" + + "---\n" + + "# Content\n"; + when(aiService.downloadSkillZip("single-item-skill")) + .thenReturn(createSkillZipWithRawMd(skillMd)); + + AgentSkill skill = repository.getSkill("single-item-skill"); + + assertNotNull(skill); + assertEquals("single-item-skill", skill.getName()); + List tags = assertInstanceOf(List.class, skill.getMetadataValue("tags")); + assertEquals(List.of("java"), tags); + } + + @Test + @DisplayName("Should load skill when array items contain special characters requiring quoting") + void testGetSkillWithSpecialCharsInArrayItems() throws NacosException, IOException { + String skillMd = + "---\n" + + "name: special-skill\n" + + "description: Special chars skill\n" + + "intents:\n" + + " - \"query: sales report\"\n" + + " - \"show #top items\"\n" + + "---\n" + + "# Content\n"; + when(aiService.downloadSkillZip("special-skill")) + .thenReturn(createSkillZipWithRawMd(skillMd)); + + AgentSkill skill = repository.getSkill("special-skill"); + + assertNotNull(skill); + assertEquals("special-skill", skill.getName()); + // Items containing ':' and '#' must survive round-trip quoting intact. + List intents = assertInstanceOf(List.class, skill.getMetadataValue("intents")); + assertEquals(List.of("query: sales report", "show #top items"), intents); + } + + @Test + @DisplayName("Should still load skill when frontmatter has both scalar and array fields") + void testGetSkillWithMixedFrontmatterFields() throws NacosException, IOException { + String skillMd = + "---\n" + + "name: mixed-skill\n" + + "description: Mixed fields skill\n" + + "version: 1.0.0\n" + + "trigger_intents:\n" + + " - \"触发意图一\"\n" + + " - \"触发意图二\"\n" + + " - \"触发意图三\"\n" + + "author: tester\n" + + "---\n" + + "# Mixed Skill\n"; + when(aiService.downloadSkillZip("mixed-skill")) + .thenReturn(createSkillZipWithRawMd(skillMd)); + + AgentSkill skill = repository.getSkill("mixed-skill"); + + assertNotNull(skill); + assertEquals("mixed-skill", skill.getName()); + assertEquals("Mixed fields skill", skill.getDescription()); + // Scalar fields adjacent to array field must be unaffected. + assertEquals("1.0.0", skill.getMetadataValue("version")); + assertEquals("tester", skill.getMetadataValue("author")); + // Array field in the middle must be parsed correctly. + List intents = assertInstanceOf(List.class, skill.getMetadataValue("trigger_intents")); + assertEquals(List.of("触发意图一", "触发意图二", "触发意图三"), intents); + } + + private static byte[] createSkillZipWithRawMd(String rawSkillMd) + throws IOException { + String root = "skill-package"; + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(baos)) { + zos.putNextEntry(new ZipEntry(root + "/SKILL.md")); + zos.write(rawSkillMd.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.finish(); + return baos.toByteArray(); + } + } + private static byte[] createSkillZip( String name, String description,