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
1 change: 1 addition & 0 deletions skills/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 25 additions & 4 deletions skills/src/commonMain/kotlin/ai/koog/skills/prompt/SkillsPrompt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
57 changes: 57 additions & 0 deletions skills/src/jvmTest/kotlin/prompt/SkillsPromptYamlTest.kt
Original file line number Diff line number Diff line change
@@ -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<String> = 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é 日本語 🐨",
)
}
}