Skip to content

Commit e6e0359

Browse files
author
sunwg2
committed
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
1 parent 0f6a1db commit e6e0359

2 files changed

Lines changed: 195 additions & 9 deletions

File tree

agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/main/java/io/agentscope/core/nacos/skill/NacosSkillRepository.java

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ public class NacosSkillRepository implements AgentSkillRepository {
7474
private static final Pattern YAML_KV =
7575
Pattern.compile("^([a-zA-Z_][a-zA-Z0-9_-]*)\\s*:\\s*(.*)$");
7676

77+
/** Matches a YAML block-sequence entry line, e.g. " - some value" or "- item". */
78+
private static final Pattern YAML_LIST_ITEM = Pattern.compile("^-\\s+(.*)$");
79+
7780
/** Skill package entry: exactly one path segment then {@value #SKILL_MD}. */
7881
private static final Pattern ROOT_SKILL_MD = Pattern.compile("^([^/]+)/" + SKILL_MD + "$");
7982

@@ -367,12 +370,15 @@ private static List<String> splitLinesPreserveTrailing(String text) {
367370

368371
/**
369372
* Fold lines that are not {@code key: value} into the previous key's value (Nacos / loose YAML
370-
* style descriptions).
373+
* style descriptions). Block-sequence entry lines ({@code - item}) under a key whose value is
374+
* empty are collected into a list and serialised as an inline YAML array so that
375+
* {@code MarkdownSkillParser}'s SnakeYAML parser can read them without error.
371376
*/
372377
private static String normalizeFoldedFlatYaml(List<String> yamlLines) {
373378
StringBuilder emit = new StringBuilder();
374379
String pendingKey = null;
375380
String pendingVal = null;
381+
List<String> pendingList = null;
376382

377383
for (String raw : yamlLines) {
378384
String t = raw.trim();
@@ -384,26 +390,60 @@ private static String normalizeFoldedFlatYaml(List<String> yamlLines) {
384390
}
385391
Matcher m = YAML_KV.matcher(t);
386392
if (m.matches()) {
387-
flushYamlKvLine(emit, pendingKey, pendingVal);
393+
flushYamlKvLine(emit, pendingKey, pendingVal, pendingList);
388394
pendingKey = m.group(1);
389395
pendingVal = m.group(2).trim();
390-
} else if (pendingKey != null) {
391-
if (pendingVal == null || pendingVal.isEmpty()) {
392-
pendingVal = t;
393-
} else {
394-
pendingVal = pendingVal + " " + t;
396+
pendingList = null;
397+
} else {
398+
Matcher listMatcher = YAML_LIST_ITEM.matcher(t);
399+
if (listMatcher.matches()
400+
&& pendingKey != null
401+
&& (pendingVal == null || pendingVal.isEmpty())) {
402+
// Block-sequence entry under a key with no inline value: collect as list.
403+
if (pendingList == null) {
404+
pendingList = new ArrayList<>();
405+
}
406+
pendingList.add(unquoteYamlString(listMatcher.group(1).trim()));
407+
} else if (pendingKey != null) {
408+
// Plain folded continuation line (original behaviour).
409+
if (pendingVal == null || pendingVal.isEmpty()) {
410+
pendingVal = t;
411+
} else {
412+
pendingVal = pendingVal + " " + t;
413+
}
395414
}
396415
}
397416
}
398-
flushYamlKvLine(emit, pendingKey, pendingVal);
417+
flushYamlKvLine(emit, pendingKey, pendingVal, pendingList);
399418
return emit.toString();
400419
}
401420

402-
private static void flushYamlKvLine(StringBuilder sb, String key, String value) {
421+
private static void flushYamlKvLine(
422+
StringBuilder sb, String key, String value, List<String> list) {
403423
if (key == null) {
404424
return;
405425
}
406426
sb.append(key).append(": ");
427+
428+
// Serialise block-sequence items as an inline YAML array: ["a", "b"].
429+
if (list != null && !list.isEmpty()) {
430+
sb.append('[');
431+
boolean first = true;
432+
for (String item : list) {
433+
if (!first) {
434+
sb.append(", ");
435+
}
436+
first = false;
437+
sb.append('"');
438+
appendYamlDoubleQuotedEscaped(sb, item);
439+
sb.append('"');
440+
}
441+
sb.append(']');
442+
sb.append('\n');
443+
return;
444+
}
445+
446+
// Scalar value (original behaviour).
407447
if (value == null || value.isEmpty()) {
408448
sb.append('\n');
409449
return;
@@ -451,6 +491,34 @@ private static boolean yamlValueNeedsQuoting(String value) {
451491
|| first == '`';
452492
}
453493

494+
/**
495+
* Strips a single layer of YAML quoting from a string value if present.
496+
*
497+
* <p>Handles the two YAML scalar quoting styles that Nacos exports use:
498+
* <ul>
499+
* <li>Double-quoted: {@code "value"} → {@code value} (also unescapes {@code \"} → {@code "}
500+
* and {@code \\} → {@code \})</li>
501+
* <li>Single-quoted: {@code 'value'} → {@code value} (also unescapes {@code ''} → {@code '})</li>
502+
* </ul>
503+
* If the value is not quoted, it is returned unchanged.
504+
*/
505+
private static String unquoteYamlString(String value) {
506+
if (value == null || value.length() < 2) {
507+
return value;
508+
}
509+
char first = value.charAt(0);
510+
char last = value.charAt(value.length() - 1);
511+
if (first == '"' && last == '"') {
512+
String inner = value.substring(1, value.length() - 1);
513+
return inner.replace("\\\"", "\"").replace("\\\\", "\\");
514+
}
515+
if (first == '\'' && last == '\'') {
516+
String inner = value.substring(1, value.length() - 1);
517+
return inner.replace("''", "'");
518+
}
519+
return value;
520+
}
521+
454522
private static void appendYamlDoubleQuotedEscaped(StringBuilder sb, String value) {
455523
for (int i = 0; i < value.length(); i++) {
456524
char c = value.charAt(i);

agentscope-extensions/agentscope-extensions-nacos/agentscope-extensions-nacos-skill/src/test/java/io/agentscope/core/nacos/skill/NacosSkillRepositoryTest.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static org.junit.jupiter.api.Assertions.assertEquals;
2020
import static org.junit.jupiter.api.Assertions.assertFalse;
21+
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
2122
import static org.junit.jupiter.api.Assertions.assertNotNull;
2223
import static org.junit.jupiter.api.Assertions.assertThrows;
2324
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -418,6 +419,123 @@ void testDelete() {
418419
assertFalse(repository.delete("any-skill"));
419420
}
420421

422+
// -------- Bug #1438: multi-line block-sequence array in frontmatter --------
423+
424+
@Test
425+
@DisplayName("Should load skill when trigger_intents uses multi-line block-sequence syntax")
426+
void testGetSkillWithMultiLineArrayFrontmatter() throws NacosException, IOException {
427+
String skillMd =
428+
"---\n"
429+
+ "name: order-skill\n"
430+
+ "description: Order management skill\n"
431+
+ "trigger_intents:\n"
432+
+ " - \"创建个单子\"\n"
433+
+ " - \"创建单子\"\n"
434+
+ "---\n"
435+
+ "# Order Skill\n";
436+
when(aiService.downloadSkillZip("order-skill"))
437+
.thenReturn(createSkillZipWithRawMd(skillMd));
438+
439+
AgentSkill skill = repository.getSkill("order-skill");
440+
441+
assertNotNull(skill);
442+
assertEquals("order-skill", skill.getName());
443+
assertEquals("Order management skill", skill.getDescription());
444+
// Core assertion: array field must be parsed as a List, not silently dropped or mangled.
445+
List<?> intents = assertInstanceOf(List.class, skill.getMetadataValue("trigger_intents"));
446+
assertEquals(List.of("创建个单子", "创建单子"), intents);
447+
}
448+
449+
@Test
450+
@DisplayName("Should load skill when array field has only one item in block-sequence syntax")
451+
void testGetSkillWithSingleItemBlockSequence() throws NacosException, IOException {
452+
String skillMd =
453+
"---\n"
454+
+ "name: single-item-skill\n"
455+
+ "description: Single item skill\n"
456+
+ "tags:\n"
457+
+ " - java\n"
458+
+ "---\n"
459+
+ "# Content\n";
460+
when(aiService.downloadSkillZip("single-item-skill"))
461+
.thenReturn(createSkillZipWithRawMd(skillMd));
462+
463+
AgentSkill skill = repository.getSkill("single-item-skill");
464+
465+
assertNotNull(skill);
466+
assertEquals("single-item-skill", skill.getName());
467+
List<?> tags = assertInstanceOf(List.class, skill.getMetadataValue("tags"));
468+
assertEquals(List.of("java"), tags);
469+
}
470+
471+
@Test
472+
@DisplayName("Should load skill when array items contain special characters requiring quoting")
473+
void testGetSkillWithSpecialCharsInArrayItems() throws NacosException, IOException {
474+
String skillMd =
475+
"---\n"
476+
+ "name: special-skill\n"
477+
+ "description: Special chars skill\n"
478+
+ "intents:\n"
479+
+ " - \"query: sales report\"\n"
480+
+ " - \"show #top items\"\n"
481+
+ "---\n"
482+
+ "# Content\n";
483+
when(aiService.downloadSkillZip("special-skill"))
484+
.thenReturn(createSkillZipWithRawMd(skillMd));
485+
486+
AgentSkill skill = repository.getSkill("special-skill");
487+
488+
assertNotNull(skill);
489+
assertEquals("special-skill", skill.getName());
490+
// Items containing ':' and '#' must survive round-trip quoting intact.
491+
List<?> intents = assertInstanceOf(List.class, skill.getMetadataValue("intents"));
492+
assertEquals(List.of("query: sales report", "show #top items"), intents);
493+
}
494+
495+
@Test
496+
@DisplayName("Should still load skill when frontmatter has both scalar and array fields")
497+
void testGetSkillWithMixedFrontmatterFields() throws NacosException, IOException {
498+
String skillMd =
499+
"---\n"
500+
+ "name: mixed-skill\n"
501+
+ "description: Mixed fields skill\n"
502+
+ "version: 1.0.0\n"
503+
+ "trigger_intents:\n"
504+
+ " - \"触发意图一\"\n"
505+
+ " - \"触发意图二\"\n"
506+
+ " - \"触发意图三\"\n"
507+
+ "author: tester\n"
508+
+ "---\n"
509+
+ "# Mixed Skill\n";
510+
when(aiService.downloadSkillZip("mixed-skill"))
511+
.thenReturn(createSkillZipWithRawMd(skillMd));
512+
513+
AgentSkill skill = repository.getSkill("mixed-skill");
514+
515+
assertNotNull(skill);
516+
assertEquals("mixed-skill", skill.getName());
517+
assertEquals("Mixed fields skill", skill.getDescription());
518+
// Scalar fields adjacent to array field must be unaffected.
519+
assertEquals("1.0.0", skill.getMetadataValue("version"));
520+
assertEquals("tester", skill.getMetadataValue("author"));
521+
// Array field in the middle must be parsed correctly.
522+
List<?> intents = assertInstanceOf(List.class, skill.getMetadataValue("trigger_intents"));
523+
assertEquals(List.of("触发意图一", "触发意图二", "触发意图三"), intents);
524+
}
525+
526+
private static byte[] createSkillZipWithRawMd(String rawSkillMd)
527+
throws IOException {
528+
String root = "skill-package";
529+
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
530+
ZipOutputStream zos = new ZipOutputStream(baos)) {
531+
zos.putNextEntry(new ZipEntry(root + "/SKILL.md"));
532+
zos.write(rawSkillMd.getBytes(StandardCharsets.UTF_8));
533+
zos.closeEntry();
534+
zos.finish();
535+
return baos.toByteArray();
536+
}
537+
}
538+
421539
private static byte[] createSkillZip(
422540
String name,
423541
String description,

0 commit comments

Comments
 (0)