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
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "$");

Expand Down Expand Up @@ -367,12 +370,15 @@ private static List<String> 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<String> yamlLines) {
StringBuilder emit = new StringBuilder();
String pendingKey = null;
String pendingVal = null;
List<String> pendingList = null;

for (String raw : yamlLines) {
String t = raw.trim();
Expand All @@ -384,26 +390,60 @@ private static String normalizeFoldedFlatYaml(List<String> 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<String> 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;
Expand Down Expand Up @@ -451,6 +491,34 @@ private static boolean yamlValueNeedsQuoting(String value) {
|| first == '`';
}

/**
* Strips a single layer of YAML quoting from a string value if present.
*
* <p>Handles the two YAML scalar quoting styles that Nacos exports use:
* <ul>
* <li>Double-quoted: {@code "value"} → {@code value} (also unescapes {@code \"} → {@code "}
* and {@code \\} → {@code \})</li>
* <li>Single-quoted: {@code 'value'} → {@code value} (also unescapes {@code ''} → {@code '})</li>
* </ul>
* 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down