diff --git a/skills/build.gradle.kts b/skills/build.gradle.kts index 234f2e0d5c..0d49fe6c00 100644 --- a/skills/build.gradle.kts +++ b/skills/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { jvmTest { dependencies { implementation(kotlin("test-junit5")) + implementation("org.snakeyaml:snakeyaml-engine:3.0.1") implementation(libs.junit.jupiter.params) implementation(libs.kotlinx.coroutines.test) runtimeOnly(libs.logback.classic) diff --git a/skills/src/commonMain/kotlin/ai/koog/skills/prompt/SkillsPrompt.kt b/skills/src/commonMain/kotlin/ai/koog/skills/prompt/SkillsPrompt.kt index 16d67c70d9..540366da36 100644 --- a/skills/src/commonMain/kotlin/ai/koog/skills/prompt/SkillsPrompt.kt +++ b/skills/src/commonMain/kotlin/ai/koog/skills/prompt/SkillsPrompt.kt @@ -230,9 +230,30 @@ private fun escapeJson(value: String): String = buildString { } } -private fun escapeYml(value: String): String = "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +private fun escapeYml(value: String): String = buildString { + append('"') + value.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + in '\u0000'..'\u001f', in '\u007f'..'\u009f', '\u2028', '\u2029' -> { + append("\\u") + append(character.code.toString(16).padStart(4, '0')) + } + else -> append(character) + } + } + append('"') +} private fun escapeYmlKey(value: String): String = - value - .replace("\\", "\\\\") - .replace(":", "\\:") + if (value.matches(Regex("[A-Za-z_][A-Za-z0-9_-]*")) && + value.lowercase() !in setOf("true", "false", "null", "yes", "no", "on", "off", "y", "n") + ) { + value + } else { + escapeYml(value) + } diff --git a/skills/src/jvmTest/kotlin/prompt/SkillsPromptYamlTest.kt b/skills/src/jvmTest/kotlin/prompt/SkillsPromptYamlTest.kt new file mode 100644 index 0000000000..84d6ca1b23 --- /dev/null +++ b/skills/src/jvmTest/kotlin/prompt/SkillsPromptYamlTest.kt @@ -0,0 +1,57 @@ +package prompt + +import ai.koog.skills.model.Skill +import ai.koog.skills.prompt.SkillsPromptFormat +import ai.koog.skills.prompt.generateSkillsPrompt +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.snakeyaml.engine.v2.api.Load +import org.snakeyaml.engine.v2.api.LoadSettings +import org.snakeyaml.engine.v2.schema.CoreSchema +import kotlin.test.assertEquals + +class SkillsPromptYamlTest { + @ParameterizedTest + @MethodSource("strings") + fun testAllFieldsRoundTrip(value: String) { + val skill = Skill(value, value, value, value, value, mapOf("author" to value), value) + val yaml = generateSkillsPrompt(listOf(skill), SkillsPromptFormat.YML, true, true, true, true, true) + assertEquals( + mapOf( + "name" to value, + "description" to value, + "location" to value, + "license" to value, + "compatibility" to value, + "metadata" to mapOf("author" to value), + "allowed-tools" to value, + ), + parseSkill(yaml), + ) + } + + @ParameterizedTest + @MethodSource("strings") + fun testMetadataKeyRoundTrip(key: String) { + val skill = Skill("example", "Example", "SKILL.md", metadata = mapOf(key to "value")) + val yaml = generateSkillsPrompt(listOf(skill), SkillsPromptFormat.YML, includeMetadata = true) + assertEquals(mapOf(key to "value"), parseSkill(yaml)["metadata"]) + } + + private fun parseSkill(yaml: String): Map<*, *> { + val document = Load(LoadSettings.builder().setSchema(CoreSchema()).build()).loadFromString(yaml) as Map<*, *> + return (document["available_skills"] as List<*>).single() as Map<*, *> + } + + companion object { + @JvmStatic + fun strings(): List = listOf( + "author", "version", "", "true", "True", "TRUE", "false", "False", "FALSE", + "null", "Null", "NULL", "~", "yes", "no", "on", "off", "y", "n", + "123", "01", "1.5", ".inf", ".NaN", "a: b", "a:b", "#comment", "[a]", "{a}", + "*alias", "&anchor", "? key", "- item", " leading", "trailing ", "a\\b", "a\"b", + "first\nsecond", "first\rsecond", "first\r\nsecond", "\n", "\t", "\u0000", "\u0001", "\u001f", + "\u007f", "\u0085", "\u009f", "\u2028", "\u2029", "café 日本語 🐨", + ) + } +}