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 b00bd088d..d87517093 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 @@ -31,6 +31,7 @@ import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; import org.yaml.snakeyaml.representer.Representer; /** @@ -153,9 +154,23 @@ private static Map parseYamlMetadata(String yamlContent) { Object loaded; try { loaded = createParserYaml().load(yamlContent); - } catch (RuntimeException e) { - logger.debug("Failed to parse YAML frontmatter, returning empty metadata", e); - return Map.of(); + } catch (YAMLException ye) { + String repaired = repairYamlWithUnquotedColons(yamlContent); + if (!repaired.equals(yamlContent)) { + try { + loaded = createParserYaml().load(repaired); + logger.warn( + "YAML frontmatter contained unquoted colons and was auto-repaired. " + + "Consider quoting scalar values containing ': ': {}", + yamlContent.substring(0, Math.min(80, yamlContent.length()))); + } catch (RuntimeException re) { + logger.debug("Failed to repair YAML frontmatter, returning empty metadata", re); + return Map.of(); + } + } else { + logger.debug("Failed to parse YAML frontmatter, returning empty metadata", ye); + return Map.of(); + } } if (loaded == null) { @@ -182,6 +197,97 @@ private static Map parseYamlMetadata(String yamlContent) { return metadata; } + /** + * Attempts to repair YAML content that contains unquoted colons in scalar values. + * + *

This handles the common case where a value contains patterns like "key:" that YAML + * interprets as mapping keys, for example: + *

+     * description: test, node: cannot find EDI partner
+     * 
+ * + *

The repair strategy wraps values in double quotes when they contain ": " patterns + * that would otherwise be parsed as key-value separators. + * + * @param yamlContent The original YAML content that failed to parse + * @return Repaired YAML content, or the original if no repair was possible + */ + private static String repairYamlWithUnquotedColons(String yamlContent) { + StringBuilder result = new StringBuilder(); + String[] lines = yamlContent.split("\n", -1); + + for (String line : lines) { + int firstColon = line.indexOf(':'); + if (firstColon > 0 && line.length() > firstColon + 1) { + String keyPart = line.substring(0, firstColon); + String valuePart = line.substring(firstColon + 1); + + String trimmedKey = keyPart.trim(); + if (!trimmedKey.isEmpty() && !trimmedKey.contains(" ") && needsQuoting(valuePart)) { + String repairedValue = quoteValue(valuePart); + line = keyPart + ":" + repairedValue; + } + } + result.append(line).append('\n'); + } + + if (!result.isEmpty()) { + result.setLength(result.length() - 1); + } + return result.toString(); + } + + /** + * Checks if a YAML value needs quoting because it contains unquoted colon-space patterns. + */ + private static boolean needsQuoting(String value) { + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + return false; + } + + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) + || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return false; + } + + return findUnquotedColonSpace(trimmed) >= 0; + } + + /** + * Finds the index of ": " that is not inside quotes. + * + * @return Index of the unquoted ": " or -1 if none found + */ + private static int findUnquotedColonSpace(String value) { + boolean inDoubleQuotes = false; + boolean inSingleQuotes = false; + + for (int i = 0; i < value.length() - 1; i++) { + char c = value.charAt(i); + if (c == '"' && !inSingleQuotes) { + inDoubleQuotes = !inDoubleQuotes; + } else if (c == '\'' && !inDoubleQuotes) { + inSingleQuotes = !inSingleQuotes; + } else if (!inDoubleQuotes + && !inSingleQuotes + && c == ':' + && value.charAt(i + 1) == ' ') { + return i; + } + } + return -1; + } + + /** + * Quotes a YAML value in double quotes, escaping any internal double quotes and backslashes. + */ + private static String quoteValue(String value) { + String trimmed = value.trim(); + String escaped = trimmed.replace("\\", "\\\\").replace("\"", "\\\""); + return " \"" + escaped + "\""; + } + private static LoaderOptions createLoaderOptions() { LoaderOptions options = new LoaderOptions(); options.setAllowDuplicateKeys(false); diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java index dd748d87a..4d95f4902 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/util/MarkdownSkillParserTest.java @@ -730,4 +730,328 @@ void testMetadataImmutable() { () -> parsed.getMetadata().put("description", "desc")); } } + + @Nested + @DisplayName("YAML Auto-Repair Tests") + class YamlAutoRepairTests { + + @Test + @DisplayName("Should auto-repair description with unquoted colons") + void testAutoRepairUnquotedColons() { + String markdown = + "---\n" + + "name: test-skills\n" + + "description: test skills, node: cannot find EDI partner, EDI partner" + + " does not exist\n" + + "---\n" + + "# Skill Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertNotNull(parsed); + assertTrue(parsed.hasFrontmatter()); + assertEquals("test-skills", parsed.getMetadata().get("name")); + String description = (String) parsed.getMetadata().get("description"); + assertNotNull(description); + assertTrue(description.contains("node:")); + assertTrue(description.contains("cannot find EDI partner")); + } + + @Test + @DisplayName("Should auto-repair description with error message containing colon") + void testAutoRepairErrorMessageWithColon() { + String markdown = + "---\n" + + "name: edi-skill\n" + + "description: When error contains: Can't find the EDI Customer setup\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + String description = (String) parsed.getMetadata().get("description"); + assertNotNull(description); + assertTrue(description.contains("Can't find the EDI Customer setup")); + } + + @Test + @DisplayName("Should handle already quoted values without double-quoting") + void testAlreadyQuotedValuesNotDoubleQuoted() { + String markdown = + "---\n" + + "name: test\n" + + "description: \"Already quoted: with colon\"\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("Already quoted: with colon", parsed.getMetadata().get("description")); + } + + @Test + @DisplayName("Should handle multiple fields with unquoted colons") + void testMultipleFieldsWithUnquotedColons() { + String markdown = + "---\n" + + "name: multi-colon\n" + + "description: Error: something failed, detail: node: not found\n" + + "example: status: error, code: 500\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + String description = (String) parsed.getMetadata().get("description"); + assertNotNull(description); + assertTrue(description.contains("Error:")); + assertTrue(description.contains("detail:")); + String example = (String) parsed.getMetadata().get("example"); + assertNotNull(example); + assertTrue(example.contains("status:")); + assertTrue(example.contains("code:")); + } + + @Test + @DisplayName("Should still parse valid YAML without repair") + void testValidYamlNoRepairNeeded() { + String markdown = + "---\n" + + "name: valid-yaml\n" + + "description: A normal description without colons\n" + + "version: 1.0.0\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("valid-yaml", parsed.getMetadata().get("name")); + assertEquals( + "A normal description without colons", parsed.getMetadata().get("description")); + assertEquals("1.0.0", parsed.getMetadata().get("version")); + } + + @Test + @DisplayName("Should handle long description with multiple colons") + void testLongDescriptionWithMultipleColons() { + String markdown = + "---\n" + + "name: edi-error-skill\n" + + "description: test skills, node: cannot find EDI partner, EDI partner" + + " does not exist, partner config error, order 850 not generated SO, order" + + " 850 error: Cannot find the EDI Customer setup in the EDI partner" + + " function, cannot find order 850. Use this skill to handle EDI 850 order" + + " errors when the EDI partner cannot be found, specifically when the 850" + + " error contains: Cannot find the EDI Customer setup in the EDI partner" + + " function.\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("edi-error-skill", parsed.getMetadata().get("name")); + String description = (String) parsed.getMetadata().get("description"); + assertNotNull(description); + assertTrue(description.contains("cannot find EDI partner")); + assertTrue(description.contains("order 850")); + assertTrue(description.contains("EDI Customer setup")); + } + + @Test + @DisplayName("Should return empty metadata when key has space (not repaired)") + void testKeyWithSpaceNotRepaired() { + // When a "key" contains space, repair skips it; YAML parse still fails -> empty + // metadata + String markdown = + "---\n" + "name: test\n" + "some text: value: here\n" + "---\n" + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.getMetadata().isEmpty()); + } + + @Test + @DisplayName("Should handle colon at start of line (firstColon == 0)") + void testColonAtLineStart() { + // When firstColon == 0, repair condition is false + String markdown = "---\n" + "name: test\n" + ": weird line\n" + "---\n" + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + // Invalid YAML, repair won't help since firstColon == 0 + assertTrue(parsed.getMetadata().isEmpty()); + } + + @Test + @DisplayName("Should handle colon at end of line (no value after)") + void testColonAtLineEnd() { + // When line.length() == firstColon + 1, repair condition is false + String markdown = + "---\n" + + "name: test\n" + + "description: text ending with colon:\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + // This is invalid YAML (mapping expects value after colon), repair skips it + assertTrue(parsed.getMetadata().isEmpty()); + } + + @Test + @DisplayName("Should handle URL without space after colon (no repair needed)") + void testColonNoSpaceAfter() { + // URL with colon but no space after - should NOT trigger needsQuoting + String markdown = + "---\n" + + "name: test\n" + + "url: http://example.com\n" + + "description: normal text\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("http://example.com", parsed.getMetadata().get("url")); + assertEquals("normal text", parsed.getMetadata().get("description")); + } + + @Test + @DisplayName("Should handle empty trimmed value in needsQuoting") + void testEmptyValueNoQuoting() { + // Value that trims to empty should not trigger quoting + String markdown = "---\n" + "name: test\n" + "description: \n" + "---\n" + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("test", parsed.getMetadata().get("name")); + } + + @Test + @DisplayName("Should handle value with only colons no spaces") + void testColonsWithoutSpaces() { + // Value contains colons but no ": " pattern - should not trigger needsQuoting + String markdown = + "---\n" + + "name: test\n" + + "data: key1:value1,key2:value2\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("key1:value1,key2:value2", parsed.getMetadata().get("data")); + } + + @Test + @DisplayName("Should handle multiple repairable lines with mixed quoting") + void testMultipleLinesMixedQuoting() { + // Mix of already-quoted and unquoted colon patterns + String markdown = + "---\n" + + "name: test\n" + + "description: \"already quoted: safe\"\n" + + "detail: error: something: happened\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("already quoted: safe", parsed.getMetadata().get("description")); + String detail = (String) parsed.getMetadata().get("detail"); + assertNotNull(detail); + assertTrue(detail.contains("error:")); + } + + @Test + @DisplayName("Should repair value containing double quotes") + void testRepairWithDoubleQuotes() { + // Value contains double quotes that need escaping during repair + String markdown = + "---\n" + + "name: test\n" + + "description: error: \"not found\": retry\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + assertEquals("test", parsed.getMetadata().get("name")); + String description = (String) parsed.getMetadata().get("description"); + assertNotNull(description); + assertTrue(description.contains("error:")); + } + + @Test + @DisplayName("Should repair value containing backslash") + void testRepairWithBackslash() { + // Value contains backslash that needs escaping during repair + String markdown = + "---\n" + + "name: test\n" + + "path: C:\\Users\\admin\\error: not found\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.hasFrontmatter()); + String path = (String) parsed.getMetadata().get("path"); + assertNotNull(path); + assertTrue(path.contains("error:")); + } + + @Test + @DisplayName("Should return empty metadata when repair still fails after quoting") + void testRepairStillFailsAfterQuoting() { + // YAML that fails initial parse, repair modifies it, but re-parse still fails + // Invalid YAML: mixing mapping and sequence at same level + String markdown = + "---\n" + + "name: test\n" + + "detail: error: something\n" + + "- broken item\n" + + "---\n" + + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + // Repair can't fix this - it's fundamentally broken YAML structure + assertTrue(parsed.getMetadata().isEmpty()); + } + + @Test + @DisplayName("Should return empty metadata when YAML parses to null") + void testYamlParsesToNull() { + // Empty YAML content between --- markers parses to null + String markdown = "---\n" + " \n" + "---\n" + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.getMetadata().isEmpty()); + } + + @Test + @DisplayName("Should return empty metadata for non-map top-level YAML") + void testNonMapTopLevelYaml() { + // YAML list as top-level instead of map + String markdown = + "---\n" + "- item1\n" + "- item2\n" + "- item3\n" + "---\n" + "# Content"; + + ParsedMarkdown parsed = MarkdownSkillParser.parse(markdown); + + assertTrue(parsed.getMetadata().isEmpty()); + } + } }